diff --git a/CMakeLists.txt b/CMakeLists.txt index 6bf216f..6449117 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -411,9 +411,10 @@ target_include_directories(drag_out PUBLIC src) # TrackFX_SetPreset so a freshly-added ReaSampler 9000 plays that capture (the # former "vst_chunk" named-config-parm write fed REAPER raw component bytes its # VST3 wrapper framing cannot apply — the blank-on-drop regression). Reuses the -# instrument's OWN serializer (sample_map::serializeComponentState) — NOT a -# parallel byte writer — so the cross-artifact contract cannot drift; links -# sample_map (which pulls bank_book/wav_trim/sampler_core transitively) and +# instrument's OWN serializer (component_state_io::serializeComponentState) — NOT +# a parallel byte writer — so the cross-artifact contract cannot drift; links +# component_state_io (Q-W2v codec split, T4-13 ≡ T2-07: the extension no longer +# links sampler_core/pitch_shift object code to serialize one preset blob) and # NEITHER SDK. The class-ID string derives from the FROZEN UID macros # (src/core/wire/reasampler_uid.h, SDK-free), channel-selected via the generated # version header — hence the generated include dir. The round-trip test parses @@ -422,7 +423,7 @@ target_include_directories(drag_out PUBLIC src) # --------------------------------------------------------------------------- add_library(instrument_drop STATIC src/core/wire/instrument_drop.cpp) target_include_directories(instrument_drop PUBLIC src ${CMAKE_CURRENT_BINARY_DIR}/generated) -target_link_libraries(instrument_drop PUBLIC sample_map) +target_link_libraries(instrument_drop PUBLIC component_state_io) # --------------------------------------------------------------------------- # 2m) Pure theme library — NO REAPER, NO SWELL, NO LICE. The Phase L (L1) palette @@ -547,10 +548,10 @@ target_link_libraries(card_drag PUBLIC drag_out bank_grid) # 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 src/vst/ (it is instrument code) but +# 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_trim also relies on). The VST3 shell (src/vst/ +# the one house precedent wav_trim also relies on). The VST3 shell (shell/instrument/ # reasampler_processor.cpp) marshals MIDI/audio to/from it and is DAW-verified. # --------------------------------------------------------------------------- # pitch_shift (S16) — the pure duration-preserving PitchShifter (Preserve-engine DSP core). @@ -813,7 +814,7 @@ add_test(NAME velocity_curve_tests COMMAND velocity_curve_tests) # (mirror of mode_switch/bank_grid). bridge_marshal: the REAPER VST-host bridge # read marshalling — GetProjExtState result decode + a small JSON string-field # reader (mirror of capture_paths/wav_trim). Both are unit-tested outside the DAW; -# the VST3 shell (src/vst/*) that draws/routes/invokes is DAW-verified. +# 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) @@ -830,19 +831,28 @@ 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) — PURE mapping logic for the Tier-0 instrument: the live bank -# blob -> selected sample (via the SHARED bank_book JSON parse, NOT a second parser), -# interleaved->mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap -# build, and the selected-sample instance-state (de)serialization. Links the three pure -# modules it composes — bank_book (shared JSON), wav_trim (shared WAV parse), and -# sampler_core (the Keymap/SampleData it yields) — and NEITHER SDK. The VST3 shell -# (reasampler_processor.cpp) does the bridge read + file I/O off the audio thread, then -# calls these; the process callback stays allocation-free. +# 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_trim (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. add_library(sample_map STATIC src/core/instrument/map/sample_map.cpp) target_include_directories(sample_map PUBLIC src) -# master_gain: the v8 component-state master-gain field validates against the pure taper's -# linear cap at the (de)serialization boundary (one cap, shared with the knob + the processor). -target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core master_gain) +target_link_libraries(sample_map PUBLIC bank_book wav_trim sampler_core) + +# 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. +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) # capture_browser (Phase S10) — PURE card-grid + bank-filter-tab layout + hit-test for the # capture-first editor's default face. The mirror of mode_switch/editor_geometry: the fiddly @@ -967,13 +977,21 @@ add_executable(embed_strip_tests tests/test_embed_strip.cpp) target_link_libraries(embed_strip_tests PRIVATE embed_strip) add_test(NAME embed_strip_tests COMMAND embed_strip_tests) -# sample_map: the S4 mapping heart. Links ONLY sample_map (+ its pure deps) — NEITHER -# the VST3 SDK nor the REAPER SDK — the same structural plain-data-boundary proof the -# sampler_core test enforces. +# sample_map: the S4 mapping heart. Links ONLY sample_map + component_state_io (+ their +# pure deps) — NEITHER the VST3 SDK nor the REAPER SDK — the same structural +# plain-data-boundary proof the sampler_core test enforces. (The historical suite spans +# both halves of the Q-W2v split; the frozen-format assertions live here unmodified.) add_executable(sample_map_tests tests/test_sample_map.cpp) -target_link_libraries(sample_map_tests PRIVATE sample_map) +target_link_libraries(sample_map_tests PRIVATE sample_map component_state_io) add_test(NAME sample_map_tests COMMAND sample_map_tests) +# component_state_io (Q-W2v): the ComponentState codec's OWN target. Links ONLY +# component_state_io (velocity_curve + master_gain transitively) — deliberately NO +# sampler_core/pitch_shift — the structural proof the codec is engine-free (T2-07). +add_executable(component_state_io_tests tests/test_component_state_io.cpp) +target_link_libraries(component_state_io_tests PRIVATE component_state_io) +add_test(NAME component_state_io_tests COMMAND component_state_io_tests) + add_executable(capture_browser_tests tests/test_capture_browser.cpp) target_link_libraries(capture_browser_tests PRIVATE capture_browser) add_test(NAME capture_browser_tests COMMAND capture_browser_tests) @@ -1209,8 +1227,22 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # --- 5b) The VST3 module (loadable .vst3 DLL). ------------------------------- add_library(reasampler_vst MODULE src/shell/instrument/vst_entry.cpp - src/vst/reasampler_processor.cpp - src/vst/reasampler_editor.cpp + # The processor family (Q-W2v, T4-12): lifecycle + process() whole (T4-29), with + # component-state I/O and the off-thread reload/publish family in sibling TUs. + 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). + 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_platform.cpp src/shell/instrument/reasampler_embed.cpp src/shell/instrument/reaper_bridge.cpp # The Phase L (L1) draw kit — the ONE source of drawing the editor + embed shells @@ -1228,8 +1260,8 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # bank->keymap mapping + state (de)ser the processor drives off the audio thread; # linking it pulls its pure deps (bank_book, wav_trim, sampler_core, bank_model, # peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile / - # projectDirOfRpp) the bridge + processor use. Its PUBLIC include dirs (src, src/vst) - # give the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). + # 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, ...). # 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 @@ -1264,16 +1296,15 @@ 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 capture_paths embed_strip app_version capture_browser keyboard_strip + 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 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; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. - # src: the Q-W1 rooted include convention ("core/..." / "shell/..."). src/vst: the - # two not-yet-split god TUs (reasampler_editor / reasampler_processor, Q-W2v) still - # live there and are included flat by the shell TUs. - target_include_directories(reasampler_vst PRIVATE src src/vst ${SDK_INC} ${WDL_INC}) + # src: the Q-W1 rooted include convention ("core/..." / "shell/..."). src/vst is GONE + # (Q-W2v): the two former god TUs live split under shell/instrument/. + target_include_directories(reasampler_vst PRIVATE src ${SDK_INC} ${WDL_INC}) # A .vst3 is a DLL with a .vst3 extension and no lib-prefix. OUTPUT_NAME is the on-disk # product name, channel-forked (S18): reasampler_9000.vst3 (stable, byte-identical to # pre-S18) / reasampler_9000_beta.vst3 (beta) — driven by REASAMPLER_VST_OUTPUT_NAME set diff --git a/src/core/instrument/engine/sampler_core.cpp b/src/core/instrument/engine/sampler_core.cpp index 0edf418..0982462 100644 --- a/src/core/instrument/engine/sampler_core.cpp +++ b/src/core/instrument/engine/sampler_core.cpp @@ -1,6 +1,16 @@ // sampler_core — pure sampler engine implementation. See sampler_core.h for the // contract and the design rationale (keymap resolution, pitch ratio, ADSR shape, // voice allocation + stealing policy). NO VST3 / REAPER / SWELL / vendor includes. +// +// DOCUMENTED HOT-PATH EXCEPTION to the Phase Q ~600-line file ceiling (Q-W2v, +// T4-14/T4-27 — Daniel-settled 2026-07-28): this TU deliberately STAYS WHOLE. +// AdsrEnvelope::tick / TriggerEnvelope::amplitudeAt / PitchEnvelope::tick are called +// per-voice-per-sample from Voice::advanceFrame, which is called per-sample from +// VoiceEngine::render — same-TU definition is what lets the compiler inline that +// stack (the build configures NO LTO). A by-class TU split would put the hottest +// inner loop across TU boundaries — the exact heuristic-(3) dispatch blowout the +// phase forbids. Do NOT "fix" this file's length; the header is split instead +// (zone_params.h carries the shared value structs). #include "core/instrument/engine/sampler_core.h" diff --git a/src/core/instrument/engine/sampler_core.h b/src/core/instrument/engine/sampler_core.h index 354845c..5ef1e88 100644 --- a/src/core/instrument/engine/sampler_core.h +++ b/src/core/instrument/engine/sampler_core.h @@ -23,6 +23,7 @@ #include #include "core/audio/peaks.h" // AudioSample (float) +#include "core/instrument/engine/zone_params.h" // per-zone play params + mode enums (Q-W2v header split) #include "core/instrument/engine/pitch_shift.h" // PitchShifter (S16 Preserve engine DSP core) #include "core/instrument/engine/velocity_curve.h" // VelocityCurve (S-VIEW-9 velocity->amp transfer curve; eval at start) @@ -35,176 +36,10 @@ using instrument::engine::PitchShifter; using instrument::engine::VelocityCurve; using instrument::engine::VelocityPoint; -// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 -// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders -// per-channel. A PERFORMANCE choice the instrument owns (component state), never written -// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain -// value so the shell (bus negotiation, state) and the engine share one spelling; the core -// itself never branches on it — the mode only picks which render overload the shell drives. -enum class ChannelMode { Mono, Stereo }; - -// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's -// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE -// priority over a held-note stack (classic mono synth: a new note takes the voice over; the -// release of the top note falls back to the most-recent still-held note). A PERFORMANCE -// choice the instrument owns (component state), never a bank fact. Default Poly preserves -// current behavior. -enum class VoiceMode { Poly, Mono }; - -// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable). -// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps -// the envelope running when a note is taken over while another is held — pitch moves without -// a re-attack (and the fallback on top-note release glides back the same way). 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; a re-attack on a sample -// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger. -enum class MonoTrigger { Retrigger, Legato }; - -// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count. -// One spelling shared by the engine, the component-state (de)serializer, and the editor's -// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool. -inline constexpr int kMinVoiceCount = 1; -inline constexpr int kMaxVoiceCount = 32; -inline constexpr int kDefaultVoiceCount = 16; - -// --------------------------------------------------------------------------- -// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because -// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching -// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower -// with the rest of the engine machinery; only the value structs need to precede SampleData. -// --------------------------------------------------------------------------- - -// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack -// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below. -struct AdsrParams { - std::int64_t attackFrames = 0; - std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR - std::int64_t decayFrames = 0; - double sustainLevel = 1.0; // 0..1 - std::int64_t releaseFrames = 0; -}; - -// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's -// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, -// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone -// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. -enum class PlayMode { Gate, Trigger }; - -// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span -// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), -// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over -// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play -// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger. -struct TriggerParams { - double lengthFraction = 1.0; // (0,1] of the post-start span to play - std::int64_t fadeInFrames = 0; // 0->1 ramp at the head - std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd -}; - -// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default -// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) -// so a third curve can join without a signature change. -enum class FadeCurve { EqualPower, Linear }; - -// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted. -inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower; - -// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration -// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances -// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). -enum class PitchEngine { Varispeed, Preserve }; - -// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" -// directive). ONE constant to flip if Varispeed should be the default instead. This is the -// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's -// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core -// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16 -// engine" holds for the core's own regression tests (an octave up still halves duration in the -// bare engine); the Preserve product default is layered on above at (de)serialization. -inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; - -// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds -// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = -// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first -// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix). -// One knob, resolved at voice allocation. -inline constexpr double kPreserveWindowMs = 50.0; - -// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always -// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to -// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack -// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-). -struct PitchEnvParams { - bool enabled = false; - std::int64_t attackFrames = 0; - std::int64_t decayFrames = 0; - double peakSemitones = 0.0; // signed depth at the peak -}; - -// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData -// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 -// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope -// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the -// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one -// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine. -struct ZonePlayParams { - PlayMode playMode = PlayMode::Gate; - AdsrParams adsr; // Gate: the AHDSR envelope - TriggerParams trigger; // Trigger: %-length + fades - PitchEngine pitchEngine = PitchEngine::Varispeed; - PitchEnvParams pitchEnv; // AD pitch modulation, off by default -}; - -// --------------------------------------------------------------------------- -// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that -// govern playback. The shell decodes the on-disk WAV and fills this; the core -// never touches a file. -// --------------------------------------------------------------------------- - -// A loop over [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. absent-loop is modeled by leaving hasLoop false. -struct SampleLoop { - bool hasLoop = false; - std::int64_t start = 0; // first looped frame (inclusive) - std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end -}; - -// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is -// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). -// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise -// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both -// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical -// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was -// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio. -struct SampleData { - std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) - std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample - int sampleRate = 0; // frames per second (for reference; ratio is - // note-relative, so rate cancels for repitch). - // 0 is explicitly invalid — every consumer must - // receive a real rate before use. - int rootNote = 60; // MIDI note recorded at (plays at unity here) - SampleLoop loop; // sustain loop, if any - // Initial read position (frame offset) a voice starts playback at — frame 0 by - // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this - // an instrument-side per-zone override (the "start point" marker); S15 builds on it - // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on: - // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. - std::int64_t startFrame = 0; - - // S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch - // envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is - // Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData. - ZonePlayParams play; - - // 2 iff a matching-length second channel exists; else 1. A framesR of a different - // length than frames is treated as absent (mono) — a malformed pair never half-plays. - int channelCount() const { - return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; - } - -}; +// The per-zone play-parameter VALUE STRUCTS + per-instance mode enums (ChannelMode / +// VoiceMode / MonoTrigger, AdsrParams / TriggerParams / PitchEnvParams / ZonePlayParams, +// SampleLoop / SampleData, and their constants) live in zone_params.h (Q-W2v header +// split, T4-14/T4-17) so param-reading TUs stop recompiling on engine-class edits. // --------------------------------------------------------------------------- // Keymap — the performance map (instrument-owned, D-B). A note+velocity resolves diff --git a/src/core/instrument/engine/zone_params.h b/src/core/instrument/engine/zone_params.h new file mode 100644 index 0000000..387375e --- /dev/null +++ b/src/core/instrument/engine/zone_params.h @@ -0,0 +1,192 @@ +#pragma once +// zone_params.h — the per-zone play-parameter VALUE STRUCTS + per-instance mode enums the +// sampler engine, the sample_map resolution layer, the ComponentState codec, and the editor +// all share (Q-W2v header split, T4-14/T4-17). Split out of sampler_core.h so a UI or codec +// TU that reads a param struct no longer recompiles when a Voice/VoiceEngine member changes. +// PURE: NO VST3, NO REAPER, NO SWELL, NO vendor/ includes — standard library + peaks only. +// The per-frame EVALUATOR classes (AdsrEnvelope / TriggerEnvelope / PitchEnvelope) and the +// engine (Keymap / Voice / VoiceEngine) stay in sampler_core.h. + +#include +#include + +#include "core/audio/peaks.h" // AudioSample (float) + +namespace reasampler { + +// Q-W1 interim: the flat `reasampler` namespace is the engine family's home until its own +// re-namespace lands; the deps live in their sub-namespace homes. +using audio::AudioSample; + +// The instrument's per-instance output channel mode (S7, D-E). MONO keeps the pre-S7 +// downmix path (one channel out); STEREO negotiates a 2-channel output bus and renders +// per-channel. A PERFORMANCE choice the instrument owns (component state), never written +// to the bank. Default Mono preserves current behavior. Lives in the pure core as a plain +// value so the shell (bus negotiation, state) and the engine share one spelling; the core +// itself never branches on it — the mode only picks which render overload the shell drives. +enum class ChannelMode { Mono, Stereo }; + +// The instrument's per-instance VOICE MODE (Phase S voice redesign). POLY is today's +// polyphonic engine (fixed pool + bounded stealing); MONO is a single voice with LAST-NOTE +// priority over a held-note stack (classic mono synth: a new note takes the voice over; the +// release of the top note falls back to the most-recent still-held note). A PERFORMANCE +// choice the instrument owns (component state), never a bank fact. Default Poly preserves +// current behavior. +enum class VoiceMode { Poly, Mono }; + +// How a MONO takeover treats the envelopes (Phase S — Daniel: explicitly toggleable). +// RETRIGGER restarts the amplitude (and pitch) envelope on every new mono note. LEGATO keeps +// the envelope running when a note is taken over while another is held — pitch moves without +// a re-attack (and the fallback on top-note release glides back the same way). 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; a re-attack on a sample +// change is the deterministic, documented fallback). Meaningless in Poly. Default Retrigger. +enum class MonoTrigger { Retrigger, Legato }; + +// The user-parameterized polyphony bound (Phase S): a per-instance persisted voice count. +// One spelling shared by the engine, the component-state (de)serializer, and the editor's +// control so the range can never drift apart. Default 16 == the pre-Phase-S fixed pool. +inline constexpr int kMinVoiceCount = 1; +inline constexpr int kMaxVoiceCount = 32; +inline constexpr int kDefaultVoiceCount = 16; + +// --------------------------------------------------------------------------- +// S15/S16 per-zone play PARAMETERS (plain data). Defined up here (before SampleData) because +// SampleData carries a ZonePlayParams by value — a voice reads it at start(). The matching +// per-frame EVALUATOR classes (AHDSR AdsrEnvelope, TriggerEnvelope, PitchEnvelope) live lower +// with the rest of the engine machinery; only the value structs need to precede SampleData. +// --------------------------------------------------------------------------- + +// AHDSR amplitude envelope parameters (S15 grows the S3 ADSR with a HOLD stage between Attack +// and Decay). holdFrames == 0 is EXACTLY the pre-S15 ADSR (back-compat). See AdsrEnvelope below. +struct AdsrParams { + std::int64_t attackFrames = 0; + std::int64_t holdFrames = 0; // S15: hold at 1.0 between Attack and Decay; 0 = pre-S15 ADSR + std::int64_t decayFrames = 0; + double sustainLevel = 1.0; // 0..1 + std::int64_t releaseFrames = 0; +}; + +// S15 play mode. GATE = classic held note (AHDSR + sustain loop + note-off release, today's +// behavior grown by the hold stage). TRIGGER = one-shot: note-off-immune, no sustain loop, +// plays a % of the sample length shaped by fade-in/out. Both honor the start point. Per-zone +// (D-B); DEFAULT Gate so an instrument with no S15 params plays exactly as before. +enum class PlayMode { Gate, Trigger }; + +// Trigger amplitude envelope parameters (S15). Playback covers the source-frame span +// [startFrame, playEnd), playEnd = startFrame + round(lengthFraction*(frames - startFrame)), +// lengthFraction in (0,1]. Amplitude ramps 0->1 over fadeInFrames at the head and 1->0 over +// fadeOutFrames anchored to playEnd; unity between. Fades clamp so fadeIn + fadeOut <= play +// length. The voice frees when the head reaches playEnd. Note-off is a no-op in Trigger. +struct TriggerParams { + double lengthFraction = 1.0; // (0,1] of the post-start span to play + std::int64_t fadeInFrames = 0; // 0->1 ramp at the head + std::int64_t fadeOutFrames = 0; // 1->0 ramp anchored to playEnd +}; + +// The fade curve for Trigger's ramps. EQUAL_POWER (constant-power sin/cos) is the default +// (click-free on one-shots, per spec); LINEAR is the build-time residual. An enum (not a bool) +// so a third curve can join without a signature change. +enum class FadeCurve { EqualPower, Linear }; + +// The DEFAULT fade curve (S15 spec: equal-power). One constant to flip if linear is wanted. +inline constexpr FadeCurve kDefaultFadeCurve = FadeCurve::EqualPower; + +// The per-zone pitch engine. VARISPEED = today's path (readPos_ += ratio_): pitch and duration +// coupled (an octave up plays half as long). PRESERVE = duration-preserving: the read advances +// at the SOURCE rate while a PitchShifter transposes the output (an octave up keeps its length). +enum class PitchEngine { Varispeed, Preserve }; + +// The PRODUCT DEFAULT pitch engine (S16-F1 — Daniel's "I want duration-preserving repitching" +// directive). ONE constant to flip if Varispeed should be the default instead. This is the +// default a NEW or absent-in-the-blob zone gets — APPLIED AT THE STATE BOUNDARY (sample_map's +// deserialize / editor zone-creation), NOT the pure-core struct default. The pure-core +// ZonePlayParams.pitchEngine member defaults to VARISPEED so that "no params == the pre-S16 +// engine" holds for the core's own regression tests (an octave up still halves duration in the +// bare engine); the Preserve product default is layered on above at (de)serialization. +inline constexpr PitchEngine kDefaultPitchEngine = PitchEngine::Preserve; + +// The OLA window (frames) the Preserve PitchShifter uses, derived from a window in milliseconds +// at the voice's sample rate. ~50 ms is the WDL quality-0 window the spec cites; larger = +// smoother on big transpositions. Onset latency is ZERO: start() primes the ring with the first +// window of real source, so output frame 0 IS source frame 0 regardless of window size (GA2 fix). +// One knob, resolved at voice allocation. +inline constexpr double kPreserveWindowMs = 50.0; + +// A per-voice AD pitch-modulation envelope (S16), OFF by default (enabled=false -> offset always +// 0 -> playback bit-identical to the un-modulated engine). At note-on the pitch offset rises to +// peakSemitones over attackFrames, then falls to 0 (base pitch) over decayFrames. A zero attack +// gives the pure "start high, drop to base" percussive drop. peakSemitones is signed (+/-). +struct PitchEnvParams { + bool enabled = false; + std::int64_t attackFrames = 0; + std::int64_t decayFrames = 0; + double peakSemitones = 0.0; // signed depth at the peak +}; + +// The bundle of S15/S16 per-zone play parameters a voice reads at start(). Lives on SampleData +// (each zone owns one SampleData in the zoned keymap). DEFAULTS are EXACTLY the pre-S15/S16 +// engine: Gate mode, AHDSR with hold 0 (= the S3 ADSR), VARISPEED pitch engine, pitch envelope +// disabled — so a bare-core voice with default play is byte-identical to the pre-S15 build (the +// core regression tests rely on this). The PRODUCT default of Preserve (S16-F1) is applied one +// layer up at (de)serialization for new/absent zones — see kDefaultPitchEngine. +struct ZonePlayParams { + PlayMode playMode = PlayMode::Gate; + AdsrParams adsr; // Gate: the AHDSR envelope + TriggerParams trigger; // Trigger: %-length + fades + PitchEngine pitchEngine = PitchEngine::Varispeed; + PitchEnvParams pitchEnv; // AD pitch modulation, off by default +}; + +// --------------------------------------------------------------------------- +// Sample data the core plays. Plain, decoded PCM + the S2 bank intrinsics that +// govern playback. The shell decodes the on-disk WAV and fills this; the core +// never touches a file. +// --------------------------------------------------------------------------- + +// A loop over [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. absent-loop is modeled by leaving hasLoop false. +struct SampleLoop { + bool hasLoop = false; + std::int64_t start = 0; // first looped frame (inclusive) + std::int64_t end = 0; // one-past-last looped frame (exclusive); start <= end +}; + +// One decoded audio sample the engine can voice. DEINTERLEAVED, per-channel: `frames` is +// channel 0 (always present) and `framesR` is channel 1 (present only for a STEREO sample). +// A sample is stereo iff `framesR` is non-empty AND the same length as `frames`; otherwise +// it is mono (the degenerate, byte-identical Tier 0-1 case — `framesR` stays empty). Both +// channels share `readPos_`, `rootNote`, and `loop`, so repitch/loop are per-frame identical +// across channels; only the sampled value differs. `rootNote` is the MIDI note the file was +// recorded at (S2 intrinsic) — the pitch that plays back at unity ratio. +struct SampleData { + std::vector frames; // channel 0 PCM (mono, or L of a stereo sample) + std::vector framesR; // channel 1 PCM (R); EMPTY for a mono sample + int sampleRate = 0; // frames per second (for reference; ratio is + // note-relative, so rate cancels for repitch). + // 0 is explicitly invalid — every consumer must + // receive a real rate before use. + int rootNote = 60; // MIDI note recorded at (plays at unity here) + SampleLoop loop; // sustain loop, if any + // Initial read position (frame offset) a voice starts playback at — frame 0 by + // default, so an unset start point is exactly the pre-S11 behavior. S11 makes this + // an instrument-side per-zone override (the "start point" marker); S15 builds on it + // (both play modes carry a modifiable start). Clamped into [0, frames) at note-on: + // a start >= the sample length is a no-op (voice starts at 0), never out of bounds. + std::int64_t startFrame = 0; + + // S15/S16 per-zone play parameters (play mode, AHDSR/Trigger envelope, pitch engine, pitch + // envelope). Defaults reproduce the pre-S15 engine EXCEPT the pitch engine default is + // Preserve (S16-F1). A voice reads this at start(). Struct defined above SampleData. + ZonePlayParams play; + + // 2 iff a matching-length second channel exists; else 1. A framesR of a different + // length than frames is treated as absent (mono) — a malformed pair never half-plays. + int channelCount() const { + return (!framesR.empty() && framesR.size() == frames.size()) ? 2 : 1; + } + +}; + +} // namespace reasampler diff --git a/src/core/instrument/map/component_state_io.cpp b/src/core/instrument/map/component_state_io.cpp new file mode 100644 index 0000000..2307c5f --- /dev/null +++ b/src/core/instrument/map/component_state_io.cpp @@ -0,0 +1,508 @@ +// 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) +// and the why-a-separate-module note (Q-W2v, T4-13 ≡ T2-07). PURE: standard library + +// the pure sample_map value types + core/wire's LE byte codec (T4-20) + velocity_curve +// + master_gain. Every wire format is FROZEN — byte-identical to the pre-split writer. + +#include "core/instrument/map/component_state_io.h" + +#include // std::min (bounded curve-point reserve) +#include // assert (v3-lift projectRate guard) +#include // std::isfinite (v8 master-gain validation) +#include // std::memcpy (serializeSelection) +#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) + +namespace reasampler::instrument::map { + +using engine::masterGainMaxLinear; +using reasampler::wire::ByteReader; +using reasampler::wire::bitsToDouble; +using reasampler::wire::doubleToBits; +using reasampler::wire::putLE; + +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 (kZonesPayloadVersion +// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail +// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes +// the zone count so any reader can detect the record shape independently of the envelope version +// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip +// through EITHER envelope with no envelope bump. +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))); + } + // S11 extension: 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)); + + // S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine). + // 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 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET). + putLE(out, doubleToBits(z.keyTrack)); + // PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE + // control-point count, then per point velocity + amp as IEEE-754 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)); + } + } +} + +// Read a zones payload from `r` into `map`. Shared by the performance parse and the component +// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the +// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — +// clean back-compat lift, the overrides simply 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 the seconds domain at the read boundary: seconds = frames / +// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed. +void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { + bool extended = false; // v2+: the S11 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 + } + const bool legacyV3Play = (pv == 3); // legacy S15/S16 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+ (S-VIEW-6): per-zone keyTrack scalar + const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last + const std::uint32_t count = r.u32(); + 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) therefore lifts every zone to those defaults (S16-F1). + PerformanceZone z; + const std::uint32_t idLen = r.u32(); + z.sampleId = r.str(idLen); + z.lowNote = r.i32(); + z.highNote = r.i32(); + const std::uint8_t hasOverride = r.u8(); + if (hasOverride) z.rootOverride = r.i32(); + if (extended) { + const std::uint8_t hasLoop = r.u8(); + if (hasLoop) { + SampleLoop lp; + lp.hasLoop = (r.u8() != 0); + lp.start = r.i64(); + lp.end = r.i64(); + z.loopOverride = lp; + } + const std::uint8_t hasStart = r.u8(); + if (hasStart) z.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 the project sample rate (threaded in as `projectRate`) + // to reach the seconds domain. 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()); + } 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()); + } + // PAYLOAD v6 (S-VIEW-6): the 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 to the pre-S-VIEW-6 engine. + if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64()); + // PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A + // pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1 + // Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones. + // fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips + // false mid-curve) 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 actually hold (16 bytes/point) so a corrupt huge + // count can't trigger a giant allocation before the bounded reads fail — the loop still + // stops on r.ok, this only caps the speculative reserve. + 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::VelocityCurve::fromPoints(std::move(pts)); + } + // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the + // seconds product defaults on z.play — a v4 blob cannot exist outside this branch. + if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest + map.zones.push_back(std::move(z)); + } +} + +} // namespace + +std::vector 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 when a LEGACY v3 payload is present. + // For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 + // blob is encountered with an invalid rate — the calller guarantees a real rate before use. + 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 S4 single-selection format (version 1 + id bytes, + // no length prefix). Lift it to one full-keyboard zone playing that id. + if (version == kSelectionStateVersion) { + const std::string id = deserializeSelection(bytes); + if (!id.empty()) { + PerformanceZone z; + z.sampleId = id; + z.lowNote = 0; + z.highNote = 127; + map.zones.push_back(std::move(z)); + } + return map; + } + if (version != kPerformanceStateVersion) return map; // unknown -> empty + + readZonesPayload(r, map, projectRate); + return map; +} + +// --- Combined component state (v3, S10) -------------------------------------- + +std::vector serializeComponentState(const ComponentState& state) { + std::vector out; + putLE(out, kComponentStateVersion); + // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body. + out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0); + // v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE + // two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that + // stops at the mode byte is a strict prefix (see the v4 lift below). + putLE(out, asU64(state.lastConsumedAssignGeneration)); + // v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows + // the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift). + out.push_back(state.previewVelocity); + // v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly, + // 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the + // velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift). + const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount + : state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount + : state.voiceCount; + out.push_back(static_cast(vc)); + out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0); + out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0); + // v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double + // (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to + // here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or + // negative falls back to unity; above the +24 dB cap clamps to the cap. + { + double g = state.masterGainLinear; + const double maxLin = masterGainMaxLinear(); + if (!std::isfinite(g) || g < 0.0) g = 1.0; + if (g > maxLin) g = maxLin; + putLE(out, doubleToBits(g)); + } + // v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag, + // 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the + // v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's + // channel count); 1 = the user deliberately toggled the mode (never fought). + out.push_back(state.channelModeExplicit ? 1 : 0); + // v10 envelope addition (pS self-contained playback): the instance-owned sample-refs + // table, following the explicit flag so a v9 blob is a strict prefix up to here (see + // the v9 lift). Wire shape per kSelectionZonesRefsV10Version: 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())); + for (const SampleRefEntry& e : state.sampleRefs) { + putLE(out, static_cast(e.sampleId.size())); + out.insert(out.end(), e.sampleId.begin(), e.sampleId.end()); + putLE(out, static_cast(e.ref.relativePath.size())); + out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end()); + putLE(out, static_cast(static_cast(e.ref.rootNote))); + out.push_back(e.ref.loop.hasLoop ? 1 : 0); + putLE(out, asU64(e.ref.loop.start)); + putLE(out, asU64(e.ref.loop.end)); + putLE(out, + static_cast(static_cast(e.ref.channelCount))); + 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, + // 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 — + // 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); + return out; +} + +ComponentState deserializeComponentState(const std::vector& bytes, + double projectRate) { + // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. + // For v5 and later blobs it is unused. See readZonesPayload for the guard. + ComponentState out; + ByteReader r(bytes); + const std::uint32_t version = r.u32(); + if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state) + + // BACK-COMPAT: an older blob predates the v3 {selection, zones} split. + // * v1 (S4 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 (S5 zones-only): restore {"", zones} — that instance had zones but no separate + // single-capture selection. + 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 (pre-S7) + } + // BACK-COMPAT: a v3 blob (pre-S7 {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) { + 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); + return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9) + } + // BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker): + // mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration + // defaults to 0, so a first assign still applies for a pre-marker instance. + if (version == kSelectionZonesModeV4Version) { + 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); + return out; // marker stays 0 (pre-S8/S9 reader) + } + // BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity + // byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte. + // previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved + // pre-S-VIEW-4 instance restores at the mid default. + if (version == kSelectionZonesModeMarkerV5Version) { + 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; + out.lastConsumedAssignGeneration = r.i64(); + if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds) + 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); + return out; // previewVelocity stays at the mid default (pre-S-VIEW-4) + } + if (version != kComponentStateVersion && + version != kSelectionZonesRefsV10Version && + version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version && + version != kSelectionZonesModeMarkerVelVoiceGainV8Version && + version != kSelectionZonesModeMarkerVelVoiceV7Version && + version != kSelectionZonesModeMarkerVelV6Version) { + return out; // unknown -> empty + } + + // v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, + // then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated + // 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; + out.lastConsumedAssignGeneration = r.i64(); + if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds) + const std::uint8_t previewVel = r.u8(); + if (!r.ok) return out; // truncated before the velocity byte -> empty (mid default holds) + // Clamp to the documented MIDI 1..127 range: a 0 byte (or any out-of-spec value from a + // corrupt blob) falls back to the mid default rather than silencing the preview trigger. + out.previewVelocity = (previewVel >= 1 && previewVel <= 127) + ? previewVel + : kPreviewVelocityDefault; + // v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the + // construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior. + if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) { + const std::uint8_t vc = r.u8(); + const std::uint8_t vm = r.u8(); + const std::uint8_t mt = r.u8(); + if (!r.ok) return out; // truncated inside the voice bytes -> empty (defaults hold) + // Out-of-range bytes fall back to the field's DEFAULT (the previewVelocity precedent + // for a corrupt blob) rather than clamping to an edge the user never chose. + out.voiceCount = (vc >= kMinVoiceCount && vc <= kMaxVoiceCount) + ? static_cast(vc) + : kDefaultVoiceCount; + out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly; + out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; + } + // v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction + // default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or + // above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting. + if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) { + 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 + out.masterGainLinear = + (std::isfinite(g) && g >= 0.0 && g <= masterGainMaxLinear() * (1.0 + 1e-9)) + ? g + : 1.0; + } + // v9 (GA): 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 + // un-touched default and the shell may auto-default it from the loaded capture. + if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) { + const std::uint8_t explicitByte = r.u8(); + if (!r.ok) return out; // truncated before the flag -> empty (implicit holds) + out.channelModeExplicit = (explicitByte == 1); + } + // v10 (pS self-contained playback): 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) { + const std::uint32_t refCount = r.u32(); + for (std::uint32_t i = 0; i < refCount && r.ok; ++i) { + SampleRefEntry e; + const std::uint32_t refIdLen = r.u32(); + e.sampleId = r.str(refIdLen); + const std::uint32_t pathLen = r.u32(); + e.ref.relativePath = r.str(pathLen); + // Range fallbacks (the refs table is the ONLY copy on the play path, so a + // corrupt field must degrade to the field's default, never poison playback — + // the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back + // to the middle-C default distill() uses; a negative channel count falls back + // to 0 = unknown (the GA auto-default then skips it). + const std::int32_t root = r.i32(); + e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60; + e.ref.loop.hasLoop = (r.u8() != 0); + e.ref.loop.start = r.i64(); + e.ref.loop.end = r.i64(); + const std::int32_t channels = r.i32(); + e.ref.channelCount = channels >= 0 ? channels : 0; + const std::uint32_t nameLen = r.u32(); + e.displayName = r.str(nameLen); + if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest + out.sampleRefs.push_back(std::move(e)); + } + if (!r.ok) return out; + } + // v11 (pS-usage): 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) { + const std::uint32_t guidLen = r.u32(); + out.instanceGuid = r.str(guidLen); + if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty + } + 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); + return out; +} + +std::vector serializeSelection(const std::string& sampleId) { + std::vector out; + out.resize(4 + sampleId.size()); + const std::uint32_t v = kSelectionStateVersion; + out[0] = static_cast(v & 0xFF); + out[1] = static_cast((v >> 8) & 0xFF); + out[2] = static_cast((v >> 16) & 0xFF); + out[3] = static_cast((v >> 24) & 0xFF); + std::memcpy(out.data() + 4, sampleId.data(), sampleId.size()); + return out; +} + +std::string deserializeSelection(const std::vector& bytes) { + if (bytes.size() < 4) return {}; // no version tag -> no selection + const std::uint32_t v = static_cast(bytes[0]) | + (static_cast(bytes[1]) << 8) | + (static_cast(bytes[2]) << 16) | + (static_cast(bytes[3]) << 24); + if (v != kSelectionStateVersion) return {}; // unknown version -> ignore + return std::string(reinterpret_cast(bytes.data() + 4), + bytes.size() - 4); +} + +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h new file mode 100644 index 0000000..a66b1f4 --- /dev/null +++ b/src/core/instrument/map/component_state_io.h @@ -0,0 +1,326 @@ +#pragma once +// component_state_io — the ComponentState ENVELOPE + zones-payload binary codec for the +// ReaSampler 9000 instrument (Q-W2v split out of sample_map, T4-13 ≡ T2-07). PURE: NO +// VST3, NO REAPER, NO SWELL, NO vendor/ includes — the same boundary sample_map keeps. +// +// WHY A SEPARATE MODULE. The codec grows on EVERY ComponentState envelope bump (v6→v11 +// in one quarter), and it is deliberately shared across BOTH artifacts: the instrument's +// processor reads/writes it at setState/getState, and the EXTENSION's instrument-drop +// path (core/wire/instrument_drop) serializes the same bytes into a transient .vstpreset +// so the payload and the instrument's reader can never drift. Housing it inside +// sample_map made the extension link the whole voice engine (sampler_core + pitch_shift) +// to serialize one preset blob; split out, both artifacts link the codec and only the +// VST links the engine. The codec's own links are velocity_curve + master_gain (wire +// value validation) — never the engine. +// +// EVERY wire format below is FROZEN (byte-identical to the pre-split writer); the full +// version ladders (envelope v1..v11, zones payload v1..v7) are preserved exactly. + +#include +#include +#include + +#include "core/instrument/map/sample_map.h" // PerformanceMap / SampleRefs / SelectedSample (+ zone_params via sampler_core) + +namespace reasampler::instrument::map { + +// --- Performance-map instance state (VST3 setState/getState) ----------------- +// +// The performance map is the instrument's OWN state (D-B), serialized to the VST3 +// component-state IBStream — NOT written to the "reasampler" bank ext-state (the +// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of +// truncation/wrong-version by design (bounded reads, never throws across the host). +// +// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the +// ZONES PAYLOAD. +// +// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones +// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) +// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState +// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the +// key composition property: the zone-record extension is versioned inside the map blob, not on +// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not +// collide on a single version number. +// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 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 (0/1), 4-byte LE rootOverride (present iff hasRootOverride). +// A payload starting with a small u32 (the zone count) is v1 — there is no marker. +// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone +// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended +// to each zone record after rootOverride: +// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, +// 8-byte LE loop.end (both two's-complement int64); +// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). +// The reader detects the marker to know the record shape — a v1 payload (no marker) reads +// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. +// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload +// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint +// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): +// 1 byte playMode (0 = Gate, 1 = Trigger); +// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; +// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); +// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); +// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); +// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); +// 8-byte LE pitchEnv.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 + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved +// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. +// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS +// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds +// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed +// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames. +// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060). +// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), +// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full +// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles): +// 1 byte playMode (0 = Gate, 1 = Trigger); +// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double); +// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); +// 1 byte pitchEngine; 1 byte pitchEnv.enabled; +// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double); +// 8-byte LE pitchEnv.peakSemitones (double); +// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double); +// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double). +// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4 +// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader +// — a v4 blob cannot exist outside this branch. The keymap builders resolve the 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 S4 single-selection format: version tag 1 + id bytes) is +// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved +// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes +// to an EMPTY map. +// +// These two functions serialize the ZONES only. Since S10 the instrument's full component +// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState +// below, the v3 format the processor actually reads/writes. serializePerformance/ +// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift. + +inline constexpr std::uint32_t kPerformanceStateVersion = 2; + +// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9). +// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 — +// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock +// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides +// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker + +// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with 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 that a legitimate zone count (bounded by 128 MIDI zones in practice, +// always tiny) can never collide with. +// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the +// full v5 play-params tail: +// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET). +// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone +// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the +// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. +// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp +// transfer curve appended to each zone record after the v6 keyTrack field: +// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp +// (double). 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() (R10-F1 +// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change: +// an already-saved zone's soft hits play LOUDER than under the pre-r10 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; // S-VIEW-9: + per-zone velocity->amp curve +inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; + +// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are +// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a +// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap 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 — S10) ------------- +// +// Since S10 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, per the S10 policy reversal, an +// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty +// state), never auto-playing sample #1. +// +// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono, +// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a +// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system +// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono +// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 +// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte +// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the +// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the +// instance-owned path + intrinsics + display name per referenced sample; wire shape at +// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 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), then a 4-byte LE +// selection-id length + id bytes, then the CURRENT zones payload (identical to +// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). +// The instance guid is the ONLY envelope-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 (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to +// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = +// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity +// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the +// un-touched default, so the GA auto-default may follow the loaded capture; 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 (pre-pS-usage), which the shell re-mints on first publish): +// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct. +// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage. +// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift). +// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode). +// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). +// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). +// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). +// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). +// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode. +// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection. +// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift. +// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state). +// +// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is +// the disambiguator that 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 (D-B), never written to the +// bank — the extension owns the assign_request key; the instrument only tracks what it consumed. +// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no +// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default. +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 + ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E) + // GA (v9): 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 count on reload (stereo capture -> Stereo, mono -> Mono); once true, the + // user's choice is never fought. Pre-v9 blobs lift to false (implicit). + bool channelModeExplicit = false; + std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed + // S-VIEW-4 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. Defaults to kPreviewVelocityDefault. + std::uint8_t previewVelocity = kPreviewVelocityDefault; + // Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT + // per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S 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 + // FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; + // up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output + // trim applied by process() AFTER the voice sum (engine + drain + preview) — never per + // voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically, + // so an older blob lifting to 1.0 plays exactly as it did. + double masterGainLinear = 1.0; + // pS self-contained playback (v10): 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). + SampleRefs sampleRefs; + // pS-usage (v11): 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 the key is stable across sessions (records do not proliferate + // per reopen). Empty = never published (a fresh or pre-v11 instance); the processor + // mints one on first publish, and RE-mints when the publish plan detects this state + // was cloned onto another track (FX copy / track duplication — planUsagePublish). + std::string instanceGuid; +}; + +inline constexpr std::uint32_t kComponentStateVersion = 11; + +// The pS-usage combined-state version (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; + +// The pS self-contained combined-state version (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: 4-byte LE id length + id bytes, 4-byte LE +// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop, +// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of +// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length + +// displayName bytes (display-only; the editor label's extension-absent fallback). +inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10; + +// The pre-GA combined-state version (everything through the FB1 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; + +// The GA combined-state version (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; + +// The pre-FB1 combined-state version (selection + zones + 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; + +// The pre-Phase-S combined-state version (selection + zones + 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; + +// The pre-S-VIEW-4 combined-state version (selection + zones + 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; + +// The pre-S8/S9-reader combined-state version (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; + +// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named +// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. +inline constexpr std::uint32_t kSelectionZonesV3Version = 3; + +// The full instance state serialized to bytes for IBStream (getState). +std::vector serializeComponentState(const ComponentState& state); + +// The full instance state parsed back from IBStream bytes (setState). Tolerant of +// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table +// above so already-saved instances restore cleanly. +// `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. +ComponentState deserializeComponentState(const std::vector& bytes, + double projectRate); + +// --- Instance state (VST3 setState/getState) -------------------------------- +// +// The instrument's OWN state is which bank sample it plays (D-B: the selection is a +// performance choice, held by the instrument, never written back to the bank). It is a +// single string id. serialize/deserialize keep the on-the-wire form explicit and +// versioned so a future Tier can extend it without breaking already-saved instances. +// +// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No +// length prefix is needed — the id runs to the end of the stream (the host tells us the +// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob +// by returning "" (no selection — under the S10 policy reversal an empty 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; the processor's live state is the v3 ComponentState above. + +inline constexpr std::uint32_t kSelectionStateVersion = 1; + +// The selected-sample id serialized to bytes for IBStream (getState). +std::vector serializeSelection(const std::string& sampleId); + +// The selected-sample id parsed back from IBStream bytes (setState). Unknown version, +// 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/sample_map.cpp b/src/core/instrument/map/sample_map.cpp index 7ae5813..cfb35be 100644 --- a/src/core/instrument/map/sample_map.cpp +++ b/src/core/instrument/map/sample_map.cpp @@ -1,19 +1,14 @@ -// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL / -// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core. +// sample_map — pure implementation (the RESOLUTION half; the ComponentState codec +// lives in component_state_io.cpp since Q-W2v). See sample_map.h. NO VST3 / REAPER / +// SWELL / vendor includes; standard library + the pure bank_book / wav_trim / sampler_core. #include "core/instrument/map/sample_map.h" #include // std::min #include // assert -#include // std::isfinite (v8 master-gain validation) -#include // std::memcpy #include // std::move -#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap - -namespace reasampler { - -using instrument::engine::masterGainMaxLinear; +namespace reasampler::instrument::map { namespace { @@ -405,568 +400,5 @@ Keymap buildZonedKeymap(const std::vector& zones, return km; // empty zones in -> empty Keymap (silence) } -// --- Performance-map instance state (setState/getState) ----------------------- -namespace { - -void putU32le(std::vector& out, std::uint32_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - out.push_back(static_cast((v >> 16) & 0xFF)); - out.push_back(static_cast((v >> 24) & 0xFF)); -} - -// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as -// two's-complement u64, mirroring the u32 signed-int idiom above). -void putU64le(std::vector& out, std::uint64_t v) { - for (int b = 0; b < 8; ++b) out.push_back(static_cast((v >> (b * 8)) & 0xFF)); -} - -std::uint64_t asU64(std::int64_t v) { return static_cast(v); } - -// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined type-pun in C++). -// Used for the S15/S16 trigger.lengthFraction + pitchEnv.peakSemitones fields. -std::uint64_t doubleToBits(double d) { - std::uint64_t bits; - std::memcpy(&bits, &d, sizeof(bits)); - return bits; -} -double bitsToDouble(std::uint64_t bits) { - double d; - std::memcpy(&d, &bits, sizeof(d)); - return d; -} - -// A bounded little-endian reader over a byte blob. Every read is length-checked; once a -// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated -// blob degrades to a partial/empty parse rather than reading out of bounds. -struct ByteReader { - const std::vector& bytes; - std::size_t pos = 0; - bool ok = true; - - explicit ByteReader(const std::vector& b) : bytes(b) {} - - std::uint32_t u32() { - if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; } - const std::uint32_t v = static_cast(bytes[pos]) | - (static_cast(bytes[pos + 1]) << 8) | - (static_cast(bytes[pos + 2]) << 16) | - (static_cast(bytes[pos + 3]) << 24); - pos += 4; - return v; - } - std::uint8_t u8() { - if (!ok || pos + 1 > bytes.size()) { ok = false; return 0; } - return bytes[pos++]; - } - std::string str(std::uint32_t len) { - if (!ok || pos + len > bytes.size()) { ok = false; return {}; } - std::string s(reinterpret_cast(bytes.data() + pos), len); - pos += len; - return s; - } - // Signed ints go on the wire as u32 two's-complement (fixed 32-bit width). - int i32() { return static_cast(static_cast(u32())); } - - std::uint64_t u64() { - if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; } - std::uint64_t v = 0; - for (int b = 0; b < 8; ++b) - v |= static_cast(bytes[pos + static_cast(b)]) << (b * 8); - pos += 8; - return v; - } - // Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width). - std::int64_t i64() { return static_cast(u64()); } - - // Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields - // 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob - // as "no marker" and falls through to the (also-guarded) v1 count read. - std::uint32_t peekU32() const { - if (!ok || pos + 4 > bytes.size()) return 0; - return static_cast(bytes[pos]) | - (static_cast(bytes[pos + 1]) << 8) | - (static_cast(bytes[pos + 2]) << 16) | - (static_cast(bytes[pos + 3]) << 24); - } -}; - -// 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 (kZonesPayloadVersion -// == v5: the S11 self-describing marker + version + EXTENDED records carrying the loop/start tail -// AND the full play-params tail with wall-clock times stored as SECONDS): the marker precedes -// the zone count so any reader can detect the record shape independently of the envelope version -// (see sample_map.h). The S11 loop/start overrides and the play params therefore round-trip -// through EITHER envelope with no envelope bump. -void putZonesPayload(std::vector& out, const PerformanceMap& map) { - putU32le(out, kZonesFormatMarker); - putU32le(out, kZonesPayloadVersion); - putU32le(out, static_cast(map.zones.size())); - for (const PerformanceZone& z : map.zones) { - putU32le(out, static_cast(z.sampleId.size())); - out.insert(out.end(), z.sampleId.begin(), z.sampleId.end()); - putU32le(out, static_cast(static_cast(z.lowNote))); - putU32le(out, static_cast(static_cast(z.highNote))); - out.push_back(z.rootOverride ? 1 : 0); - if (z.rootOverride) { - putU32le(out, - static_cast(static_cast(*z.rootOverride))); - } - // S11 extension: 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); - putU64le(out, asU64(z.loopOverride->start)); - putU64le(out, asU64(z.loopOverride->end)); - } - out.push_back(z.startPoint ? 1 : 0); - if (z.startPoint) putU64le(out, asU64(*z.startPoint)); - - // S15/S16 play params (PAYLOAD v5): always present (every zone has a play mode + engine). - // 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); - putU64le(out, doubleToBits(pp.adsr.holdSeconds)); // wall-clock seconds - putU64le(out, doubleToBits(pp.trigger.lengthFraction)); // fraction - putU64le(out, asU64(pp.trigger.fadeInFrames)); // source frames - putU64le(out, asU64(pp.trigger.fadeOutFrames)); // source frames - out.push_back(pp.pitchEngine == PitchEngine::Preserve ? 1 : 0); - out.push_back(pp.pitchEnv.enabled ? 1 : 0); - putU64le(out, doubleToBits(pp.pitchEnv.attackSeconds)); // wall-clock seconds - putU64le(out, doubleToBits(pp.pitchEnv.decaySeconds)); // wall-clock seconds - putU64le(out, doubleToBits(pp.pitchEnv.peakSemitones)); // depth - // Full AHDSR A/D/S/R tail — wall-clock SECONDS (sustainLevel is a level). - putU64le(out, doubleToBits(pp.adsr.attackSeconds)); - putU64le(out, doubleToBits(pp.adsr.decaySeconds)); - putU64le(out, doubleToBits(pp.adsr.sustainLevel)); - putU64le(out, doubleToBits(pp.adsr.releaseSeconds)); - // PAYLOAD v6 (S-VIEW-6): the per-zone key-tracking scalar (1.0 = 100% ET). - putU64le(out, doubleToBits(z.keyTrack)); - // PAYLOAD v7 (S-VIEW-9): the per-zone velocity->amp transfer curve, appended last. 4-byte LE - // control-point count, then per point velocity + amp as IEEE-754 doubles (endpoints included). - const std::vector& pts = z.velocityCurve.points(); - putU32le(out, static_cast(pts.size())); - for (const VelocityPoint& p : pts) { - putU64le(out, doubleToBits(p.velocity)); - putU64le(out, doubleToBits(p.amp)); - } - } -} - -// Read a zones payload from `r` into `map`. Shared by the performance parse and the component -// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the -// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail — -// clean back-compat lift, the overrides simply 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 the seconds domain at the read boundary: seconds = frames / -// projectRate. Must be > 0 (callers guard). v5 and later blobs carry seconds directly; no rate needed. -void readZonesPayload(ByteReader& r, PerformanceMap& map, double projectRate) { - bool extended = false; // v2+: the S11 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 - } - const bool legacyV3Play = (pv == 3); // legacy S15/S16 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+ (S-VIEW-6): per-zone keyTrack scalar - const bool curveTail = (pv >= 7); // v7+ (S-VIEW-9): per-zone velocity->amp curve, appended last - const std::uint32_t count = r.u32(); - 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) therefore lifts every zone to those defaults (S16-F1). - PerformanceZone z; - const std::uint32_t idLen = r.u32(); - z.sampleId = r.str(idLen); - z.lowNote = r.i32(); - z.highNote = r.i32(); - const std::uint8_t hasOverride = r.u8(); - if (hasOverride) z.rootOverride = r.i32(); - if (extended) { - const std::uint8_t hasLoop = r.u8(); - if (hasLoop) { - SampleLoop lp; - lp.hasLoop = (r.u8() != 0); - lp.start = r.i64(); - lp.end = r.i64(); - z.loopOverride = lp; - } - const std::uint8_t hasStart = r.u8(); - if (hasStart) z.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 the project sample rate (threaded in as `projectRate`) - // to reach the seconds domain. 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()); - } 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()); - } - // PAYLOAD v6 (S-VIEW-6): the 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 to the pre-S-VIEW-6 engine. - if (keyTrackTail) z.keyTrack = bitsToDouble(r.u64()); - // PAYLOAD v7 (S-VIEW-9): the velocity->amp transfer curve, appended after the v6 keyTrack. A - // pre-v7 payload (no field) leaves the PerformanceZone default (VelocityCurve::flat() — R10-F1 - // Option A, flat y=1), the deliberate NON-back-compat behavior change for already-saved zones. - // fromPoints repairs the X-order/endpoint invariant defensively; a truncated read (r.ok flips - // false mid-curve) 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 actually hold (16 bytes/point) so a corrupt huge - // count can't trigger a giant allocation before the bounded reads fail — the loop still - // stops on r.ok, this only caps the speculative reserve. - 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::VelocityCurve::fromPoints(std::move(pts)); - } - // Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the - // seconds product defaults on z.play — a v4 blob cannot exist outside this branch. - if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest - map.zones.push_back(std::move(z)); - } -} - -} // namespace - -std::vector serializePerformance(const PerformanceMap& map) { - std::vector out; - putU32le(out, kPerformanceStateVersion); - putZonesPayload(out, map); - return out; -} - -PerformanceMap deserializePerformance(const std::vector& bytes, - double projectRate) { - // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. - // For v5 and later blobs it is unused. The assert inside readZonesPayload fires if a v3 - // blob is encountered with an invalid rate — the calller guarantees a real rate before use. - 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 S4 single-selection format (version 1 + id bytes, - // no length prefix). Lift it to one full-keyboard zone playing that id. - if (version == kSelectionStateVersion) { - const std::string id = deserializeSelection(bytes); - if (!id.empty()) { - PerformanceZone z; - z.sampleId = id; - z.lowNote = 0; - z.highNote = 127; - map.zones.push_back(std::move(z)); - } - return map; - } - if (version != kPerformanceStateVersion) return map; // unknown -> empty - - readZonesPayload(r, map, projectRate); - return map; -} - -// --- Combined component state (v3, S10) -------------------------------------- - -std::vector serializeComponentState(const ComponentState& state) { - std::vector out; - putU32le(out, kComponentStateVersion); - // v4 envelope addition: the channel mode (0 = mono, 1 = stereo) precedes the v3 body. - out.push_back(state.channelMode == ChannelMode::Stereo ? 1 : 0); - // v5 envelope addition (S8/S9 reader): the last-consumed assignment generation, 8-byte LE - // two's-complement, precedes the selection id. Follows the mode byte so a v4 reader that - // stops at the mode byte is a strict prefix (see the v4 lift below). - putU64le(out, asU64(state.lastConsumedAssignGeneration)); - // v6 envelope addition (S-VIEW-4): the preview-trigger velocity, 1 byte (MIDI 1..127). Follows - // the marker so a v5 blob is a strict prefix of a v6 blob up to this byte (see the v5 lift). - out.push_back(state.previewVelocity); - // v7 envelope addition (Phase S voice system): voice count (1..32), voice mode (0 = Poly, - // 1 = Mono), mono trigger (0 = Retrigger, 1 = Legato) — one byte each, following the - // velocity byte so a v6 blob is a strict prefix up to here (see the v6 lift). - const int vc = state.voiceCount < kMinVoiceCount ? kDefaultVoiceCount - : state.voiceCount > kMaxVoiceCount ? kMaxVoiceCount - : state.voiceCount; - out.push_back(static_cast(vc)); - out.push_back(state.voiceMode == VoiceMode::Mono ? 1 : 0); - out.push_back(state.monoTrigger == MonoTrigger::Legato ? 1 : 0); - // v8 envelope addition (FB1 master gain): the post-mixer LINEAR gain as an IEEE-754 double - // (bit-cast to u64 LE), following the voice bytes so a v7 blob is a strict prefix up to - // here (see the v7 lift). The WRITER never emits an out-of-range value: non-finite or - // negative falls back to unity; above the +24 dB cap clamps to the cap. - { - double g = state.masterGainLinear; - const double maxLin = masterGainMaxLinear(); - if (!std::isfinite(g) || g < 0.0) g = 1.0; - if (g > maxLin) g = maxLin; - putU64le(out, doubleToBits(g)); - } - // v9 envelope addition (GA channel-mode auto-default): the channel-mode-EXPLICIT flag, - // 1 byte, following the gain double so a v8 blob is a strict prefix up to here (see the - // v8 lift). 0 = implicit (the shell may auto-default the mode from the loaded capture's - // channel count); 1 = the user deliberately toggled the mode (never fought). - out.push_back(state.channelModeExplicit ? 1 : 0); - // v10 envelope addition (pS self-contained playback): the instance-owned sample-refs - // table, following the explicit flag so a v9 blob is a strict prefix up to here (see - // the v9 lift). Wire shape per kSelectionZonesRefsV10Version: entry count, then per - // entry id + path (length-prefixed), rootNote, loop (hasLoop + start/end, always - // written), channelCount, displayName (length-prefixed; display-only). - putU32le(out, static_cast(state.sampleRefs.size())); - for (const SampleRefEntry& e : state.sampleRefs) { - putU32le(out, static_cast(e.sampleId.size())); - out.insert(out.end(), e.sampleId.begin(), e.sampleId.end()); - putU32le(out, static_cast(e.ref.relativePath.size())); - out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end()); - putU32le(out, static_cast(static_cast(e.ref.rootNote))); - out.push_back(e.ref.loop.hasLoop ? 1 : 0); - putU64le(out, asU64(e.ref.loop.start)); - putU64le(out, asU64(e.ref.loop.end)); - putU32le(out, - static_cast(static_cast(e.ref.channelCount))); - putU32le(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, - // 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. - putU32le(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 — - // unlike the v1 selection blob where the id ran to end-of-stream). - putU32le(out, static_cast(state.selectionId.size())); - out.insert(out.end(), state.selectionId.begin(), state.selectionId.end()); - putZonesPayload(out, state.map); - return out; -} - -ComponentState deserializeComponentState(const std::vector& bytes, - double projectRate) { - // projectRate is only consumed by readZonesPayload when a LEGACY v3 payload is present. - // For v5 and later blobs it is unused. See readZonesPayload for the guard. - ComponentState out; - ByteReader r(bytes); - const std::uint32_t version = r.u32(); - if (!r.ok) return out; // no version tag -> empty (the S10 silent empty state) - - // BACK-COMPAT: an older blob predates the v3 {selection, zones} split. - // * v1 (S4 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 (S5 zones-only): restore {"", zones} — that instance had zones but no separate - // single-capture selection. - 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 (pre-S7) - } - // BACK-COMPAT: a v3 blob (pre-S7 {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) { - 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); - return out; // channelMode stays Mono, marker stays 0 (pre-S7/S8/S9) - } - // BACK-COMPAT: a v4 blob (pre-S8/S9 reader {mode, selection, zones}, no consumed marker): - // mode byte, then the id + zones body — no 8-byte marker. lastConsumedAssignGeneration - // defaults to 0, so a first assign still applies for a pre-marker instance. - if (version == kSelectionZonesModeV4Version) { - 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); - return out; // marker stays 0 (pre-S8/S9 reader) - } - // BACK-COMPAT: a v5 blob (pre-S-VIEW-4 {mode, marker, selection, zones}, no preview-velocity - // byte): mode byte, then the 8-byte marker, then the id + zones body — no velocity byte. - // previewVelocity defaults to kPreviewVelocityDefault (set at construction), so an already-saved - // pre-S-VIEW-4 instance restores at the mid default. - if (version == kSelectionZonesModeMarkerV5Version) { - 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; - out.lastConsumedAssignGeneration = r.i64(); - if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds) - 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); - return out; // previewVelocity stays at the mid default (pre-S-VIEW-4) - } - if (version != kComponentStateVersion && - version != kSelectionZonesRefsV10Version && - version != kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version && - version != kSelectionZonesModeMarkerVelVoiceGainV8Version && - version != kSelectionZonesModeMarkerVelVoiceV7Version && - version != kSelectionZonesModeMarkerVelV6Version) { - return out; // unknown -> empty - } - - // v6..v10 shared prefix: the channel-mode byte, then the 8-byte consumed-assignment marker, - // then the 1-byte preview velocity, precede the v3 body. A non-{0,1} mode byte is treated - // 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; - out.lastConsumedAssignGeneration = r.i64(); - if (!r.ok) return out; // truncated before/inside the marker -> empty (marker 0 holds) - const std::uint8_t previewVel = r.u8(); - if (!r.ok) return out; // truncated before the velocity byte -> empty (mid default holds) - // Clamp to the documented MIDI 1..127 range: a 0 byte (or any out-of-spec value from a - // corrupt blob) falls back to the mid default rather than silencing the preview trigger. - out.previewVelocity = (previewVel >= 1 && previewVel <= 127) - ? previewVel - : kPreviewVelocityDefault; - // v7+ (Phase S): the three voice-system bytes. A v6 blob (pre-Phase-S) skips them — the - // construction defaults {16, Poly, Retrigger} hold, reproducing pre-Phase-S behavior. - if (version >= kSelectionZonesModeMarkerVelVoiceV7Version) { - const std::uint8_t vc = r.u8(); - const std::uint8_t vm = r.u8(); - const std::uint8_t mt = r.u8(); - if (!r.ok) return out; // truncated inside the voice bytes -> empty (defaults hold) - // Out-of-range bytes fall back to the field's DEFAULT (the previewVelocity precedent - // for a corrupt blob) rather than clamping to an edge the user never chose. - out.voiceCount = (vc >= kMinVoiceCount && vc <= kMaxVoiceCount) - ? static_cast(vc) - : kDefaultVoiceCount; - out.voiceMode = (vm == 1) ? VoiceMode::Mono : VoiceMode::Poly; - out.monoTrigger = (mt == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger; - } - // v8+ (FB1): the master-gain LINEAR double. A v7 blob (pre-FB1) skips it — the construction - // default (unity) holds, reproducing pre-FB1 output exactly. A non-finite, negative, or - // above-cap value (a corrupt blob) falls back to unity rather than silencing/blasting. - if (version >= kSelectionZonesModeMarkerVelVoiceGainV8Version) { - 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 - out.masterGainLinear = - (std::isfinite(g) && g >= 0.0 && g <= masterGainMaxLinear() * (1.0 + 1e-9)) - ? g - : 1.0; - } - // v9 (GA): 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 - // un-touched default and the shell may auto-default it from the loaded capture. - if (version >= kSelectionZonesModeMarkerVelVoiceGainExplicitV9Version) { - const std::uint8_t explicitByte = r.u8(); - if (!r.ok) return out; // truncated before the flag -> empty (implicit holds) - out.channelModeExplicit = (explicitByte == 1); - } - // v10 (pS self-contained playback): 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) { - const std::uint32_t refCount = r.u32(); - for (std::uint32_t i = 0; i < refCount && r.ok; ++i) { - SampleRefEntry e; - const std::uint32_t refIdLen = r.u32(); - e.sampleId = r.str(refIdLen); - const std::uint32_t pathLen = r.u32(); - e.ref.relativePath = r.str(pathLen); - // Range fallbacks (the refs table is the ONLY copy on the play path, so a - // corrupt field must degrade to the field's default, never poison playback — - // the previewVelocity/voiceCount posture): an out-of-MIDI-range root falls back - // to the middle-C default distill() uses; a negative channel count falls back - // to 0 = unknown (the GA auto-default then skips it). - const std::int32_t root = r.i32(); - e.ref.rootNote = (root >= 0 && root <= 127) ? root : 60; - e.ref.loop.hasLoop = (r.u8() != 0); - e.ref.loop.start = r.i64(); - e.ref.loop.end = r.i64(); - const std::int32_t channels = r.i32(); - e.ref.channelCount = channels >= 0 ? channels : 0; - const std::uint32_t nameLen = r.u32(); - e.displayName = r.str(nameLen); - if (!r.ok) break; // truncated mid-entry -> keep what parsed, drop the rest - out.sampleRefs.push_back(std::move(e)); - } - if (!r.ok) return out; - } - // v11 (pS-usage): 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) { - const std::uint32_t guidLen = r.u32(); - out.instanceGuid = r.str(guidLen); - if (!r.ok) { out.instanceGuid.clear(); return out; } // truncated -> empty - } - 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); - return out; -} - -std::vector serializeSelection(const std::string& sampleId) { - std::vector out; - out.resize(4 + sampleId.size()); - const std::uint32_t v = kSelectionStateVersion; - out[0] = static_cast(v & 0xFF); - out[1] = static_cast((v >> 8) & 0xFF); - out[2] = static_cast((v >> 16) & 0xFF); - out[3] = static_cast((v >> 24) & 0xFF); - std::memcpy(out.data() + 4, sampleId.data(), sampleId.size()); - return out; -} - -std::string deserializeSelection(const std::vector& bytes) { - if (bytes.size() < 4) return {}; // no version tag -> no selection - const std::uint32_t v = static_cast(bytes[0]) | - (static_cast(bytes[1]) << 8) | - (static_cast(bytes[2]) << 16) | - (static_cast(bytes[3]) << 24); - if (v != kSelectionStateVersion) return {}; // unknown version -> ignore - return std::string(reinterpret_cast(bytes.data() + 4), - bytes.size() - 4); -} - -} // namespace reasampler +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 76764b6..cf06733 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -27,10 +27,10 @@ #include "core/instrument/engine/sampler_core.h" // Keymap, SampleData, SampleLoop #include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) -namespace reasampler { +namespace reasampler::instrument::map { -// Q-W1 interim: clean deps live in their sub-namespace homes now; sample_map -// re-namespaces in its own split wave (Q-W2v). +// Cross-subsystem deps by their real namespace homes (Q-W2v: sample_map now lives in +// instrument::map; the engine family stays in flat `reasampler` until its own wave). using audio::AudioSample; using instrument::engine::VelocityCurve; using instrument::engine::VelocityPoint; @@ -413,302 +413,10 @@ Keymap buildZonedKeymap(const std::vector& zones, DecodedZonePcm decodeChannels(const std::vector& interleaved, int sourceChannels, ChannelMode mode, int sampleRate); -// --- Performance-map instance state (VST3 setState/getState) ----------------- -// -// The performance map is the instrument's OWN state (D-B), serialized to the VST3 -// component-state IBStream — NOT written to the "reasampler" bank ext-state (the -// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of -// truncation/wrong-version by design (bounded reads, never throws across the host). -// -// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the -// ZONES PAYLOAD. -// -// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones -// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides) -// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState -// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the -// key composition property: the zone-record extension is versioned inside the map blob, not on -// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not -// collide on a single version number. -// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 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 (0/1), 4-byte LE rootOverride (present iff hasRootOverride). -// A payload starting with a small u32 (the zone count) is v1 — there is no marker. -// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone -// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended -// to each zone record after rootOverride: -// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start, -// 8-byte LE loop.end (both two's-complement int64); -// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64). -// The reader detects the marker to know the record shape — a v1 payload (no marker) reads -// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope. -// * PAYLOAD v3 (S15/S16, LEGACY — exists in Daniel's beta projects): the same marker + payload -// version (== 3), THEN the v2 body PLUS, appended to each zone record after the S11 startPoint -// tail (the S15/S16 per-zone play params — always present, NOT flag-gated): -// 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdFrames (int64) — the S15 AHDSR hold stage, FRAMES at 44.1k nominal; -// 8-byte LE trigger.lengthFraction as an IEEE-754 double (bit-cast to u64 LE); -// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); -// 1 byte pitchEngine (0 = Varispeed, 1 = Preserve); -// 1 byte pitchEnv.enabled (0/1); 8-byte LE pitchEnv.attackFrames (int64, FRAMES 44.1k nom); -// 8-byte LE pitchEnv.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 + disabled pitch env) — the deliberate S16-F1 behavior change for already-saved -// instruments. A truncated mid-v3-tail record keeps the zones that parsed and drops the rest. -// LEGACY-READ CONVERSION (S12): the v3 wall-clock frame counts (hold, pitchEnv A/D) were ALWAYS -// written by the S15/S16 editor as nominal frames at a baked-in rate. They convert to the seconds -// domain by dividing by the PROJECT sample rate threaded into the v3 lift path at read time (passed -// as a parameter — no constant). Source-timeline fields (trigger %-length + fades) stay frames. -// A/D/S/R are absent in v3 -> lifted to the tier-0 seconds defaults (0.003 / 0 / 1.0 / 0.060). -// * PAYLOAD v5 (S12 remediation — CURRENT WRITE FORMAT): the same marker + payload version (== 5), -// THEN the v2 body PLUS, appended to each zone record after the S11 startPoint tail, the full -// per-zone play params with WALL-CLOCK TIMES STORED AS SECONDS (rate-free, IEEE-754 doubles): -// 1 byte playMode (0 = Gate, 1 = Trigger); -// 8-byte LE adsr.holdSeconds (double); 8-byte LE trigger.lengthFraction (double); -// 8-byte LE trigger.fadeInFrames (int64); 8-byte LE trigger.fadeOutFrames (int64); -// 1 byte pitchEngine; 1 byte pitchEnv.enabled; -// 8-byte LE pitchEnv.attackSeconds (double); 8-byte LE pitchEnv.decaySeconds (double); -// 8-byte LE pitchEnv.peakSemitones (double); -// 8-byte LE adsr.attackSeconds (double); 8-byte LE adsr.decaySeconds (double); -// 8-byte LE adsr.sustainLevel (double); 8-byte LE adsr.releaseSeconds (double). -// Trigger fades stay int64 SOURCE frames (a source-timeline fact, PLAN.md §S15). PAYLOAD v4 -// (the branch-only frames-tail) was NEVER shipped and is intentionally dropped from the reader -// — a v4 blob cannot exist outside this branch. The keymap builders resolve the 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 S4 single-selection format: version tag 1 + id bytes) is -// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved -// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes -// to an EMPTY map. -// -// These two functions serialize the ZONES only. Since S10 the instrument's full component -// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState -// below, the v3 format the processor actually reads/writes. serializePerformance/ -// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift. +// The ComponentState envelope + zones-payload binary codec (serializePerformance / +// serializeComponentState / serializeSelection + the deserializers and every version +// constant) lives in component_state_io.h (Q-W2v split, T4-13 ≡ T2-07): the codec grows +// on every envelope bump and is consumed by the EXTENSION's preset-blob path too — the +// split lets both artifacts share the codec while only the VST links the voice engine. -inline constexpr std::uint32_t kPerformanceStateVersion = 2; - -// The zones-payload format version and its detection marker (S11/S15/S16/S12/S-VIEW-6/S-VIEW-9). -// serializePerformance and serializeComponentState both emit the CURRENT payload version (v7 — -// marker + version + records with the S11 loop/start tail, the full play-params tail with wall-clock -// times in SECONDS, the v6 keyTrack scalar, and the v7 velocity->amp curve) so the overrides -// round-trip through EITHER envelope. Readers accept a v1 payload (no marker), a v2 payload (marker + -// version 2, no play tail), and a v3 payload (legacy S15/S16 play tail with 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 that a legitimate zone count (bounded by 128 MIDI zones in practice, -// always tiny) can never collide with. -// * PAYLOAD v6 (S-VIEW-6): identical to v5, PLUS one field appended to each zone record after the -// full v5 play-params tail: -// 8-byte LE keyTrack (IEEE-754 double) — the per-zone key-tracking scalar (1.0 = 100% ET). -// A v1–v5 payload (no keyTrack field) lifts every zone to keyTrack = 1.0 (the PerformanceZone -// default), so already-saved instances are BIT-IDENTICAL — the 100% default reproduces the -// pre-S-VIEW-6 repitch exactly. A truncated mid-keyTrack record keeps the zones that parsed. -// * PAYLOAD v7 (S-VIEW-9 — CURRENT WRITE FORMAT): identical to v6, PLUS the per-zone velocity->amp -// transfer curve appended to each zone record after the v6 keyTrack field: -// 4-byte LE control-point count N, then per point: 8-byte LE velocity (double), 8-byte LE amp -// (double). 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() (R10-F1 -// Option A — flat y=1). This is a DELIBERATE, Daniel-approved NON-back-compat behavior change: -// an already-saved zone's soft hits play LOUDER than under the pre-r10 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; // S-VIEW-9: + per-zone velocity->amp curve -inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u; - -// (No kLegacyV3NominalRate constant.) The legacy v3 zone payload's wall-clock frame counts are -// converted to seconds at the v3 read boundary using the PROJECT sample rate threaded in as a -// parameter — frames ÷ projectRate = seconds. The project rate is the same rate keymap 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 — S10) ------------- -// -// Since S10 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, per the S10 policy reversal, an -// instance with NO pick and NO zones restores EMPTY (silence + the "pick a capture" empty -// state), never auto-playing sample #1. -// -// Format (envelope v10): 4-byte LE version tag (== 10), then a 1-byte channel-mode field (0 = mono, -// 1 = stereo), then an 8-byte LE last-consumed-assignment generation (S8/S9 reader marker), then a -// 1-byte preview-trigger velocity (S-VIEW-4, MIDI 1..127), then the THREE Phase-S voice-system -// bytes: a 1-byte voice count (1..32), a 1-byte voice mode (0 = Poly, 1 = Mono), a 1-byte mono -// trigger (0 = Retrigger, 1 = Legato), then the FB1 8-byte LE master-gain LINEAR value (IEEE-754 -// double, bit-cast; 0.0 = -inf/silence, 1.0 = unity, cap ~15.849 = +24 dB), then the GA 1-byte -// channel-mode-EXPLICIT flag (0 = implicit/auto-default, 1 = the user deliberately toggled the -// mode — see ComponentState::channelModeExplicit), then the pS SAMPLE-REFS table (v10 — the -// instance-owned path + intrinsics + display name per referenced sample; wire shape at -// kSelectionZonesRefsV10Version below), then the pS-usage INSTANCE GUID (v11 — a 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), then a 4-byte LE -// selection-id length + id bytes, then the CURRENT zones payload (identical to -// serializePerformance's body — its own self-describing version, see the ZONES-PAYLOAD block). -// The instance guid is the ONLY envelope-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 (the previewVelocity precedent). BACK-COMPAT on read (every older blob lifts to -// channelMode = MONO, lastConsumedAssignGeneration = 0, previewVelocity = -// kPreviewVelocityDefault, the Phase-S voice defaults {16 voices, Poly, Retrigger}, unity -// master gain, channelModeExplicit = FALSE — a pre-v9 mode byte is treated as the -// un-touched default, so the GA auto-default may follow the loaded capture; 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 (pre-pS-usage), which the shell re-mints on first publish): -// * v11 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, explicit, sampleRefs, instanceGuid, selectionId, zones} direct. -// * v10 blob -> the v11 fields minus instanceGuid (empty — minted on first publish): pre-pS-usage. -// * v9 blob -> the v10 fields minus sampleRefs (empty table): pre-pS (bridge-resolve lift). -// * v8 blob -> {channelMode, marker, previewVelocity, voice bytes, masterGainLinear, selectionId, zones}: pre-GA (implicit mode). -// * v7 blob -> {channelMode, marker, previewVelocity, voiceCount, voiceMode, monoTrigger, selectionId, zones}: pre-FB1 (unity master gain). -// * v6 blob -> {channelMode, marker, previewVelocity, selectionId, zones}: pre-Phase-S (voice defaults). -// * v5 blob -> {channelMode, lastConsumedAssignGeneration, mid, selectionId, zones}: pre-S-VIEW-4 (no velocity). -// * v4 blob -> {channelMode, 0, mid, selectionId, zones}: pre-S8/S9 reader (no marker). -// * v3 blob -> {mono, 0, mid, selectionId, zones}: pre-S7 had no channel mode. -// * v2 blob -> {mono, 0, mid, "", zones}: an S5 instance had zones but no separate selection. -// * v1 blob -> {mono, 0, mid, id, one full-keyboard zone}: the S4 single-selection lift. -// * empty/unknown -> {mono, 0, mid, "", no zones}: EMPTY (the S10 silent empty state). -// -// WHY THE MARKER PERSISTS (S8 reader requirement). The last-consumed assignment generation is -// the disambiguator that 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 (D-B), never written to the -// bank — the extension owns the assign_request key; the instrument only tracks what it consumed. -// The preview-trigger velocity default (S-VIEW-4): a mid MIDI velocity. An older blob with no -// velocity byte lifts to this, and a fresh instance starts here — an audible-but-not-hot default. -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 - ChannelMode channelMode = ChannelMode::Mono; // S7 decode mode; default mono (D-E) - // GA (v9): 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 count on reload (stereo capture -> Stereo, mono -> Mono); once true, the - // user's choice is never fought. Pre-v9 blobs lift to false (implicit). - bool channelModeExplicit = false; - std::int64_t lastConsumedAssignGeneration = 0; // S8/S9: last assign_request generation consumed - // S-VIEW-4 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. Defaults to kPreviewVelocityDefault. - std::uint8_t previewVelocity = kPreviewVelocityDefault; - // Phase S voice system: PER-INSTANCE performance choices (siblings of channelMode, NOT - // per-zone). Defaults {16, Poly, Retrigger} reproduce pre-Phase-S 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 - // FB1 (Wave B) post-mixer master gain, stored LINEAR (0.0 = -inf/true silence; 1.0 = unity; - // up to ~15.849 = +24 dB — the master_gain module owns the dB taper). PER-INSTANCE output - // trim applied by process() AFTER the voice sum (engine + drain + preview) — never per - // voice, never a keymap fact. Default unity reproduces pre-FB1 output byte-identically, - // so an older blob lifting to 1.0 plays exactly as it did. - double masterGainLinear = 1.0; - // pS self-contained playback (v10): 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). - SampleRefs sampleRefs; - // pS-usage (v11): 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 the key is stable across sessions (records do not proliferate - // per reopen). Empty = never published (a fresh or pre-v11 instance); the processor - // mints one on first publish, and RE-mints when the publish plan detects this state - // was cloned onto another track (FX copy / track duplication — planUsagePublish). - std::string instanceGuid; -}; - -inline constexpr std::uint32_t kComponentStateVersion = 11; - -// The pS-usage combined-state version (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; - -// The pS self-contained combined-state version (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: 4-byte LE id length + id bytes, 4-byte LE -// path length + path bytes, 4-byte LE rootNote (two's-complement), 1 byte loop.hasLoop, -// 8-byte LE loop.start + 8-byte LE loop.end (two's-complement int64, written regardless of -// hasLoop), 4-byte LE channelCount (two's-complement), 4-byte LE displayName length + -// displayName bytes (display-only; the editor label's extension-absent fallback). -inline constexpr std::uint32_t kSelectionZonesRefsV10Version = 10; - -// The pre-GA combined-state version (everything through the FB1 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; - -// The GA combined-state version (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; - -// The pre-FB1 combined-state version (selection + zones + 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; - -// The pre-Phase-S combined-state version (selection + zones + 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; - -// The pre-S-VIEW-4 combined-state version (selection + zones + 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; - -// The pre-S8/S9-reader combined-state version (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; - -// The pre-S7 combined-state version (selection + zones, no channel mode). Retained as a named -// constant so deserializeComponentState can lift a v3 blob to {mono, selection, zones}. -inline constexpr std::uint32_t kSelectionZonesV3Version = 3; - -// The full instance state serialized to bytes for IBStream (getState). -std::vector serializeComponentState(const ComponentState& state); - -// The full instance state parsed back from IBStream bytes (setState). Tolerant of -// truncation/wrong-version (bounded reads, never throws); older blobs lift per the table -// above so already-saved instances restore cleanly. -// `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. -ComponentState deserializeComponentState(const std::vector& bytes, - double projectRate); - -// --- Instance state (VST3 setState/getState) -------------------------------- -// -// The instrument's OWN state is which bank sample it plays (D-B: the selection is a -// performance choice, held by the instrument, never written back to the bank). It is a -// single string id. serialize/deserialize keep the on-the-wire form explicit and -// versioned so a future Tier can extend it without breaking already-saved instances. -// -// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No -// length prefix is needed — the id runs to the end of the stream (the host tells us the -// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob -// by returning "" (no selection — under the S10 policy reversal an empty 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; the processor's live state is the v3 ComponentState above. - -inline constexpr std::uint32_t kSelectionStateVersion = 1; - -// The selected-sample id serialized to bytes for IBStream (getState). -std::vector serializeSelection(const std::string& sampleId); - -// The selected-sample id parsed back from IBStream bytes (setState). Unknown version, -// too-short, or empty -> "" (graceful no-selection). -std::string deserializeSelection(const std::vector& bytes); - -} // namespace reasampler +} // namespace reasampler::instrument::map diff --git a/src/core/instrument/ui/browser_scroll.cpp b/src/core/instrument/ui/browser_scroll.cpp index 3a7f9ad..c88ef1b 100644 --- a/src/core/instrument/ui/browser_scroll.cpp +++ b/src/core/instrument/ui/browser_scroll.cpp @@ -3,6 +3,8 @@ #include "core/instrument/ui/browser_scroll.h" +#include "core/instrument/ui/editor_geometry.h" // kPad / kTitleHeight / kNavButtonWidth (Q-W2v hoist) + #include #include @@ -155,4 +157,25 @@ std::vector filterNameIndices(const std::vector& names, return out; } +// The Browse-modal regions (hoisted from the editor shell, Q-W2v/T2-06 — body verbatim; +// the band metrics come from editor_geometry, the search height from searchBoxRect). +BrowseModal computeBrowseModal(int w, int h) { + constexpr int kBrowseFooterH = 30; + BrowseModal m; + const int titleH = (std::min)(kTitleHeight, h); + m.title = Rect::ltrb(0, 0, w, titleH); + m.back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2)); + // Search box below the title, spanning the width (searchBoxRect lays it out from 0). + const Rect sb = searchBoxRect(w); + m.search = Rect::ltrb(kPad, titleH, w - kPad, titleH + sb.height); + const int footerTop = (std::max)(m.search.bottom(), h - kBrowseFooterH); + m.content = Rect::ltrb(0, m.search.bottom(), w, footerTop); + // Footer: Cancel (left) + Load (right). + const int fTop = footerTop + 3; + const int fBot = (std::max)(fTop, h - 3); + m.cancel = Rect::ltrb(kPad, fTop, kPad + 90, fBot); + m.confirm = Rect::ltrb(w - kPad - 90, fTop, w - kPad, fBot); + return m; +} + } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/browser_scroll.h b/src/core/instrument/ui/browser_scroll.h index 641d409..566ee69 100644 --- a/src/core/instrument/ui/browser_scroll.h +++ b/src/core/instrument/ui/browser_scroll.h @@ -104,4 +104,21 @@ bool nameMatchesQuery(const std::string& name, const std::string& query); std::vector filterNameIndices(const std::vector& names, const std::string& query); +// --- The Browse-modal (S-VIEW-5) top-level regions (Q-W2v hoist, T2-06) ------- +// +// A title band with a Back button, the search box, the browser sub-area (tabs + card +// grid — layoutBrowser's origin), and a footer with Cancel / Load-confirm. The picker +// covers the full window (F3: full-window overlay). Draw + hit-test both derive from +// this single layout so they never drift. Homed here (not editor_geometry) because the +// search-box height feeds it — browser_scroll already owns the search/scroll geometry. +struct BrowseModal { + Rect title; + Rect back; // the "Back" title-band button + Rect search; // the type-to-filter box (absolute) + Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin + Rect cancel; // footer Cancel + Rect confirm; // footer Load (confirm) +}; +BrowseModal computeBrowseModal(int w, int h); + } // namespace reasampler::instrument::ui diff --git a/src/core/instrument/ui/editor_geometry.cpp b/src/core/instrument/ui/editor_geometry.cpp index 90ccfe5..4cbc809 100644 --- a/src/core/instrument/ui/editor_geometry.cpp +++ b/src/core/instrument/ui/editor_geometry.cpp @@ -159,4 +159,150 @@ bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y) { return contains(layout.addZoneButton, x, y); } +// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- +// Bodies moved verbatim from the reasampler_editor shell (behavior-identical); the +// only signature change is clusterRects' `knobSize` parameter (formerly knob_deck's +// kDeckKnobSize read directly — passed in so this module stays knob_deck-free). + +namespace { + +// Fixed band metrics (formerly the editor shell's anon-ns constants). +constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11) +constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle +constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip) + +// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial +// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo). +constexpr int kPreviewBtnW = 64; +constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) +constexpr int kCurveBtnSize = 28; // the square curve-preview button + +// The S7 mono/stereo toggle segments. +constexpr int kChanSegW = 52; +constexpr int kChanSegH = 18; + +} // namespace + +// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed +// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- +// anchored). When the window is too short for the floor (below the checkSizeConstraint +// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the +// window bottom gracefully. +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; +} + +// Draw + hit-test both derive from this ONE formula. +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()); +} + +// Zone content sits below the "+ Add Zone" affordance (top+4, height 20) with a 12px +// gap, padded kPad horizontally. All call sites use this formula. +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 +// without re-inlining the strip arithmetic here. +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); +} + +// FB2 (R11-F2 parity): the deck lays out from the panel top (top-anchored), with a +// column at the panel's right reserved for the mini 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 a0c7ff9..b1e8239 100644 --- a/src/core/instrument/ui/editor_geometry.h +++ b/src/core/instrument/ui/editor_geometry.h @@ -139,4 +139,86 @@ ZoneHit zoneHitTest(const KeymapEditorLayout& layout, int zoneCount, int x, int // True if (x, y) lands on the "Add Zone" button. Pure. bool addZoneHitTest(const KeymapEditorLayout& layout, int x, int y); +// --- r11 Sample / Zone face layout (Q-W2v hoist, T2-06) ---------------------- +// +// The capture-first editor's band/cluster/zone-surface layout math, hoisted out of the +// reasampler_editor shell where it had accreted untestable (the §2 scope gap). Draw and +// hit-test both derive every rect from these ONE 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 — dependency-clean placement beside its scroll/search siblings). + +// Shared band metrics (the shell's remaining direct uses: horizontal padding + the +// title-band height; everything else is internal to the layout functions below). +inline constexpr int kPad = 8; +inline constexpr int kTitleHeight = 26; +inline constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons + +// The r11 Sample-face bands (top->bottom): a TITLE band (name + Browse/Zone nav), the +// FULL-WIDTH ELASTIC HERO (absorbs all height left after the fixed bands, floor +// kHeroMinHeight), the ROOT + PREVIEW CLUSTER, and the bottom-anchored KNOB DECK +// (height `deckH` from the pure knob_deck wrap). When the window is too short for the +// hero floor (below the checkSizeConstraint minimum — defensive), the hero keeps its +// floor and the lower bands clip past the window bottom gracefully. +struct SampleBands { + Rect title; // top: name + Browse/Zone nav buttons + Rect navBrowse; // the "Browse" title-band button + Rect navZone; // the "Zone" title-band button + Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay + Rect cluster; // root strip + preview + vel knob + curve button + channel toggle + Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap) +}; +SampleBands computeSampleBands(int w, int h, int deckH); + +// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the +// right side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve +// preview button 28 · Mono|Stereo). `knobSize` is the deck knob square (knob_deck's +// kDeckKnobSize — passed in so this module does not depend on knob_deck). +struct ClusterRects { + Rect rootStrip; // remainder-width fenced root strip + Rect preview; // the preview-trigger button + Rect velCell; // the radial preview-velocity knob cell (knob + label band) + Rect velKnob; // the knob square at the cell's top + Rect velLabel; // the label band beneath it + Rect curveBtn; // the mini curve-preview button (opens the popup) +}; +ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono, int knobSize); + +// The S7 mono/stereo toggle: a two-segment control right-anchored in `area`, vertically +// centered. Returns {mono-segment, stereo-segment}, side by side. +struct ChannelToggleRects { + Rect mono; + Rect stereo; +}; +ChannelToggleRects channelToggleRects(const Rect& area); + +// The Zone-view (S-VIEW-8) content area: the whole window below the title band. +Rect zoneContentArea(int w, int h); + +// The Zone/Browse "Back" title-band button (right-anchored — the same slot the Sample +// face's Zone nav button occupies). +Rect zoneBackRect(int w, int h); + +// The "+ Add Zone" affordance at the top of the Zone content, 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); + +// The Zone-view keyboard strip rect: below the "+ Add Zone" affordance with a 12px gap, +// padded kPad horizontally. +Rect zonesStripArea(const Rect& content); + +// The S12 numeric-entry field ROW area inside the Zones legend (a band to the right of +// the sample label), and the rect of field `f` (0=low, 1=high, 2=root) within it — +// three equal segments left-to-right. An out-of-range index yields an empty rect. +Rect noteEntryFieldsArea(const Rect& content); +Rect noteEntryFieldRect(const Rect& fields, int f); + +// The per-zone parameter panel below the strip + the one-line legend, running to the +// content bottom; the FB2 knob-deck area within it (a column at the right reserved for +// the mini curve-preview button); and that button's rect (the cluster's 28px square, +// 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/wire/bytes.h b/src/core/wire/bytes.h new file mode 100644 index 0000000..db4cff8 --- /dev/null +++ b/src/core/wire/bytes.h @@ -0,0 +1,110 @@ +// core/wire/bytes.h — the ONE little-endian byte codec (Q-W2v; audit T4-20). +// Pure, header-only: standard library only — NO REAPER, NO SWELL, NO VST3. +// +// Five hand-rolled LE copies existed at the Q-W0 census (sample_map's +// putU32le/putU64le + ByteReader, capture_realtime's writeU32LE, capture_paths' +// readU32LE lambda, ingest's putU32 lambda, instrument_drop's appendU32LE). This +// template is the single survivor: compile-time dispatched, zero runtime cost, +// entirely off hot paths (serialization / file I/O only). The ComponentState +// codec (component_state_io) is its biggest consumer; the remaining hand-rolled +// copies rewire opportunistically in the waves that already open their files. +// +// Wire formats are FROZEN: putLE/putLE emit exactly the bytes the +// retired putU32le/putU64le emitted (LSB first, fixed width), and ByteReader +// preserves the latch-on-truncation contract (once a read runs past the end, +// ok latches false and every subsequent read yields zeros/empties — a truncated +// blob degrades to a partial parse, never out-of-bounds). + +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace reasampler::wire { + +// Append `v` little-endian (LSB first, sizeof(T) bytes). Unsigned integral types +// only — signed values go on the wire as their two's-complement unsigned image +// (cast at the call site, the established idiom: u32 for int, u64 for int64). +template +inline void putLE(std::vector& out, T v) { + static_assert(std::is_unsigned_v, "putLE takes the unsigned wire image"); + for (std::size_t b = 0; b < sizeof(T); ++b) { + out.push_back(static_cast((v >> (b * 8)) & 0xFF)); + } +} + +// IEEE-754 double <-> u64 bit-cast for the wire (memcpy is the only defined +// type-pun in C++17). Doubles ride the wire as their u64 bit image via putLE. +inline std::uint64_t doubleToBits(double d) { + std::uint64_t bits; + std::memcpy(&bits, &d, sizeof(bits)); + return bits; +} +inline double bitsToDouble(std::uint64_t bits) { + double d; + std::memcpy(&d, &bits, sizeof(d)); + return d; +} + +// A bounded little-endian reader over a byte blob. Every read is length-checked; +// once a read runs past the end the reader latches `ok=false` and yields zeros, +// so a truncated blob degrades to a partial/empty parse rather than reading out +// of bounds. (The class formerly private to sample_map.cpp, promoted here as the +// codec's tested primitive — T4-20.) +struct ByteReader { + const std::vector& bytes; + std::size_t pos = 0; + bool ok = true; + + explicit ByteReader(const std::vector& b) : bytes(b) {} + + // Read one unsigned integral little-endian (fixed sizeof(T) width). + template + T readLE() { + static_assert(std::is_unsigned_v, "readLE yields the unsigned wire image"); + if (!ok || pos + sizeof(T) > bytes.size()) { + ok = false; + return 0; + } + T v = 0; + for (std::size_t b = 0; b < sizeof(T); ++b) { + v |= static_cast(bytes[pos + b]) << (b * 8); + } + pos += sizeof(T); + return v; + } + + std::uint8_t u8() { return readLE(); } + std::uint32_t u32() { return readLE(); } + std::uint64_t u64() { return readLE(); } + // Signed ints ride the wire as fixed-width two's-complement unsigned images. + int i32() { return static_cast(static_cast(u32())); } + std::int64_t i64() { return static_cast(u64()); } + + std::string str(std::uint32_t len) { + if (!ok || pos + len > bytes.size()) { + ok = false; + return {}; + } + std::string s(reinterpret_cast(bytes.data() + pos), len); + pos += len; + return s; + } + + // Non-consuming peek of the next u32 (format-marker probes). Yields 0 and + // latches nothing when fewer than 4 bytes remain — the caller treats a short + // blob as "no marker" and falls through to its (also-guarded) fallback read. + std::uint32_t peekU32() const { + if (!ok || pos + 4 > bytes.size()) return 0; + return static_cast(bytes[pos]) | + (static_cast(bytes[pos + 1]) << 8) | + (static_cast(bytes[pos + 2]) << 16) | + (static_cast(bytes[pos + 3]) << 24); + } +}; + +} // namespace reasampler::wire diff --git a/src/core/wire/instrument_drop.cpp b/src/core/wire/instrument_drop.cpp index e475830..78c1b0a 100644 --- a/src/core/wire/instrument_drop.cpp +++ b/src/core/wire/instrument_drop.cpp @@ -7,25 +7,19 @@ #include #include "core/wire/reasampler_uid.h" // REASAMPLER_ACTIVE_UID_* — the FROZEN, channel-selected class UID -#include "core/instrument/map/sample_map.h" // ComponentState + serializeComponentState (the SHARED writer) +#include "core/instrument/map/component_state_io.h" // ComponentState + serializeComponentState (the SHARED writer, Q-W2v codec split) +#include "core/wire/bytes.h" // putLE — the ONE LE byte codec (T4-20) namespace reasampler::wire { +using instrument::map::ComponentState; +using instrument::map::serializeComponentState; + namespace { -// Little-endian appenders — the .vstpreset container stores its integers little-endian on -// disk (public.sdk vstpresetfile.cpp swaps only on big-endian hosts). -void appendU32LE(std::vector& out, std::uint32_t v) { - out.push_back(static_cast(v & 0xFF)); - out.push_back(static_cast((v >> 8) & 0xFF)); - out.push_back(static_cast((v >> 16) & 0xFF)); - out.push_back(static_cast((v >> 24) & 0xFF)); -} - -void appendU64LE(std::vector& out, std::uint64_t v) { - for (int i = 0; i < 8; ++i) - out.push_back(static_cast((v >> (8 * i)) & 0xFF)); -} +// The .vstpreset container stores its integers little-endian on disk (public.sdk +// vstpresetfile.cpp swaps only on big-endian hosts) — putLE (core/wire/bytes.h) is +// exactly that byte order; the former appendU32LE/appendU64LE copies are retired (T4-20). void appendFourCC(std::vector& out, const char id[4]) { out.insert(out.end(), id, id + 4); @@ -58,19 +52,19 @@ std::vector buildVstPresetBytes( out.reserve(static_cast(listOffset) + 4 + 4 + (4 + 8 + 8)); appendFourCC(out, "VST3"); - appendU32LE(out, 1); // kFormatVersion + putLE(out, 1); // kFormatVersion out.insert(out.end(), classIdHex32.begin(), classIdHex32.end()); - appendU64LE(out, listOffset); + putLE(out, listOffset); // Data area: the one 'Comp' chunk's bytes, at offset kHeaderSize. out.insert(out.end(), componentState.begin(), componentState.end()); // Chunk list: 'List' + entry count + one entry {'Comp', offset, size}. appendFourCC(out, "List"); - appendU32LE(out, 1); + putLE(out, 1); appendFourCC(out, "Comp"); - appendU64LE(out, kHeaderSize); - appendU64LE(out, compSize); + putLE(out, kHeaderSize); + putLE(out, compSize); return out; } diff --git a/src/shell/instrument/editor_controls.cpp b/src/shell/instrument/editor_controls.cpp new file mode 100644 index 0000000..56892de --- /dev/null +++ b/src/shell/instrument/editor_controls.cpp @@ -0,0 +1,397 @@ +// editor_controls.cpp — the ReaSamplerEditor's PARAMETER PLUMBING (Q-W2v split of +// reasampler_editor.cpp, T4-11): the control-value domain maps (controlValue / +// applyControl — seconds/fraction/frames <-> normalized 0..1), the r11 knob-deck +// group descriptors + control-id<->value binding, the S-VIEW-3 envelope pack/unpack +// (the TRIGGER SEAM converter), the curve-popup target resolution, and applyZoneControl. +// Value logic only — no painting, no window plumbing. + +#include "shell/instrument/reasampler_editor.h" + +#include +#include +#include // snprintf (deck value labels) +#include +#include + +#include "core/instrument/engine/master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1) +#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters (S-VIEW-3) +#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 instrument::engine::formatMasterGainLabel; +using instrument::engine::masterGainLinearFromNorm; +using instrument::engine::masterGainNormFromLinear; +using util::clamp01; + +namespace { +// The S12/S15/S16 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 at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) +// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is +// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent — +// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the +// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate +// ruling). Build-time residual — one place to retune; not persisted. +constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) +constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock) +constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered +constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%) + +} // namespace + +double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { + // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over + // the rate-resolved frames ceiling (T3-03). 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 + // pins it) and gets rewritten down on the next knob touch — deliberate, matching the old + // fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200. + const double fadeMax = fadeMaxFrames(); + const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; + const auto framesToNorm = [fadeMax](std::int64_t f) { + // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. + return fadeMax > 0.0 ? clamp01(static_cast(f) / fadeMax) : 0.0; + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; + case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; + case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds); + case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds); + case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds); + case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); + case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds); + case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction); + case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); + case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); + case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; + case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds); + case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds); + case ParamControl::kPitchEnvDepth: + // Signed depth centered at 0.5 (0.5 == 0 semitones). + return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); + default: return 0.0; + } +} + +void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, + int segment) const { + const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale + const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; + const auto normToFrames = [fadeMax](double v) -> std::int64_t { + // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. + if (fadeMax <= 0.0) return 0; + return static_cast(clamp01(v) * fadeMax + 0.5); + }; + switch (static_cast(id)) { + case ParamControl::kPlayMode: + play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; + break; + case ParamControl::kPitchEngine: + play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; + break; + case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break; + case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break; + case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break; + case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break; + case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break; + case ParamControl::kTrigLength: + // lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing. + play.trigger.lengthFraction = (std::max)(0.01, clamp01(value)); + break; + case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break; + case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break; + case ParamControl::kPitchEnvEnable: + play.pitchEnv.enabled = (segment == 1); + break; + case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break; + case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break; + case ParamControl::kPitchEnvDepth: + play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; + break; + default: break; + } +} + +double ReaSamplerEditor::liveSampleRate() const { + return processor_ ? processor_->sampleRate() : 0.0; +} + +double ReaSamplerEditor::fadeMaxFrames() const { + // T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) + // resolved against the live rate — the SAME time base the envelope overlay already uses + // to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()), + // and the rate captures are made at (the capture path renders at the project rate). + // Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the + // exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) — + // callers treat a <= 0 return as "ceiling unavailable yet" and degrade the knob to inert + // rather than guess a rate. Storage stays SOURCE FRAMES — this resolves the UI ceiling only. + const double rate = liveSampleRate(); + if (rate <= 0.0) return 0.0; + return kFadeMaxSeconds * rate; +} + +double ReaSamplerEditor::previewVelocity01() const { + if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; + 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 (FB2: 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 (r11). + std::vector out; + { + DeckGroupDesc amp; + amp.id = kGroupAmpEnv; + amp.captionWidth = 78; + amp.captionToggle = {static_cast(ParamControl::kPlayMode), 44}; + if (play.playMode == PlayMode::Gate) { + amp.cellIds = {static_cast(ParamControl::kAttack), + static_cast(ParamControl::kHold), + static_cast(ParamControl::kDecay), + static_cast(ParamControl::kSustain), + static_cast(ParamControl::kRelease)}; + } else { + // Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out — + // matches the drawn envelope), plus the two reserved blanks. + amp.cellIds = {static_cast(ParamControl::kTrigFadeIn), + static_cast(ParamControl::kTrigLength), + static_cast(ParamControl::kTrigFadeOut), -1, -1}; + } + out.push_back(std::move(amp)); + } + { + DeckGroupDesc pitch; + pitch.id = kGroupPitch; + pitch.captionWidth = 38; + pitch.captionToggle = {static_cast(ParamControl::kPitchEngine), 48}; + pitch.cellIds = {static_cast(ParamControl::kKeyTrack)}; + out.push_back(std::move(pitch)); + } + { + DeckGroupDesc penv; + penv.id = kGroupPitchEnv; + penv.captionWidth = 58; + penv.captionToggle = {static_cast(ParamControl::kPitchEnvEnable), 32}; + penv.cellIds = {static_cast(ParamControl::kPitchEnvAttack), + static_cast(ParamControl::kPitchEnvDecay), + 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. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the + // post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays + // OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs. + std::vector out = zoneDeckGroupDescs(play); + { + DeckGroupDesc voice; + voice.id = kGroupVoice; + voice.captionWidth = 38; + voice.captionToggle = {static_cast(ParamControl::kVoiceMode), 40}; + voice.cellIds = {static_cast(ParamControl::kVoiceCount)}; + voice.rowToggle = {static_cast(ParamControl::kMonoTrigger), 44}; + out.push_back(std::move(voice)); + } + { + DeckGroupDesc master; + master.id = kGroupMaster; + master.captionWidth = 46; + master.cellIds = {static_cast(ParamControl::kMasterGain)}; + out.push_back(std::move(master)); + } + return out; +} + +double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const { + if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob + switch (static_cast(id)) { + case ParamControl::kKeyTrack: + return clamp01(zone.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); + } +} + +void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) { + if (!processor_) return; + norm = clamp01(norm); + if (id == -2) { + // Preview velocity: live processor write (persisted per-instance; the setter clamps + // to MIDI 1..127 so the knob's bottom still strikes audibly). + processor_->setPreviewVelocity(static_cast(norm * 127.0 + 0.5)); + return; + } + switch (static_cast(id)) { + case ParamControl::kVoiceCount: { + // Stepped: quantize the continuous drag to the integer count and track it live + // for the label/needle. The actual engine rebuild (setVoiceCount) fires ONCE on + // WM_LBUTTONUP — not per step — so a full drag (~31 steps) costs one rebuild, + // not thirty. + const int count = + kMinVoiceCount + + static_cast(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5); + voiceCount_ = count; + return; + } + case ParamControl::kMasterGain: + // Post-mixer gain: one atomic store; the audio thread picks it up next block. + processor_->setMasterGainLinear(masterGainLinearFromNorm(norm)); + return; + default: + applyZoneControl(zoneIndex, id, norm, 0); + return; + } +} + +std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const { + char buf[24]; + buf[0] = '\0'; + const ZonePlaySeconds& play = zone.play; + switch (id == -2 ? ParamControl::kCount : static_cast(id)) { + case ParamControl::kAttack: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break; + case ParamControl::kHold: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break; + case ParamControl::kDecay: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); break; + case ParamControl::kSustain: + snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break; + case ParamControl::kRelease: + snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break; + case ParamControl::kTrigLength: + snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break; + case ParamControl::kTrigFadeIn: + snprintf(buf, sizeof(buf), "%lldf", + static_cast(play.trigger.fadeInFrames)); break; + case ParamControl::kTrigFadeOut: + snprintf(buf, sizeof(buf), "%lldf", + static_cast(play.trigger.fadeOutFrames)); break; + case ParamControl::kPitchEnvAttack: + snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break; + case ParamControl::kPitchEnvDecay: + snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break; + 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; + case ParamControl::kVoiceCount: + snprintf(buf, sizeof(buf), "%d", voiceCount_); break; + case ParamControl::kMasterGain: + formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break; + default: + // -2 (preview velocity) is labeled at its cluster call site; nothing else here. + break; + } + return std::string(buf); +} + +EnvClampBounds ReaSamplerEditor::envClampBounds() const { + // Match the control-panel sliders' own domains so a node drag can never produce a param a + // slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the + // Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use). + EnvClampBounds b; + b.maxAttackSeconds = kEnvTimeMaxSeconds; + b.maxHoldSeconds = kEnvTimeMaxSeconds; + b.maxDecaySeconds = kEnvTimeMaxSeconds; + b.maxReleaseSeconds = kEnvTimeMaxSeconds; + b.maxFadeInFraction = 1.0; + b.maxFadeOutFraction = 1.0; + b.maxLengthFraction = 1.0; + return b; +} + +AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames, + std::int64_t startFrame) const { + AmpEnvelope env; + env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate; + // AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws). + env.attackSeconds = play.adsr.attackSeconds; + env.holdSeconds = play.adsr.holdSeconds; + env.decaySeconds = play.adsr.decaySeconds; + 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 + // 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; + const std::int64_t playLen = + triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); + env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen); + env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen); + return env; +} + +void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, + std::int64_t startFrame, ZonePlaySeconds& play) const { + if (env.mode == EnvMode::Gate) { + play.adsr.attackSeconds = env.attackSeconds; + play.adsr.holdSeconds = env.holdSeconds; + play.adsr.decaySeconds = env.decaySeconds; + play.adsr.sustainLevel = env.sustainLevel; + play.adsr.releaseSeconds = env.releaseSeconds; + } 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 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); + const std::int64_t playLen = + triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); + play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen); + play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen); + } +} + +PerformanceZone ReaSamplerEditor::popupZone() const { + // The zone the popup displays: the Zone surface's SELECTED zone (FB2), 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)]; + 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; + } else { + applyControl(id, z.play, value, segment); + } +} +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_input_browse_zone.cpp b/src/shell/instrument/editor_input_browse_zone.cpp new file mode 100644 index 0000000..063b592 --- /dev/null +++ b/src/shell/instrument/editor_input_browse_zone.cpp @@ -0,0 +1,440 @@ +// editor_input_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE +// input + the hover resolver (Q-W2v split of reasampler_editor.cpp, T4-11): the L3 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 S13 degraded +// drop affordance. Windows-only (D5). + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 + +#include +#include +#include +#include + +#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12) +#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 (S12 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; + +// --- Hover resolution (Phase L, L3) ------------------------------------------ +// +// 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 r11 curve popup — modal over Sample AND Zone (FB2) + 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())) { + // FB2: 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, r11 recomposition) + 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 (formerly inline in onMouseDown — +// behavior-identical; 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 (formerly the tail of onMouseDown — +// behavior-identical; the curve popup is modal over the Zone surface too, FB2). +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); + const int pad = 8; + 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 (pre-fix bleed survivor), 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; + } + + // S12 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 (FB2): 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 (FA4): 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) { + // r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face + // or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search + // cannot hold focus under it). + if (curvePopupOpen_ && ch == 27) { + curvePopupOpen_ = false; + invalidate(); + return; + } + + // S12 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; + } + + // S12 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) { + // S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact + // ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff + // decision point), 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_sample.cpp b/src/shell/instrument/editor_input_sample.cpp new file mode 100644 index 0000000..b236d76 --- /dev/null +++ b/src/shell/instrument/editor_input_sample.cpp @@ -0,0 +1,586 @@ +// editor_input_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE input + the drag-state +// machine (Q-W2v split of reasampler_editor.cpp, T4-11): 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 +// (D5). 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 (r11) +#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag (S-VIEW-3) +#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize +#include "core/instrument/ui/param_slider.h" // knobDragValue (FA4 grab-anchored drag) +#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap (S11) +#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 r11 curve popup: while open the sheet is MODAL over its host face — the Sample home + // (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash + // click dismiss (outside only when no drag is in flight, per the spec); 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 at the cursor via the pure + // inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller + // gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the + // caption band — must not add a point; a click there would clamp to velocity 0/127 and + // produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab + // an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is + // handled above; only the add path is box-gated here. + 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 (S-VIEW-5): the face branch lives in editor_input_browse_zone ---- + if (view_ == View::kBrowse) { + mouseDownBrowse(w, h, x, y); + return; + } + + // ---- Sample home (S-VIEW-2 / r11) ---- + if (view_ == View::kSample) { + // r11 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 (r11): GRAB-ANCHORED vertical drag — the grab itself + // never jumps the value (FA4); 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 (r11): toggles commit at once (a discrete, final edit — the slider + // precedent); 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 (S-VIEW-3) first, then the S11 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 since r11). + 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 (S-VIEW-8 / FB2): 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) { + // r11 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 (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP. + const int dy = y - dragStartY_; + applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy)); + invalidate(); + return; + } + + // r11: 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 (the D-B override vehicle) — + // 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) { + // S-VIEW-3: 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) { + // S-VIEW-10: 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) { + // S11: 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 (the S2 zero-crossing-aware + // requirement). 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) { + // S12: 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; + } + // S-VIEW-10 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) { + // r11 (issue 3c): 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 (FB2; 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_internal.h b/src/shell/instrument/editor_internal.h new file mode 100644 index 0000000..438eadf --- /dev/null +++ b/src/shell/instrument/editor_internal.h @@ -0,0 +1,239 @@ +// editor_internal.h — INTERNAL shared helpers for the ReaSamplerEditor TU family +// (Q-W2v: the eight face-axis TUs split out of the former reasampler_editor.cpp). +// Included ONLY by the editor's own shell TUs (editor_session / editor_controls / +// editor_paint_* / editor_input_* / editor_platform) — never a public seam. Holds the +// former god-TU's anonymous-namespace helpers that more than one split TU needs: the +// Rect<->kit adapters, the small draw primitives (knob face / spectral strip / root +// marker / title band), the label helpers, the deck group ids, and the velocity-curve +// box derivation. All inline; behavior-identical to the pre-split definitions. + +#pragma once + +#include +#include +#include +#include + +#include "core/instrument/engine/velocity_curve.h" // VelocityCurve::Box (curveBoxFromRect) +#include "core/instrument/map/sample_map.h" // SampleChoice / SampleRefs (sampleLabel) +#include "core/instrument/ui/editor_geometry.h" // Rect (the shared sub-rect type) + +#ifdef _WIN32 +#include "wdltypes.h" +#include "lice/lice.h" + +#include "core/audio/peaks.h" // Envelope (drawEnvelope) +#include "core/instrument/ui/capture_browser.h" // BrowserLayout / cardThumbnailRect (thumbBins) +#include "core/instrument/ui/param_slider.h" // KnobGeometry / KnobArc (drawKnobFace, FA4) +#include "core/instrument/ui/keyboard_strip.h" // StripLayout / keyRect / isNaturalKey (spectral strip) +#include "core/ui/component_geometry.h" // KitBox / waveformColumnCount +#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor +#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/text/drawWaveform/toLice +#endif + +namespace reasampler::vst { + +// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck +// order. Shared by the deck-desc builders (editor_controls) and the deck painter. +enum DeckGroup { + kGroupAmpEnv = 0, + kGroupPitch, + kGroupPitchEnv, + kGroupVoice, + kGroupMaster, +}; + +// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the +// curve in the POPUP (curve_popup), each summoned from its own mini preview button. The +// INSET keeps node handles + the pick radius inside the border so an endpoint at amp 0/1 +// stays grabbable — the ONE curveBoxFromRect grammar the popup derives its mapping box +// through. Drag-off: release beyond box+margin deletes the dragged node. +inline constexpr int kVelCurveInset = 14; +inline constexpr int kCurveDragOffMargin = 24; + +// The pure-module mapping Box for a drawn curve rect: inset from the border so node +// handles and the pick radius stay inside the box. Every consumer (paint, hit-test, add, +// drag) derives the Box through this ONE formula, so drawn nodes and grabs never drift. +inline instrument::engine::VelocityCurve::Box curveBoxFromRect( + const instrument::ui::Rect& r) { + return instrument::engine::VelocityCurve::Box{ + r.x + kVelCurveInset, r.y + kVelCurveInset, + (std::max)(0, r.width - 2 * kVelCurveInset), + (std::max)(0, r.height - 2 * kVelCurveInset)}; +} + +// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the +// common DAW convention REAPER uses). +inline std::string noteLabel(int note) { + static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"}; + if (note < 0) note = 0; + if (note > 127) note = 127; + const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4 + return std::string(kNames[note % 12]) + std::to_string(octave); +} + +// A display name for a bank sample id: the snapshotted bank list first, then the +// instance-OWNED ref's displayName (pS — the label survives with the extension absent / +// bank unreadable). "?" only when neither source knows the id. +inline std::string sampleLabel(const std::vector& samples, + const instrument::map::SampleRefs& refs, + const std::string& id) { + for (const instrument::map::SampleChoice& c : samples) { + if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; + } + for (const instrument::map::SampleRefEntry& e : refs) { + if (e.sampleId == id && !e.displayName.empty()) return e.displayName; + } + return "?"; +} + +#ifdef _WIN32 + +// --- Rect <-> kit adapters (Phase L, L3) ------------------------------------- +// +// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against +// `KitBox` (component_geometry). This is the single boundary that bridges them so every +// draw routes through the L1 kit (theme roles + draw_kit). +inline ui::KitBox toKitBox(const instrument::ui::Rect& r) { + return ui::KitBox{r.x, r.y, r.width, r.height}; +} + +// Kit text in a palette ROLE (the common case). Left/Right/Center via Align. +inline void kitText(LICE_IBitmap* bmp, const instrument::ui::Rect& r, const char* s, + Font font, ui::Role role, Align align = Align::Left) { + text(bmp, toKitBox(r), s, font, role, align); +} + +inline void kitTextCentered(LICE_IBitmap* bmp, const instrument::ui::Rect& r, + const char* s, Font font, ui::Role role) { + text(bmp, toKitBox(r), s, font, role, Align::Center); +} + +// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3). +inline void drawEnvelope(LICE_IBitmap* bmp, const instrument::ui::Rect& r, + const audio::Envelope& env) { + drawWaveform(bmp, toKitBox(r), env); +} + +// The bin count a card's thumbnail is computed at: one bin per drawn pixel column — the +// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins. +// thumbnailFor clamps the request to the decoded frame count. +inline int thumbBins(const instrument::ui::BrowserLayout& layout) { + return (std::max)(1, kWaveformOversample * + ui::waveformColumnCount(toKitBox( + instrument::ui::cardThumbnailRect(layout, 0)))); +} + +// Draw the title band with the live readout. Shared by the Sample face (nav visible) — +// Browse/Zone draw their own back button in place of the nav. +inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title, + const std::string& readout) { + fillSurface(bmp, toKitBox(title), ui::Role::BgPanel, ui::InteractionState::Rest); + instrument::ui::Rect titleText = + instrument::ui::Rect::ltrb(title.x + 8, title.y, title.right() - 8, title.bottom()); + kitText(bmp, titleText, readout.c_str(), Font::Title, ui::Role::TextPrimary); +} + +// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle +// map; this turns it into LICE calls through the kit's palette roles. LICE's arc +// convention matches param_slider's (angle 0 = 12 o'clock, positive clockwise) — but LICE +// takes RADIANS, and drawing the 7->5 o'clock sweep THROUGH the top needs a continuous +// angle span, so the degrees convert as (deg - 360) * pi/180, mapping 210..510 onto +// -150..+150 degrees. One conversion, both arcs. +inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect, + double value01, ui::InteractionState st) { + using instrument::ui::KnobArc; + using instrument::ui::KnobGeometry; + using instrument::ui::KnobPoint; + const KnobGeometry kg = instrument::ui::computeKnob(knobRect); + if (kg.radius <= 1.0) return; + constexpr double kDegToRad = 3.14159265358979323846 / 180.0; + const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep + const float cx = static_cast(kg.centerX); + const float cy = static_cast(kg.centerY); + const float rOuter = static_cast(kg.radius) - 0.5f; + const bool disabled = (st == ui::InteractionState::Disabled); + const bool hot = (st == ui::InteractionState::Dragging || st == ui::InteractionState::Hover); + + // Face: a filled circle in the cell surface color under the interaction state. + LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(ui::roleColorState(ui::Role::BgCell, st)), + 1.0f, 0, true); + // Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare). + const float a0 = static_cast((arc.startDeg - 360.0) * kDegToRad); + const float a1 = static_cast( + (arc.startDeg + instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad); + LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(ui::roleColor(ui::Role::LineHairline)), 1.0f, 0, + true); + // Value arc: start -> the value's angle, in the live accent (hot while under the pointer / + // dragging, dim when disabled). + const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01); + if (v > 0.0) { + const float av = static_cast( + (arc.startDeg + v * instrument::ui::knobSweepDeg(arc) - 360.0) * kDegToRad); + const ui::Role valueRole = disabled ? ui::Role::TextDim + : (hot ? ui::Role::AccentHot : ui::Role::AccentPrimary); + LICE_Arc(bmp, cx, cy, rOuter, a0, av, toLice(ui::roleColor(valueRole)), 1.0f, 0, true); + } + // Needle: from ~35% radius out to the rim at the value's angle. + const KnobPoint tip = instrument::ui::knobNeedlePoint(kg, arc, v); + const float ix = cx + static_cast((tip.x - kg.centerX) * 0.35); + const float iy = cy + static_cast((tip.y - kg.centerY) * 0.35); + const ui::Role needleRole = disabled ? ui::Role::TextDim : ui::Role::TextPrimary; + LICE_Line(bmp, static_cast(ix + 0.5f), static_cast(iy + 0.5f), + static_cast(tip.x + 0.5f), static_cast(tip.y + 0.5f), + toLice(ui::roleColor(needleRole)), 1.0f, 0, true); +} + +// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature +// surface. Fills each MIDI key column with its spectral hue, then draws faint per-octave +// hairline ticks. Shared by the setup face + the Zones strip so both read as the same +// spectrum. S-VIEW-7: accidentals get a dark bg/base wash over the hue (an OVERLAY, not +// a keyboard shape) so pitch position reads as 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; + const StripLayout sl = instrument::ui::layoutStrip(stripArea.width, stripArea.height); + const int sx = stripArea.x; + const int sy = stripArea.y; + const int h = stripArea.height; + const LICE_pixel darkKey = toLice(ui::roleColor(ui::Role::BgBase)); + for (int n = 0; n <= 127; ++n) { + const instrument::ui::Rect k = instrument::ui::keyRect(sl, n); + const int x0 = k.x + sx; + const int x1 = + (n < 127) ? instrument::ui::keyRect(sl, n + 1).x + sx : stripArea.right(); + const int cw = (std::max)(1, x1 - x0); + const ui::KitColor hue = ui::spectralColor(static_cast(n) / 127.0); + LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0); + if (!instrument::ui::isNaturalKey(n)) { + LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0); + } + } + // Faint per-octave key ticks (hairline role) for orientation. + const LICE_pixel tick = toLice(ui::roleColor(ui::Role::LineHairline)); + for (int n = 0; n <= 127; n += 12) { + const instrument::ui::Rect k = instrument::ui::keyRect(sl, n); + LICE_Line(bmp, k.x + sx, sy, k.x + sx, sy + h, tick, 1.0f, 0, false); + } +} + +// Draw the single-capture root marker on the strip: an accent-primary bar with a soft +// STATIC glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. +inline void drawRootMarker(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea, + const instrument::ui::StripLayout& sl, int root) { + const int sx = stripArea.x; + const int sy = stripArea.y; + const int h = stripArea.height; + const instrument::ui::Rect marker = instrument::ui::rootMarkerRect(sl, root); + const int mw = (std::max)(2, marker.width); + const LICE_pixel accent = toLice(ui::roleColor(ui::Role::AccentPrimary)); + const LICE_pixel glow = toLice(ui::roleColor(ui::Role::AccentHot)); + // Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse). + LICE_FillRect(bmp, marker.x + sx - 3, sy, mw + 6, h, glow, 0.30f, 0); + LICE_FillRect(bmp, marker.x + sx, sy, mw, h, accent, 1.0f, 0); +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_paint_browse_zone.cpp b/src/shell/instrument/editor_paint_browse_zone.cpp new file mode 100644 index 0000000..2d99f7e --- /dev/null +++ b/src/shell/instrument/editor_paint_browse_zone.cpp @@ -0,0 +1,278 @@ +// editor_paint_browse_zone.cpp — the ReaSamplerEditor's BROWSE-MODAL and ZONE-SURFACE +// painting (Q-W2v split of reasampler_editor.cpp, T4-11): the full-window select-then- +// confirm picker (S-VIEW-5 — wash, search box, filter tabs, card grid, scrollbar, +// footer) and the Zone keymap surface (S-VIEW-8/FB2 — add/delete, the spectral zones +// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only +// (D5). Shares the Sample face's painters (title band / empty state / deck / curve +// button / popup) via the class + editor_internal.h. + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 + +#include +#include +#include +#include + +#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry (S12) +#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout (FB2) +#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + 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 + +void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { + // A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying + // Sample face with a bg/base wash, then draw the picker opaque on top. + LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0); + const BrowseModal bm = computeBrowseModal(w, h); + + // Title band + Back button (returns to Sample, discarding any pending pick). + drawTitleBand(bmp, bm.title, "Browse - pick a capture"); + { + const KitButtonBox box{toKitBox(bm.back)}; + const InteractionState st = + isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Back", st, /*warn=*/false); + } + + // Search box (type-to-filter). A focused box lifts to Focus + a ring; else Rest/Hover. + const Rect searchAbs = bm.search; + const InteractionState searchState = + searchFocused_ ? InteractionState::Focus + : (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover + : InteractionState::Rest); + fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState); + if (searchFocused_) { + LICE_DrawRect(bmp, searchAbs.x, searchAbs.y, searchAbs.width - 1, + searchAbs.height - 1, toLice(roleColor(Role::TextPrimary)), 1.0f, 0); + } + { + std::string sb = searchQuery_.empty() + ? std::string("Search captures...") + : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : "")); + Rect sbText = Rect::ltrb(searchAbs.x + 6, searchAbs.y, searchAbs.right() - 6, searchAbs.bottom()); + kitText(bmp, sbText, sb.c_str(), Font::Label, + searchQuery_.empty() ? Role::TextDim : Role::TextPrimary); + } + + // Tabs + card grid, laid out over the content sub-area by the pure module (origin-offset). + const Rect browserArea = bm.content; + const BrowserLayout bl = layoutBrowser(browserArea.width, browserArea.height); + const int ox = browserArea.x; + const int oy = browserArea.y; + scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); + + const int tabCount = static_cast(banks_.size()) + 1; + for (int i = 0; i < tabCount; ++i) { + Rect t = filterTabRect(bl, tabCount, i); + t = Rect::ltrb(t.x + ox, t.y + oy, t.right() + ox, t.bottom() + oy); + const std::string label = (i == 0) ? "All" : banks_[static_cast(i - 1)].displayName; + const bool active = (i == 0) ? activeFilterBankId_.empty() + : (banks_[static_cast(i - 1)].id == activeFilterBankId_); + const InteractionState state = + active ? InteractionState::Active + : (isHovered(HoverKind::kFilterTab, i) ? InteractionState::Hover + : InteractionState::Rest); + fillSurface(bmp, toKitBox(t), Role::BgCell, state); + kitTextCentered(bmp, t, label.c_str(), Font::Label, + active ? Role::BgBase : Role::TextPrimary); + } + + // Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_) + // is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border. + const int bins = thumbBins(bl); + const int cardCount = static_cast(visible_.size()); + const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); + for (int i = vr.first; i < vr.last; ++i) { + Rect content = cardContentRect(bl, i); + Rect thumb = cardThumbnailRect(bl, i); + Rect labelR = cardLabelRect(bl, i); + content = Rect::ltrb(content.x + ox, content.y + oy - scrollOffset_, + content.right() + ox, content.bottom() + oy - scrollOffset_); + thumb = Rect::ltrb(thumb.x + ox, thumb.y + oy - scrollOffset_, + thumb.right() + ox, thumb.bottom() + oy - scrollOffset_); + labelR = Rect::ltrb(labelR.x + ox, labelR.y + oy - scrollOffset_, + labelR.right() + ox, labelR.bottom() + oy - scrollOffset_); + + const SampleChoice& s = visible_[static_cast(i)]; + const bool pending = (s.id == browsePendingId_); + const bool loaded = (s.id == selectedId_); + const InteractionState cardState = + isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest; + fillSurface(bmp, toKitBox(content), Role::BgCell, cardState); + const KitColor cardBorder = pending ? roleColor(Role::AccentPrimary) + : (loaded ? roleColor(Role::AccentTertiary) + : roleColor(Role::LineHairline)); + LICE_DrawRect(bmp, content.x, content.y, content.width - 1, content.height - 1, + toLice(cardBorder), 1.0f, 0); + drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); + + std::string caption = s.displayName.empty() ? s.id : s.displayName; + Rect nameR = Rect::ltrb(labelR.x + 3, labelR.y, labelR.right() - 3, labelR.y + labelR.height / 2); + Rect badgeR = Rect::ltrb(labelR.x + 3, nameR.bottom(), labelR.right() - 3, labelR.bottom()); + kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary); + std::string badge; + if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); + else if (s.key) badge = *s.key; + else badge = "root -"; + kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim); + } + + // Scrollbar thumb. + { + const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); + if (thumb.height > 0) { + const bool dragging = (drag_ == DragKind::kScrollThumb); + const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary); + LICE_FillRect(bmp, thumb.x + ox, thumb.y + oy, thumb.width, thumb.height, + toLice(tc), 0.8f, 0); + } + } + + if (visible_.empty()) paintEmptyState(bmp, browserArea); + + // Footer: Cancel (discard, return to Sample) + Load (commit the pending pick). Load is inert + // (no accent) until a card is picked. Draw a footer strip so the buttons read as a modal bar. + Rect footer = Rect::ltrb(0, bm.content.bottom(), w, h); + fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest); + { + const KitButtonBox box{toKitBox(bm.cancel)}; + const InteractionState st = + isHovered(HoverKind::kBrowseCancel, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Cancel", st, /*warn=*/false); + } + { + const KitButtonBox box{toKitBox(bm.confirm)}; + const bool armed = !browsePendingId_.empty(); + const InteractionState st = armed + ? (isHovered(HoverKind::kBrowseConfirm, -1) ? InteractionState::Hover : InteractionState::Active) + : InteractionState::Rest; + drawButton(bmp, box, "Load", st, /*warn=*/false); + } +} + +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); + const int pad = 8; + + // 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) — S12 direct numeric entry. 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. FB2 (R11-F2): the SAME knob deck + + // curve-preview-button/popup grammar as the Sample face — one control language over the one + // storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. 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 (FB2): 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_sample.cpp b/src/shell/instrument/editor_paint_sample.cpp new file mode 100644 index 0000000..6194f9d --- /dev/null +++ b/src/shell/instrument/editor_paint_sample.cpp @@ -0,0 +1,518 @@ +// editor_paint_sample.cpp — the ReaSamplerEditor's SAMPLE-FACE painting (Q-W2v split of +// reasampler_editor.cpp, T4-11): the WM_PAINT dispatch, the r11 Sample home face (title +// band + elastic hero waveform + root/preview cluster + bottom-anchored knob deck), the +// S-VIEW-3 envelope overlay, the velocity-curve editor + mini preview button + popup +// sheet (shared painters the Zone surface reuses, FB2), and the empty state. Windows-only +// (D5); draws through the L1 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" // r11 centered curve-popup sheet geometry (FB1) +#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize +#include "core/instrument/ui/waveform_view.h" // frameToX (S11 markers) +#include "core/version/app_version.h" // vstPluginName (channel-derived title band, S18) +#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 (Phase L, L3) — 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))); + + // S-VIEW-1 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 (the "picker over the document" grammar). + if (view_ == View::kZone) { + paintZone(&bmp, w, h); + } else { + paintSample(&bmp, w, h); + if (view_ == View::kBrowse) paintBrowse(&bmp, w, h); + } + + // S13 (relay degraded): 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) { + // r11: 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. Standard B palette — the beta channel gets NO distinct + // accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable + // signal. + std::string title = version::vstPluginName(); // channel-derived (S18) + if (processor_ && processor_->bridge().isConnected()) { + // The instance's OWN loaded state outranks bank availability (pS: 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 (S15-F2 — 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 + S11 markers + S-VIEW-3 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) { + // FA3 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); + } + + // S-VIEW-3: 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 (r11: 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 (r11 — 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 (r11): opens the popup editor. Shared painter with the + // Zone panel's button (FB2 — 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 (r11: the fenced control groups, bottom-anchored) ------------------- + paintKnobDeck(bmp, bands.deck, zone, deckDescs); + + // --- The curve popup (r11): 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. FA2 guarantees every vertex is + // in-bounds (the pre-FA2 right-edge clip is dead and removed — 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 (FB2: 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 — r11: 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 (r11: 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 (r11). + 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 (r11/FB2 — 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 (FB2). + 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 S13 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_platform.cpp b/src/shell/instrument/editor_platform.cpp new file mode 100644 index 0000000..956f405 --- /dev/null +++ b/src/shell/instrument/editor_platform.cpp @@ -0,0 +1,271 @@ +// editor_platform.cpp — the ReaSamplerEditor's IPlugView + Win32 window plumbing (Q-W2v +// split of reasampler_editor.cpp, T4-11): platform-type/resize negotiation, the child +// window class + creation/destruction, the S9/S8 sync timer lifetime, the WM_* dispatch +// (wndProc — paint, mouse, keyboard, capture-loss rollback, drop-accept, timer), and the +// non-Windows stubs (D5 makes Windows the only build target; the TU still compiles +// elsewhere). + +#include "shell/instrument/reasampler_editor.h" + +#ifdef _WIN32 +#include // GET_X_LPARAM / GET_Y_LPARAM +#include // DragAcceptFiles / DragQueryFile / DragFinish — S13 drop-accept +#endif + +#include "shell/instrument/editor_internal.h" // (transitively: lice + the kit, Windows only) +#include "shell/instrument/reasampler_processor.h" + +using namespace Steinberg; + +namespace reasampler::vst { + +#ifdef _WIN32 +namespace { +constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; + +// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency +// UI-thread timer: responsive enough that a recapture/ingest/assign refreshes "within a +// bounded cadence" (the S9 verify criterion) yet cheap — three small ext-state reads per +// tick, coalescing many bumps between ticks into one reload. 500 ms is a deliberate +// build-time residual. The id is a per-window SetTimer id (any nonzero). +constexpr UINT_PTR kSyncTimerId = 1; +constexpr UINT kSyncTimerIntervalMs = 500; +} // namespace +#endif + +tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) { +#ifdef _WIN32 + if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue; +#endif + return kResultFalse; +} + +tresult PLUGIN_API ReaSamplerEditor::canResize() { + return kResultTrue; +} + +tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) { + // Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before + // every resize; clamp the proposed rect in place and return kResultTrue so the host applies the + // (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title + // + hero waveform + cluster + a few control rows visible (the control strip clips gracefully + // below the panel bottom); anything smaller would clip essential UI. The default 840×620 is + // above this floor. + constexpr int kMinW = 560; + constexpr int kMinH = 460; + if (!rect) return kResultFalse; + if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW; + if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH; + return kResultTrue; +} + +#ifdef _WIN32 + +void ReaSamplerEditor::invalidate() { + if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); +} + +void ReaSamplerEditor::attachedToParent() { + HWND parent = static_cast(systemWindow); + if (!parent) return; + + HINSTANCE hInst = + reinterpret_cast(GetWindowLongPtr(parent, GWLP_HINSTANCE)); + if (!hInst) hInst = GetModuleHandle(nullptr); + + static bool classRegistered = false; + if (!classRegistered) { + WNDCLASSW wc{}; + wc.lpfnWndProc = &ReaSamplerEditor::wndProc; + wc.hInstance = hInst; + wc.lpszClassName = kChildClassName; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.style = CS_HREDRAW | CS_VREDRAW; + RegisterClassW(&wc); + classRegistered = true; + } + + // Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a + // reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on + // editor close: the embed strip in the SAME binary shares the kit's process-global font + // set, so a per-view shutdown could free fonts still in use by the other view. The tiny + // static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note. + kitFontsInit(); + + refreshFromBank(); + + const ViewRect& r = getRect(); + childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, + r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); + if (childHwnd_) { + SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); + // S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT + // ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show + // the "drop on the panel" affordance instead of the OS bouncing the drop silently. + DragAcceptFiles(childHwnd_, TRUE); + // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's + // lifetime — created here, killed in removedFromParent — so an instance whose editor + // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation). + SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr); + // Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor + // was closed) or a bank change applies the instant the editor opens, rather than waiting + // up to one timer interval. refreshFromBank above already primed the view; this folds in + // any pending assign/generation so the just-opened editor shows the assigned capture. + onSyncTimer(); + } +} + +void ReaSamplerEditor::removedFromParent() { + if (childHwnd_) { + KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away + DestroyWindow(childHwnd_); + childHwnd_ = nullptr; + } +} + +tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { + tresult res = CPluginView::onSize(newSize); + if (childHwnd_ && newSize) { + MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE); + thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them + } + return res; +} + +LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, + LPARAM lParam) { + auto* self = + reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); + switch (msg) { + case WM_PAINT: { + PAINTSTRUCT ps{}; + HDC hdc = BeginPaint(hwnd, &ps); + if (self) self->paint(hdc); + EndPaint(hwnd, &ps); + return 0; + } + case WM_LBUTTONDOWN: + if (self) { + SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child + SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12) + self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + } + return 0; + case WM_MOUSEMOVE: + if (self) { + const int mx = GET_X_LPARAM(lParam); + const int my = GET_Y_LPARAM(lParam); + // Hover feedback (Phase L, L3): resolve the element under the pointer and + // repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover + // clears when the pointer leaves the child (TrackMouseEvent is one-shot). + if (!self->mouseTracking_) { + TRACKMOUSEEVENT tme{}; + tme.cbSize = sizeof(tme); + tme.dwFlags = TME_LEAVE; + tme.hwndTrack = hwnd; + TrackMouseEvent(&tme); + self->mouseTracking_ = true; + } + // While a drag is in flight the drag owns the surface; skip hover resolution + // (a hover repaint mid-drag would fight the live drag feedback). + if (self->drag_ == DragKind::kNone) self->resolveHover(mx, my); + self->onMouseMove(mx, my); + } + return 0; + case WM_MOUSELEAVE: + if (self) { + self->mouseTracking_ = false; + if (self->hover_.kind != HoverKind::kNone) { + self->hover_ = HoverTarget{}; + self->invalidate(); + } + } + return 0; + case WM_MOUSEWHEEL: + // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. + if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)); + return 0; + case WM_CHAR: + // S12 type-to-filter search keystrokes (only acted on when the search box is focused). + if (self) self->onSearchChar(static_cast(wParam)); + return 0; + case WM_GETDLGCODE: + // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). + return DLGC_WANTCHARS | DLGC_WANTARROWS; + case WM_LBUTTONUP: + if (self) { + self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + ReleaseCapture(); + } + return 0; + case WM_RBUTTONDOWN: + // r11: right-click — the curve popup's primary node-delete affordance (issue 3c). + // Routed explicitly (the child wndproc historically handled only left-button). + if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); + return 0; + case WM_RBUTTONUP: + return 0; // claimed so the pair never reaches DefWindowProc (no context menu) + case WM_CAPTURECHANGED: + // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its + // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset + // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. + // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. + if (self) { + // A held preview note must be released here too (peer of WM_LBUTTONUP) — capture + // loss otherwise leaves the momentary-key voice hung with no note-off. + if (self->previewingNote_ >= 0) { + if (self->processor_) self->processor_->previewNoteOff(self->previewingNote_); + self->previewingNote_ = -1; + self->invalidate(); + } + 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. + 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_; + self->drag_ = DragKind::kNone; + self->dragParamId_ = -1; + self->dragParamZone_ = -1; + self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset) + self->dragCurveZone_ = -1; + self->invalidate(); + } + } + return 0; + case WM_DROPFILES: { + // S13 (relay degraded): count the dropped files and flash the affordance. We do NOT + // read/ingest the paths (the instrument never ingests — the relay to the extension is + // unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner. + HDROP drop = reinterpret_cast(wParam); + const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); + DragFinish(drop); + if (self) self->onFilesDropped(static_cast(count)); + return 0; + } + case WM_TIMER: + if (self && wParam == kSyncTimerId) self->onSyncTimer(); + return 0; + case WM_ERASEBKGND: + return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase + default: + return DefWindowProcW(hwnd, msg, wParam, lParam); + } +} + +#else // non-Windows: not a build target (D5), but keep the TU compilable. + +void ReaSamplerEditor::attachedToParent() {} +void ReaSamplerEditor::removedFromParent() {} +tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { + return CPluginView::onSize(newSize); +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/shell/instrument/editor_session.cpp b/src/shell/instrument/editor_session.cpp new file mode 100644 index 0000000..a7a18e2 --- /dev/null +++ b/src/shell/instrument/editor_session.cpp @@ -0,0 +1,371 @@ +// editor_session.cpp — the ReaSamplerEditor's SESSION/BRIDGE state (Q-W2v split of +// reasampler_editor.cpp, T4-11): construction, the live-bank snapshot (refreshFromBank / +// rebuildVisible), the S9/S8 sync tick, the commit-and-reload seam, selection loading, +// the picked-capture marker resolution/upsert helpers, and the decoded-PCM + peak +// thumbnail caches (the mirror of bank_panel's, keyed through the pure ThumbnailKey — +// T2-10 rider). UI thread only; every edit commits OFF the audio thread via the +// processor's reloadInstrument. + +#include "shell/instrument/reasampler_editor.h" + +#include +#include +#include +#include + +#include "core/audio/peaks.h" // computeEnvelope (the cached peak thumbnail) +#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames +#include "core/ui/bank_grid.h" // ThumbnailKey / thumbnailKeyString (T2-10: the pure key) +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) +#include "ext_keys.h" +#include "core/instrument/ui/browser_scroll.h" // nameMatchesQuery (S12 type-to-filter) +#include "shell/instrument/reaper_bridge.h" +#include "shell/instrument/reasampler_processor.h" + +using namespace Steinberg; + +namespace reasampler::vst { + +using namespace reasampler::instrument::map; // sample_map vocabulary (selectSample / listSamples / …) +using audio::computeEnvelope; +using capture::WavLayout; +using capture::extractFloatFrames; +using capture::parseWavLayout; +using capture::resolveBankFile; +using instrument::ui::nameMatchesQuery; +using ui::ThumbnailKey; +using ui::thumbnailKeyString; +using util::readFileBytes; + +ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) + : CPluginView(nullptr), processor_(processor) { + // Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample + // home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate + // mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll + // on a 1080p screen with headroom. Wide enough that the control strip's label + value columns + // read comfortably. + ViewRect r(0, 0, 840, 620); + setRect(r); +} + +void ReaSamplerEditor::refreshFromBank() { + // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). + thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks + pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source) + if (!processor_) { + samples_.clear(); + banks_.clear(); + visible_.clear(); + selectedId_.clear(); + map_.zones.clear(); + selectedZone_ = -1; + 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(); + channelMode_ = processor_->channelMode(); + voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot + voiceMode_ = processor_->voiceMode(); + monoTrigger_ = processor_->monoTrigger(); + if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; + // r11: a refresh that emptied the selection (a bank change on the sync tick) closes the + // curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible + // modal would swallow clicks. + if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false; + // FB2: on the Zone surface the popup edits the SELECTED zone; close it if the zones list + // shrank (selectedZone_ past-end), OR if the zone count changed at all — a mid-list + // deletion leaves selectedZone_ in range but now naming a DIFFERENT zone (silent retarget). + if (view_ == View::kZone && curvePopupOpen_) { + const auto newZoneCount = static_cast(map_.zones.size()); + if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false; + } + // Drop a filter that names a bank no longer present. + if (!activeFilterBankId_.empty()) { + bool found = false; + for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true; + if (!found) activeFilterBankId_.clear(); + } + rebuildVisible(); +} + +void ReaSamplerEditor::rebuildVisible() { + // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search + // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). + visible_.clear(); + for (const SampleChoice& s : samples_) { + const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; + if (!inBank) continue; + const std::string& name = s.displayName.empty() ? s.id : s.displayName; + if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); + } + // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel + // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it + // must not reset the user's scroll here. +} + +#ifdef _WIN32 +// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared +// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes +// Windows the only build target, but the TU must still compile elsewhere). +void ReaSamplerEditor::onSyncTimer() { + // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the + // processor (off the audio thread — the poll itself never touches process()). NEVER while a + // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the + // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release. + if (!processor_) return; + if (drag_ != DragKind::kNone) return; // defer past the in-flight edit + + // An open editor marks THIS instance the focused assignment target (the thundering-herd + // policy — only an editor-open instance applies a pending assign; see the handoff). Pass + // true so this instance consumes the request; instances with no editor open do not poll at + // all (the timer is bound to the child window), so they never contend for the request. + const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); + + // Re-snapshot the editor's own view only when something changed (a reload from a bank + // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the + // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM + // caches, then repaints — so the browser + setup surface reflect the new bank hands-free. + if (r.reloaded || r.applied) { + refreshFromBank(); + invalidate(); + } + + // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. + if (dropHintTicks_ > 0) { + --dropHintTicks_; + invalidate(); + } +} +#endif // _WIN32 + +void ReaSamplerEditor::commitAndReload() { + // UI thread only. Publish the edited selection + zones to the processor, then rebuild + // the instrument off the audio thread (reloadInstrument bakes them into the live Keymap). + // pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank + // blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the + // moment the instance becomes self-contained for that sample. + if (!processor_) return; + processor_->setSelectedSampleId(selectedId_); + processor_->setPerformanceMap(map_); + processor_->reloadInstrument(); + // GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's + // channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode + // the engine actually decoded with. + channelMode_ = processor_->channelMode(); +#ifdef _WIN32 + invalidate(); +#endif +} + +void ReaSamplerEditor::loadSelection(const std::string& id) { + // Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous + // sample's materialized full-range zone must not linger — first-match resolve would + // keep playing it while the editor draws the new pick's zone (matched by sampleId, + // order-blind). Authored Zone-view maps (any narrow key range) are left untouched. + selectedId_ = id; + if (reconcileSingleCaptureZones(map_, selectedId_)) { + selectedZone_ = map_.zones.empty() ? -1 : 0; + } + commitAndReload(); +} + +ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { + SetupMarkers m; + // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override + // for the picked id win (the instrument's performance choice, D-B). 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 + // (pS fallback). The override lives in map_. + if (processor_) { + std::optional sel; + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) sel = selectSample(*banksJson, selectedId_); + if (!sel) { + const SampleRefs refs = processor_->sampleRefs(); + if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r; + } + if (sel && sel->loop.hasLoop) { + m.hasLoop = true; + m.loopStart = sel->loop.start; + 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; + } + // 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). + if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; + 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, D-B). selectedId_ must + // be non-empty; callers are responsible for that guard. + // Returns the zone index (0-based) so callers can update selectedZone_. + 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). Mirrors the S15-F2 single-storage-site lean. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_) return z; + } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + return z; +} + +int ReaSamplerEditor::effectiveRoot() const { + int root = 60; + for (const SampleChoice& s : samples_) { + if (s.id == selectedId_ && s.rootNote) root = *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, + // mirror of the root-marker path): 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 (read-only bank consumer, D-B). + if (selectedId_.empty()) return; + upsertPickedOverride(m); + commitAndReload(); +} + +const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { + auto it = pcmCache_.find(sampleId); + if (it != pcmCache_.end()) return it->second; + + // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so + // resolve the path from the live bank blob (selectSample) and decode via the shared WAV + // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY + // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width- + // independent) — the thumbnail bins this at whatever width, the snap scans it directly. + std::string relativePath; + std::vector mono; + if (processor_) { + auto banksJson = + processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + if (banksJson) { + if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; + } + if (relativePath.empty()) { + // pS fallback: the bank blob is not readable (extension absent / not yet parsed) + // or the id went stale there — the instance-OWNED ref still carries the path, so + // a self-contained instance draws its loaded sound's waveform regardless. + const SampleRefs refs = processor_->sampleRefs(); + if (const SelectedSample* r = findRef(refs, sampleId)) { + relativePath = r->relativePath; + } + } + if (!relativePath.empty()) { + const std::string projectDir = processor_->bridge().activeProjectDir(); + const std::string abs = resolveBankFile(projectDir, relativePath); + // Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure. + const std::vector bytes = readFileBytes(abs); + const WavLayout layout = parseWavLayout(bytes); + if (layout.valid) { + std::vector interleaved = + extractFloatFrames(bytes, layout, 0, layout.frameCount()); + mono = downmixToMono(interleaved, layout.channelCount); + } + } + } + auto ins = pcmCache_.emplace(sampleId, std::move(mono)); + return ins.first->second; +} + +const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { + // T2-10 rider: key through the PURE ThumbnailKey (bank_grid) instead of the former + // ad-hoc "id|binCount" concat, so both thumbnail pipelines share one tested key + // grammar (length-prefixed id — collision-proof). The editor invalidates by wholesale + // clear() on refresh/resize, so the bank generation carries no information here — 0. + const std::string key = + thumbnailKeyString(ThumbnailKey{sampleId, binCount, /*generation=*/0}); + auto it = thumbCache_.find(key); + if (it != thumbCache_.end()) return it->second; + + // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by + // every thumbnail width AND the S11 waveform surface + snap. + const std::vector& mono = monoPcmFor(sampleId); + Envelope env; + if (!mono.empty()) { + // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with + // trailing empty {0,0} bins, which would render a very short sample as a comb of + // spikes over flat gaps. + const std::size_t bins = + (std::min)(static_cast((std::max)(1, binCount)), mono.size()); + env = computeEnvelope(mono, 1, mono.size(), bins); + } + auto ins = thumbCache_.emplace(key, std::move(env)); + return ins.first->second; +} + +ReaSamplerEditor::~ReaSamplerEditor() { +#ifdef _WIN32 + if (childHwnd_) { + DestroyWindow(childHwnd_); + childHwnd_ = nullptr; + } +#endif +} + +} // namespace reasampler::vst diff --git a/src/shell/instrument/processor_reload.cpp b/src/shell/instrument/processor_reload.cpp new file mode 100644 index 0000000..d1fef2f --- /dev/null +++ b/src/shell/instrument/processor_reload.cpp @@ -0,0 +1,489 @@ +// processor_reload.cpp — the ReaSamplerProcessor's OFF-AUDIO-THREAD instrument +// lifecycle: reloadInstrument (self-contained refs resolve + WAV decode + keymap +// build), the safety-critical publishBuiltLocked drain-slot swap, the voice-param +// light rebuild, idle-drain retirement, the pre-v10 legacy-lift gate, the S9/S8 +// bank-sync poll, and the pS-usage publish. Split out of reasampler_processor.cpp +// (Q-W2v, T4-12). NOTHING here runs on the audio thread — process() (the lifecycle +// TU) only touches the atomics this family publishes; the atomic-pointer-swap +// pattern deliberately gains NO virtual seam (T4-29). + +#include "shell/instrument/reasampler_processor.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) +#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) +#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision +#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (pS self-contained) +#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) +#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) +#include "core/wire/sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) +#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey + +namespace reasampler::vst { + +using namespace instrument::map; // resolution + bank-sync vocabulary this TU drives +using namespace reasampler::wire; // assignment_request + sample_usage wire records + +namespace { + +// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is +// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather +// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice +// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the +// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays +// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. +constexpr std::size_t kPreserveVoiceCap = 8; + +// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy +// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the +// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic +// strength) is the requirement: two instances sharing a key is the copy-collision +// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision +// vanishingly unlikely. Off-thread only. +std::string mintUsageInstanceGuid() { + std::random_device rd; + std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); + std::uniform_int_distribution dist; + char buf[33] = {0}; + std::snprintf(buf, sizeof(buf), "%016llx%016llx", + static_cast(dist(gen)), + static_cast(dist(gen))); + return std::string(buf); +} + +// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03). +// Off-thread only (blocking file I/O). Empty on any failure — the caller treats +// an unreadable WAV as "nothing to play". + +// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file +// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode +// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for +// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path +// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no +// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by +// the zoned build and the single-capture path so both decode identically for the active mode. +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); + const WavLayout layout = parseWavLayout(bytes); + 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)); + if (out.monoFrames.empty()) return std::nullopt; + return out; +} + +} // namespace + +std::string ReaSamplerProcessor::reloadInstrument() { + // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so + // the retired-slot free is single-writer. This mutex is NEVER taken on the audio + // thread — process() only touches the atomic. + std::lock_guard lock(reloadMutex_); + + // Mint this reload's generation number first so we can stamp the built instrument + // with it before publishing. Under reloadMutex_ no other reload races here. + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + + // 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of + // truth for what to decode. The live bank blob, WHEN readable, is folded into the + // table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in + // mechanism and the S9 recapture sync in one — but its absence changes NOTHING + // below: a project restored before the extension's PROJEXTSTATE parses (or with + // the extension absent entirely) resolves + plays from the persisted refs. The + // project dir comes from REAPER itself (EnumProjects), not from the extension. + const std::string selId = selectedSampleId(); + const PerformanceMap map = performanceMap(); + const std::vector ids = referencedSampleIds(selId, map); + SampleRefs refs; + { + std::optional banksJson = + bridge_.readReasamplerExtState(kProjExtBanksKey); + std::lock_guard rl(refsMutex_); + if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids); + // The LOAD path never prunes the owned table: dropping entries here on a transient + // bank miss could destroy the owned intrinsics of the previous selection — the ONE + // copy that survives with the extension absent. Entries for de-referenced ids stay + // in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary, + // where getState filters its snapshot via retainRefs to what the instance plays. + refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) + } + const std::string projectDir = bridge_.activeProjectDir(); + // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). + // Read once under its mutex, off the audio thread, before the decode loop. The single- + // capture branch below may auto-default it (GA) before its decode. + ChannelMode mode = channelMode(); + // Phase S: snapshot the voice-system parameters once — they are baked into the built + // engine's construction (the engine's config is immutable; a later change rebuilds). + int builtVoiceCount = kDefaultVoiceCount; + VoiceMode builtVoiceMode = VoiceMode::Poly; + MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; + { + std::lock_guard vp(voiceParamsMutex_); + builtVoiceCount = voiceCount_; + builtVoiceMode = voiceMode_; + builtMonoTrigger = monoTrigger_; + } + + std::string resolvedId; + std::unique_ptr built; + Keymap km; + bool haveKeymap = false; + + // 2. Tier 1 first: if the instrument's 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 ZONED keymap. Each surviving zone plays + // its sample repitched from its effective root note (override > ref intrinsic > + // C4). A zone whose WAV fails to decode — a MISSING FILE included — 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(); + } + } + + // 3. Single-capture fast path (S10): an empty performance map plays the ONE + // deliberately-selected capture chromatically across the whole keyboard, resolved + // against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a + // selection with no ref) resolves to nothing, so an un-picked instrument stays + // SILENT (the editor shows its "pick a capture" empty state) rather than + // auto-playing sample #1 (S10 policy reversal of the S4 convenience default). + if (!haveKeymap) { + if (const SelectedSample* sel = findRef(refs, selId)) { + // GA auto-default: channelModeFor computes the mode from the loaded capture's + // REQUESTED channel count (always 2 for extension captures; mono only for + // ingest-imported mono files). An unknown count (0) or explicit user choice + // returns the current mode unchanged. Decode-only: the output bus is fixed + // stereo, so no bus work follows a flip. + { + 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) { + // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). + // Every voice's shifter is pre-sized to this off-thread here, so process()-time + // note-on never allocates. Floored at 2 so a valid window is always a real ring + // (which also covers a pathological host rate <= 0 — no rate literal needed). + std::int64_t preserveWindow = static_cast( + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; + built = std::make_unique( + std::move(km), static_cast(builtVoiceCount), gen, + kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); + } + + // 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the + // DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices — + // a reload never cuts a sounding note; the next note-on plays the new state. The + // instrument evicted FROM the drain slot (two reloads old) goes to the graveyard + // (process may still be mid-block reading it). A null `built` (no ref / unreadable + // WAV) installs silence while the displaced tails still ring out via the drain. + // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted + // pointer is re-owned by the graveyard. + publishBuiltLocked(std::move(built)); + + // 5. pS-usage: publish this instance's held captures so the extension's prune can + // never reclaim them (see publishUsage). AFTER the instrument swap, still off the + // audio thread and under reloadMutex_. Publishes regardless of decode success: + // the holds are the refs the instance RETAINS (its play-set), not what decoded — + // a transiently unreadable WAV must stay protected. + publishUsage(refs, ids); + return resolvedId; +} + +void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, + const std::vector& ids) { + if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do + + UsageRecord mine; + mine.trackGuid = bridge_.currentTrackGuid(); + for (const std::string& id : ids) { + if (const SelectedSample* ref = findRef(refs, id)) { + if (!ref->relativePath.empty()) { + mine.holds.push_back(UsageHold{id, ref->relativePath}); + } + } + } + + std::lock_guard lock(usageMutex_); + // A never-published instance with nothing held writes nothing — no key litter for + // fresh/empty instances. Once an identity exists, empties DO publish (they release + // holds the prune would otherwise keep protecting). + if (instanceGuid_.empty() && mine.holds.empty()) return; + if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); + // The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so + // planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track + // sibling's byte-identical hold set can never pass as ours (its nonce differs), so + // siblings always union and never clean-replace over each other's held paths. + if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid(); + mine.ownerNonce = usageNonce_; + + const std::optional existing = + bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); + const UsagePublishPlan plan = planUsagePublish(existing, mine); + if (plan.remint) { + // This state was cloned onto another track (FX copy / track duplication): take a + // fresh identity and leave the original's record untouched. The abandoned old + // identity's record dies by the extension's liveness rule when its track no + // longer hosts an instance. getState persists the new guid on the next save. + instanceGuid_ = mintUsageInstanceGuid(); + } else if (plan.skipWrite) { + return; // idle tick, or a union that adds nothing — no ext-state churn + } + bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire); +} + +void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { + // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by + // reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance. + // + // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is + // the minimum installedAt process() published over the pointers it holds. Both + // slots are monotone in installedAt, so seen is monotone and any future process() + // load yields installedAt >= seen — an entry below seen is provably unreachable + // (see the header proof). Remaining entries drain at setActive(false) / terminate() + // when process is guaranteed stopped. + const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); + graveyard_.erase( + std::remove_if(graveyard_.begin(), graveyard_.end(), + [seen](const std::unique_ptr& e) { + return e->installedAt < seen; + }), + graveyard_.end()); + LoadedInstrument* prev = live_.exchange(built.release()); + LoadedInstrument* evicted = draining_.exchange(prev); + if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); +} + +void ReaSamplerProcessor::rebuildVoiceEngine() { + // OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract: + // 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 bridge, no disk — + // and publishes through the same drain-slot swap, so ringing tails survive. + std::lock_guard lock(reloadMutex_); + LoadedInstrument* cur = live_.load(std::memory_order_acquire); + if (!cur) return; // nothing loaded: the new params bake into the next real reload. + + int builtVoiceCount = kDefaultVoiceCount; + VoiceMode builtVoiceMode = VoiceMode::Poly; + MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; + { + std::lock_guard vp(voiceParamsMutex_); + builtVoiceCount = voiceCount_; + builtVoiceMode = voiceMode_; + builtMonoTrigger = monoTrigger_; + } + + const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; + // Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate). + std::int64_t preserveWindow = static_cast( + kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); + if (preserveWindow < 2) preserveWindow = 2; + + // Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap + // is immutable after construction, and under reloadMutex_ nobody can free `cur`. + Keymap km = cur->keymap; + auto built = std::make_unique( + std::move(km), static_cast(builtVoiceCount), gen, + kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); + publishBuiltLocked(std::move(built)); +} + +void ReaSamplerProcessor::retireIdleDrain() { + // Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or + // it still sounds" — the common case costs one relaxed load and no mutex. + const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire); + if (idleGen == 0) return; + std::lock_guard lock(reloadMutex_); + LoadedInstrument* drain = draining_.load(std::memory_order_acquire); + // Retire ONLY if the publication names the drain currently in the slot. A stale value + // (about an already-evicted, older drain) can never match the newer occupant's + // installedAt — the slot is monotone in generation — so a mid-swap race is closed by + // this identity check, not by timing. + if (!drain || drain->installedAt != idleGen) return; + draining_.store(nullptr, std::memory_order_release); + graveyard_.push_back(std::unique_ptr(drain)); + // Prune what is now provably unreachable — the same monotone-generation proof as the + // reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be + // held by process() now or ever again. The just-parked drain frees here immediately when + // process() has already published past it; otherwise on the next reload/retire/deactivate. + const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); + graveyard_.erase( + std::remove_if(graveyard_.begin(), graveyard_.end(), + [seen](const std::unique_ptr& e) { + return e->installedAt < seen; + }), + graveyard_.end()); +} + +bool ReaSamplerProcessor::legacyLiftShouldRun() { + // #A terminating guard for the pre-v10 legacy lift. The caller has already established + // refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before + // paying for a full reload. Once concluded, the steady state is this one relaxed load — + // no bank read, no parse, no reload churn. + if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false; + const LegacyLiftDecision decision = legacyLiftDecision( + bridge_.readReasamplerExtState(kProjExtBanksKey), + referencedSampleIds(selectedSampleId(), performanceMap())); + if (decision == LegacyLiftDecision::Stale) { + // Provably stale (the bank parses and knows none of the referenced ids): give up + // PERMANENTLY. A later bank change that re-introduces an id bumps the generation, + // and the genChanged reload refreshes the refs without consulting this latch. + legacyLiftConcluded_.store(true, std::memory_order_relaxed); + return false; + } + return true; // Retry (blob not readable yet) or Lift (a ref can be copied in) +} + +ReaSamplerProcessor::BankSyncResult +ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { + // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call + // REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER + // host, or before connect) yields nullopt for both reads, so this no-ops cleanly. + BankSyncResult result; + + // Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer + // cadence that drives reloads — an edited-away instrument stops costing memory as soon + // as its tails die instead of squatting in the drain slot until the next reload. + retireIdleDrain(); + + // --- S8: assignment-request consume FIRST ------------------------------------- + // Decode the pending assignment request (nullopt when absent/malformed). Resolve its + // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when + // the sampleId names an existing sample (the reader requirement — an unresolvable pair is + // dropped). Then run the pure consume decision against this instance's persisted marker. + std::optional request; + if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) { + request = decodeAssignmentRequest(*raw); + } + + bool resolves = false; + if (request) { + // Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request + // whose sample was rolled back by an extension undo resolves to nullopt -> dropped). + if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) { + resolves = selectSample(*banksJson, request->sampleId).has_value(); + } + } + + // Read lastConsumed and conditionally write it back under a single lock scope so there + // is no interleave window between the read and the write (a concurrent getState could + // otherwise observe a stale marker between the two separate lock acquisitions). + std::int64_t lastConsumed = 0; + const AssignConsumeDecision decision = [&] { + std::lock_guard lock(assignMarkerMutex_); + lastConsumed = lastConsumedAssignGeneration_; + const AssignConsumeDecision d = + consumeDecision(request, lastConsumed, resolves, isFocusedTarget); + // Advance the persisted consumed marker whenever the decision consumed the request + // (applied OR dropped-as-seen). getState will persist it on the next project save so + // a re-open does not re-apply. A non-target instance leaves the marker (decision + // returns it unchanged) so it stays eligible if focus later lands here. + if (d.consumedGeneration != lastConsumed) { + lastConsumedAssignGeneration_ = d.consumedGeneration; + } + return d; + }(); + + if (decision.apply) { + // Apply the assignment as this instance's own selection (the same path a user card-pick + // takes) — the instrument updates its OWN state, never the bank. reloadInstrument below + // rebuilds against the new selection, so skip a redundant reload here. + setSelectedSampleId(decision.sampleId); + // Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone + // materialized for the previously loaded sample would shadow the assigned pick under + // first-match resolve. Authored maps (any narrow key range) are untouched. + PerformanceMap reconciled = performanceMap(); + if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) { + setPerformanceMap(reconciled); + } + result.applied = true; + } + + // --- S9: bank-generation change-detection ------------------------------------- + // Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll + // (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — + // setState already loaded the instrument from its OWNED refs (pS), so a redundant reload + // on open would only churn. A later + // generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the + // reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced). + std::int64_t currentGen = kBankGenerationAbsent; + if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) { + currentGen = parseBankGeneration(*rawGen); + } + const bool firstPoll = (lastSeenBankGeneration_ < 0); + const bool genChanged = + !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen); + lastSeenBankGeneration_ = currentGen; + + // LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones) + // but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had + // nothing to decode unless the bank happened to be readable already. Reload on this + // editor tick until the lift lands: reloadInstrument folds the bank blob into the refs + // when readable, after which the table is non-empty and this never fires again (the + // next save is then self-contained). A deliberately-empty instance has no intent and + // never churns; a bank that is not readable YET retries a cheap null publish on the + // editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob + // PARSES and no referenced id resolves in it, the ids are provably stale — there is + // nothing to lift, so the lift concludes permanently instead of churning a full bank + // read + reload every tick forever. This is a MIGRATION convenience for old projects, + // NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS). + bool legacyLift = false; + if (!genChanged && !result.applied && sampleRefs().empty()) { + const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty(); + legacyLift = hasIntent && legacyLiftShouldRun(); + } + + if (genChanged || result.applied || legacyLift) { + reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard) + // Report the reload distinctly from an S8 apply so the editor re-snapshots its bank + // view. A legacy lift counts only when it actually landed an instrument (otherwise + // every retry tick would churn the editor's caches for nothing). + result.reloaded = + genChanged || + (legacyLift && live_.load(std::memory_order_acquire) != nullptr); + } + return result; +} + +} // namespace reasampler::vst diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp new file mode 100644 index 0000000..202e82e --- /dev/null +++ b/src/shell/instrument/processor_state.cpp @@ -0,0 +1,311 @@ +// processor_state.cpp — the 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, master gain, preview-note mailbox posts). Split out of +// reasampler_processor.cpp (Q-W2v, T4-12). Everything here runs OFF the audio +// thread (UI / host load-save); the setters hand work to the reload family +// (processor_reload.cpp) or store atomics process() picks up at block start. + +#include "shell/instrument/reasampler_processor.h" + +#include +#include +#include + +#include "pluginterfaces/base/ibstream.h" + +#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) +#include "core/instrument/map/component_state_io.h" // the ComponentState codec (Q-W2v split) +#include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds + +using namespace Steinberg; +using namespace Steinberg::Vst; + +namespace reasampler::vst { + +using namespace instrument::map; // the codec + resolution vocabulary this TU marshals + +tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { + if (!state) return kResultFalse; + // Read the whole component-state blob (the performance map, versioned). The blob is + // small; read in one shot into a growable buffer. + std::vector bytes; + std::uint8_t chunk[256]; + int32 got = 0; + while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { + bytes.insert(bytes.end(), chunk, chunk + got); + } + // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The + // selection and the zones are DISTINCT — the default face is one picked capture, zones + // are a demoted overlay — so both are restored explicitly (no more inferring a selection + // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only + // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so + // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 + // silent empty state (no first-sample fallback in reloadInstrument). + // Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at + // the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing + // before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a + // pre-setup call would assert inside readZonesPayload (a programming error, not a field case). + const ComponentState cs = deserializeComponentState(bytes, sampleRate_); + setSelectedSampleId(cs.selectionId); + // Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a + // pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the + // saved selection under first-match resolve. Reconciling here restores "the sample the + // editor shows is the sample the engine plays" for already-affected projects; authored + // Zone-view maps (any narrow key range) pass through untouched. + PerformanceMap restored = cs.map; + reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load + setPerformanceMap(restored); + // S8: restore the last-consumed assignment generation so a re-open does not re-apply a + // stale assign_request (the user may have manually changed the selection after the assign). + { + std::lock_guard lock(assignMarkerMutex_); + lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; + } + // Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see + // initialize) — the mode only governs how the reload below decodes, so no bus work here. + { + std::lock_guard lock(channelModeMutex_); + channelMode_ = cs.channelMode; + channelModeExplicit_ = cs.channelModeExplicit; + } + // S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2 + // the editor's velocity knob is a concurrent UI-thread writer. + { + std::lock_guard lock(previewMutex_); + previewVelocity_ = cs.previewVelocity; + } + // Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly, + // Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the + // reload below so the rebuilt engine is born with the saved polyphony/mode. + { + std::lock_guard lock(voiceParamsMutex_); + voiceCount_ = cs.voiceCount; + voiceMode_ = cs.voiceMode; + monoTrigger_ = cs.monoTrigger; + } + // FB1: restore the post-mixer master gain (v8; older blobs lift to unity in + // deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks + // it up at the next block start. + setMasterGainLinear(cs.masterGainLinear); + // pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the + // reload so it decodes straight from them — no bank read required to play. A pre-v10 + // blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob + // becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift), + // after which the next save is self-contained. + { + std::lock_guard lock(refsMutex_); + sampleRefs_ = cs.sampleRefs; + } + // pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty — + // minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME + // for the copy-collision analysis (the fresh nonce means this incarnation can never + // be mistaken for the previous one's writes — or for a copy-sibling's). + { + std::lock_guard lock(usageMutex_); + instanceGuid_ = cs.instanceGuid; + usageNonce_.clear(); + } + // A new blob is new facts: a staleness proof latched against the PREVIOUS state does + // not carry over (#A — the legacy lift gets one fresh run per restored state). + legacyLiftConcluded_.store(false, std::memory_order_relaxed); + // Rebuild from the restored state (off-thread — setState is a load-time call). + reloadInstrument(); + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { + if (!state) return kResultFalse; + // Persist the full instance state (v3, S10): the single-capture selection id AND the + // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler" + // bank ext-state. An instance with no pick and no zones serializes to {"", no zones} + // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing + // sample #1. + ComponentState state_out; + state_out.selectionId = selectedSampleId(); + state_out.map = performanceMap(); + { + // S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9). + std::lock_guard lock(channelModeMutex_); + state_out.channelMode = channelMode_; + state_out.channelModeExplicit = channelModeExplicit_; + } + { + std::lock_guard lock(assignMarkerMutex_); + state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker + } + state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity + { + // Phase S: persist the voice-system parameters (component state v7). + std::lock_guard lock(voiceParamsMutex_); + state_out.voiceCount = voiceCount_; + state_out.voiceMode = voiceMode_; + state_out.monoTrigger = monoTrigger_; + } + state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8) + // pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to + // decode + play with no extension present. Filtered (on the snapshot copy, the member is + // untouched) to exactly 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)); + // pS-usage: persist the publish identity (v11) so the instance's usage key is + // stable across sessions (records do not proliferate per reopen). + { + std::lock_guard lock(usageMutex_); + state_out.instanceGuid = instanceGuid_; + } + const std::vector bytes = serializeComponentState(state_out); + if (!bytes.empty()) { + const tresult wr = state->write(const_cast(bytes.data()), + static_cast(bytes.size()), nullptr); + if (wr != kResultOk) return wr; + } + return kResultOk; +} + +std::string ReaSamplerProcessor::selectedSampleId() { + std::lock_guard lock(selectionMutex_); + return selectedSampleId_; +} + +void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { + std::lock_guard lock(selectionMutex_); + selectedSampleId_ = id; +} + +PerformanceMap ReaSamplerProcessor::performanceMap() { + std::lock_guard lock(performanceMutex_); + return performanceMap_; +} + +void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { + std::lock_guard lock(performanceMutex_); + performanceMap_ = map; +} + +SampleRefs ReaSamplerProcessor::sampleRefs() { + std::lock_guard lock(refsMutex_); + return sampleRefs_; +} + +ChannelMode ReaSamplerProcessor::channelMode() { + std::lock_guard lock(channelModeMutex_); + return channelMode_; +} + +std::uint8_t ReaSamplerProcessor::previewVelocity() { + std::lock_guard lock(previewMutex_); + return previewVelocity_; +} + +void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { + // Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview + // strike must sound). The editor's knob maps its 0..1 domain into this range before calling. + if (velocity < 1) velocity = 1; + if (velocity > 127) velocity = 127; + std::lock_guard lock(previewMutex_); + previewVelocity_ = velocity; +} + +int ReaSamplerProcessor::voiceCount() { + std::lock_guard lock(voiceParamsMutex_); + return voiceCount_; +} + +void ReaSamplerProcessor::setVoiceCount(int count) { + // Clamp to the shared pure-core range so the engine, the state bytes, and the editor's + // control can never disagree about the legal polyphony span. + if (count < kMinVoiceCount) count = kMinVoiceCount; + if (count > kMaxVoiceCount) count = kMaxVoiceCount; + { + std::lock_guard lock(voiceParamsMutex_); + if (voiceCount_ == count) return; // no-op: don't churn a rebuild + voiceCount_ = count; + } + // LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from + // the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change + // touches no audio data) and the displaced instrument keeps rendering its ringing tails, + // so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every + // zone from disk. Same contract for the mode/trigger setters below. + rebuildVoiceEngine(); +} + +VoiceMode ReaSamplerProcessor::voiceMode() { + std::lock_guard lock(voiceParamsMutex_); + return voiceMode_; +} + +void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) { + { + std::lock_guard lock(voiceParamsMutex_); + if (voiceMode_ == mode) return; + voiceMode_ = mode; + } + rebuildVoiceEngine(); +} + +MonoTrigger ReaSamplerProcessor::monoTrigger() { + std::lock_guard lock(voiceParamsMutex_); + return monoTrigger_; +} + +void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { + { + std::lock_guard lock(voiceParamsMutex_); + if (monoTrigger_ == trigger) return; + monoTrigger_ = trigger; + } + rebuildVoiceEngine(); +} + +void ReaSamplerProcessor::setMasterGainLinear(double linear) { + // Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap = + // +24 dB). One relaxed atomic store — the audio thread reads it at the next block start; + // no rebuild, no lock (a post-sum output trim is not a keymap fact). + if (!(linear >= 0.0)) linear = 0.0; // also catches NaN + const double maxLin = masterGainMaxLinear(); + if (linear > maxLin) linear = maxLin; + masterGain_.store(static_cast(linear), std::memory_order_relaxed); +} + +void ReaSamplerProcessor::previewNoteOn(int note) { + if (note < 0) note = 0; + if (note > 127) note = 127; + const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request + // Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as + // long as we never land back on the exact value the audio thread last consumed in one step — + // 16 bits gives 65535 posts between collisions, unreachable at UI-click rates). + const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_; + const std::uint32_t packed = (static_cast(seq) << 16) | + (static_cast(vel) << 8) | + static_cast(note & 0xFF); + previewOnRequest_.store(packed, std::memory_order_release); +} + +void ReaSamplerProcessor::previewNoteOff(int note) { + if (note < 0) note = 0; + if (note > 127) note = 127; + const std::uint16_t seq = ++previewOffSeq_ == 0 ? ++previewOffSeq_ : previewOffSeq_; + const std::uint32_t packed = (static_cast(seq) << 16) | + static_cast(note & 0xFF); + previewOffRequest_.store(packed, std::memory_order_release); +} + +void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { + { + std::lock_guard lock(channelModeMutex_); + // The editor toggle is a DELIBERATE choice either way: latch explicit even on a + // same-mode click (the user confirmed the mode; the GA auto-default stops fighting it). + channelModeExplicit_ = true; + if (channelMode_ == mode) return; // no decode change: don't churn a reload + channelMode_ = mode; + } + // The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no + // restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread + // (mono = downmix, stereo = L/R split) and the RT path just keeps rendering. + reloadInstrument(); +} + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_editor.h b/src/shell/instrument/reasampler_editor.h similarity index 96% rename from src/vst/reasampler_editor.h rename to src/shell/instrument/reasampler_editor.h index 18a26ea..3de98fd 100644 --- a/src/vst/reasampler_editor.h +++ b/src/shell/instrument/reasampler_editor.h @@ -22,7 +22,6 @@ // to create/destroy the child window and onSize to resize it. #pragma once -#include "core/namespaces.h" #include #include @@ -47,6 +46,25 @@ class LICE_IBitmap; // fwd: the paint helpers take one; lice.h is included only namespace reasampler::vst { +// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim +// is retired from the editor family; engine symbols — ChannelMode, VoiceMode, MonoTrigger, +// the voice-count constants, VelocityCurve via the engine re-export — stay in flat +// `reasampler` and resolve via the enclosing namespace). +using audio::AudioSample; +using audio::Envelope; +using instrument::map::BankChoice; +using instrument::map::PerformanceMap; +using instrument::map::PerformanceZone; +using instrument::map::SampleChoice; +using instrument::map::SampleRefEntry; +using instrument::map::SampleRefs; +using instrument::map::ZonePlaySeconds; +using instrument::ui::AmpEnvelope; +using instrument::ui::DeckGroupDesc; +using instrument::ui::EnvClampBounds; +using instrument::ui::EnvNode; +using instrument::ui::Rect; + class ReaSamplerProcessor; class ReaSamplerEditor : public Steinberg::CPluginView { @@ -202,6 +220,12 @@ private: bool handlePopupMouseDown(int w, int h, int x, int y); void onMouseDown(int x, int y); + // The Browse-modal and Zone-surface halves of the mouse-down dispatch (Q-W2v: the + // input TUs split along the face axis — onMouseDown keeps the Sample-face branch and + // delegates these two; bodies in editor_input_browse_zone.cpp). Behavior-identical + // to the former inline branches. + void mouseDownBrowse(int w, int h, int x, int y); + void mouseDownZone(int w, int h, int x, int y); void onMouseMove(int x, int y); void onMouseUp(int x, int y); // r11: right-click — the curve popup's PRIMARY node-delete affordance (issue 3c). Only diff --git a/src/shell/instrument/reasampler_embed.cpp b/src/shell/instrument/reasampler_embed.cpp index e3c46bf..f5b8ddc 100644 --- a/src/shell/instrument/reasampler_embed.cpp +++ b/src/shell/instrument/reasampler_embed.cpp @@ -16,7 +16,7 @@ #include "core/instrument/ui/embed_strip.h" // the pure strip layout + hit-test #include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey #include "shell/instrument/reaper_bridge.h" -#include "reasampler_processor.h" +#include "shell/instrument/reasampler_processor.h" #include "core/ui/theme.h" // Role / InteractionState / spectralColor (L3) // wdltypes.h first: it defines INT_PTR portably (and pulls on Windows), which diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp new file mode 100644 index 0000000..8f11876 --- /dev/null +++ b/src/shell/instrument/reasampler_processor.cpp @@ -0,0 +1,425 @@ +// reasampler_processor.cpp — see reasampler_processor.h. Since Q-W2v (T4-12) this TU is +// the VST3 LIFECYCLE + the REAL-TIME process() path ONLY: factory/queryInterface, +// initialize/terminate/setActive, bus setup, and the block render (MIDI marshal, preview +// mailbox drain, engine + drain sum, master-gain ramp). Component-state I/O + parameter +// accessors live in processor_state.cpp; the off-thread reload/publish family lives in +// processor_reload.cpp. process() and its per-block work stay ONE TU (T4-29): no virtual +// seam, no cross-TU call on the per-sample path. + +#include "shell/instrument/reasampler_processor.h" + +#include +#include +#include + +#include "pluginterfaces/vst/ivstaudioprocessor.h" +#include "pluginterfaces/vst/ivstevents.h" +#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) +#include "pluginterfaces/vst/vstspeaker.h" + +#include "shell/instrument/reasampler_editor.h" // createView hands the host our IPlugView editor +#include "shell/instrument/reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) + +using namespace Steinberg; +using namespace Steinberg::Vst; + +namespace reasampler::vst { + +namespace { + +// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a +// linear per-sample step derived from this at setupProcessing (gainRampStep_ = +// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing +// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as +// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is +// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_ +// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks. +constexpr double kGainRampSeconds = 0.020; + +} // namespace + +FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { + // The host owns the returned reference. Cast up to the combined interface the SDK + // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. + return static_cast(new ReaSamplerProcessor()); +} + +// Out-of-line so unique_ptr sees the complete type here. +ReaSamplerProcessor::~ReaSamplerProcessor() = default; + +tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { + // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for + // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed + // shell. We own the shell (unique_ptr); the borrowed reference is valid because the + // processor outlives it. All other iids fall through to the SDK's queryInterface. + if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { + if (!embed_) embed_ = std::make_unique(this); + embed_->addRef(); + *obj = static_cast(embed_.get()); + return kResultOk; + } + return SingleComponentEffect::queryInterface(iid, obj); +} + +tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { + tresult result = SingleComponentEffect::initialize(context); + if (result != kResultOk) return result; + + // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the + // instrument still loads, it just has no live bank to play. + bridge_.connect(context); + + // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no + // audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of + // the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders + // dual-mono through the stereo bus (both channels equal, centered), which is audibly + // identical to a mono bus but never asks the host to re-map a live instance's pins. The + // prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every + // mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The + // in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer + // write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's + // re-routing of the live instance's pins across the arrangement change. A fixed arrangement + // is the maximally-standard VSTi shape and removes that whole negotiation surface. + addEventInput(STR16("MIDI In"), 16); + addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo); + + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::terminate() { + // process() is not running at terminate: free the live + draining instruments and + // drain the graveyard. Take the pointers out of the atomics first so nothing else + // races them. + std::lock_guard lock(reloadMutex_); + delete live_.exchange(nullptr); + delete draining_.exchange(nullptr); + graveyard_.clear(); + return SingleComponentEffect::terminate(); +} + +tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { + // Activating: build the instrument from the currently-selected sample so the first + // block after activation can play. Deactivating: process is now GUARANTEED stopped by + // the host, so this is the safe point to reclaim the graveyard (the displaced engines + // no reload could free while active). The build/drain are off the audio thread — + // setActive is a main/UI-thread call. + if (state) { + // Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED + // sample refs — it needs no bank read, so it plays regardless of whether the + // extension's PROJEXTSTATE has parsed yet (or the extension exists at all). + // + // #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a + // pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic + // refreshRefsFromBank copies the refs in when the bank blob is readable by + // activation time, so an upgraded project plays on load without the instrument + // ever being opened (and the next save is self-contained). Residual load-order + // race, DAW-verifiable only: if the host activates this instance BEFORE the + // project's ext-state lines parse, the lift misses here and — with no editor open — + // nothing retries until the next activation or editor tick. MIGRATION NOTE: open a + // pre-v10 instrument once after upgrading if it restores silent. + reloadInstrument(); + } else { + std::lock_guard lock(reloadMutex_); + // process is guaranteed stopped: free EVERYTHING. The live instrument too — its + // voices are frozen mid-flight, and if it survived deactivation the reactivate + // reload would displace it into the DRAIN slot, resurrecting stale sustained + // voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above), + // so nothing is lost by clearing here. + delete live_.exchange(nullptr); + delete draining_.exchange(nullptr); + graveyard_.clear(); + } + return kResultOk; +} + +tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { + sampleRate_ = setup.sampleRate; + maxBlockSize_ = setup.maxSamplesPerBlock; + // T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at + // every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host + // guarantees setupProcessing never overlaps process), read on the audio thread only. + if (sampleRate_ > 0.0) { + gainRampStep_ = static_cast(1.0 / (kGainRampSeconds * sampleRate_)); + } + return SingleComponentEffect::setupProcessing(setup); +} + +tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( + SpeakerArrangement* inputs, int32 numIns, + SpeakerArrangement* outputs, int32 numOuts) { + // ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a + // decode policy, never a bus fact). We take NO audio input, so any inputs are rejected. + // Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse) + // and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a + // proposal keeps a valid arrangement of its own) — the host adapts its routing to us. + if (numIns < 0 || numOuts < 0) return kInvalidArgument; + if (numIns > 0) return kResultFalse; // no audio input bus to arrange + if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue; + return kResultFalse; +} + +tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { + // REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments + // once for the whole block (two atomic acquires), then publish the MINIMUM installedAt + // over the pointers held so the off-thread graveyard pruner knows exactly which + // generations this block is holding (see the header proof). + // + // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an + // ordering race: reading reloadGeneration_ after the slots could observe a generation + // newer than the pointers we actually hold, causing the pruner to free an instrument + // process is still reading. installedAt was set on the reload path before the atomic + // exchange that made the instrument visible. + // + // The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the + // last reload: its already-sounding voices keep rendering (and receive note-offs) so a + // curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons. + // A racing reload can briefly leave the same pointer in both slots (live_ was loaded + // before the swap, draining_ after); collapse that to live-only so one engine is never + // advanced twice per frame. + LoadedInstrument* inst = live_.load(std::memory_order_acquire); + LoadedInstrument* drain = draining_.load(std::memory_order_acquire); + if (drain == inst) drain = nullptr; + std::uint64_t heldGen = 0; + if (inst && drain) { + heldGen = inst->installedAt < drain->installedAt ? inst->installedAt + : drain->installedAt; + } else if (inst) { + heldGen = inst->installedAt; + } else if (drain) { + heldGen = drain->installedAt; + } + processGeneration_.store(heldGen, std::memory_order_release); + + // Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine + // voice silent) by naming its OWN installedAt (0 = no drain / still + // sounding). Evaluated at block START — idleness is monotone for a drain (it receives no + // note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply + // publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe. + drainIdleGeneration_.store( + (drain && drain->fullyIdle()) ? drain->installedAt : 0, + std::memory_order_relaxed); + + // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps + // events at block granularity (no per-event sample-offset split) — audible timing is + // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. + // Note-offs also route to the DRAIN engine so a note held across a reload releases + // its old-snapshot voice too (otherwise it would sustain until the next reload). + if (data.inputEvents) { + const int32 count = data.inputEvents->getEventCount(); + for (int32 i = 0; i < count; ++i) { + Event e; + if (data.inputEvents->getEvent(i, e) != kResultOk) continue; + if (e.type == Event::kNoteOnEvent) { + // A note-on with velocity 0 is a note-off by MIDI convention. + const int vel = static_cast(e.noteOn.velocity * 127.0f + 0.5f); + if (vel <= 0) { + if (inst) inst->engine.noteOff(e.noteOn.pitch); + if (drain) drain->engine.noteOff(e.noteOn.pitch); + } else if (inst) { + inst->engine.noteOn(e.noteOn.pitch, vel); + } + } else if (e.type == Event::kNoteOffEvent) { + if (inst) inst->engine.noteOff(e.noteOff.pitch); + if (drain) drain->engine.noteOff(e.noteOff.pitch); + } else if (e.type == Event::kLegacyMIDICCOutEvent) { + // PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a + // VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism + // — the type is nominally an output event; DAW-verify, see handoff). + // CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR + // release tail; Trigger one-shots play through their bounded play length. + // CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless + // of play mode, including Trigger one-shots that ignore CC 123. This is the + // true "panic" for a ringing one-shot (e.g. a full-length capture). + // Both clear the mono held stack. Both apply to live AND drain. A ringing + // preview note is a real engine voice since the PreviewCard retirement, so + // the panics cover it with no separate routing. allNotesOff / allSoundsOff + // are RT-safe (no allocation, bounded scans). + const auto cc = static_cast(e.midiCCOut.controlNumber); + if (cc == kCtrlAllSoundsOff) { + if (inst) inst->engine.allSoundsOff(); + if (drain) drain->engine.allSoundsOff(); + } else if (cc == kCtrlAllNotesOff) { + if (inst) inst->engine.allNotesOff(); + if (drain) drain->engine.allNotesOff(); + } + } + } + } + + // S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed + // atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last + // one we consumed; fire it once, then latch the sequence so the same request never re-fires. + // Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact + // noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real + // voice: it counts against the voice count, can steal / be stolen, and respects + // Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's + // isolation). The editor posts the root note, so it plays at unity. + // Consume (advance the sequence) even when inst is null so a note-on posted while no instrument + // is loaded does not re-fire stale on the next instrument load. + { + const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire); + const std::uint16_t onSeq = static_cast(on >> 16); + if (onSeq != 0 && onSeq != previewOnConsumed_) { + previewOnConsumed_ = onSeq; + if (inst) { + const int vel = static_cast((on >> 8) & 0xFF); + const int note = static_cast(on & 0xFF); + if (vel > 0) inst->engine.noteOn(note, vel); + } + } + } + { + const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire); + const std::uint16_t offSeq = static_cast(off >> 16); + if (offSeq != 0 && offSeq != previewOffConsumed_) { + // Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending + // while nothing was loaded would otherwise survive until a (heal) reload lands + // and release the NEXT preview press in the same block. + previewOffConsumed_ = offSeq; + // Route the preview note-off to BOTH engines (mirror of the host note-off): a + // preview held across a reload — e.g. a curve edit committed mid-press — must + // release the old-snapshot voice now draining, not just the (fresh) live one. + // NOTE: preview shares the host-MIDI note space — noteOff releases the newest + // voice at that pitch, so a preview release can release a host-held note at + // the same pitch (inherent to routing preview through the real note path). + if (inst) inst->engine.noteOff(static_cast(off & 0xFF)); + if (drain) drain->engine.noteOff(static_cast(off & 0xFF)); + } + } + + if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { + embedPeak_.store(0.f, std::memory_order_relaxed); + return kResultOk; + } + AudioBusBuffers& out = data.outputs[0]; + const int32 frames = data.numSamples; + + // 64-bit host processing is not supported by the mono float core; emit silence + // rather than mis-render. REAPER runs 32-bit float by default. + if (data.symbolicSampleSize != kSample32) { + embedPeak_.store(0.f, std::memory_order_relaxed); + for (int32 ch = 0; ch < out.numChannels; ++ch) { + if (double* buf = out.channelBuffers64[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; + } + } + out.silenceFlags = (out.numChannels >= 64) + ? ~0ULL + : ((1ULL << out.numChannels) - 1); + return kResultOk; + } + + // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked + // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here + // we simply match the buffers the host handed us: >=2 channels -> true stereo render into + // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the + // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here. + float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; + float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; + if (ch0 && ch1) { + // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo + // path (both channels equal), so a mono capture in stereo mode is centered, not silent. + // The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer). + for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } + if (inst) inst->engine.render(ch0, ch1, static_cast(frames)); + if (drain) drain->engine.render(ch0, ch1, static_cast(frames)); + // FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so + // continuous knob drags produce no zipper noise and the true-zero bottom causes no click. + // Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the + // actual output. Branch-free inner loop; early-out when already at target. RT-safe. + { + const float gTarget = masterGain_.load(std::memory_order_relaxed); + const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step + const float gSnap = 0.5f * gStep; + const float diff = gTarget - gainCurrent_; + if (diff < -gSnap || diff > gSnap) { + // Ramp toward target: step per sample, then apply the per-sample gain. + for (int32 i = 0; i < frames; ++i) { + const float d = gTarget - gainCurrent_; + if (d > gStep) gainCurrent_ += gStep; + else if (d < -gStep) gainCurrent_ -= gStep; + else gainCurrent_ = gTarget; + ch0[i] *= gainCurrent_; + ch1[i] *= gainCurrent_; + } + } else { + gainCurrent_ = gTarget; + if (gTarget != 1.f) { + for (int32 i = 0; i < frames; ++i) { ch0[i] *= gTarget; ch1[i] *= gTarget; } + } + } + } + // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). + for (int32 ch = 2; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; + if (a0 > peak) peak = a0; + if (a1 > peak) peak = a1; + } + embedPeak_.store(peak, std::memory_order_relaxed); + } else if (ch0) { + // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; + // the replicate is defensive for a host that still hands >1 channel on a mono bus). + for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; + if (inst) inst->engine.render(ch0, static_cast(frames)); + if (drain) drain->engine.render(ch0, static_cast(frames)); + // FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch: + // post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe. + { + const float gTarget = masterGain_.load(std::memory_order_relaxed); + const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step + const float gSnap = 0.5f * gStep; + const float diff = gTarget - gainCurrent_; + if (diff < -gSnap || diff > gSnap) { + for (int32 i = 0; i < frames; ++i) { + const float d = gTarget - gainCurrent_; + if (d > gStep) gainCurrent_ += gStep; + else if (d < -gStep) gainCurrent_ -= gStep; + else gainCurrent_ = gTarget; + ch0[i] *= gainCurrent_; + } + } else { + gainCurrent_ = gTarget; + if (gTarget != 1.f) { + for (int32 i = 0; i < frames; ++i) ch0[i] *= gTarget; + } + } + } + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + if (a > peak) peak = a; + } + embedPeak_.store(peak, std::memory_order_relaxed); + for (int32 ch = 1; ch < out.numChannels; ++ch) { + if (float* buf = out.channelBuffers32[ch]) { + for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; + } + } + } + + // Report silence only when nothing is loaded (lets the host optimize when idle). + // With an instrument loaded — or a drain snapshot still ringing out — we clear the + // flag so a ringing voice is not skipped. + out.silenceFlags = (inst || drain) ? 0 + : ((out.numChannels >= 64) + ? ~0ULL + : ((1ULL << out.numChannels) - 1)); + return kResultOk; +} + +IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) { + if (name && FIDStringsEqual(name, ViewType::kEditor)) { + return new ReaSamplerEditor(this); + } + return nullptr; +} + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h similarity index 98% rename from src/vst/reasampler_processor.h rename to src/shell/instrument/reasampler_processor.h index e339048..3c01c6c 100644 --- a/src/vst/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -25,7 +25,6 @@ // single atomic pointer swap. See the LoadedInstrument handoff below. #pragma once -#include "core/namespaces.h" #include #include @@ -38,10 +37,20 @@ #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" namespace reasampler::vst { +// Cross-subsystem deps by their real namespace homes (Q-W2v: the core/namespaces.h shim +// is retired from the processor family; the engine family's symbols — Keymap, VoiceEngine, +// ChannelMode, VoiceMode, MonoTrigger, the voice-count constants — still live in flat +// `reasampler` and resolve via the enclosing namespace). +using instrument::map::ComponentState; +using instrument::map::PerformanceMap; +using instrument::map::SampleRefs; +using instrument::map::kPreviewVelocityDefault; + class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface) // One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice diff --git a/src/shell/instrument/vst_entry.cpp b/src/shell/instrument/vst_entry.cpp index 4b32533..f41070c 100644 --- a/src/shell/instrument/vst_entry.cpp +++ b/src/shell/instrument/vst_entry.cpp @@ -24,7 +24,7 @@ #include "core/version/app_version.h" // vstPluginName / appVersion — the channel-derived identity #include "ext_keys.h" // kProjExtNamespace — the pairing-surface assertion target -#include "reasampler_processor.h" +#include "shell/instrument/reasampler_processor.h" #include "shell/instrument/reasampler_vst.h" // channel-selected class UID (REASAMPLER_ACTIVE_UID_*) // CHANNEL PAIRING INVARIANT (S18). The instrument's PLUGIN identity forks by the ONE channel diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp deleted file mode 100644 index 0bf74fb..0000000 --- a/src/vst/reasampler_editor.cpp +++ /dev/null @@ -1,3084 +0,0 @@ -#include "core/namespaces.h" -#include "core/util/clamp01.h" -// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the -// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is -// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults. - -#include "reasampler_editor.h" - -#include -#include -#include // snprintf (Phase S voice-count readout) -#include -#include -#include - -#include "core/instrument/ui/browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry -#include "core/instrument/ui/capture_browser.h" -#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) -#include "core/ui/component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3) -#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) -#include "core/instrument/ui/curve_popup.h" // r11 centered curve-popup sheet geometry (FB1) -#include "shell/panel/draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3) -#include "core/instrument/ui/editor_geometry.h" // Rect, contains -#include "ext_keys.h" -#include "core/instrument/ui/keyboard_strip.h" -#include "core/instrument/engine/master_gain.h" // r11 master-gain dB<->linear<->knob taper (FB1) -#include "core/ui/theme.h" // Role / InteractionState / KitColor / spectralColor (L3) -#include "core/instrument/map/note_entry.h" // S12 direct numeric note-entry parse -#include "core/instrument/ui/param_slider.h" // the FA4 radial-knob primitive (value<->needle map, drag delta) -#include "core/audio/peaks.h" // computeEnvelope -#include "shell/instrument/reaper_bridge.h" -#include "reasampler_processor.h" -#include "core/version/app_version.h" // vstPluginName (channel-derived editor title band, S18) -#include "core/instrument/map/sample_map.h" -#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames -#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / framesToFadeFraction / fadeFractionToFrames (S-VIEW-3) -#include "core/instrument/ui/waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) - -#ifdef _WIN32 -#include // GET_X_LPARAM / GET_Y_LPARAM -#include // DragAcceptFiles / DragQueryFile / DragFinish — S13 editor drop-accept - -#include "wdltypes.h" -#include "lice/lice.h" -#endif - -using namespace Steinberg; - -namespace reasampler::vst { - -namespace { -#ifdef _WIN32 -constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; - -// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread -// timer: responsive enough that a recapture/ingest/assign refreshes "within a bounded cadence" -// (the S9 verify criterion) yet cheap — three small ext-state reads per tick, coalescing many -// bumps between ticks into one reload. 500 ms is a deliberate build-time residual: fast enough -// to feel hands-free, slow enough to be free. The id is a per-window SetTimer id (any nonzero). -constexpr UINT_PTR kSyncTimerId = 1; -constexpr UINT kSyncTimerIntervalMs = 500; - -// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry is in -// capture_browser / keyboard_strip / knob_deck). r11 Sample face (top->bottom): a TITLE band -// (name + Browse/Zone nav buttons), the FULL-WIDTH ELASTIC HERO (absorbs all height left after -// the fixed bands, floor kHeroMinHeight — the S11 markers + the S-VIEW-3 envelope overlay trace -// over it), the ROOT + PREVIEW CLUSTER (remainder-width root strip + preview-trigger + radial -// velocity knob + mini curve-preview button + Mono/Stereo), and the bottom-anchored KNOB DECK -// (the fenced control groups — the r11 replacement for the slider control strip). Browse + Zone -// reuse the browser grid / zone strip machinery unchanged. -constexpr int kTitleHeight = 26; -constexpr int kHeroMinHeight = 150; // the elastic hero's floor (r11) -constexpr int kClusterHeight = 52; // root strip + preview + vel knob + curve btn + channel toggle -constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip) -constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons - -// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette. The waveform's -// start point + the sustain-loop ends are CATEGORICAL kinds (a distinct affordance class, -// §2.1), not the live/active layer, so they take the categorical accents: 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; - -// --- Rect <-> kit adapters (Phase L, L3) ------------------------------------- -// -// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against `KitBox` -// (component_geometry). This is the single boundary that bridges them so every draw routes -// through the L1 kit (theme roles + draw_kit), retiring the shell's raw LICE_RGBA palette + -// GDI DrawTextA path. -KitBox toKitBox(const Rect& r) { - return KitBox{r.x, r.y, r.width, r.height}; -} - -// Kit text in a palette ROLE (the common case). Left/Right/Center via Align. -void kitText(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role, - Align align = Align::Left) { - text(bmp, toKitBox(r), s, font, role, align); -} - -void kitTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role) { - text(bmp, toKitBox(r), s, font, role, Align::Center); -} - -// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the -// common DAW convention REAPER uses). -std::string noteLabel(int note) { - static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F", - "F#", "G", "G#", "A", "A#", "B"}; - if (note < 0) note = 0; - if (note > 127) note = 127; - const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4 - return std::string(kNames[note % 12]) + std::to_string(octave); -} - -// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3): -// midline + accent-primary min/max columns with the same dB display compression the dock -// panel thumbnail uses, so a waveform reads identically wherever it is drawn. The caller has -// already filled the surface behind it (bg/panel), matching drawWaveform's contract. -void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) { - drawWaveform(bmp, toKitBox(r), env); -} - -// A display name for a bank sample id: the snapshotted bank list first, then the -// instance-OWNED ref's displayName (pS — the label survives with the extension absent / -// bank unreadable, mirroring the waveform + loop-marker ref fallback). "?" only when -// neither source knows the id (a stale zone naming a deleted sample, or a pre-displayName -// refs table not yet back-filled by a bank refresh). -std::string sampleLabel(const std::vector& samples, const SampleRefs& refs, - const std::string& id) { - for (const SampleChoice& c : samples) { - if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; - } - for (const SampleRefEntry& e : refs) { - if (e.sampleId == id && !e.displayName.empty()) return e.displayName; - } - return "?"; -} - -// The bin count a card's thumbnail is computed at: one bin per drawn pixel column — the -// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins. -// thumbnailFor clamps the request to the decoded frame count. -int thumbBins(const BrowserLayout& layout) { - return (std::max)(1, kWaveformOversample * - waveformColumnCount(toKitBox(cardThumbnailRect(layout, 0)))); -} -#endif -} // namespace - -ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) - : CPluginView(nullptr), processor_(processor) { - // Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample - // home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate - // mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll - // on a 1080p screen with headroom. Wide enough that the control strip's label + value columns - // read comfortably. - ViewRect r(0, 0, 840, 620); - setRect(r); -} - -void ReaSamplerEditor::refreshFromBank() { - // Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). - thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks - pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source) - if (!processor_) { - samples_.clear(); - banks_.clear(); - visible_.clear(); - selectedId_.clear(); - map_.zones.clear(); - selectedZone_ = -1; - 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(); - channelMode_ = processor_->channelMode(); - voiceCount_ = processor_->voiceCount(); // Phase S voice-deck snapshot - voiceMode_ = processor_->voiceMode(); - monoTrigger_ = processor_->monoTrigger(); - if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; - // r11: a refresh that emptied the selection (a bank change on the sync tick) closes the - // curve popup — the empty-state Sample face no longer draws it, and an open-but-invisible - // modal would swallow clicks. - if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false; - // FB2: on the Zone surface the popup edits the SELECTED zone; close it if the zones list - // shrank (selectedZone_ past-end), OR if the zone count changed at all — a mid-list - // deletion leaves selectedZone_ in range but now naming a DIFFERENT zone (silent retarget). - if (view_ == View::kZone && curvePopupOpen_) { - const auto newZoneCount = static_cast(map_.zones.size()); - if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false; - } - // Drop a filter that names a bank no longer present. - if (!activeFilterBankId_.empty()) { - bool found = false; - for (const BankChoice& b : banks_) if (b.id == activeFilterBankId_) found = true; - if (!found) activeFilterBankId_.clear(); - } - rebuildVisible(); -} - -void ReaSamplerEditor::rebuildVisible() { - // S12 composition: the bank filter picks the bank FIRST, then the type-to-filter search - // narrows the survivors by name substring (nameMatchesQuery — empty query is the identity). - visible_.clear(); - for (const SampleChoice& s : samples_) { - const bool inBank = activeFilterBankId_.empty() || s.bankId == activeFilterBankId_; - if (!inBank) continue; - const std::string& name = s.displayName.empty() ? s.id : s.displayName; - if (nameMatchesQuery(name, searchQuery_)) visible_.push_back(s); - } - // NOTE: scrollOffset_ is clamped at paint + wheel time (where the browser layout / panel - // height is known); rebuildVisible runs cross-platform + on the sync-timer refresh, so it - // must not reset the user's scroll here. -} - -#ifdef _WIN32 -// Windows-only (the WM_TIMER cadence + invalidate() are the Win32 child-window path). Declared -// under the same _WIN32 guard in the header; keep the definition guarded to match (D5 makes -// Windows the only build target, but the TU must still compile elsewhere). -void ReaSamplerEditor::onSyncTimer() { - // UI thread (WM_TIMER). Poll the S9 bank generation + the S8 assignment request via the - // processor (off the audio thread — the poll itself never touches process()). NEVER while a - // drag is in flight: a reload mid-drag would rebuild the instrument and repaint under the - // user's cursor, yanking the edit. The next tick (500 ms) picks up the change after release. - if (!processor_) return; - if (drag_ != DragKind::kNone) return; // defer past the in-flight edit - - // An open editor marks THIS instance the focused assignment target (the thundering-herd - // policy — only an editor-open instance applies a pending assign; see the handoff). Pass - // true so this instance consumes the request; instances with no editor open do not poll at - // all (the timer is bound to the child window), so they never contend for the request. - const ReaSamplerProcessor::BankSyncResult r = processor_->pollBankSync(/*isFocusedTarget=*/true); - - // Re-snapshot the editor's own view only when something changed (a reload from a bank - // content change, or an applied assignment). refreshFromBank re-reads the bank blob + the - // processor's (possibly just-updated) selection/map and drops the stale thumbnail/PCM - // caches, then repaints — so the browser + setup surface reflect the new bank hands-free. - if (r.reloaded || r.applied) { - refreshFromBank(); - invalidate(); - } - - // S13: decay the drop-affordance banner so it auto-dismisses a few ticks after a drop. - if (dropHintTicks_ > 0) { - --dropHintTicks_; - invalidate(); - } -} -#endif // _WIN32 - -void ReaSamplerEditor::commitAndReload() { - // UI thread only. Publish the edited selection + zones to the processor, then rebuild - // the instrument off the audio thread (reloadInstrument bakes them into the live Keymap). - // pS: the reload also COPIES the picked capture's file ref + intrinsics from the bank - // blob into the instance-owned refs table (refreshRefsFromBank) — a browser load is the - // moment the instance becomes self-contained for that sample. - if (!processor_) return; - processor_->setSelectedSampleId(selectedId_); - processor_->setPerformanceMap(map_); - processor_->reloadInstrument(); - // GA: the reload may have AUTO-DEFAULTED the channel mode from the loaded capture's - // channel count (implicit mode only) — re-read so the Mono/Stereo toggle draws the mode - // the engine actually decoded with. - channelMode_ = processor_->channelMode(); -#ifdef _WIN32 - invalidate(); -#endif -} - -void ReaSamplerEditor::loadSelection(const std::string& id) { - // Zone-bleed fix (3a): a Sample-face load REPLACES the loaded sound. The previous - // sample's materialized full-range zone must not linger — first-match resolve would - // keep playing it while the editor draws the new pick's zone (matched by sampleId, - // order-blind). Authored Zone-view maps (any narrow key range) are left untouched. - selectedId_ = id; - if (reconcileSingleCaptureZones(map_, selectedId_)) { - selectedZone_ = map_.zones.empty() ? -1 : 0; - } - commitAndReload(); -} - -ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const { - SetupMarkers m; - // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override - // for the picked id win (the instrument's performance choice, D-B). 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 - // (pS fallback). The override lives in map_. - if (processor_) { - std::optional sel; - auto banksJson = - processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - if (banksJson) sel = selectSample(*banksJson, selectedId_); - if (!sel) { - const SampleRefs refs = processor_->sampleRefs(); - if (const SelectedSample* r = findRef(refs, selectedId_)) sel = *r; - } - if (sel && sel->loop.hasLoop) { - m.hasLoop = true; - m.loopStart = sel->loop.start; - 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; - } - // 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). - if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0; - 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, D-B). selectedId_ must - // be non-empty; callers are responsible for that guard. - // Returns the zone index (0-based) so callers can update selectedZone_. - 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). Mirrors the S15-F2 single-storage-site lean. - for (const PerformanceZone& z : map_.zones) { - if (z.sampleId == selectedId_) return z; - } - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - return z; -} - -int ReaSamplerEditor::effectiveRoot() const { - int root = 60; - for (const SampleChoice& s : samples_) { - if (s.id == selectedId_ && s.rootNote) root = *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; -} - -namespace { -// The S12/S15/S16 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 at the live rate. SOURCE-timeline fade sliders (Trigger fade-in/out) -// STORE source frames (PLAN.md §S15 — never a wall-clock second; the storage domain is -// settled-correct and unchanged), but the knob's FULL-SCALE THROW is a wall-clock intent — -// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames(), Q-W0 T3-03; the -// prior 88200-frame constant baked 2 s x 44.1 kHz into src/, against the no-hardcoded-rate -// ruling). Build-time residual — one place to retune; not persisted. -constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) -constexpr double kFadeMaxSeconds = 2.0; // Trigger fade throw ceiling (wall-clock) -constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered -constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%) - -} // namespace - -double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const { - // Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over - // the rate-resolved frames ceiling (T3-03). 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 - // pins it) and gets rewritten down on the next knob touch — deliberate, matching the old - // fixed-ceiling clamp behavior in kind, just rate-dependent now instead of fixed at 88200. - const double fadeMax = fadeMaxFrames(); - const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); }; - const auto framesToNorm = [fadeMax](std::int64_t f) { - // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. - return fadeMax > 0.0 ? clamp01(static_cast(f) / fadeMax) : 0.0; - }; - switch (static_cast(id)) { - case ParamControl::kPlayMode: return play.playMode == PlayMode::Trigger ? 1.0 : 0.0; - case ParamControl::kPitchEngine: return play.pitchEngine == PitchEngine::Preserve ? 1.0 : 0.0; - case ParamControl::kAttack: return secToNorm(play.adsr.attackSeconds); - case ParamControl::kHold: return secToNorm(play.adsr.holdSeconds); - case ParamControl::kDecay: return secToNorm(play.adsr.decaySeconds); - case ParamControl::kSustain: return clamp01(play.adsr.sustainLevel); - case ParamControl::kRelease: return secToNorm(play.adsr.releaseSeconds); - case ParamControl::kTrigLength: return clamp01(play.trigger.lengthFraction); - case ParamControl::kTrigFadeIn: return framesToNorm(play.trigger.fadeInFrames); - case ParamControl::kTrigFadeOut: return framesToNorm(play.trigger.fadeOutFrames); - case ParamControl::kPitchEnvEnable:return play.pitchEnv.enabled ? 1.0 : 0.0; - case ParamControl::kPitchEnvAttack:return secToNorm(play.pitchEnv.attackSeconds); - case ParamControl::kPitchEnvDecay: return secToNorm(play.pitchEnv.decaySeconds); - case ParamControl::kPitchEnvDepth: - // Signed depth centered at 0.5 (0.5 == 0 semitones). - return clamp01(0.5 + play.pitchEnv.peakSemitones / (2.0 * kPitchDepthMaxSemis)); - default: return 0.0; - } -} - -void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, - int segment) const { - const double fadeMax = fadeMaxFrames(); // T3-03: rate-resolved knob full-scale - const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; }; - const auto normToFrames = [fadeMax](double v) -> std::int64_t { - // Ceiling unavailable (rate not yet known): inert until fadeMaxFrames() resolves. - if (fadeMax <= 0.0) return 0; - return static_cast(clamp01(v) * fadeMax + 0.5); - }; - switch (static_cast(id)) { - case ParamControl::kPlayMode: - play.playMode = (segment == 1) ? PlayMode::Trigger : PlayMode::Gate; - break; - case ParamControl::kPitchEngine: - play.pitchEngine = (segment == 1) ? PitchEngine::Preserve : PitchEngine::Varispeed; - break; - case ParamControl::kAttack: play.adsr.attackSeconds = normToSec(value); break; - case ParamControl::kHold: play.adsr.holdSeconds = normToSec(value); break; - case ParamControl::kDecay: play.adsr.decaySeconds = normToSec(value); break; - case ParamControl::kSustain: play.adsr.sustainLevel = clamp01(value); break; - case ParamControl::kRelease: play.adsr.releaseSeconds = normToSec(value); break; - case ParamControl::kTrigLength: - // lengthFraction is (0,1]; keep a small floor so a zero-length trigger never plays nothing. - play.trigger.lengthFraction = (std::max)(0.01, clamp01(value)); - break; - case ParamControl::kTrigFadeIn: play.trigger.fadeInFrames = normToFrames(value); break; - case ParamControl::kTrigFadeOut: play.trigger.fadeOutFrames = normToFrames(value); break; - case ParamControl::kPitchEnvEnable: - play.pitchEnv.enabled = (segment == 1); - break; - case ParamControl::kPitchEnvAttack: play.pitchEnv.attackSeconds = normToSec(value); break; - case ParamControl::kPitchEnvDecay: play.pitchEnv.decaySeconds = normToSec(value); break; - case ParamControl::kPitchEnvDepth: - play.pitchEnv.peakSemitones = (clamp01(value) - 0.5) * 2.0 * kPitchDepthMaxSemis; - break; - default: break; - } -} - -double ReaSamplerEditor::liveSampleRate() const { - return processor_ ? processor_->sampleRate() : 0.0; -} - -double ReaSamplerEditor::fadeMaxFrames() const { - // T3-03: the Trigger-fade knob's full-scale throw is kFadeMaxSeconds (2 s wall-clock) - // resolved against the live rate — the SAME time base the envelope overlay already uses - // to place these source-frame fades on screen (totalSeconds = frames / liveSampleRate()), - // and the rate captures are made at (the capture path renders at the project rate). - // Pre-setupProcessing the rate is still 0: rather than substitute a literal rate (the - // exact residue T3-03 removed), bail the same way paintEnvelopeOverlay does (~line 1396) — - // callers treat a <= 0 return as "ceiling unavailable yet" and degrade the knob to inert - // rather than guess a rate. Storage stays SOURCE FRAMES — this resolves the UI ceiling only. - const double rate = liveSampleRate(); - if (rate <= 0.0) return 0.0; - return kFadeMaxSeconds * rate; -} - -double ReaSamplerEditor::previewVelocity01() const { - if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; - return static_cast(processor_->previewVelocity()) / 127.0; -} - -// --- r11 knob-deck plumbing (FB1) --------------------------------------------- - -namespace { -// The deck group ids (shell-owned; knob_deck treats them opaquely). Left-to-right deck order. -enum DeckGroup { - kGroupAmpEnv = 0, - kGroupPitch, - kGroupPitchEnv, - kGroupVoice, - kGroupMaster, -}; -} // namespace - -std::vector ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const { - // The PER-ZONE groups — the deck grammar both surfaces share (FB2: 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 (r11). - std::vector out; - { - DeckGroupDesc amp; - amp.id = kGroupAmpEnv; - amp.captionWidth = 78; - amp.captionToggle = {static_cast(ParamControl::kPlayMode), 44}; - if (play.playMode == PlayMode::Gate) { - amp.cellIds = {static_cast(ParamControl::kAttack), - static_cast(ParamControl::kHold), - static_cast(ParamControl::kDecay), - static_cast(ParamControl::kSustain), - static_cast(ParamControl::kRelease)}; - } else { - // Trigger, TIME-ORDERED left-to-right (r11: Fade In · Length % · Fade Out — - // matches the drawn envelope), plus the two reserved blanks. - amp.cellIds = {static_cast(ParamControl::kTrigFadeIn), - static_cast(ParamControl::kTrigLength), - static_cast(ParamControl::kTrigFadeOut), -1, -1}; - } - out.push_back(std::move(amp)); - } - { - DeckGroupDesc pitch; - pitch.id = kGroupPitch; - pitch.captionWidth = 38; - pitch.captionToggle = {static_cast(ParamControl::kPitchEngine), 48}; - pitch.cellIds = {static_cast(ParamControl::kKeyTrack)}; - out.push_back(std::move(pitch)); - } - { - DeckGroupDesc penv; - penv.id = kGroupPitchEnv; - penv.captionWidth = 58; - penv.captionToggle = {static_cast(ParamControl::kPitchEnvEnable), 32}; - penv.cellIds = {static_cast(ParamControl::kPitchEnvAttack), - static_cast(ParamControl::kPitchEnvDecay), - 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. VOICE + MASTER are the FB1 homes for the provisional voice-deck controls and the - // post-mixer gain — the r11 spec predates both; per-instance state (ComponentState) stays - // OFF the Zone panel (FB2), so they are appended here, not in zoneDeckGroupDescs. - std::vector out = zoneDeckGroupDescs(play); - { - DeckGroupDesc voice; - voice.id = kGroupVoice; - voice.captionWidth = 38; - voice.captionToggle = {static_cast(ParamControl::kVoiceMode), 40}; - voice.cellIds = {static_cast(ParamControl::kVoiceCount)}; - voice.rowToggle = {static_cast(ParamControl::kMonoTrigger), 44}; - out.push_back(std::move(voice)); - } - { - DeckGroupDesc master; - master.id = kGroupMaster; - master.captionWidth = 46; - master.cellIds = {static_cast(ParamControl::kMasterGain)}; - out.push_back(std::move(master)); - } - return out; -} - -double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const { - if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob - switch (static_cast(id)) { - case ParamControl::kKeyTrack: - return clamp01(zone.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); - } -} - -void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) { - if (!processor_) return; - norm = clamp01(norm); - if (id == -2) { - // Preview velocity: live processor write (persisted per-instance; the setter clamps - // to MIDI 1..127 so the knob's bottom still strikes audibly). - processor_->setPreviewVelocity(static_cast(norm * 127.0 + 0.5)); - return; - } - switch (static_cast(id)) { - case ParamControl::kVoiceCount: { - // Stepped: quantize the continuous drag to the integer count and track it live - // for the label/needle. The actual engine rebuild (setVoiceCount) fires ONCE on - // WM_LBUTTONUP — not per step — so a full drag (~31 steps) costs one rebuild, - // not thirty. - const int count = - kMinVoiceCount + - static_cast(norm * (kMaxVoiceCount - kMinVoiceCount) + 0.5); - voiceCount_ = count; - return; - } - case ParamControl::kMasterGain: - // Post-mixer gain: one atomic store; the audio thread picks it up next block. - processor_->setMasterGainLinear(masterGainLinearFromNorm(norm)); - return; - default: - applyZoneControl(zoneIndex, id, norm, 0); - return; - } -} - -std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const { - char buf[24]; - buf[0] = '\0'; - const ZonePlaySeconds& play = zone.play; - switch (id == -2 ? ParamControl::kCount : static_cast(id)) { - case ParamControl::kAttack: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break; - case ParamControl::kHold: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.holdSeconds); break; - case ParamControl::kDecay: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.decaySeconds); break; - case ParamControl::kSustain: - snprintf(buf, sizeof(buf), "%.0f%%", play.adsr.sustainLevel * 100.0); break; - case ParamControl::kRelease: - snprintf(buf, sizeof(buf), "%.3fs", play.adsr.releaseSeconds); break; - case ParamControl::kTrigLength: - snprintf(buf, sizeof(buf), "%.0f%%", play.trigger.lengthFraction * 100.0); break; - case ParamControl::kTrigFadeIn: - snprintf(buf, sizeof(buf), "%lldf", - static_cast(play.trigger.fadeInFrames)); break; - case ParamControl::kTrigFadeOut: - snprintf(buf, sizeof(buf), "%lldf", - static_cast(play.trigger.fadeOutFrames)); break; - case ParamControl::kPitchEnvAttack: - snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.attackSeconds); break; - case ParamControl::kPitchEnvDecay: - snprintf(buf, sizeof(buf), "%.3fs", play.pitchEnv.decaySeconds); break; - 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; - case ParamControl::kVoiceCount: - snprintf(buf, sizeof(buf), "%d", voiceCount_); break; - case ParamControl::kMasterGain: - formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break; - default: - // -2 (preview velocity) is labeled at its cluster call site; nothing else here. - break; - } - return std::string(buf); -} - -EnvClampBounds ReaSamplerEditor::envClampBounds() const { - // Match the control-panel sliders' own domains so a node drag can never produce a param a - // slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the - // Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use). - EnvClampBounds b; - b.maxAttackSeconds = kEnvTimeMaxSeconds; - b.maxHoldSeconds = kEnvTimeMaxSeconds; - b.maxDecaySeconds = kEnvTimeMaxSeconds; - b.maxReleaseSeconds = kEnvTimeMaxSeconds; - b.maxFadeInFraction = 1.0; - b.maxFadeOutFraction = 1.0; - b.maxLengthFraction = 1.0; - return b; -} - -AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames, - std::int64_t startFrame) const { - AmpEnvelope env; - env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate; - // AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws). - env.attackSeconds = play.adsr.attackSeconds; - env.holdSeconds = play.adsr.holdSeconds; - env.decaySeconds = play.adsr.decaySeconds; - 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 - // 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; - const std::int64_t playLen = - triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); - env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen); - env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen); - return env; -} - -void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, - std::int64_t startFrame, ZonePlaySeconds& play) const { - if (env.mode == EnvMode::Gate) { - play.adsr.attackSeconds = env.attackSeconds; - play.adsr.holdSeconds = env.holdSeconds; - play.adsr.decaySeconds = env.decaySeconds; - play.adsr.sustainLevel = env.sustainLevel; - play.adsr.releaseSeconds = env.releaseSeconds; - } 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 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); - const std::int64_t playLen = - triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); - play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen); - play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen); - } -} - -void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { - // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, - // mirror of the root-marker path): 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 (read-only bank consumer, D-B). - if (selectedId_.empty()) return; - upsertPickedOverride(m); - commitAndReload(); -} - -const std::vector& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) { - auto it = pcmCache_.find(sampleId); - if (it != pcmCache_.end()) return it->second; - - // SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so - // resolve the path from the live bank blob (selectSample) and decode via the shared WAV - // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY - // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width- - // independent) — the thumbnail bins this at whatever width, the snap scans it directly. - std::string relativePath; - std::vector mono; - if (processor_) { - auto banksJson = - processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); - if (banksJson) { - if (auto sel = selectSample(*banksJson, sampleId)) relativePath = sel->relativePath; - } - if (relativePath.empty()) { - // pS fallback: the bank blob is not readable (extension absent / not yet parsed) - // or the id went stale there — the instance-OWNED ref still carries the path, so - // a self-contained instance draws its loaded sound's waveform regardless. - const SampleRefs refs = processor_->sampleRefs(); - if (const SelectedSample* r = findRef(refs, sampleId)) { - relativePath = r->relativePath; - } - } - if (!relativePath.empty()) { - const std::string projectDir = processor_->bridge().activeProjectDir(); - const std::string abs = resolveBankFile(projectDir, relativePath); - // Shared core/util whole-file loader (Q-W1, T2-03): empty on any failure. - const std::vector bytes = readFileBytes(abs); - const WavLayout layout = parseWavLayout(bytes); - if (layout.valid) { - std::vector interleaved = - extractFloatFrames(bytes, layout, 0, layout.frameCount()); - mono = downmixToMono(interleaved, layout.channelCount); - } - } - } - auto ins = pcmCache_.emplace(sampleId, std::move(mono)); - return ins.first->second; -} - -const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { - const std::string key = sampleId + "|" + std::to_string(binCount); - auto it = thumbCache_.find(key); - if (it != thumbCache_.end()) return it->second; - - // Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by - // every thumbnail width AND the S11 waveform surface + snap. - const std::vector& mono = monoPcmFor(sampleId); - Envelope env; - if (!mono.empty()) { - // Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with - // trailing empty {0,0} bins, which would render a very short sample as a comb of - // spikes over flat gaps. - const std::size_t bins = - (std::min)(static_cast((std::max)(1, binCount)), mono.size()); - env = computeEnvelope(mono, 1, mono.size(), bins); - } - auto ins = thumbCache_.emplace(key, std::move(env)); - return ins.first->second; -} - -ReaSamplerEditor::~ReaSamplerEditor() { -#ifdef _WIN32 - if (childHwnd_) { - DestroyWindow(childHwnd_); - childHwnd_ = nullptr; - } -#endif -} - -tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) { -#ifdef _WIN32 - if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue; -#endif - return kResultFalse; -} - -tresult PLUGIN_API ReaSamplerEditor::canResize() { - return kResultTrue; -} - -tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) { - // Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before - // every resize; clamp the proposed rect in place and return kResultTrue so the host applies the - // (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title - // + hero waveform + cluster + a few control rows visible (the control strip clips gracefully - // below the panel bottom); anything smaller would clip essential UI. The default 840×620 is - // above this floor. - constexpr int kMinW = 560; - constexpr int kMinH = 460; - if (!rect) return kResultFalse; - if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW; - if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH; - return kResultTrue; -} - -#ifdef _WIN32 - -void ReaSamplerEditor::invalidate() { - if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE); -} - -void ReaSamplerEditor::attachedToParent() { - HWND parent = static_cast(systemWindow); - if (!parent) return; - - HINSTANCE hInst = - reinterpret_cast(GetWindowLongPtr(parent, GWLP_HINSTANCE)); - if (!hInst) hInst = GetModuleHandle(nullptr); - - static bool classRegistered = false; - if (!classRegistered) { - WNDCLASSW wc{}; - wc.lpfnWndProc = &ReaSamplerEditor::wndProc; - wc.hInstance = hInst; - wc.lpszClassName = kChildClassName; - wc.hCursor = LoadCursor(nullptr, IDC_ARROW); - wc.style = CS_HREDRAW | CS_VREDRAW; - RegisterClassW(&wc); - classRegistered = true; - } - - // Create the kit's cached AA fonts before the first paint (Phase L, L3). Idempotent, so a - // reopen (or a co-resident embed strip that also inits) is a cheap no-op. NOT torn down on - // editor close: the embed strip in the SAME binary shares the kit's process-global font - // set, so a per-view shutdown could free fonts still in use by the other view. The tiny - // static HFONT set is reclaimed by the OS at module unload. See the L3 handoff note. - kitFontsInit(); - - refreshFromBank(); - - const ViewRect& r = getRect(); - childHwnd_ = CreateWindowExW(0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, - r.getWidth(), r.getHeight(), parent, nullptr, hInst, nullptr); - if (childHwnd_) { - SetWindowLongPtr(childHwnd_, GWLP_USERDATA, reinterpret_cast(this)); - // S13: accept OS file drops on the editor window (WM_DROPFILES). The drop is NOT - // ingested here (the relay is degraded — see onFilesDropped); accepting it lets us show - // the "drop on the panel" affordance instead of the OS bouncing the drop silently. - DragAcceptFiles(childHwnd_, TRUE); - // Start the S9/S8 change-detection poll (UI thread). Tied to the child window's - // lifetime — created here, killed in removedFromParent — so an instance whose editor - // is closed does NOT poll (the editor-open-only cadence; see the handoff limitation). - SetTimer(childHwnd_, kSyncTimerId, kSyncTimerIntervalMs, nullptr); - // Poll ONCE immediately so a pending assignment (an S8 ingest fired while this editor - // was closed) or a bank change applies the instant the editor opens, rather than waiting - // up to one timer interval. refreshFromBank above already primed the view; this folds in - // any pending assign/generation so the just-opened editor shows the assigned capture. - onSyncTimer(); - } -} - -void ReaSamplerEditor::removedFromParent() { - if (childHwnd_) { - KillTimer(childHwnd_, kSyncTimerId); // stop the poll before the window goes away - DestroyWindow(childHwnd_); - childHwnd_ = nullptr; - } -} - -tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { - tresult res = CPluginView::onSize(newSize); - if (childHwnd_ && newSize) { - MoveWindow(childHwnd_, 0, 0, newSize->getWidth(), newSize->getHeight(), TRUE); - thumbCache_.clear(); // thumbnails are width-bound; a resize invalidates them - } - return res; -} - -// The Sample-view (S-VIEW-2) bands. The TITLE band names the plugin + a live readout and hosts -// the Browse/Zone nav buttons at its right; the HERO band is the enlarged waveform + envelope -// overlay; the CLUSTER band is the fenced root strip + preview + channel toggle; the CONTROL band -// is the param panel. Every band is padded 8px horizontally by its consumers. Browse + Zone views -// derive their own areas from `title` + `content` below. -namespace { -constexpr int kPad = 8; - -// The S-VIEW-10 velocity-curve editor box metrics. Since r11/FB2 BOTH surfaces host the curve -// in the POPUP (curve_popup), each summoned from its own mini preview button — the Sample -// cluster's and the Zone panel's (the inline Zone box is retired). The INSET keeps node handles -// + the pick radius inside the border so an endpoint at amp 0/1 stays grabbable — the ONE -// curveBoxFromRect grammar the popup derives its mapping box through. -constexpr int kVelCurveInset = 14; // border -> mapping-box inset: keeps endpoint handles + pick radius inside the border -constexpr int kCurveDragOffMargin = 24; // release beyond box+margin -> drag-off delete - -// The r11 cluster's fixed right-anchored run (left -> right: Preview button, the radial -// preview-velocity knob cell, the mini curve-preview button, Mono|Stereo). -constexpr int kPreviewBtnW = 64; -constexpr int kVelCellW = 48; // the Vel knob cell (deck cell grammar) -constexpr int kCurveBtnSize = 28; // the square curve-preview button - -struct SampleBands { - Rect title; // top: name + Browse/Zone nav buttons - Rect navBrowse; // the "Browse" title-band button - Rect navZone; // the "Zone" title-band button - Rect hero; // the FULL-WIDTH ELASTIC hero waveform + S-VIEW-3 envelope overlay (r11) - Rect cluster; // root strip + preview + vel knob + curve button + channel toggle - Rect deck; // the bottom-anchored knob deck (height from the pure knob_deck wrap) -}; -// r11 band order: title (fixed) -> hero (ELASTIC: absorbs all height left after the fixed -// bands, floor kHeroMinHeight) -> cluster (fixed) -> deck (fixed height `deckH`, bottom- -// anchored). When the window is too short for the floor (below the checkSizeConstraint -// minimum — a defensive case), the hero keeps its floor and the lower bands clip past the -// window bottom gracefully. -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; -} - -// The r11 cluster sub-rects: the root strip keeps the left side at REMAINDER width; the right -// side is the fixed-width right-anchored run (Preview 64 · Vel knob cell 48 · curve preview -// button 28 · Mono|Stereo). Draw + hit-test both derive from this ONE formula. -struct ClusterRects { - Rect rootStrip; // remainder-width fenced root strip - Rect preview; // the preview-trigger button - Rect velCell; // the radial preview-velocity knob cell (knob + label band) - Rect velKnob; // the 28px knob square at the cell's top - Rect velLabel; // the 12px label band beneath it - Rect curveBtn; // the mini curve-preview button (opens the popup) -}; -ClusterRects clusterRects(const Rect& cluster, const Rect& chanMono) { - 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 - kDeckKnobSize) / 2; - r.velKnob = Rect::ltrb(knobLeft, r.velCell.y, knobLeft + kDeckKnobSize, - r.velCell.y + kDeckKnobSize); - 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; -} - -// The Zone-view keyboard strip rect. Zone content sits below the "+ Add Zone" affordance -// (top+4, height 20) with a 12px gap, padded 8px horizontally. All call sites use this formula. -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); -} - -// The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the -// sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw + -// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom() so -// the legend top tracks the strip bottom without re-inlining the strip arithmetic here. -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); -} - -// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal -// segments left-to-right. An out-of-range index yields an empty rect. -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()); -} - -// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip + -// the one-line selected-zone legend, running to the content bottom. `bands.content` is the -// Zones mode-content area. Both draw + hit-test use this single formula so they never drift. -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); -} - -// FB2 (R11-F2 parity): the Zone panel's per-zone controls render as the SAME knob deck the -// Sample face uses. The deck lays out from the panel top (top-anchored — the Zone panel reads -// top-down, unlike the Sample face's bottom-anchored band), with a column at the panel's right -// reserved for the mini curve-preview button so no deck row starts inside it (layoutDeck places -// the first group of each row unconditionally; collision avoidance relies on the available -// width margin in practice). Both draw + hit-test derive from these two formulas so they never -// drift. -Rect zonesDeckArea(const Rect& content) { - const Rect panel = zonesControlPanel(content); - return Rect::ltrb(panel.x, panel.y, panel.right() - kCurveBtnSize - kPad, panel.bottom()); -} -// The Zone panel's mini curve-preview button (opens the SAME popup editor as the Sample -// cluster's button): the cluster's 28px square, right-anchored at the panel top. -Rect zonesCurveButton(const Rect& content) { - const Rect panel = zonesControlPanel(content); - return Rect::ltrb(panel.right() - kCurveBtnSize, panel.y, panel.right(), panel.y + kCurveBtnSize); -} - -// The pure-module mapping Box for a drawn curve rect: inset from the border so node handles and -// the pick radius stay inside the box. Every consumer (paint, hit-test, add, drag) derives the -// Box through this ONE formula, so drawn nodes and grabs can never drift apart. -VelocityCurve::Box curveBoxFromRect(const Rect& r) { - return VelocityCurve::Box{r.x + kVelCurveInset, r.y + kVelCurveInset, - (std::max)(0, r.width - 2 * kVelCurveInset), - (std::max)(0, r.height - 2 * kVelCurveInset)}; -} - -// The S7 mono/stereo toggle (S-VIEW-2: moved here from Browse to the Sample cluster band — it is -// a per-capture output-mode concern, not a choosing concern). A two-segment control right-anchored -// in `area` and vertically centered. Returns {mono-segment, stereo-segment}, each kChanSegW wide, -// kChanSegH tall, side by side. -constexpr int kChanSegW = 52; -constexpr int kChanSegH = 18; -struct ChannelToggleRects { Rect mono; Rect stereo; }; -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}; -} - -// Draw one radial knob face (r11): the FA4 param_slider primitive owns the value<->angle map; -// this turns it into LICE calls through the kit's palette roles. LICE's arc convention matches -// param_slider's (angle 0 = 12 o'clock, positive clockwise: point = (cx + r*sin(a), cy - -// r*cos(a)), verified in vendor/WDL lice_arc.cpp) — but LICE takes RADIANS, and drawing the -// 7->5 o'clock sweep THROUGH the top needs a continuous angle span, so the degrees convert as -// (deg - 360) * pi/180, mapping 210..510 onto -150..+150 degrees. One conversion, both arcs. -void drawKnobFace(LICE_IBitmap* bmp, const Rect& knobRect, double value01, - InteractionState st) { - const KnobGeometry kg = computeKnob(knobRect); - if (kg.radius <= 1.0) return; - constexpr double kDegToRad = 3.14159265358979323846 / 180.0; - const KnobArc arc{}; // the FA4 default 7->5 o'clock sweep - const float cx = static_cast(kg.centerX); - const float cy = static_cast(kg.centerY); - const float rOuter = static_cast(kg.radius) - 0.5f; - const bool disabled = (st == InteractionState::Disabled); - const bool hot = (st == InteractionState::Dragging || st == InteractionState::Hover); - - // Face: a filled circle in the cell surface color under the interaction state. - LICE_FillCircle(bmp, cx, cy, rOuter - 1.f, toLice(roleColorState(Role::BgCell, st)), - 1.0f, 0, true); - // Track: the full sweep as a hairline arc (the dead 60-degree arc at the bottom stays bare). - const float a0 = static_cast((arc.startDeg - 360.0) * kDegToRad); - const float a1 = static_cast((arc.startDeg + knobSweepDeg(arc) - 360.0) * kDegToRad); - LICE_Arc(bmp, cx, cy, rOuter, a0, a1, toLice(roleColor(Role::LineHairline)), 1.0f, 0, true); - // Value arc: start -> the value's angle, in the live accent (hot while under the pointer / - // dragging, dim when disabled). - const double v = value01 < 0.0 ? 0.0 : (value01 > 1.0 ? 1.0 : value01); - if (v > 0.0) { - const float av = static_cast( - (arc.startDeg + v * knobSweepDeg(arc) - 360.0) * kDegToRad); - const Role valueRole = disabled ? Role::TextDim - : (hot ? Role::AccentHot : Role::AccentPrimary); - LICE_Arc(bmp, cx, cy, rOuter, a0, av, toLice(roleColor(valueRole)), 1.0f, 0, true); - } - // Needle: from ~35% radius out to the rim at the value's angle. - const KnobPoint tip = knobNeedlePoint(kg, arc, v); - const float ix = cx + static_cast((tip.x - kg.centerX) * 0.35); - const float iy = cy + static_cast((tip.y - kg.centerY) * 0.35); - const Role needleRole = disabled ? Role::TextDim : Role::TextPrimary; - LICE_Line(bmp, static_cast(ix + 0.5f), static_cast(iy + 0.5f), - static_cast(tip.x + 0.5f), static_cast(tip.y + 0.5f), - toLice(roleColor(needleRole)), 1.0f, 0, true); -} - -// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature surface. -// Fills each MIDI key column with its spectral hue (spectralColor over note/127), then draws -// faint per-octave hairline ticks for orientation. Shared by the setup face + the Zones strip -// so both read as the same spectrum. `stripArea` is the absolute strip rect. -void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) { - if (stripArea.width <= 0 || stripArea.height <= 0) return; - const StripLayout sl = layoutStrip(stripArea.width, stripArea.height); - const int sx = stripArea.x; - const int sy = stripArea.y; - const int h = stripArea.height; - // A pastel spectral column per key. Each key's local x from keyRect; fill from this key's - // left to the next key's left so the sweep tiles with no gaps. Low alpha keeps it a quiet - // backdrop the root/zone marks sit over. S-VIEW-7: OVERLAY the two-tone piano-key pattern — - // naturals (white keys) keep the bright spectral fill; accidentals (C#/D#/F#/G#/A#) get a - // dark bg/base wash over the hue, so a glance reads pitch position as a keyboard without - // counting. The pattern is an OVERLAY (not a keyboard shape) per the spec. - const LICE_pixel darkKey = toLice(roleColor(Role::BgBase)); - for (int n = 0; n <= 127; ++n) { - const Rect k = keyRect(sl, n); - const int x0 = k.x + sx; - const int x1 = (n < 127) ? keyRect(sl, n + 1).x + sx : stripArea.right(); - const int cw = (std::max)(1, x1 - x0); - const KitColor hue = spectralColor(static_cast(n) / 127.0); - LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0); - if (!isNaturalKey(n)) { - // Darken the accidental over the hue (a semi-opaque bg/base wash) so the black-key - // pattern reads while the spectral tint still shows through. - LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0); - } - } - // Faint per-octave key ticks (hairline role) for orientation. - const LICE_pixel tick = toLice(roleColor(Role::LineHairline)); - for (int n = 0; n <= 127; n += 12) { - const Rect k = keyRect(sl, n); - LICE_Line(bmp, k.x + sx, sy, k.x + sx, sy + h, tick, 1.0f, 0, false); - } -} - -// Draw the single-capture root marker on the strip: an accent-primary bar with a soft STATIC -// glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. Never animated. -void drawRootMarker(LICE_IBitmap* bmp, const Rect& stripArea, const StripLayout& sl, int root) { - const int sx = stripArea.x; - const int sy = stripArea.y; - const int h = stripArea.height; - const Rect marker = rootMarkerRect(sl, root); - const int mw = (std::max)(2, marker.width); - const LICE_pixel accent = toLice(roleColor(Role::AccentPrimary)); - const LICE_pixel glow = toLice(roleColor(Role::AccentHot)); - // Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse). - LICE_FillRect(bmp, marker.x + sx - 3, sy, mw + 6, h, glow, 0.30f, 0); - LICE_FillRect(bmp, marker.x + sx, sy, mw, h, accent, 1.0f, 0); -} -} // 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))); - - // S-VIEW-1 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 (the "picker over the document" grammar). - if (view_ == View::kZone) { - paintZone(&bmp, w, h); - } else { - paintSample(&bmp, w, h); - if (view_ == View::kBrowse) paintBrowse(&bmp, w, h); - } - - // S13 (relay degraded): 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); -} - -// A small helper: draw the title band with the live readout + the Browse/Zone nav buttons. Shared -// by the Sample face (nav visible) — Browse/Zone draw their own back button in place of the nav. -namespace { -void drawTitleBand(LICE_IBitmap* bmp, const Rect& title, const std::string& readout) { - fillSurface(bmp, toKitBox(title), Role::BgPanel, InteractionState::Rest); - Rect titleText = Rect::ltrb(title.x + 8, title.y, title.right() - 8, title.bottom()); - kitText(bmp, titleText, readout.c_str(), Font::Title, Role::TextPrimary); -} -} // namespace - -void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { - // r11: 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. Standard B palette — the beta channel gets NO distinct - // accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable - // signal. - std::string title = reasampler::vstPluginName(); // channel-derived (S18) - if (processor_ && processor_->bridge().isConnected()) { - // The instance's OWN loaded state outranks bank availability (pS: 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 (S15-F2 — 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 + S11 markers + S-VIEW-3 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) { - // FA3 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); - } - - // S-VIEW-3: 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 (r11: 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); - 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 (r11 — 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 (r11): opens the popup editor. Shared painter with the - // Zone panel's button (FB2 — 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 (r11: the fenced control groups, bottom-anchored) ------------------- - paintKnobDeck(bmp, bands.deck, zone, deckDescs); - - // --- The curve popup (r11): 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. FA2 guarantees every vertex is - // in-bounds (the pre-FA2 right-edge clip is dead and removed — 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 (FB2: 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 — r11: 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 (r11: 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 (r11). - 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 (r11/FB2 — 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 (FB2). - paintVelocityCurve(bmp, pl.curveBox, popupZone()); -} - -PerformanceZone ReaSamplerEditor::popupZone() const { - // The zone the popup displays: the Zone surface's SELECTED zone (FB2), 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(); -} - -bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) { - // The r11 curve popup: while open the sheet is MODAL over its host face — the Sample home - // (FB1) or the Zone surface (FB2) — it owns every left-click. Close click / outside-wash - // click dismiss (outside only when no drag is in flight, per the spec); 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 at the cursor via the pure - // inverse map, then grab it — the click flows straight into a placing drag. Guard: the caller - // gates on contains(r, x, y) (the full border rect), but the 6+px inset ring — including the - // caption band — must not add a point; a click there would clamp to velocity 0/127 and - // produce an undeletable duplicate stacked on an endpoint. Clicks in the ring may still grab - // an existing node (pointAtPixel's pick radius legitimately extends into the ring), which is - // handled above; only the add path is box-gated here. - 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 -} - -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 S13 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); -} - -// The Browse-modal (S-VIEW-5) top-level regions: a title band with a Back button, the search box, -// the browser sub-area (tabs + card grid), and a footer with Cancel / Load-confirm. The picker -// covers the full window (F3 resolved: full-window overlay). Both draw + hit-test derive from this -// single layout so they never drift. `content` is the sub-area layoutBrowser lays out over. -namespace { -struct BrowseModal { - Rect title; - Rect back; // the "Back" title-band button - Rect search; // the type-to-filter box (absolute) - Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin - Rect cancel; // footer Cancel - Rect confirm; // footer Load (confirm) -}; -constexpr int kBrowseFooterH = 30; -BrowseModal computeBrowseModal(int w, int h) { - BrowseModal m; - const int titleH = (std::min)(kTitleHeight, h); - m.title = Rect::ltrb(0, 0, w, titleH); - m.back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2)); - // Search box below the title, spanning the width (searchBoxRect lays it out from 0). - const Rect sb = searchBoxRect(w); - m.search = Rect::ltrb(kPad, titleH, w - kPad, titleH + sb.height); - const int footerTop = (std::max)(m.search.bottom(), h - kBrowseFooterH); - m.content = Rect::ltrb(0, m.search.bottom(), w, footerTop); - // Footer: Cancel (left) + Load (right). - const int fTop = footerTop + 3; - const int fBot = (std::max)(fTop, h - 3); - m.cancel = Rect::ltrb(kPad, fTop, kPad + 90, fBot); - m.confirm = Rect::ltrb(w - kPad - 90, fTop, w - kPad, fBot); - return m; -} -} // namespace - -void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { - // A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying - // Sample face with a bg/base wash, then draw the picker opaque on top. - LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0); - const BrowseModal bm = computeBrowseModal(w, h); - - // Title band + Back button (returns to Sample, discarding any pending pick). - drawTitleBand(bmp, bm.title, "Browse - pick a capture"); - { - const KitButtonBox box{toKitBox(bm.back)}; - const InteractionState st = - isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "Back", st, /*warn=*/false); - } - - // Search box (type-to-filter). A focused box lifts to Focus + a ring; else Rest/Hover. - const Rect searchAbs = bm.search; - const InteractionState searchState = - searchFocused_ ? InteractionState::Focus - : (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover - : InteractionState::Rest); - fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState); - if (searchFocused_) { - LICE_DrawRect(bmp, searchAbs.x, searchAbs.y, searchAbs.width - 1, - searchAbs.height - 1, toLice(roleColor(Role::TextPrimary)), 1.0f, 0); - } - { - std::string sb = searchQuery_.empty() - ? std::string("Search captures...") - : ("Search: " + searchQuery_ + (searchFocused_ ? "_" : "")); - Rect sbText = Rect::ltrb(searchAbs.x + 6, searchAbs.y, searchAbs.right() - 6, searchAbs.bottom()); - kitText(bmp, sbText, sb.c_str(), Font::Label, - searchQuery_.empty() ? Role::TextDim : Role::TextPrimary); - } - - // Tabs + card grid, laid out over the content sub-area by the pure module (origin-offset). - const Rect browserArea = bm.content; - const BrowserLayout bl = layoutBrowser(browserArea.width, browserArea.height); - const int ox = browserArea.x; - const int oy = browserArea.y; - scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); - - const int tabCount = static_cast(banks_.size()) + 1; - for (int i = 0; i < tabCount; ++i) { - Rect t = filterTabRect(bl, tabCount, i); - t = Rect::ltrb(t.x + ox, t.y + oy, t.right() + ox, t.bottom() + oy); - const std::string label = (i == 0) ? "All" : banks_[static_cast(i - 1)].displayName; - const bool active = (i == 0) ? activeFilterBankId_.empty() - : (banks_[static_cast(i - 1)].id == activeFilterBankId_); - const InteractionState state = - active ? InteractionState::Active - : (isHovered(HoverKind::kFilterTab, i) ? InteractionState::Hover - : InteractionState::Rest); - fillSurface(bmp, toKitBox(t), Role::BgCell, state); - kitTextCentered(bmp, t, label.c_str(), Font::Label, - active ? Role::BgBase : Role::TextPrimary); - } - - // Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_) - // is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border. - const int bins = thumbBins(bl); - const int cardCount = static_cast(visible_.size()); - const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); - for (int i = vr.first; i < vr.last; ++i) { - Rect content = cardContentRect(bl, i); - Rect thumb = cardThumbnailRect(bl, i); - Rect labelR = cardLabelRect(bl, i); - content = Rect::ltrb(content.x + ox, content.y + oy - scrollOffset_, - content.right() + ox, content.bottom() + oy - scrollOffset_); - thumb = Rect::ltrb(thumb.x + ox, thumb.y + oy - scrollOffset_, - thumb.right() + ox, thumb.bottom() + oy - scrollOffset_); - labelR = Rect::ltrb(labelR.x + ox, labelR.y + oy - scrollOffset_, - labelR.right() + ox, labelR.bottom() + oy - scrollOffset_); - - const SampleChoice& s = visible_[static_cast(i)]; - const bool pending = (s.id == browsePendingId_); - const bool loaded = (s.id == selectedId_); - const InteractionState cardState = - isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest; - fillSurface(bmp, toKitBox(content), Role::BgCell, cardState); - const KitColor cardBorder = pending ? roleColor(Role::AccentPrimary) - : (loaded ? roleColor(Role::AccentTertiary) - : roleColor(Role::LineHairline)); - LICE_DrawRect(bmp, content.x, content.y, content.width - 1, content.height - 1, - toLice(cardBorder), 1.0f, 0); - drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); - - std::string caption = s.displayName.empty() ? s.id : s.displayName; - Rect nameR = Rect::ltrb(labelR.x + 3, labelR.y, labelR.right() - 3, labelR.y + labelR.height / 2); - Rect badgeR = Rect::ltrb(labelR.x + 3, nameR.bottom(), labelR.right() - 3, labelR.bottom()); - kitText(bmp, nameR, caption.c_str(), Font::Label, Role::TextPrimary); - std::string badge; - if (s.rootNote) badge = "root " + noteLabel(*s.rootNote); - else if (s.key) badge = *s.key; - else badge = "root -"; - kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim); - } - - // Scrollbar thumb. - { - const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); - if (thumb.height > 0) { - const bool dragging = (drag_ == DragKind::kScrollThumb); - const KitColor tc = roleColor(dragging ? Role::AccentHot : Role::AccentPrimary); - LICE_FillRect(bmp, thumb.x + ox, thumb.y + oy, thumb.width, thumb.height, - toLice(tc), 0.8f, 0); - } - } - - if (visible_.empty()) paintEmptyState(bmp, browserArea); - - // Footer: Cancel (discard, return to Sample) + Load (commit the pending pick). Load is inert - // (no accent) until a card is picked. Draw a footer strip so the buttons read as a modal bar. - Rect footer = Rect::ltrb(0, bm.content.bottom(), w, h); - fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest); - { - const KitButtonBox box{toKitBox(bm.cancel)}; - const InteractionState st = - isHovered(HoverKind::kBrowseCancel, -1) ? InteractionState::Hover : InteractionState::Rest; - drawButton(bmp, box, "Cancel", st, /*warn=*/false); - } - { - const KitButtonBox box{toKitBox(bm.confirm)}; - const bool armed = !browsePendingId_.empty(); - const InteractionState st = armed - ? (isHovered(HoverKind::kBrowseConfirm, -1) ? InteractionState::Hover : InteractionState::Active) - : InteractionState::Rest; - drawButton(bmp, box, "Load", st, /*warn=*/false); - } -} - -// The Zone-view (S-VIEW-8) content area: the whole window below the title band. -namespace { -Rect zoneContentArea(int w, int h) { - const int titleH = (std::min)(kTitleHeight, h); - return Rect::ltrb(0, titleH, w, h); -} -} // namespace - -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 = Rect::ltrb(w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, title.bottom() - 2)); - 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); - const int pad = 8; - - // 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 = Rect::ltrb(content.x + pad, content.y + 4, content.x + pad + 96, - content.y + 4 + 20); - { - 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 = Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); - 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) — S12 direct numeric entry. 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. FB2 (R11-F2): the SAME knob deck + - // curve-preview-button/popup grammar as the Sample face — one control language over the one - // storage site (S15-F2) — replacing the retired param_slider rows + inline curve box. 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 (FB2): a centered sheet over the whole Zone surface, drawn LAST — - // the same modal grammar as the Sample face. - if (curvePopupOpen_) paintCurvePopup(bmp, w, h); -} - -// --- Hover resolution (Phase L, L3) ------------------------------------------ -// -// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an -// idle move is free — the "sub-frame feedback, zero cost when nothing changed" discipline). -// Mirrors onMouseDown's hit-test order, but read-only: it never mutates selection/map. Only -// the frequently-touched interactive surfaces light on hover; a purely decorative region -// resolves to kNone (clearing any prior hover). 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 r11 curve popup — modal over Sample AND Zone (FB2) - 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 = Rect::ltrb(w - kPad - kNavButtonWidth, 2, - w - kPad, (std::max)(2, (std::min)(kTitleHeight, hgt) - 2)); - const Rect content = zoneContentArea(w, hgt); - Rect addR = Rect::ltrb(content.x + kPad, content.y + 4, content.x + kPad + 96, content.y + 4 + 20); - Rect delR = Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); - 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())) { - // FB2: 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, r11 recomposition) - 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); - 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(); - } -} - -// --- 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 (S-VIEW-5): pick + confirm/cancel over the Sample face ---- - if (view_ == View::kBrowse) { - 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; - } - - // ---- Sample home (S-VIEW-2 / r11) ---- - if (view_ == View::kSample) { - // r11 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); - - // 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 (r11): GRAB-ANCHORED vertical drag — the grab itself - // never jumps the value (FA4); 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 (r11): toggles commit at once (a discrete, final edit — the slider - // precedent); 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 (S-VIEW-3) first, then the S11 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 since r11). - 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 (S-VIEW-8 / FB2) ---- - // The curve popup is modal over the Zone surface too (FB2) — it owns every click while - // open, checked before every Zone affordance (incl. Back). - if (handlePopupMouseDown(w, h, x, y)) return; - const Rect back = Rect::ltrb(w - kPad - kNavButtonWidth, 2, - w - kPad, (std::max)(2, (std::min)(kTitleHeight, h) - 2)); - if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; } - const Rect content = zoneContentArea(w, h); - const int pad = 8; - Rect addR = Rect::ltrb(content.x + pad, content.y + 4, content.x + pad + 96, - content.y + 4 + 20); - 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 (pre-fix bleed survivor), 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 = Rect::ltrb(addR.right() + 8, addR.y, addR.right() + 8 + 64, addR.bottom()); - 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; - } - - // S12 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 (FB2): 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 (FA4): 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::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)]; - 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; - } else { - applyControl(id, z.play, value, segment); - } -} - -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) { - // r11 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 (FA4). Live feedback; zone-param commits land on WM_LBUTTONUP. - const int dy = y - dragStartY_; - applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy)); - invalidate(); - return; - } - - // r11: 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 (the D-B override vehicle) — - // 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).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) { - // S-VIEW-3: 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) { - // S-VIEW-10: 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) { - // S11: 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 (the S2 zero-crossing-aware - // requirement). 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) { - // S12: 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; - } - // S-VIEW-10 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) { - // r11 (issue 3c): 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 (FB2; 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(); - } -} - -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) { - // r11 curve popup: Esc dismisses (checked first — the popup is modal over the Sample face - // or the Zone surface, FB2; opening it clears any note-entry focus, and the Browse search - // cannot hold focus under it). - if (curvePopupOpen_ && ch == 27) { - curvePopupOpen_ = false; - invalidate(); - return; - } - - // S12 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; - } - - // S12 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) { - // S13 relay DEGRADED. The instrument is a read-only bank consumer and the cross-artifact - // ingest relay (editor drop -> extension) is not shipped (see the header note + the handoff - // decision point), 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 -} - -LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, - LPARAM lParam) { - auto* self = - reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); - switch (msg) { - case WM_PAINT: { - PAINTSTRUCT ps{}; - HDC hdc = BeginPaint(hwnd, &ps); - if (self) self->paint(hdc); - EndPaint(hwnd, &ps); - return 0; - } - case WM_LBUTTONDOWN: - if (self) { - SetCapture(hwnd); // keep receiving moves/up if the cursor leaves the child - SetFocus(hwnd); // take keyboard focus so WM_CHAR reaches the search box (S12) - self->onMouseDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - } - return 0; - case WM_MOUSEMOVE: - if (self) { - const int mx = GET_X_LPARAM(lParam); - const int my = GET_Y_LPARAM(lParam); - // Hover feedback (Phase L, L3): resolve the element under the pointer and - // repaint on change. Arm WM_MOUSELEAVE once per "over" cycle so the hover - // clears when the pointer leaves the child (TrackMouseEvent is one-shot). - if (!self->mouseTracking_) { - TRACKMOUSEEVENT tme{}; - tme.cbSize = sizeof(tme); - tme.dwFlags = TME_LEAVE; - tme.hwndTrack = hwnd; - TrackMouseEvent(&tme); - self->mouseTracking_ = true; - } - // While a drag is in flight the drag owns the surface; skip hover resolution - // (a hover repaint mid-drag would fight the live drag feedback). - if (self->drag_ == DragKind::kNone) self->resolveHover(mx, my); - self->onMouseMove(mx, my); - } - return 0; - case WM_MOUSELEAVE: - if (self) { - self->mouseTracking_ = false; - if (self->hover_.kind != HoverKind::kNone) { - self->hover_ = HoverTarget{}; - self->invalidate(); - } - } - return 0; - case WM_MOUSEWHEEL: - // S12 browser scroll. GET_WHEEL_DELTA_WPARAM is signed; positive == wheel up. - if (self) self->onMouseWheel(GET_WHEEL_DELTA_WPARAM(wParam)); - return 0; - case WM_CHAR: - // S12 type-to-filter search keystrokes (only acted on when the search box is focused). - if (self) self->onSearchChar(static_cast(wParam)); - return 0; - case WM_GETDLGCODE: - // Claim all keys (incl. chars) so the host doesn't eat them before WM_CHAR (S12 search). - return DLGC_WANTCHARS | DLGC_WANTARROWS; - case WM_LBUTTONUP: - if (self) { - self->onMouseUp(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - ReleaseCapture(); - } - return 0; - case WM_RBUTTONDOWN: - // r11: right-click — the curve popup's primary node-delete affordance (issue 3c). - // Routed explicitly (the child wndproc historically handled only left-button). - if (self) self->onMouseRDown(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)); - return 0; - case WM_RBUTTONUP: - return 0; // claimed so the pair never reaches DefWindowProc (no context menu) - case WM_CAPTURECHANGED: - // Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its - // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset - // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. - // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. - if (self) { - // A held preview note must be released here too (peer of WM_LBUTTONUP) — capture - // loss otherwise leaves the momentary-key voice hung with no note-off. - if (self->previewingNote_ >= 0) { - if (self->processor_) self->processor_->previewNoteOff(self->previewingNote_); - self->previewingNote_ = -1; - self->invalidate(); - } - 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. - 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_; - self->drag_ = DragKind::kNone; - self->dragParamId_ = -1; - self->dragParamZone_ = -1; - self->curvePointIndex_ = -1; // S-VIEW-10 curve-node drag state (peer reset) - self->dragCurveZone_ = -1; - self->invalidate(); - } - } - return 0; - case WM_DROPFILES: { - // S13 (relay degraded): count the dropped files and flash the affordance. We do NOT - // read/ingest the paths (the instrument never ingests — the relay to the extension is - // unshipped); DragQueryFile with 0xFFFFFFFF just returns the count for the banner. - HDROP drop = reinterpret_cast(wParam); - const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); - DragFinish(drop); - if (self) self->onFilesDropped(static_cast(count)); - return 0; - } - case WM_TIMER: - if (self && wParam == kSyncTimerId) self->onSyncTimer(); - return 0; - case WM_ERASEBKGND: - return 1; // fully repaint in WM_PAINT; skip the flicker-inducing erase - default: - return DefWindowProcW(hwnd, msg, wParam, lParam); - } -} - -#else // non-Windows: not a build target (D5), but keep the TU compilable. - -void ReaSamplerEditor::attachedToParent() {} -void ReaSamplerEditor::removedFromParent() {} -tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { - return CPluginView::onSize(newSize); -} - -#endif // _WIN32 - -} // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp deleted file mode 100644 index 4cce960..0000000 --- a/src/vst/reasampler_processor.cpp +++ /dev/null @@ -1,1168 +0,0 @@ -#include "core/namespaces.h" -// reasampler_processor.cpp — see reasampler_processor.h. - -#include "reasampler_processor.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "pluginterfaces/base/ibstream.h" -#include "pluginterfaces/vst/ivstaudioprocessor.h" -#include "pluginterfaces/vst/ivstevents.h" -#include "pluginterfaces/vst/ivstmidicontrollers.h" // kCtrlAllNotesOff / kCtrlAllSoundsOff (panic) -#include "pluginterfaces/vst/vstspeaker.h" - -#include "core/wire/assignment_request.h" // decodeAssignmentRequest (S8 request wire parse) -#include "core/instrument/map/bank_sync.h" // S9/S8 pure decisions: parseBankGeneration, consumeDecision -#include "core/capture/capture_paths.h" // resolveBankFile (shared M4 path resolution) -#include "core/util/file_bytes.h" // shared whole-file loader (Q-W1, T2-03) -#include "ext_keys.h" // kProjExtBanksKey / kProjExtBankGenKey / kProjExtAssignKey (shared wire contract) -#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (FB1 post-mixer gain clamp) -#include "reasampler_editor.h" -#include "shell/instrument/reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) -#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap, state (de)ser (pS self-contained) -#include "core/wire/sample_usage.h" // pS-usage publish plan + wire (prune-protection seam) -#include "core/capture/wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) - -using namespace Steinberg; -using namespace Steinberg::Vst; - -namespace reasampler::vst { - -namespace { - -// S16 Preserve-mode voice cap: a Preserve voice runs a per-voice OLA pitch shifter and is -// materially heavier than a Varispeed voice. A Preserve note-on past the cap is dropped rather -// than glitching (Varispeed notes are unaffected). Set from the measured/estimated per-voice -// cost — see the handoff CPU note. 8 is conservative pending DAW profiling. Phase S: the -// polyphony bound itself is now the USER-SET voiceCount (1..32, persisted) — this cap stays -// FIXED so raising the voice count never multiplies shifter CPU past the profiled budget. -constexpr std::size_t kPreserveVoiceCap = 8; - -// FB1 post-mixer gain ramp TIME (wall-clock). gainCurrent_ converges to masterGain_ by a -// linear per-sample step derived from this at setupProcessing (gainRampStep_ = -// 1 / (kGainRampSeconds * sampleRate_)) — the kPreserveWindowMs pattern, per the standing -// no-hardcoded-rate ruling (Q-W0 T3-01; the prior constant baked 20 ms x 48 kHz in as -// 1/960, silently shortening the ramp at higher host rates). A full 0-to-unity ramp is -// ~20 ms at EVERY host rate; the snap threshold (half a step, below which gainCurrent_ -// jumps to the target) avoids long sub-LSB creep and the ramp loop on idle blocks. -constexpr double kGainRampSeconds = 0.020; - -// pS-usage: mint a fresh publish identity — 32 lowercase hex chars from the OS entropy -// source. Used for BOTH the persisted per-instance key guid (instanceGuid_) and the -// in-memory per-LIFETIME owner nonce (usageNonce_). Uniqueness (not cryptographic -// strength) is the requirement: two instances sharing a key is the copy-collision -// planUsagePublish resolves fail-safe anyway; the mint just makes accidental collision -// vanishingly unlikely. Off-thread only. -std::string mintUsageInstanceGuid() { - std::random_device rd; - std::mt19937_64 gen((static_cast(rd()) << 32) ^ rd()); - std::uniform_int_distribution dist; - char buf[33] = {0}; - std::snprintf(buf, sizeof(buf), "%016llx%016llx", - static_cast(dist(gen)), - static_cast(dist(gen))); - return std::string(buf); -} - -// Whole-file reads go through the shared core/util readFileBytes (Q-W1, T2-03). -// Off-thread only (blocking file I/O). Empty on any failure — the caller treats -// an unreadable WAV as "nothing to play". - -// Resolve a project-relative WAV path (the M4 way persist does), read + decode it (file -// I/O — off-thread only), and apply the S7 cross-mode channel policy for `mode`: mono mode -// downmixes to one channel (existing policy); stereo mode yields two channels (dual-mono for -// a mono source, L/R for a stereo source) — see decodeChannels. Returns nullopt when the path -// fails to resolve, the file is unreadable, the WAV is malformed, or the decode yields no -// frames — the caller drops the zone (zoned map) or plays silence (single capture). Shared by -// the zoned build and the single-capture path so both decode identically for the active mode. -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); - const WavLayout layout = parseWavLayout(bytes); - 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)); - if (out.monoFrames.empty()) return std::nullopt; - return out; -} - -} // namespace - -FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { - // The host owns the returned reference. Cast up to the combined interface the SDK - // exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted. - return static_cast(new ReaSamplerProcessor()); -} - -// Out-of-line so unique_ptr sees the complete type here. -ReaSamplerProcessor::~ReaSamplerProcessor() = default; - -tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { - // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for - // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed - // shell. We own the shell (unique_ptr); the borrowed reference is valid because the - // processor outlives it. All other iids fall through to the SDK's queryInterface. - if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { - if (!embed_) embed_ = std::make_unique(this); - embed_->addRef(); - *obj = static_cast(embed_.get()); - return kResultOk; - } - return SingleComponentEffect::queryInterface(iid, obj); -} - -tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { - tresult result = SingleComponentEffect::initialize(context); - if (result != kResultOk) return result; - - // Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the - // instrument still loads, it just has no live bank to play. - bridge_.connect(context); - - // Instrument bus topology: one event input (MIDI in, 16 channels), one audio output, no - // audio input. GA fix (hard-right pan): the output bus is a FIXED STEREO bus regardless of - // the channel mode. The mode is a DECODE policy (downmix vs L/R split) — mono mode renders - // dual-mono through the stereo bus (both channels equal, centered), which is audibly - // identical to a mono bus but never asks the host to re-map a live instance's pins. The - // prior design flipped the bus kMono<->kStereo via restartComponent(kIoChanged) on every - // mode change/restore; in the DAW that flip panned a dual-mono capture hard RIGHT. The - // in-plugin path is provably symmetric (decode, per-voice stereo render, engine sum, buffer - // write — see testDualMonoStereoSampleRendersCentered), so the asymmetry sat in the host's - // re-routing of the live instance's pins across the arrangement change. A fixed arrangement - // is the maximally-standard VSTi shape and removes that whole negotiation surface. - addEventInput(STR16("MIDI In"), 16); - addAudioOutput(STR16("Audio Out"), SpeakerArr::kStereo); - - return kResultOk; -} - -tresult PLUGIN_API ReaSamplerProcessor::terminate() { - // process() is not running at terminate: free the live + draining instruments and - // drain the graveyard. Take the pointers out of the atomics first so nothing else - // races them. - std::lock_guard lock(reloadMutex_); - delete live_.exchange(nullptr); - delete draining_.exchange(nullptr); - graveyard_.clear(); - return SingleComponentEffect::terminate(); -} - -tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) { - // Activating: build the instrument from the currently-selected sample so the first - // block after activation can play. Deactivating: process is now GUARANTEED stopped by - // the host, so this is the safe point to reclaim the graveyard (the displaced engines - // no reload could free while active). The build/drain are off the audio thread — - // setActive is a main/UI-thread call. - if (state) { - // Self-contained (pS): this rebuild resolves + decodes from the instance-OWNED - // sample refs — it needs no bank read, so it plays regardless of whether the - // extension's PROJEXTSTATE has parsed yet (or the extension exists at all). - // - // #B: this unconditional rebuild is ALSO the NON-editor legacy trigger for a - // pre-v10 blob (refs empty + intent): reloadInstrument's opportunistic - // refreshRefsFromBank copies the refs in when the bank blob is readable by - // activation time, so an upgraded project plays on load without the instrument - // ever being opened (and the next save is self-contained). Residual load-order - // race, DAW-verifiable only: if the host activates this instance BEFORE the - // project's ext-state lines parse, the lift misses here and — with no editor open — - // nothing retries until the next activation or editor tick. MIGRATION NOTE: open a - // pre-v10 instrument once after upgrading if it restores silent. - reloadInstrument(); - } else { - std::lock_guard lock(reloadMutex_); - // process is guaranteed stopped: free EVERYTHING. The live instrument too — its - // voices are frozen mid-flight, and if it survived deactivation the reactivate - // reload would displace it into the DRAIN slot, resurrecting stale sustained - // voices as ghosts. Reactivation rebuilds from scratch (reloadInstrument above), - // so nothing is lost by clearing here. - delete live_.exchange(nullptr); - delete draining_.exchange(nullptr); - graveyard_.clear(); - } - return kResultOk; -} - -tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) { - sampleRate_ = setup.sampleRate; - maxBlockSize_ = setup.maxSamplesPerBlock; - // T3-01: resolve the FB1 gain-ramp step against the live host rate (20 ms wall-clock at - // every rate). At 48 kHz this is exactly the former 1/960 constant. Written here (host - // guarantees setupProcessing never overlaps process), read on the audio thread only. - if (sampleRate_ > 0.0) { - gainRampStep_ = static_cast(1.0 / (kGainRampSeconds * sampleRate_)); - } - return SingleComponentEffect::setupProcessing(setup); -} - -tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { - if (!state) return kResultFalse; - // Read the whole component-state blob (the performance map, versioned). The blob is - // small; read in one shot into a growable buffer. - std::vector bytes; - std::uint8_t chunk[256]; - int32 got = 0; - while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) { - bytes.insert(bytes.end(), chunk, chunk + got); - } - // Component state (v3, S10) is {single-capture selection id, opt-in zones}. The - // selection and the zones are DISTINCT — the default face is one picked capture, zones - // are a demoted overlay — so both are restored explicitly (no more inferring a selection - // from a lone zone). deserializeComponentState lifts older blobs cleanly: a v2 zones-only - // blob restores {"", zones}; a v1 S4 single-selection blob restores {id, one-zone map} so - // the old pick survives as both; an empty/unknown blob restores {"", no zones} — the S10 - // silent empty state (no first-sample fallback in reloadInstrument). - // Pass sampleRate_ as the project rate for legacy v3 blob conversion (frames -> seconds at - // the v3 read boundary). sampleRate_ is set by setupProcessing; REAPER calls setupProcessing - // before setState on project load, so sampleRate_ is the real host rate here. A v3 blob on a - // pre-setup call would assert inside readZonesPayload (a programming error, not a field case). - const ComponentState cs = deserializeComponentState(bytes, sampleRate_); - setSelectedSampleId(cs.selectionId); - // Zone-bleed fix (3a) heal-on-load: a blob saved under the pre-fix editor may carry a - // pile of stale full-range zones (one per sample ever browsed), the oldest shadowing the - // saved selection under first-match resolve. Reconciling here restores "the sample the - // editor shows is the sample the engine plays" for already-affected projects; authored - // Zone-view maps (any narrow key range) pass through untouched. - PerformanceMap restored = cs.map; - reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: setPerformanceMap + reloadInstrument run unconditionally on load - setPerformanceMap(restored); - // S8: restore the last-consumed assignment generation so a re-open does not re-apply a - // stale assign_request (the user may have manually changed the selection after the assign). - { - std::lock_guard lock(assignMarkerMutex_); - lastConsumedAssignGeneration_ = cs.lastConsumedAssignGeneration; - } - // Restore the S7 channel mode + the GA explicit flag. The output bus is FIXED stereo (see - // initialize) — the mode only governs how the reload below decodes, so no bus work here. - { - std::lock_guard lock(channelModeMutex_); - channelMode_ = cs.channelMode; - channelModeExplicit_ = cs.channelModeExplicit; - } - // S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2 - // the editor's velocity knob is a concurrent UI-thread writer. - { - std::lock_guard lock(previewMutex_); - previewVelocity_ = cs.previewVelocity; - } - // Phase S: restore the voice-system parameters (v7; older blobs lift to {16, Poly, - // Retrigger} in deserializeComponentState — pre-Phase-S behavior). Restored BEFORE the - // reload below so the rebuilt engine is born with the saved polyphony/mode. - { - std::lock_guard lock(voiceParamsMutex_); - voiceCount_ = cs.voiceCount; - voiceMode_ = cs.voiceMode; - monoTrigger_ = cs.monoTrigger; - } - // FB1: restore the post-mixer master gain (v8; older blobs lift to unity in - // deserializeComponentState — pre-FB1 output). One atomic store; the audio thread picks - // it up at the next block start. - setMasterGainLinear(cs.masterGainLinear); - // pS self-contained playback: restore the instance-OWNED sample refs (v10) BEFORE the - // reload so it decodes straight from them — no bank read required to play. A pre-v10 - // blob lifts to an EMPTY table; the reload then resolves nothing until the bank blob - // becomes readable (the reload's opportunistic refresh, or pollBankSync's legacy lift), - // after which the next save is self-contained. - { - std::lock_guard lock(refsMutex_); - sampleRefs_ = cs.sampleRefs; - } - // pS-usage: restore the persisted publish identity (v11; pre-v11 lifts to empty — - // minted on first publish). usageNonce_ resets: a restored blob is a NEW LIFETIME - // for the copy-collision analysis (the fresh nonce means this incarnation can never - // be mistaken for the previous one's writes — or for a copy-sibling's). - { - std::lock_guard lock(usageMutex_); - instanceGuid_ = cs.instanceGuid; - usageNonce_.clear(); - } - // A new blob is new facts: a staleness proof latched against the PREVIOUS state does - // not carry over (#A — the legacy lift gets one fresh run per restored state). - legacyLiftConcluded_.store(false, std::memory_order_relaxed); - // Rebuild from the restored state (off-thread — setState is a load-time call). - reloadInstrument(); - return kResultOk; -} - -tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { - if (!state) return kResultFalse; - // Persist the full instance state (v3, S10): the single-capture selection id AND the - // opt-in zones — the instrument's own state (D-B), NEVER written to the "reasampler" - // bank ext-state. An instance with no pick and no zones serializes to {"", no zones} - // and restores as the S10 empty state (silence + "pick a capture"), never auto-playing - // sample #1. - ComponentState state_out; - state_out.selectionId = selectedSampleId(); - state_out.map = performanceMap(); - { - // S7: persist the per-instance mono/stereo decode mode + the GA explicit flag (v9). - std::lock_guard lock(channelModeMutex_); - state_out.channelMode = channelMode_; - state_out.channelModeExplicit = channelModeExplicit_; - } - { - std::lock_guard lock(assignMarkerMutex_); - state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker - } - state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity - { - // Phase S: persist the voice-system parameters (component state v7). - std::lock_guard lock(voiceParamsMutex_); - state_out.voiceCount = voiceCount_; - state_out.voiceMode = voiceMode_; - state_out.monoTrigger = monoTrigger_; - } - state_out.masterGainLinear = masterGainLinear(); // FB1: persist the post-mixer gain (v8) - // pS: persist the OWNED sample refs (v10) — the saved blob carries everything needed to - // decode + play with no extension present. Filtered (on the snapshot copy, the member is - // untouched) to exactly 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)); - // pS-usage: persist the publish identity (v11) so the instance's usage key is - // stable across sessions (records do not proliferate per reopen). - { - std::lock_guard lock(usageMutex_); - state_out.instanceGuid = instanceGuid_; - } - const std::vector bytes = serializeComponentState(state_out); - if (!bytes.empty()) { - const tresult wr = state->write(const_cast(bytes.data()), - static_cast(bytes.size()), nullptr); - if (wr != kResultOk) return wr; - } - return kResultOk; -} - -std::string ReaSamplerProcessor::selectedSampleId() { - std::lock_guard lock(selectionMutex_); - return selectedSampleId_; -} - -void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) { - std::lock_guard lock(selectionMutex_); - selectedSampleId_ = id; -} - -PerformanceMap ReaSamplerProcessor::performanceMap() { - std::lock_guard lock(performanceMutex_); - return performanceMap_; -} - -void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) { - std::lock_guard lock(performanceMutex_); - performanceMap_ = map; -} - -SampleRefs ReaSamplerProcessor::sampleRefs() { - std::lock_guard lock(refsMutex_); - return sampleRefs_; -} - -ChannelMode ReaSamplerProcessor::channelMode() { - std::lock_guard lock(channelModeMutex_); - return channelMode_; -} - -std::uint8_t ReaSamplerProcessor::previewVelocity() { - std::lock_guard lock(previewMutex_); - return previewVelocity_; -} - -void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { - // Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview - // strike must sound). The editor's knob maps its 0..1 domain into this range before calling. - if (velocity < 1) velocity = 1; - if (velocity > 127) velocity = 127; - std::lock_guard lock(previewMutex_); - previewVelocity_ = velocity; -} - -int ReaSamplerProcessor::voiceCount() { - std::lock_guard lock(voiceParamsMutex_); - return voiceCount_; -} - -void ReaSamplerProcessor::setVoiceCount(int count) { - // Clamp to the shared pure-core range so the engine, the state bytes, and the editor's - // control can never disagree about the legal polyphony span. - if (count < kMinVoiceCount) count = kMinVoiceCount; - if (count > kMaxVoiceCount) count = kMaxVoiceCount; - { - std::lock_guard lock(voiceParamsMutex_); - if (voiceCount_ == count) return; // no-op: don't churn a rebuild - voiceCount_ = count; - } - // LIGHT rebuild OFF-thread through the drain-slot swap: the engine is reconstructed from - // the already-decoded keymap (no bridge re-read, no WAV re-decode — a polyphony change - // touches no audio data) and the displaced instrument keeps rendering its ringing tails, - // so a voice-param change never cuts a sounding note NOR stalls the UI re-decoding every - // zone from disk. Same contract for the mode/trigger setters below. - rebuildVoiceEngine(); -} - -VoiceMode ReaSamplerProcessor::voiceMode() { - std::lock_guard lock(voiceParamsMutex_); - return voiceMode_; -} - -void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) { - { - std::lock_guard lock(voiceParamsMutex_); - if (voiceMode_ == mode) return; - voiceMode_ = mode; - } - rebuildVoiceEngine(); -} - -MonoTrigger ReaSamplerProcessor::monoTrigger() { - std::lock_guard lock(voiceParamsMutex_); - return monoTrigger_; -} - -void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) { - { - std::lock_guard lock(voiceParamsMutex_); - if (monoTrigger_ == trigger) return; - monoTrigger_ = trigger; - } - rebuildVoiceEngine(); -} - -void ReaSamplerProcessor::setMasterGainLinear(double linear) { - // Clamp to the control's legal span (the master_gain taper: 0 = -inf/silence, cap = - // +24 dB). One relaxed atomic store — the audio thread reads it at the next block start; - // no rebuild, no lock (a post-sum output trim is not a keymap fact). - if (!(linear >= 0.0)) linear = 0.0; // also catches NaN - const double maxLin = masterGainMaxLinear(); - if (linear > maxLin) linear = maxLin; - masterGain_.store(static_cast(linear), std::memory_order_relaxed); -} - -void ReaSamplerProcessor::previewNoteOn(int note) { - if (note < 0) note = 0; - if (note > 127) note = 127; - const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request - // Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as - // long as we never land back on the exact value the audio thread last consumed in one step — - // 16 bits gives 65535 posts between collisions, unreachable at UI-click rates). - const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_; - const std::uint32_t packed = (static_cast(seq) << 16) | - (static_cast(vel) << 8) | - static_cast(note & 0xFF); - previewOnRequest_.store(packed, std::memory_order_release); -} - -void ReaSamplerProcessor::previewNoteOff(int note) { - if (note < 0) note = 0; - if (note > 127) note = 127; - const std::uint16_t seq = ++previewOffSeq_ == 0 ? ++previewOffSeq_ : previewOffSeq_; - const std::uint32_t packed = (static_cast(seq) << 16) | - static_cast(note & 0xFF); - previewOffRequest_.store(packed, std::memory_order_release); -} - -void ReaSamplerProcessor::setChannelMode(ChannelMode mode) { - { - std::lock_guard lock(channelModeMutex_); - // The editor toggle is a DELIBERATE choice either way: latch explicit even on a - // same-mode click (the user confirmed the mode; the GA auto-default stops fighting it). - channelModeExplicit_ = true; - if (channelMode_ == mode) return; // no decode change: don't churn a reload - channelMode_ = mode; - } - // The DECODE policy changed. The output bus is FIXED stereo (GA fix — no bus repoint, no - // restartComponent): reloading re-decodes the loaded WAV(s) under the new mode off-thread - // (mono = downmix, stereo = L/R split) and the RT path just keeps rendering. - reloadInstrument(); -} - -tresult PLUGIN_API ReaSamplerProcessor::setBusArrangements( - SpeakerArrangement* inputs, int32 numIns, - SpeakerArrangement* outputs, int32 numOuts) { - // ONE canonical arrangement: the fixed stereo output bus (GA fix — the channel mode is a - // decode policy, never a bus fact). We take NO audio input, so any inputs are rejected. - // Accept (kResultTrue) only a single stereo output proposal; otherwise reject (kResultFalse) - // and keep our stereo arrangement (per the VST3 contract, a plug-in that can't honor a - // proposal keeps a valid arrangement of its own) — the host adapts its routing to us. - if (numIns < 0 || numOuts < 0) return kInvalidArgument; - if (numIns > 0) return kResultFalse; // no audio input bus to arrange - if (numOuts == 1 && outputs && outputs[0] == SpeakerArr::kStereo) return kResultTrue; - return kResultFalse; -} - -std::string ReaSamplerProcessor::reloadInstrument() { - // OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so - // the retired-slot free is single-writer. This mutex is NEVER taken on the audio - // thread — process() only touches the atomic. - std::lock_guard lock(reloadMutex_); - - // Mint this reload's generation number first so we can stamp the built instrument - // with it before publishing. Under reloadMutex_ no other reload races here. - const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; - - // 1. SELF-CONTAINED RESOLUTION (pS). The instance-OWNED refs table is the source of - // truth for what to decode. The live bank blob, WHEN readable, is folded into the - // table first (refreshRefsFromBank) — that is the browser's copy-the-ref-in - // mechanism and the S9 recapture sync in one — but its absence changes NOTHING - // below: a project restored before the extension's PROJEXTSTATE parses (or with - // the extension absent entirely) resolves + plays from the persisted refs. The - // project dir comes from REAPER itself (EnumProjects), not from the extension. - const std::string selId = selectedSampleId(); - const PerformanceMap map = performanceMap(); - const std::vector ids = referencedSampleIds(selId, map); - SampleRefs refs; - { - std::optional banksJson = - bridge_.readReasamplerExtState(kProjExtBanksKey); - std::lock_guard rl(refsMutex_); - if (banksJson) refreshRefsFromBank(sampleRefs_, *banksJson, ids); - // The LOAD path never prunes the owned table: dropping entries here on a transient - // bank miss could destroy the owned intrinsics of the previous selection — the ONE - // copy that survives with the extension absent. Entries for de-referenced ids stay - // in memory (bounded by in-session browsing); hygiene lives at the PERSIST boundary, - // where getState filters its snapshot via retainRefs to what the instance plays. - refs = sampleRefs_; // snapshot for the decode below (outside the refs lock) - } - const std::string projectDir = bridge_.activeProjectDir(); - // The active channel mode (S7) governs how each WAV decodes (mono downmix vs 2-channel). - // Read once under its mutex, off the audio thread, before the decode loop. The single- - // capture branch below may auto-default it (GA) before its decode. - ChannelMode mode = channelMode(); - // Phase S: snapshot the voice-system parameters once — they are baked into the built - // engine's construction (the engine's config is immutable; a later change rebuilds). - int builtVoiceCount = kDefaultVoiceCount; - VoiceMode builtVoiceMode = VoiceMode::Poly; - MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; - { - std::lock_guard vp(voiceParamsMutex_); - builtVoiceCount = voiceCount_; - builtVoiceMode = voiceMode_; - builtMonoTrigger = monoTrigger_; - } - - std::string resolvedId; - std::unique_ptr built; - Keymap km; - bool haveKeymap = false; - - // 2. Tier 1 first: if the instrument's 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 ZONED keymap. Each surviving zone plays - // its sample repitched from its effective root note (override > ref intrinsic > - // C4). A zone whose WAV fails to decode — a MISSING FILE included — 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(); - } - } - - // 3. Single-capture fast path (S10): an empty performance map plays the ONE - // deliberately-selected capture chromatically across the whole keyboard, resolved - // against the OWNED refs. NO first-sample fallback: an EMPTY selection (or a - // selection with no ref) resolves to nothing, so an un-picked instrument stays - // SILENT (the editor shows its "pick a capture" empty state) rather than - // auto-playing sample #1 (S10 policy reversal of the S4 convenience default). - if (!haveKeymap) { - if (const SelectedSample* sel = findRef(refs, selId)) { - // GA auto-default: channelModeFor computes the mode from the loaded capture's - // REQUESTED channel count (always 2 for extension captures; mono only for - // ingest-imported mono files). An unknown count (0) or explicit user choice - // returns the current mode unchanged. Decode-only: the output bus is fixed - // stereo, so no bus work follows a flip. - { - 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) { - // Preserve OLA window in OUTPUT frames from the host sample rate (kPreserveWindowMs). - // Every voice's shifter is pre-sized to this off-thread here, so process()-time - // note-on never allocates. Floored at 2 so a valid window is always a real ring - // (which also covers a pathological host rate <= 0 — no rate literal needed). - std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); - if (preserveWindow < 2) preserveWindow = 2; - built = std::make_unique( - std::move(km), static_cast(builtVoiceCount), gen, - kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); - } - - // 4. Publish. Atomically install the new instrument; the DISPLACED one moves into the - // DRAIN slot (FA1, bug 3b) where process() keeps rendering its ringing voices — - // a reload never cuts a sounding note; the next note-on plays the new state. The - // instrument evicted FROM the drain slot (two reloads old) goes to the graveyard - // (process may still be mid-block reading it). A null `built` (no ref / unreadable - // WAV) installs silence while the displaced tails still ring out via the drain. - // `built` is heap-owned; release() hands ownership to the atomic; the drain-evicted - // pointer is re-owned by the graveyard. - publishBuiltLocked(std::move(built)); - - // 5. pS-usage: publish this instance's held captures so the extension's prune can - // never reclaim them (see publishUsage). AFTER the instrument swap, still off the - // audio thread and under reloadMutex_. Publishes regardless of decode success: - // the holds are the refs the instance RETAINS (its play-set), not what decoded — - // a transiently unreadable WAV must stay protected. - publishUsage(refs, ids); - return resolvedId; -} - -void ReaSamplerProcessor::publishUsage(const SampleRefs& refs, - const std::vector& ids) { - if (!bridge_.isConnected()) return; // non-REAPER host / no ext-state — nothing to do - - UsageRecord mine; - mine.trackGuid = bridge_.currentTrackGuid(); - for (const std::string& id : ids) { - if (const SelectedSample* ref = findRef(refs, id)) { - if (!ref->relativePath.empty()) { - mine.holds.push_back(UsageHold{id, ref->relativePath}); - } - } - } - - std::lock_guard lock(usageMutex_); - // A never-published instance with nothing held writes nothing — no key litter for - // fresh/empty instances. Once an identity exists, empties DO publish (they release - // holds the prune would otherwise keep protecting). - if (instanceGuid_.empty() && mine.holds.empty()) return; - if (instanceGuid_.empty()) instanceGuid_ = mintUsageInstanceGuid(); - // The per-LIFETIME owner nonce rides INSIDE the wire (UsageRecord.ownerNonce) so - // planUsagePublish can prove "exactly this incarnation wrote the key" — a same-track - // sibling's byte-identical hold set can never pass as ours (its nonce differs), so - // siblings always union and never clean-replace over each other's held paths. - if (usageNonce_.empty()) usageNonce_ = mintUsageInstanceGuid(); - mine.ownerNonce = usageNonce_; - - const std::optional existing = - bridge_.readReasamplerExtState(usageKeyFor(instanceGuid_)); - const UsagePublishPlan plan = planUsagePublish(existing, mine); - if (plan.remint) { - // This state was cloned onto another track (FX copy / track duplication): take a - // fresh identity and leave the original's record untouched. The abandoned old - // identity's record dies by the extension's liveness rule when its track no - // longer hosts an instance. getState persists the new guid on the next save. - instanceGuid_ = mintUsageInstanceGuid(); - } else if (plan.skipWrite) { - return; // idle tick, or a union that adds nothing — no ext-state churn - } - bridge_.writeUsageExtState(usageKeyFor(instanceGuid_), plan.wire); -} - -void ReaSamplerProcessor::publishBuiltLocked(std::unique_ptr built) { - // REQUIRES reloadMutex_ held (single-writer over both slots + the graveyard). Shared by - // reloadInstrument and rebuildVoiceEngine — the one safety-critical swap dance. - // - // Bounded reclaim: free graveyard entries whose installedAt < seen, where seen is - // the minimum installedAt process() published over the pointers it holds. Both - // slots are monotone in installedAt, so seen is monotone and any future process() - // load yields installedAt >= seen — an entry below seen is provably unreachable - // (see the header proof). Remaining entries drain at setActive(false) / terminate() - // when process is guaranteed stopped. - const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); - graveyard_.erase( - std::remove_if(graveyard_.begin(), graveyard_.end(), - [seen](const std::unique_ptr& e) { - return e->installedAt < seen; - }), - graveyard_.end()); - LoadedInstrument* prev = live_.exchange(built.release()); - LoadedInstrument* evicted = draining_.exchange(prev); - if (evicted) graveyard_.push_back(std::unique_ptr(evicted)); -} - -void ReaSamplerProcessor::rebuildVoiceEngine() { - // OFF THE AUDIO THREAD (the editor's voice-deck click handlers). See the header contract: - // 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 bridge, no disk — - // and publishes through the same drain-slot swap, so ringing tails survive. - std::lock_guard lock(reloadMutex_); - LoadedInstrument* cur = live_.load(std::memory_order_acquire); - if (!cur) return; // nothing loaded: the new params bake into the next real reload. - - int builtVoiceCount = kDefaultVoiceCount; - VoiceMode builtVoiceMode = VoiceMode::Poly; - MonoTrigger builtMonoTrigger = MonoTrigger::Retrigger; - { - std::lock_guard vp(voiceParamsMutex_); - builtVoiceCount = voiceCount_; - builtVoiceMode = voiceMode_; - builtMonoTrigger = monoTrigger_; - } - - const std::uint64_t gen = reloadGeneration_.fetch_add(1, std::memory_order_relaxed) + 1; - // Same Preserve-window derivation as reloadInstrument (kPreserveWindowMs at the host rate). - std::int64_t preserveWindow = static_cast( - kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5); - if (preserveWindow < 2) preserveWindow = 2; - - // Deep-copy the decoded PCM + zones. Safe to read concurrently with process(): the keymap - // is immutable after construction, and under reloadMutex_ nobody can free `cur`. - Keymap km = cur->keymap; - auto built = std::make_unique( - std::move(km), static_cast(builtVoiceCount), gen, - kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger); - publishBuiltLocked(std::move(built)); -} - -void ReaSamplerProcessor::retireIdleDrain() { - // Phase S (FA1-review Major #2). Cheap early-out BEFORE the lock: 0 means "no drain, or - // it still sounds" — the common case costs one relaxed load and no mutex. - const std::uint64_t idleGen = drainIdleGeneration_.load(std::memory_order_acquire); - if (idleGen == 0) return; - std::lock_guard lock(reloadMutex_); - LoadedInstrument* drain = draining_.load(std::memory_order_acquire); - // Retire ONLY if the publication names the drain currently in the slot. A stale value - // (about an already-evicted, older drain) can never match the newer occupant's - // installedAt — the slot is monotone in generation — so a mid-swap race is closed by - // this identity check, not by timing. - if (!drain || drain->installedAt != idleGen) return; - draining_.store(nullptr, std::memory_order_release); - graveyard_.push_back(std::unique_ptr(drain)); - // Prune what is now provably unreachable — the same monotone-generation proof as the - // reload path's reclaim (see reloadInstrument): an entry with installedAt < seen cannot be - // held by process() now or ever again. The just-parked drain frees here immediately when - // process() has already published past it; otherwise on the next reload/retire/deactivate. - const std::uint64_t seen = processGeneration_.load(std::memory_order_acquire); - graveyard_.erase( - std::remove_if(graveyard_.begin(), graveyard_.end(), - [seen](const std::unique_ptr& e) { - return e->installedAt < seen; - }), - graveyard_.end()); -} - -bool ReaSamplerProcessor::legacyLiftShouldRun() { - // #A terminating guard for the pre-v10 legacy lift. The caller has already established - // refs-empty + intent; this decides whether a lift attempt can MAKE PROGRESS before - // paying for a full reload. Once concluded, the steady state is this one relaxed load — - // no bank read, no parse, no reload churn. - if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false; - const LegacyLiftDecision decision = legacyLiftDecision( - bridge_.readReasamplerExtState(kProjExtBanksKey), - referencedSampleIds(selectedSampleId(), performanceMap())); - if (decision == LegacyLiftDecision::Stale) { - // Provably stale (the bank parses and knows none of the referenced ids): give up - // PERMANENTLY. A later bank change that re-introduces an id bumps the generation, - // and the genChanged reload refreshes the refs without consulting this latch. - legacyLiftConcluded_.store(true, std::memory_order_relaxed); - return false; - } - return true; // Retry (blob not readable yet) or Lift (a ref can be copied in) -} - -ReaSamplerProcessor::BankSyncResult -ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) { - // OFF THE AUDIO THREAD (the editor's UI timer calls this). Both reads allocate and call - // REAPER via the bridge — never invoked from process(). A disconnected bridge (non-REAPER - // host, or before connect) yields nullopt for both reads, so this no-ops cleanly. - BankSyncResult result; - - // Phase S: park an idle drain snapshot in the graveyard (and prune) on the same UI-timer - // cadence that drives reloads — an edited-away instrument stops costing memory as soon - // as its tails die instead of squatting in the drain slot until the next reload. - retireIdleDrain(); - - // --- S8: assignment-request consume FIRST ------------------------------------- - // Decode the pending assignment request (nullopt when absent/malformed). Resolve its - // (bankId, sampleId) against the live bank blob: selectSample returns non-nullopt only when - // the sampleId names an existing sample (the reader requirement — an unresolvable pair is - // dropped). Then run the pure consume decision against this instance's persisted marker. - std::optional request; - if (auto raw = bridge_.readReasamplerExtState(kProjExtAssignKey)) { - request = decodeAssignmentRequest(*raw); - } - - bool resolves = false; - if (request) { - // Resolve the assigned sample against the CURRENT bank blob (a fresh read, so a request - // whose sample was rolled back by an extension undo resolves to nullopt -> dropped). - if (auto banksJson = bridge_.readReasamplerExtState(kProjExtBanksKey)) { - resolves = selectSample(*banksJson, request->sampleId).has_value(); - } - } - - // Read lastConsumed and conditionally write it back under a single lock scope so there - // is no interleave window between the read and the write (a concurrent getState could - // otherwise observe a stale marker between the two separate lock acquisitions). - std::int64_t lastConsumed = 0; - const AssignConsumeDecision decision = [&] { - std::lock_guard lock(assignMarkerMutex_); - lastConsumed = lastConsumedAssignGeneration_; - const AssignConsumeDecision d = - consumeDecision(request, lastConsumed, resolves, isFocusedTarget); - // Advance the persisted consumed marker whenever the decision consumed the request - // (applied OR dropped-as-seen). getState will persist it on the next project save so - // a re-open does not re-apply. A non-target instance leaves the marker (decision - // returns it unchanged) so it stays eligible if focus later lands here. - if (d.consumedGeneration != lastConsumed) { - lastConsumedAssignGeneration_ = d.consumedGeneration; - } - return d; - }(); - - if (decision.apply) { - // Apply the assignment as this instance's own selection (the same path a user card-pick - // takes) — the instrument updates its OWN state, never the bank. reloadInstrument below - // rebuilds against the new selection, so skip a redundant reload here. - setSelectedSampleId(decision.sampleId); - // Zone-bleed fix (3a), peer of the editor's Browse Load: a stale full-range zone - // materialized for the previously loaded sample would shadow the assigned pick under - // first-match resolve. Authored maps (any narrow key range) are untouched. - PerformanceMap reconciled = performanceMap(); - if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) { - setPerformanceMap(reconciled); - } - result.applied = true; - } - - // --- S9: bank-generation change-detection ------------------------------------- - // Read the generation stamp; parse (absent/malformed -> 0, the pre-S9 default). FIRST poll - // (lastSeenBankGeneration_ == -1 sentinel): BASELINE the seen value without a reload — - // setState already loaded the instrument from its OWNED refs (pS), so a redundant reload - // on open would only churn. A later - // generation CHANGE (a recapture/ingest/remove, or an undo that lowers it) then drives the - // reload. An assignment we just applied also needs a reload; fold both into ONE (coalesced). - std::int64_t currentGen = kBankGenerationAbsent; - if (auto rawGen = bridge_.readReasamplerExtState(kProjExtBankGenKey)) { - currentGen = parseBankGeneration(*rawGen); - } - const bool firstPoll = (lastSeenBankGeneration_ < 0); - const bool genChanged = - !firstPoll && bankGenerationChanged(lastSeenBankGeneration_, currentGen); - lastSeenBankGeneration_ = currentGen; - - // LEGACY LIFT (pre-v10 blob): the restored state carries intent (a selection or zones) - // but NO owned refs — a pre-pS blob had no path table, so the setState-time reload had - // nothing to decode unless the bank happened to be readable already. Reload on this - // editor tick until the lift lands: reloadInstrument folds the bank blob into the refs - // when readable, after which the table is non-empty and this never fires again (the - // next save is then self-contained). A deliberately-empty instance has no intent and - // never churns; a bank that is not readable YET retries a cheap null publish on the - // editor cadence only. TERMINATING GUARD (#A, legacyLiftShouldRun): once the bank blob - // PARSES and no referenced id resolves in it, the ids are provably stale — there is - // nothing to lift, so the lift concludes permanently instead of churning a full bank - // read + reload every tick forever. This is a MIGRATION convenience for old projects, - // NOT a playback dependency — a v10 blob plays from its refs with no poll at all (pS). - bool legacyLift = false; - if (!genChanged && !result.applied && sampleRefs().empty()) { - const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty(); - legacyLift = hasIntent && legacyLiftShouldRun(); - } - - if (genChanged || result.applied || legacyLift) { - reloadInstrument(); // atomic pointer-swap handoff — glitch-free mid-play (S4 graveyard) - // Report the reload distinctly from an S8 apply so the editor re-snapshots its bank - // view. A legacy lift counts only when it actually landed an instrument (otherwise - // every retry tick would churn the editor's caches for nothing). - result.reloaded = - genChanged || - (legacyLift && live_.load(std::memory_order_acquire) != nullptr); - } - return result; -} - -tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { - // REAL-TIME: no allocation, no IO, no locks. Load the live AND draining instruments - // once for the whole block (two atomic acquires), then publish the MINIMUM installedAt - // over the pointers held so the off-thread graveyard pruner knows exactly which - // generations this block is holding (see the header proof). - // - // We publish installedAt — not a fresh re-read of reloadGeneration_ — to close an - // ordering race: reading reloadGeneration_ after the slots could observe a generation - // newer than the pointers we actually hold, causing the pruner to free an instrument - // process is still reading. installedAt was set on the reload path before the atomic - // exchange that made the instrument visible. - // - // The DRAIN instrument (FA1, bug 3b) is the previously-live snapshot displaced by the - // last reload: its already-sounding voices keep rendering (and receive note-offs) so a - // curve/param edit or bank refresh never cuts a ringing note. It receives NO note-ons. - // A racing reload can briefly leave the same pointer in both slots (live_ was loaded - // before the swap, draining_ after); collapse that to live-only so one engine is never - // advanced twice per frame. - LoadedInstrument* inst = live_.load(std::memory_order_acquire); - LoadedInstrument* drain = draining_.load(std::memory_order_acquire); - if (drain == inst) drain = nullptr; - std::uint64_t heldGen = 0; - if (inst && drain) { - heldGen = inst->installedAt < drain->installedAt ? inst->installedAt - : drain->installedAt; - } else if (inst) { - heldGen = inst->installedAt; - } else if (drain) { - heldGen = drain->installedAt; - } - processGeneration_.store(heldGen, std::memory_order_release); - - // Phase S drain retirement: publish whether the drain snapshot is FULLY idle (every engine - // voice silent) by naming its OWN installedAt (0 = no drain / still - // sounding). Evaluated at block START — idleness is monotone for a drain (it receives no - // note-ons), so a snapshot observed idle here stays idle; a tail that dies mid-block simply - // publishes one block later. Bounded scan (<= maxVoices), relaxed store — RT-safe. - drainIdleGeneration_.store( - (drain && drain->fullyIdle()) ? drain->installedAt : 0, - std::memory_order_relaxed); - - // Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps - // events at block granularity (no per-event sample-offset split) — audible timing is - // within one block, adequate for Tier 0; sample-accurate scheduling is a later tier. - // Note-offs also route to the DRAIN engine so a note held across a reload releases - // its old-snapshot voice too (otherwise it would sustain until the next reload). - if (data.inputEvents) { - const int32 count = data.inputEvents->getEventCount(); - for (int32 i = 0; i < count; ++i) { - Event e; - if (data.inputEvents->getEvent(i, e) != kResultOk) continue; - if (e.type == Event::kNoteOnEvent) { - // A note-on with velocity 0 is a note-off by MIDI convention. - const int vel = static_cast(e.noteOn.velocity * 127.0f + 0.5f); - if (vel <= 0) { - if (inst) inst->engine.noteOff(e.noteOn.pitch); - if (drain) drain->engine.noteOff(e.noteOn.pitch); - } else if (inst) { - inst->engine.noteOn(e.noteOn.pitch, vel); - } - } else if (e.type == Event::kNoteOffEvent) { - if (inst) inst->engine.noteOff(e.noteOff.pitch); - if (drain) drain->engine.noteOff(e.noteOff.pitch); - } else if (e.type == Event::kLegacyMIDICCOutEvent) { - // PANIC (Phase S voice-review Major #2): REAPER delivers raw input MIDI CC to a - // VST3 instrument as kLegacyMIDICCOut events on the INPUT event list (a REAPER-ism - // — the type is nominally an output event; DAW-verify, see handoff). - // CC 123 (All Notes Off): release semantics — Gate voices enter their AHDSR - // release tail; Trigger one-shots play through their bounded play length. - // CC 120 (All Sounds Off): hard-stop semantics — immediate silence regardless - // of play mode, including Trigger one-shots that ignore CC 123. This is the - // true "panic" for a ringing one-shot (e.g. a full-length capture). - // Both clear the mono held stack. Both apply to live AND drain. A ringing - // preview note is a real engine voice since the PreviewCard retirement, so - // the panics cover it with no separate routing. allNotesOff / allSoundsOff - // are RT-safe (no allocation, bounded scans). - const auto cc = static_cast(e.midiCCOut.controlNumber); - if (cc == kCtrlAllSoundsOff) { - if (inst) inst->engine.allSoundsOff(); - if (drain) drain->engine.allSoundsOff(); - } else if (cc == kCtrlAllNotesOff) { - if (inst) inst->engine.allNotesOff(); - if (drain) drain->engine.allNotesOff(); - } - } - } - } - - // S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed - // atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last - // one we consumed; fire it once, then latch the sequence so the same request never re-fires. - // Preview redesign: the drained requests drive the MAIN VoiceEngine — the exact - // noteOn/noteOff calls the host MIDI marshal above makes — so a preview note is a real - // voice: it counts against the voice count, can steal / be stolen, and respects - // Poly/Mono + Retrigger/Legato (deliberate reversal of the retired PreviewCard's - // isolation). The editor posts the root note, so it plays at unity. - // Consume (advance the sequence) even when inst is null so a note-on posted while no instrument - // is loaded does not re-fire stale on the next instrument load. - { - const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire); - const std::uint16_t onSeq = static_cast(on >> 16); - if (onSeq != 0 && onSeq != previewOnConsumed_) { - previewOnConsumed_ = onSeq; - if (inst) { - const int vel = static_cast((on >> 8) & 0xFF); - const int note = static_cast(on & 0xFF); - if (vel > 0) inst->engine.noteOn(note, vel); - } - } - } - { - const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire); - const std::uint16_t offSeq = static_cast(off >> 16); - if (offSeq != 0 && offSeq != previewOffConsumed_) { - // Consume UNCONDITIONALLY (mirror of the on path): a stale off left pending - // while nothing was loaded would otherwise survive until a (heal) reload lands - // and release the NEXT preview press in the same block. - previewOffConsumed_ = offSeq; - // Route the preview note-off to BOTH engines (mirror of the host note-off): a - // preview held across a reload — e.g. a curve edit committed mid-press — must - // release the old-snapshot voice now draining, not just the (fresh) live one. - // NOTE: preview shares the host-MIDI note space — noteOff releases the newest - // voice at that pitch, so a preview release can release a host-held note at - // the same pitch (inherent to routing preview through the real note path). - if (inst) inst->engine.noteOff(static_cast(off & 0xFF)); - if (drain) drain->engine.noteOff(static_cast(off & 0xFF)); - } - } - - if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { - embedPeak_.store(0.f, std::memory_order_relaxed); - return kResultOk; - } - AudioBusBuffers& out = data.outputs[0]; - const int32 frames = data.numSamples; - - // 64-bit host processing is not supported by the mono float core; emit silence - // rather than mis-render. REAPER runs 32-bit float by default. - if (data.symbolicSampleSize != kSample32) { - embedPeak_.store(0.f, std::memory_order_relaxed); - for (int32 ch = 0; ch < out.numChannels; ++ch) { - if (double* buf = out.channelBuffers64[ch]) { - for (int32 i = 0; i < frames; ++i) buf[i] = 0.0; - } - } - out.silenceFlags = (out.numChannels >= 64) - ? ~0ULL - : ((1ULL << out.numChannels) - 1); - return kResultOk; - } - - // Render per the host's NEGOTIATED output channel count (S7). The channel mode was baked - // into the LoadedInstrument's decode + negotiated onto the output bus off-thread, so here - // we simply match the buffers the host handed us: >=2 channels -> true stereo render into - // ch0/ch1 (then replicate any extra channels); exactly 1 -> the mono render. Either way the - // render ADDS into a cleared buffer — RT-safe (no alloc/IO/lock). NEVER reads the mode here. - float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr; - float* ch1 = out.numChannels > 1 ? out.channelBuffers32[1] : nullptr; - if (ch0 && ch1) { - // Stereo: clear both, render L/R. A mono sample plays dual-mono via the engine's stereo - // path (both channels equal), so a mono capture in stereo mode is centered, not silent. - // The DRAIN engine's ringing tails ADD on top (render mixes into the cleared buffer). - for (int32 i = 0; i < frames; ++i) { ch0[i] = 0.f; ch1[i] = 0.f; } - if (inst) inst->engine.render(ch0, ch1, static_cast(frames)); - if (drain) drain->engine.render(ch0, ch1, static_cast(frames)); - // FB1 post-mixer master gain: ramp gainCurrent_ toward the atomic target per-sample so - // continuous knob drags produce no zipper noise and the true-zero bottom causes no click. - // Applied AFTER the voice sum and BEFORE the extra-channel mirror + peak so both see the - // actual output. Branch-free inner loop; early-out when already at target. RT-safe. - { - const float gTarget = masterGain_.load(std::memory_order_relaxed); - const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step - const float gSnap = 0.5f * gStep; - const float diff = gTarget - gainCurrent_; - if (diff < -gSnap || diff > gSnap) { - // Ramp toward target: step per sample, then apply the per-sample gain. - for (int32 i = 0; i < frames; ++i) { - const float d = gTarget - gainCurrent_; - if (d > gStep) gainCurrent_ += gStep; - else if (d < -gStep) gainCurrent_ -= gStep; - else gainCurrent_ = gTarget; - ch0[i] *= gainCurrent_; - ch1[i] *= gainCurrent_; - } - } else { - gainCurrent_ = gTarget; - if (gTarget != 1.f) { - for (int32 i = 0; i < frames; ++i) { ch0[i] *= gTarget; ch1[i] *= gTarget; } - } - } - } - // Any channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2). - for (int32 ch = 2; ch < out.numChannels; ++ch) { - if (float* buf = out.channelBuffers32[ch]) { - for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; - } - } - // Block peak (max across L/R) for the embed strip's level indicator; RT-safe. - float peak = 0.f; - for (int32 i = 0; i < frames; ++i) { - const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i]; - const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i]; - if (a0 > peak) peak = a0; - if (a1 > peak) peak = a1; - } - embedPeak_.store(peak, std::memory_order_relaxed); - } else if (ch0) { - // Mono: render into channel 0, replicate to any extra channels (mono bus is 1 channel; - // the replicate is defensive for a host that still hands >1 channel on a mono bus). - for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f; - if (inst) inst->engine.render(ch0, static_cast(frames)); - if (drain) drain->engine.render(ch0, static_cast(frames)); - // FB1 post-mixer master gain (mono path) — same ramp contract as the stereo branch: - // post-sum, pre-peak/replicate, per-sample gainCurrent_ ramp toward target, RT-safe. - { - const float gTarget = masterGain_.load(std::memory_order_relaxed); - const float gStep = gainRampStep_; // T3-01: rate-derived per-sample step - const float gSnap = 0.5f * gStep; - const float diff = gTarget - gainCurrent_; - if (diff < -gSnap || diff > gSnap) { - for (int32 i = 0; i < frames; ++i) { - const float d = gTarget - gainCurrent_; - if (d > gStep) gainCurrent_ += gStep; - else if (d < -gStep) gainCurrent_ -= gStep; - else gainCurrent_ = gTarget; - ch0[i] *= gainCurrent_; - } - } else { - gainCurrent_ = gTarget; - if (gTarget != 1.f) { - for (int32 i = 0; i < frames; ++i) ch0[i] *= gTarget; - } - } - } - float peak = 0.f; - for (int32 i = 0; i < frames; ++i) { - const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; - if (a > peak) peak = a; - } - embedPeak_.store(peak, std::memory_order_relaxed); - for (int32 ch = 1; ch < out.numChannels; ++ch) { - if (float* buf = out.channelBuffers32[ch]) { - for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i]; - } - } - } - - // Report silence only when nothing is loaded (lets the host optimize when idle). - // With an instrument loaded — or a drain snapshot still ringing out — we clear the - // flag so a ringing voice is not skipped. - out.silenceFlags = (inst || drain) ? 0 - : ((out.numChannels >= 64) - ? ~0ULL - : ((1ULL << out.numChannels) - 1)); - return kResultOk; -} - -IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) { - if (name && FIDStringsEqual(name, ViewType::kEditor)) { - return new ReaSamplerEditor(this); - } - return nullptr; -} - -} // namespace reasampler::vst diff --git a/tests/test_browser_scroll.cpp b/tests/test_browser_scroll.cpp index a1114cf..fdb1441 100644 --- a/tests/test_browser_scroll.cpp +++ b/tests/test_browser_scroll.cpp @@ -160,6 +160,26 @@ static void testFilterIndices() { CHECK(filterNameIndices(names, "zzz").empty()); } +// --- The Browse-modal regions (Q-W2v hoist, T2-06) --------------------------- + +// Title / search / content / footer partition the window top-to-bottom without gaps; +// Back right-anchors in the title band; Cancel/Load flank the footer. +static void testBrowseModalPartition() { + const BrowseModal m = computeBrowseModal(840, 620); + CHECK(m.title.y == 0 && m.title.width == 840 && m.title.height == kTitleHeight); + CHECK(m.back.right() == 840 - kPad && m.back.bottom() <= m.title.bottom()); + CHECK(m.search.y == m.title.bottom()); + CHECK(m.search.height == searchBoxRect(840).height); + CHECK(m.content.y == m.search.bottom()); + CHECK(m.content.bottom() == 620 - 30); // the footer band (kBrowseFooterH) + CHECK(m.cancel.x == kPad); + CHECK(m.confirm.right() == 840 - kPad); + CHECK(m.cancel.y == m.content.bottom() + 3 && m.cancel.y == m.confirm.y); + // Degenerate short window: the footer clamps below the search box (no inversion). + const BrowseModal s = computeBrowseModal(840, 40); + CHECK(s.content.bottom() >= s.content.y); +} + int main() { testContentHeight(); testMaxOffsetFitsAndOverflows(); @@ -174,6 +194,7 @@ int main() { testSearchBoxRect(); testNameMatch(); testFilterIndices(); + testBrowseModalPartition(); if (g_fail == 0) std::printf("browser_scroll: all tests passed\n"); return g_fail != 0; diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp new file mode 100644 index 0000000..d84ad8c --- /dev/null +++ b/tests/test_component_state_io.cpp @@ -0,0 +1,179 @@ +// 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. + +#include "../src/core/instrument/map/component_state_io.h" + +#include +#include +#include + +using namespace reasampler; +using namespace reasampler::instrument::map; + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + std::printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + ++failures; \ + } \ + } while (0) + +// A full round-trip through the CURRENT envelope (v11): every field survives. +static void testComponentStateRoundTrip() { + ComponentState in; + in.selectionId = "smp-1"; + in.channelMode = ChannelMode::Stereo; + in.channelModeExplicit = true; + in.lastConsumedAssignGeneration = 42; + in.previewVelocity = 99; + in.voiceCount = 7; + in.voiceMode = VoiceMode::Mono; + in.monoTrigger = MonoTrigger::Legato; + in.masterGainLinear = 0.5; + in.instanceGuid = "0123456789abcdef0123456789abcdef"; + SampleRefEntry e; + e.sampleId = "smp-1"; + e.ref.relativePath = "bank/smp-1.wav"; + e.ref.rootNote = 64; + e.ref.loop.hasLoop = true; + e.ref.loop.start = 100; + e.ref.loop.end = 2000; + 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); + + const std::vector bytes = serializeComponentState(in); + const ComponentState out = deserializeComponentState(bytes, 48000.0); + + CHECK(out.selectionId == "smp-1"); + CHECK(out.channelMode == ChannelMode::Stereo); + CHECK(out.channelModeExplicit); + CHECK(out.lastConsumedAssignGeneration == 42); + CHECK(out.previewVelocity == 99); + CHECK(out.voiceCount == 7); + CHECK(out.voiceMode == VoiceMode::Mono); + CHECK(out.monoTrigger == MonoTrigger::Legato); + CHECK(out.masterGainLinear == 0.5); + CHECK(out.instanceGuid == "0123456789abcdef0123456789abcdef"); + CHECK(out.sampleRefs.size() == 1); + if (out.sampleRefs.size() == 1) { + CHECK(out.sampleRefs[0].sampleId == "smp-1"); + CHECK(out.sampleRefs[0].ref.relativePath == "bank/smp-1.wav"); + CHECK(out.sampleRefs[0].ref.rootNote == 64); + CHECK(out.sampleRefs[0].ref.loop.hasLoop); + CHECK(out.sampleRefs[0].ref.loop.start == 100); + CHECK(out.sampleRefs[0].ref.loop.end == 2000); + 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); + } +} + +// 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. +static void testEnvelopePrefixBytesFrozen() { + ComponentState in; // defaults: mono, implicit, no refs, no selection, no zones + const std::vector bytes = serializeComponentState(in); + CHECK(bytes.size() > 5); + if (bytes.size() > 5) { + CHECK(bytes[0] == 11 && bytes[1] == 0 && bytes[2] == 0 && bytes[3] == 0); + CHECK(bytes[4] == 0); // ChannelMode::Mono + } + CHECK(kComponentStateVersion == 11); + CHECK(kZonesPayloadVersion == 7); + CHECK(kZonesFormatMarker == 0xFFFFFF00u); +} + +// A v1 selection blob lifts to {id, one full-keyboard zone} — 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); + } +} + +// Truncation degrades to a partial/empty parse — never out-of-bounds, never throws. +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); +} + +// 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); + } +} + +int main() { + testComponentStateRoundTrip(); + testEnvelopePrefixBytesFrozen(); + testV1SelectionLift(); + testTruncationDegradesCleanly(); + testPerformanceRoundTrip(); + if (failures == 0) { + std::printf("component_state_io_tests: all tests passed\n"); + return 0; + } + std::printf("component_state_io_tests: %d FAILURE(S)\n", failures); + return 1; +} diff --git a/tests/test_editor_geometry.cpp b/tests/test_editor_geometry.cpp index 858fcb7..8a4eef2 100644 --- a/tests/test_editor_geometry.cpp +++ b/tests/test_editor_geometry.cpp @@ -311,6 +311,78 @@ static void testZoneHitTestMisses() { 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(); @@ -332,6 +404,9 @@ int main() { 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_instrument_drop.cpp b/tests/test_instrument_drop.cpp index 43467c0..2a6a934 100644 --- a/tests/test_instrument_drop.cpp +++ b/tests/test_instrument_drop.cpp @@ -7,7 +7,7 @@ // cross-artifact contract guard — the same pattern assignment_request_tests uses. #include "../src/core/wire/instrument_drop.h" -#include "../src/core/instrument/map/sample_map.h" // deserializeComponentState — the instrument's OWN reader +#include "../src/core/instrument/map/component_state_io.h" // deserializeComponentState — the instrument's OWN reader #include "version_generated.h" // REASAMPLER_CHANNEL_IS_BETA — pins the per-channel class ID @@ -18,6 +18,7 @@ using namespace reasampler; using namespace reasampler::wire; +using namespace reasampler::instrument::map; // ComponentState + the codec (Q-W2v re-namespace) static int g_fail = 0; #define CHECK(cond) do { if(!(cond)) { \ diff --git a/tests/test_sample_map.cpp b/tests/test_sample_map.cpp index 1a7a96d..d0f68c2 100644 --- a/tests/test_sample_map.cpp +++ b/tests/test_sample_map.cpp @@ -23,6 +23,7 @@ // channel-count stride downmixToMono divides by). #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 #include @@ -36,7 +37,8 @@ using namespace reasampler; using namespace reasampler::instrument::engine; -using namespace reasampler::capture; // wav_trim (WavLayout) — sample_map re-exports live in reasampler until Q-W2v +using namespace reasampler::instrument::map; // sample_map + component_state_io (Q-W2v re-namespace) +using namespace reasampler::capture; // wav_trim (WavLayout) using namespace reasampler::model; static int g_fail = 0; diff --git a/tests/test_wire.cpp b/tests/test_wire.cpp index d2420b4..edb0354 100644 --- a/tests/test_wire.cpp +++ b/tests/test_wire.cpp @@ -9,9 +9,12 @@ // reference-bound std::string — the Cursor never outlives its buffer. #include "../src/core/wire/wire.h" +#include "../src/core/wire/bytes.h" // putLE / ByteReader — the ONE LE byte codec (Q-W2v, T4-20) +#include #include #include +#include using namespace reasampler; using namespace reasampler::wire; @@ -173,7 +176,56 @@ static void testParseUnsignedDecimal() { CHECK(!wire::parseUnsignedDecimal(std::string(40, '9'), v)); // long run cannot wrap } +// --- core/wire/bytes.h — the LE byte codec (Q-W2v, T4-20) -------------------- + +// putLE emits exactly the bytes the retired hand-rolled putU32le/putU64le emitted +// (LSB first, fixed width) — the FROZEN wire byte order. +static void testPutLEExactBytes() { + std::vector out; + wire::putLE(out, static_cast(0x0403'0201u)); + CHECK(out.size() == 4); + CHECK(out[0] == 0x01 && out[1] == 0x02 && out[2] == 0x03 && out[3] == 0x04); + out.clear(); + wire::putLE(out, static_cast(0x0807'0605'0403'0201ull)); + CHECK(out.size() == 8); + CHECK(out[0] == 0x01 && out[7] == 0x08); +} + +// ByteReader round-trips putLE output, and the double bit-cast is lossless. +static void testByteReaderRoundTrip() { + std::vector out; + wire::putLE(out, static_cast(7)); + wire::putLE(out, static_cast(wire::doubleToBits(-2.5))); + wire::putLE(out, static_cast(0xAB)); + wire::putLE(out, + static_cast(static_cast(-42))); // i64 image + wire::ByteReader r(out); + CHECK(r.peekU32() == 7); + CHECK(r.u32() == 7); + CHECK(wire::bitsToDouble(r.u64()) == -2.5); + CHECK(r.u8() == 0xAB); + CHECK(r.i64() == -42); + CHECK(r.ok); +} + +// Truncation latches ok=false and every subsequent read yields zero/empty — +// the partial-parse contract every codec consumer leans on. +static void testByteReaderLatchesOnTruncation() { + std::vector out; + wire::putLE(out, static_cast(9)); + out.pop_back(); // truncate mid-u32 + wire::ByteReader r(out); + CHECK(r.u32() == 0); + CHECK(!r.ok); + CHECK(r.u8() == 0); // latched: even an in-bounds width now fails + CHECK(r.str(1).empty()); + CHECK(r.peekU32() == 0); +} + int main() { + testPutLEExactBytes(); + testByteReaderRoundTrip(); + testByteReaderLatchesOnTruncation(); testPutFieldExactBytes(); testFieldRoundTripIncludingSeparators(); testLiteralMismatchFails();