Q-W2v: split VST god-modules — editor 8 face-axis TUs (+pure layout hoist), processor 3 TUs, component_state_io codec split (extension drops the voice engine), zone_params.h, core/wire putLE; formats frozen, 61/61 green
This commit is contained in:
+62
-31
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include <vector>
|
||||
|
||||
#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<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
|
||||
std::vector<AudioSample> 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
|
||||
|
||||
@@ -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 <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#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<AudioSample> frames; // channel 0 PCM (mono, or L of a stereo sample)
|
||||
std::vector<AudioSample> 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
|
||||
@@ -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 <algorithm> // std::min (bounded curve-point reserve)
|
||||
#include <cassert> // assert (v3-lift projectRate guard)
|
||||
#include <cmath> // std::isfinite (v8 master-gain validation)
|
||||
#include <cstring> // std::memcpy (serializeSelection)
|
||||
#include <utility> // std::move
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear — the v8 master-gain wire cap
|
||||
#include "core/wire/bytes.h" // putLE / ByteReader / doubleToBits (the ONE LE codec, T4-20)
|
||||
|
||||
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<std::uint64_t>(v); }
|
||||
|
||||
// Append the zones payload — the shared body of the performance blob and the component blob,
|
||||
// so both write zones identically. Always emits the CURRENT PAYLOAD version (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<std::uint8_t>& out, const PerformanceMap& map) {
|
||||
putLE(out, kZonesFormatMarker);
|
||||
putLE(out, kZonesPayloadVersion);
|
||||
putLE(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putLE(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
||||
out.push_back(z.rootOverride ? 1 : 0);
|
||||
if (z.rootOverride) {
|
||||
putLE(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||
}
|
||||
// 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<VelocityPoint>& pts = z.velocityCurve.points();
|
||||
putLE(out, static_cast<std::uint32_t>(pts.size()));
|
||||
for (const VelocityPoint& p : pts) {
|
||||
putLE(out, doubleToBits(p.velocity));
|
||||
putLE(out, doubleToBits(p.amp));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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<double>(r.i64()) / liftRate;
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} 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<VelocityPoint> 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<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) z.velocityCurve = reasampler::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putLE(out, kPerformanceStateVersion);
|
||||
putZonesPayload(out, map);
|
||||
return out;
|
||||
}
|
||||
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& 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<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
std::vector<std::uint8_t> 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<std::uint8_t>(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<std::uint32_t>(state.sampleRefs.size()));
|
||||
for (const SampleRefEntry& e : state.sampleRefs) {
|
||||
putLE(out, static_cast<std::uint32_t>(e.sampleId.size()));
|
||||
out.insert(out.end(), e.sampleId.begin(), e.sampleId.end());
|
||||
putLE(out, static_cast<std::uint32_t>(e.ref.relativePath.size()));
|
||||
out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end());
|
||||
putLE(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(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<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount)));
|
||||
putLE(out, static_cast<std::uint32_t>(e.displayName.size()));
|
||||
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
||||
}
|
||||
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
|
||||
// length-prefixed, following the refs table so a v10 blob is a strict prefix up to
|
||||
// here (see the v10 lift). Empty = never published — legal, round-trips as empty.
|
||||
putLE(out, static_cast<std::uint32_t>(state.instanceGuid.size()));
|
||||
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
|
||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||
putLE(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
|
||||
putZonesPayload(out, state.map);
|
||||
return out;
|
||||
}
|
||||
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& 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<int>(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<std::uint8_t> serializeSelection(const std::string& sampleId) {
|
||||
std::vector<std::uint8_t> out;
|
||||
out.resize(4 + sampleId.size());
|
||||
const std::uint32_t v = kSelectionStateVersion;
|
||||
out[0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||
out[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||
out[2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||
out[3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||
std::memcpy(out.data() + 4, sampleId.data(), sampleId.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
|
||||
if (bytes.size() < 4) return {}; // no version tag -> no selection
|
||||
const std::uint32_t v = static_cast<std::uint32_t>(bytes[0]) |
|
||||
(static_cast<std::uint32_t>(bytes[1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[3]) << 24);
|
||||
if (v != kSelectionStateVersion) return {}; // unknown version -> ignore
|
||||
return std::string(reinterpret_cast<const char*>(bytes.data() + 4),
|
||||
bytes.size() - 4);
|
||||
}
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -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 <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
|
||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
|
||||
// --- Combined component state (VST3 setState/getState, v3 — 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_<guid>" 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_<guid>" 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<std::uint8_t> 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<std::uint8_t>& 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<std::uint8_t> 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<std::uint8_t>& bytes);
|
||||
|
||||
|
||||
} // namespace reasampler::instrument::map
|
||||
@@ -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 <algorithm> // std::min
|
||||
#include <cassert> // assert
|
||||
#include <cmath> // std::isfinite (v8 master-gain validation)
|
||||
#include <cstring> // std::memcpy
|
||||
#include <utility> // 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<ResolvedZone>& zones,
|
||||
return km; // empty zones in -> empty Keymap (silence)
|
||||
}
|
||||
|
||||
// --- Performance-map instance state (setState/getState) -----------------------
|
||||
|
||||
namespace {
|
||||
|
||||
void putU32le(std::vector<std::uint8_t>& out, std::uint32_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((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<std::uint8_t>& out, std::uint64_t v) {
|
||||
for (int b = 0; b < 8; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFF));
|
||||
}
|
||||
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(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<std::uint8_t>& bytes;
|
||||
std::size_t pos = 0;
|
||||
bool ok = true;
|
||||
|
||||
explicit ByteReader(const std::vector<std::uint8_t>& b) : bytes(b) {}
|
||||
|
||||
std::uint32_t u32() {
|
||||
if (!ok || pos + 4 > bytes.size()) { ok = false; return 0; }
|
||||
const std::uint32_t v = static_cast<std::uint32_t>(bytes[pos]) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(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<const char*>(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<int>(static_cast<std::int32_t>(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<std::uint64_t>(bytes[pos + static_cast<std::size_t>(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<std::int64_t>(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<std::uint32_t>(bytes[pos]) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(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<std::uint8_t>& out, const PerformanceMap& map) {
|
||||
putU32le(out, kZonesFormatMarker);
|
||||
putU32le(out, kZonesPayloadVersion);
|
||||
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
out.insert(out.end(), z.sampleId.begin(), z.sampleId.end());
|
||||
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.lowNote)));
|
||||
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(z.highNote)));
|
||||
out.push_back(z.rootOverride ? 1 : 0);
|
||||
if (z.rootOverride) {
|
||||
putU32le(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*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<VelocityPoint>& pts = z.velocityCurve.points();
|
||||
putU32le(out, static_cast<std::uint32_t>(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<double>(r.i64()) / liftRate;
|
||||
z.play.trigger.lengthFraction = bitsToDouble(r.u64());
|
||||
z.play.trigger.fadeInFrames = r.i64();
|
||||
z.play.trigger.fadeOutFrames = r.i64();
|
||||
z.play.pitchEngine = (r.u8() != 0) ? PitchEngine::Preserve : PitchEngine::Varispeed;
|
||||
z.play.pitchEnv.enabled = (r.u8() != 0);
|
||||
z.play.pitchEnv.attackSeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.decaySeconds = static_cast<double>(r.i64()) / liftRate;
|
||||
z.play.pitchEnv.peakSemitones = bitsToDouble(r.u64());
|
||||
} 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<VelocityPoint> 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<std::size_t>(ptCount), remaining / 16));
|
||||
for (std::uint32_t p = 0; p < ptCount && r.ok; ++p) {
|
||||
const double vel = bitsToDouble(r.u64());
|
||||
const double amp = bitsToDouble(r.u64());
|
||||
pts.push_back(VelocityPoint{vel, amp});
|
||||
}
|
||||
if (r.ok) z.velocityCurve = reasampler::VelocityCurve::fromPoints(std::move(pts));
|
||||
}
|
||||
// Payload versions 4 (branch-only frames tail, never shipped) and any unknown pv leave the
|
||||
// seconds product defaults on z.play — a v4 blob cannot exist outside this branch.
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map) {
|
||||
std::vector<std::uint8_t> out;
|
||||
putU32le(out, kPerformanceStateVersion);
|
||||
putZonesPayload(out, map);
|
||||
return out;
|
||||
}
|
||||
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& 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<std::uint8_t> serializeComponentState(const ComponentState& state) {
|
||||
std::vector<std::uint8_t> 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<std::uint8_t>(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<std::uint32_t>(state.sampleRefs.size()));
|
||||
for (const SampleRefEntry& e : state.sampleRefs) {
|
||||
putU32le(out, static_cast<std::uint32_t>(e.sampleId.size()));
|
||||
out.insert(out.end(), e.sampleId.begin(), e.sampleId.end());
|
||||
putU32le(out, static_cast<std::uint32_t>(e.ref.relativePath.size()));
|
||||
out.insert(out.end(), e.ref.relativePath.begin(), e.ref.relativePath.end());
|
||||
putU32le(out, static_cast<std::uint32_t>(static_cast<std::int32_t>(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<std::uint32_t>(static_cast<std::int32_t>(e.ref.channelCount)));
|
||||
putU32le(out, static_cast<std::uint32_t>(e.displayName.size()));
|
||||
out.insert(out.end(), e.displayName.begin(), e.displayName.end());
|
||||
}
|
||||
// v11 envelope addition (pS-usage instance identity): the minted per-instance guid,
|
||||
// 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<std::uint32_t>(state.instanceGuid.size()));
|
||||
out.insert(out.end(), state.instanceGuid.begin(), state.instanceGuid.end());
|
||||
// Length-prefixed selection id (it precedes the zones payload, so it MUST be framed —
|
||||
// unlike the v1 selection blob where the id ran to end-of-stream).
|
||||
putU32le(out, static_cast<std::uint32_t>(state.selectionId.size()));
|
||||
out.insert(out.end(), state.selectionId.begin(), state.selectionId.end());
|
||||
putZonesPayload(out, state.map);
|
||||
return out;
|
||||
}
|
||||
|
||||
ComponentState deserializeComponentState(const std::vector<std::uint8_t>& 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<int>(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<std::uint8_t> serializeSelection(const std::string& sampleId) {
|
||||
std::vector<std::uint8_t> out;
|
||||
out.resize(4 + sampleId.size());
|
||||
const std::uint32_t v = kSelectionStateVersion;
|
||||
out[0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||
out[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||
out[2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||
out[3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||
std::memcpy(out.data() + 4, sampleId.data(), sampleId.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
|
||||
if (bytes.size() < 4) return {}; // no version tag -> no selection
|
||||
const std::uint32_t v = static_cast<std::uint32_t>(bytes[0]) |
|
||||
(static_cast<std::uint32_t>(bytes[1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[3]) << 24);
|
||||
if (v != kSelectionStateVersion) return {}; // unknown version -> ignore
|
||||
return std::string(reinterpret_cast<const char*>(bytes.data() + 4),
|
||||
bytes.size() - 4);
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -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<ResolvedZone>& zones,
|
||||
DecodedZonePcm decodeChannels(const std::vector<AudioSample>& 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<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
|
||||
// The performance map parsed back from IBStream bytes (setState). A v2 blob parses
|
||||
// directly; a v1 blob lifts to a single full-keyboard zone; anything else -> empty map.
|
||||
// `projectRate` is the live host/project sample rate (must be > 0) used to convert the
|
||||
// legacy v3 wall-clock frame counts to the seconds domain at the read boundary.
|
||||
PerformanceMap deserializePerformance(const std::vector<std::uint8_t>& bytes,
|
||||
double projectRate);
|
||||
|
||||
// --- Combined component state (VST3 setState/getState, v3 — 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_<guid>" 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_<guid>" 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<std::uint8_t> 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<std::uint8_t>& 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<std::uint8_t> 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<std::uint8_t>& bytes);
|
||||
|
||||
} // namespace reasampler
|
||||
} // namespace reasampler::instrument::map
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
@@ -155,4 +157,25 @@ std::vector<int> filterNameIndices(const std::vector<std::string>& 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
|
||||
|
||||
@@ -104,4 +104,21 @@ bool nameMatchesQuery(const std::string& name, const std::string& query);
|
||||
std::vector<int> filterNameIndices(const std::vector<std::string>& 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<u32>/putLE<u64> 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 <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <vector>
|
||||
|
||||
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 <class T>
|
||||
inline void putLE(std::vector<std::uint8_t>& out, T v) {
|
||||
static_assert(std::is_unsigned_v<T>, "putLE takes the unsigned wire image");
|
||||
for (std::size_t b = 0; b < sizeof(T); ++b) {
|
||||
out.push_back(static_cast<std::uint8_t>((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<std::uint8_t>& bytes;
|
||||
std::size_t pos = 0;
|
||||
bool ok = true;
|
||||
|
||||
explicit ByteReader(const std::vector<std::uint8_t>& b) : bytes(b) {}
|
||||
|
||||
// Read one unsigned integral little-endian (fixed sizeof(T) width).
|
||||
template <class T>
|
||||
T readLE() {
|
||||
static_assert(std::is_unsigned_v<T>, "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<T>(bytes[pos + b]) << (b * 8);
|
||||
}
|
||||
pos += sizeof(T);
|
||||
return v;
|
||||
}
|
||||
|
||||
std::uint8_t u8() { return readLE<std::uint8_t>(); }
|
||||
std::uint32_t u32() { return readLE<std::uint32_t>(); }
|
||||
std::uint64_t u64() { return readLE<std::uint64_t>(); }
|
||||
// Signed ints ride the wire as fixed-width two's-complement unsigned images.
|
||||
int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }
|
||||
std::int64_t i64() { return static_cast<std::int64_t>(u64()); }
|
||||
|
||||
std::string str(std::uint32_t len) {
|
||||
if (!ok || pos + len > bytes.size()) {
|
||||
ok = false;
|
||||
return {};
|
||||
}
|
||||
std::string s(reinterpret_cast<const char*>(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<std::uint32_t>(bytes[pos]) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace reasampler::wire
|
||||
@@ -7,25 +7,19 @@
|
||||
#include <cstdio>
|
||||
|
||||
#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<std::uint8_t>& out, std::uint32_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>(v & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFF));
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||
}
|
||||
|
||||
void appendU64LE(std::vector<std::uint8_t>& out, std::uint64_t v) {
|
||||
for (int i = 0; i < 8; ++i)
|
||||
out.push_back(static_cast<std::uint8_t>((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<std::uint8_t>& out, const char id[4]) {
|
||||
out.insert(out.end(), id, id + 4);
|
||||
@@ -58,19 +52,19 @@ std::vector<std::uint8_t> buildVstPresetBytes(
|
||||
|
||||
out.reserve(static_cast<std::size_t>(listOffset) + 4 + 4 + (4 + 8 + 8));
|
||||
appendFourCC(out, "VST3");
|
||||
appendU32LE(out, 1); // kFormatVersion
|
||||
putLE<std::uint32_t>(out, 1); // kFormatVersion
|
||||
out.insert(out.end(), classIdHex32.begin(), classIdHex32.end());
|
||||
appendU64LE(out, listOffset);
|
||||
putLE<std::uint64_t>(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<std::uint32_t>(out, 1);
|
||||
appendFourCC(out, "Comp");
|
||||
appendU64LE(out, kHeaderSize);
|
||||
appendU64LE(out, compSize);
|
||||
putLE<std::uint64_t>(out, kHeaderSize);
|
||||
putLE<std::uint64_t>(out, compSize);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio> // snprintf (deck value labels)
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<double>(f) / fadeMax) : 0.0;
|
||||
};
|
||||
switch (static_cast<ParamControl>(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<std::int64_t>(clamp01(v) * fadeMax + 0.5);
|
||||
};
|
||||
switch (static_cast<ParamControl>(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<double>(kPreviewVelocityDefault) / 127.0;
|
||||
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
||||
}
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The PER-ZONE groups — the deck grammar both surfaces share (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<DeckGroupDesc> out;
|
||||
{
|
||||
DeckGroupDesc amp;
|
||||
amp.id = kGroupAmpEnv;
|
||||
amp.captionWidth = 78;
|
||||
amp.captionToggle = {static_cast<int>(ParamControl::kPlayMode), 44};
|
||||
if (play.playMode == PlayMode::Gate) {
|
||||
amp.cellIds = {static_cast<int>(ParamControl::kAttack),
|
||||
static_cast<int>(ParamControl::kHold),
|
||||
static_cast<int>(ParamControl::kDecay),
|
||||
static_cast<int>(ParamControl::kSustain),
|
||||
static_cast<int>(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<int>(ParamControl::kTrigFadeIn),
|
||||
static_cast<int>(ParamControl::kTrigLength),
|
||||
static_cast<int>(ParamControl::kTrigFadeOut), -1, -1};
|
||||
}
|
||||
out.push_back(std::move(amp));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc pitch;
|
||||
pitch.id = kGroupPitch;
|
||||
pitch.captionWidth = 38;
|
||||
pitch.captionToggle = {static_cast<int>(ParamControl::kPitchEngine), 48};
|
||||
pitch.cellIds = {static_cast<int>(ParamControl::kKeyTrack)};
|
||||
out.push_back(std::move(pitch));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc penv;
|
||||
penv.id = kGroupPitchEnv;
|
||||
penv.captionWidth = 58;
|
||||
penv.captionToggle = {static_cast<int>(ParamControl::kPitchEnvEnable), 32};
|
||||
penv.cellIds = {static_cast<int>(ParamControl::kPitchEnvAttack),
|
||||
static_cast<int>(ParamControl::kPitchEnvDecay),
|
||||
static_cast<int>(ParamControl::kPitchEnvDepth)};
|
||||
out.push_back(std::move(penv));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER
|
||||
// groups. 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<DeckGroupDesc> out = zoneDeckGroupDescs(play);
|
||||
{
|
||||
DeckGroupDesc voice;
|
||||
voice.id = kGroupVoice;
|
||||
voice.captionWidth = 38;
|
||||
voice.captionToggle = {static_cast<int>(ParamControl::kVoiceMode), 40};
|
||||
voice.cellIds = {static_cast<int>(ParamControl::kVoiceCount)};
|
||||
voice.rowToggle = {static_cast<int>(ParamControl::kMonoTrigger), 44};
|
||||
out.push_back(std::move(voice));
|
||||
}
|
||||
{
|
||||
DeckGroupDesc master;
|
||||
master.id = kGroupMaster;
|
||||
master.captionWidth = 46;
|
||||
master.cellIds = {static_cast<int>(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<ParamControl>(id)) {
|
||||
case ParamControl::kKeyTrack:
|
||||
return clamp01(zone.keyTrack / kKeyTrackMax);
|
||||
case ParamControl::kVoiceCount:
|
||||
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
|
||||
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
|
||||
case ParamControl::kMasterGain:
|
||||
return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0);
|
||||
default:
|
||||
return controlValue(id, zone.play);
|
||||
}
|
||||
}
|
||||
|
||||
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<std::uint8_t>(norm * 127.0 + 0.5));
|
||||
return;
|
||||
}
|
||||
switch (static_cast<ParamControl>(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<int>(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<ParamControl>(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<long long>(play.trigger.fadeInFrames)); break;
|
||||
case ParamControl::kTrigFadeOut:
|
||||
snprintf(buf, sizeof(buf), "%lldf",
|
||||
static_cast<long long>(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<int>(map_.zones.size())) {
|
||||
return map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
}
|
||||
return effectiveSampleZone();
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::popupZoneIndex() {
|
||||
// The map_.zones index a popup edit lands on, or -1 when there is no valid target. The
|
||||
// Zone surface never materializes (the button only shows for an explicit selection); the
|
||||
// Sample face finds-or-materializes the picked id's one-zone site.
|
||||
if (view_ == View::kZone) {
|
||||
return (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size()))
|
||||
? selectedZone_
|
||||
: -1;
|
||||
}
|
||||
return ensureSampleZone();
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) {
|
||||
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
|
||||
if (id == static_cast<int>(ParamControl::kKeyTrack)) {
|
||||
// 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
|
||||
@@ -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 <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<int>(banks_.size()) + 1;
|
||||
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||
const int card = (tab >= 0)
|
||||
? -1
|
||||
: cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (tab >= 0) h = {HoverKind::kFilterTab, tab};
|
||||
else if (card >= 0) h = {HoverKind::kCard, card};
|
||||
}
|
||||
} else if (curvePopupOpen_) { // the 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<int>(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<std::size_t>(selectedZone_)].play;
|
||||
const Rect deckArea = zonesDeckArea(content);
|
||||
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x,
|
||||
deckArea.y, deckArea.width);
|
||||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||||
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
|
||||
}
|
||||
}
|
||||
} else { // Sample view (home, r11 recomposition)
|
||||
const PerformanceZone zone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> descs = deckGroupDescs(zone.play);
|
||||
const SampleBands bands =
|
||||
computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad));
|
||||
if (contains(bands.navBrowse, x, y)) {
|
||||
h = {HoverKind::kNavBrowse, -1};
|
||||
} else if (contains(bands.navZone, x, y)) {
|
||||
h = {HoverKind::kNavZone, -1};
|
||||
} else if (selectedId_.empty() && map_.zones.empty()) {
|
||||
// Empty state — no interactive surfaces beyond the nav.
|
||||
} else {
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
|
||||
if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1};
|
||||
else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1};
|
||||
else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1};
|
||||
else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
|
||||
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
|
||||
else if (contains(bands.deck, x, y)) {
|
||||
// A deck knob/toggle under the pointer: knobs light + swap label->value.
|
||||
const DeckLayout dl =
|
||||
layoutDeck(descs, bands.deck.x, bands.deck.y, bands.deck.width);
|
||||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||||
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (h != hover_) {
|
||||
hover_ = h;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
// The Browse-modal branch of the mouse-down dispatch (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<int>(banks_.size()) + 1;
|
||||
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||
if (tab >= 0) {
|
||||
activeFilterBankId_ = (tab == 0) ? std::string()
|
||||
: banks_[static_cast<std::size_t>(tab - 1)].id;
|
||||
rebuildVisible();
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
|
||||
if (thumb.height > 0 &&
|
||||
contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y,
|
||||
thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) {
|
||||
drag_ = DragKind::kScrollThumb;
|
||||
dragStartY_ = y;
|
||||
dragStartScrollOffset_ = scrollOffset_;
|
||||
return;
|
||||
}
|
||||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (card >= 0) {
|
||||
// Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card
|
||||
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
|
||||
const std::string id = visible_[static_cast<std::size_t>(card)].id;
|
||||
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
|
||||
loadSelection(id);
|
||||
browsePendingId_.clear();
|
||||
lastBrowseClickCard_ = -1;
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
} else {
|
||||
browsePendingId_ = id;
|
||||
lastBrowseClickCard_ = card;
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastBrowseClickCard_ = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// The Zone-surface branch of the mouse-down dispatch (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<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) {
|
||||
selectedZone_ = i;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Look up the seed's root note from the browser list (absent root defaults to 60).
|
||||
int seedRoot = 60;
|
||||
for (const SampleChoice& sc : samples_) {
|
||||
if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; }
|
||||
}
|
||||
const int lo = (std::max)(0, seedRoot - 6);
|
||||
const int hi = (std::min)(127, seedRoot + 5);
|
||||
PerformanceZone z;
|
||||
z.sampleId = seed;
|
||||
z.lowNote = lo;
|
||||
z.highNote = hi;
|
||||
map_.zones.push_back(z);
|
||||
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
|
||||
commitAndReload();
|
||||
return;
|
||||
}
|
||||
Rect delR = zoneDeleteRect(addR);
|
||||
if (selectedZone_ >= 0 && contains(delR, x, y)) {
|
||||
map_.zones.erase(map_.zones.begin() + selectedZone_);
|
||||
selectedZone_ = -1;
|
||||
commitAndReload();
|
||||
return;
|
||||
}
|
||||
|
||||
// The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the
|
||||
// selected zone's root.
|
||||
const Rect stripArea = zonesStripArea(content);
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
const int lx = x - stripArea.x;
|
||||
const int ly = y - stripArea.y;
|
||||
|
||||
std::vector<int> lows, highs;
|
||||
lows.reserve(map_.zones.size());
|
||||
highs.reserve(map_.zones.size());
|
||||
for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); }
|
||||
const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(),
|
||||
highs.empty() ? nullptr : highs.data(),
|
||||
static_cast<int>(map_.zones.size()), lx, ly);
|
||||
if (hit.zoneIndex >= 0) {
|
||||
selectedZone_ = hit.zoneIndex;
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(hit.zoneIndex)];
|
||||
dragStartX_ = x;
|
||||
dragStartLow_ = z.lowNote;
|
||||
dragStartHigh_ = z.highNote;
|
||||
dragStartMap_ = map_;
|
||||
switch (hit.grab) {
|
||||
case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break;
|
||||
case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break;
|
||||
case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break;
|
||||
default: drag_ = DragKind::kNone; break;
|
||||
}
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// A bare key-click inside the strip sets the selected zone's root override.
|
||||
if (contains(stripArea, x, y) && selectedZone_ >= 0 &&
|
||||
selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const int note = keyAtPoint(sl, lx, ly);
|
||||
if (note >= 0) {
|
||||
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
|
||||
commitAndReload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 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<int>(map_.zones.size())) {
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
if (contains(noteEntryFieldRect(fields, f), x, y)) {
|
||||
entryField_ = f;
|
||||
entryText_.clear();
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry
|
||||
|
||||
// The per-zone param surface (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<int>(map_.zones.size())) {
|
||||
if (contains(zonesCurveButton(content), x, y)) {
|
||||
curvePopupOpen_ = true;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||||
const Rect deckArea = zonesDeckArea(content);
|
||||
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y,
|
||||
deckArea.width);
|
||||
const DeckHit hit = hitTestDeck(dl, x, y);
|
||||
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
|
||||
// Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete,
|
||||
// final edit committed at once (the deck precedent). No per-instance ids reach
|
||||
// here — VOICE/MASTER are not in the zone group set.
|
||||
applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment);
|
||||
commitAndReload();
|
||||
return;
|
||||
}
|
||||
if (hit.kind == DeckHitKind::Knob) {
|
||||
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the
|
||||
// Sample deck's guard, mirrored.
|
||||
const bool pitchEnvKnob =
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||||
if (pitchEnvKnob && !play.pitchEnv.enabled) return;
|
||||
// GRAB-ANCHORED vertical drag (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<std::size_t>(selectedZone_)]);
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
|
||||
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive
|
||||
// delta (wheel up) scrolls toward the top (smaller offset).
|
||||
if (view_ != View::kBrowse) return;
|
||||
const int rows = delta / 120;
|
||||
if (rows == 0) return;
|
||||
scrollOffset_ -= rows * kBrowserCardHeight;
|
||||
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
// 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<int>(map_.zones.size())) {
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
if (entryField_ == 0) z.lowNote = (std::min)(*note, z.highNote);
|
||||
else if (entryField_ == 1) z.highNote = (std::max)(*note, z.lowNote);
|
||||
else z.rootOverride = *note;
|
||||
commitAndReload();
|
||||
}
|
||||
}
|
||||
entryField_ = -1;
|
||||
entryText_.clear();
|
||||
invalidate();
|
||||
} else if (ch == 27) { // Escape cancels
|
||||
entryField_ = -1;
|
||||
entryText_.clear();
|
||||
invalidate();
|
||||
} else if (ch == 8) { // backspace
|
||||
if (!entryText_.empty()) entryText_.pop_back();
|
||||
invalidate();
|
||||
} else if (ch >= 32 && ch < 127) {
|
||||
entryText_.push_back(static_cast<char>(ch));
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 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<char>(ch));
|
||||
} else {
|
||||
return; // ignore other control chars
|
||||
}
|
||||
scrollOffset_ = 0; // a new filter resets the scroll to the top of the narrowed list
|
||||
rebuildVisible();
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onFilesDropped(int droppedCount) {
|
||||
// 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
|
||||
@@ -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 <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + thumbDragToOffset (scroll drag)
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet (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<int>(map_.zones.size())) return;
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
|
||||
|
||||
int idx = z.velocityCurve.pointAtPixel(box, x, y);
|
||||
|
||||
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
|
||||
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
|
||||
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
|
||||
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||||
selectedZone_ = zoneIndex;
|
||||
commitAndReload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight
|
||||
// ADD (mirror of the other map-editing drags' dragStartMap_ contract).
|
||||
dragStartMap_ = map_;
|
||||
|
||||
// Empty-space click inside the MAPPING BOX: add a control point 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<int>(z.velocityCurve.addPoint(p.velocity, p.amp));
|
||||
}
|
||||
}
|
||||
|
||||
if (idx < 0) return; // ring click with no node hit — nothing to grab
|
||||
|
||||
drag_ = DragKind::kCurveNode;
|
||||
curvePointIndex_ = idx;
|
||||
dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base
|
||||
dragCurveRect_ = r;
|
||||
dragCurveZone_ = zoneIndex;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
selectedZone_ = zoneIndex;
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
}
|
||||
|
||||
// --- Input: the drag-state machine -------------------------------------------
|
||||
|
||||
void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
if (!processor_) return;
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
|
||||
// ---- Browse modal (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<DeckGroupDesc> deckDescs = deckGroupDescs(probeZone.play);
|
||||
const SampleBands bands =
|
||||
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
if (contains(bands.navBrowse, x, y)) {
|
||||
// Open the Browse modal; seed its pending pick from the loaded id so the current
|
||||
// capture reads as pre-selected.
|
||||
browsePendingId_ = selectedId_;
|
||||
lastBrowseClickCard_ = -1;
|
||||
view_ = View::kBrowse;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; }
|
||||
if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only
|
||||
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
|
||||
|
||||
// Preview-trigger button: fire the loaded capture at its root through the voice engine
|
||||
// (momentary — note-on on press, note-off on release).
|
||||
if (contains(cr.preview, x, y)) {
|
||||
const int note = effectiveRoot();
|
||||
if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_);
|
||||
previewingNote_ = note;
|
||||
processor_->previewNoteOn(note);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Radial preview-velocity knob (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<ParamControl>(hit.id)) {
|
||||
case ParamControl::kVoiceMode: {
|
||||
// Processor-side per-instance param: live setter (engine rebuild via
|
||||
// the drain-slot swap — tails survive), local snapshot in step.
|
||||
const VoiceMode m =
|
||||
(hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
||||
if (m != voiceMode_) {
|
||||
voiceMode_ = m;
|
||||
processor_->setVoiceMode(m);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
case ParamControl::kMonoTrigger: {
|
||||
if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly
|
||||
const MonoTrigger t =
|
||||
(hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
||||
if (t != monoTrigger_) {
|
||||
monoTrigger_ = t;
|
||||
processor_->setMonoTrigger(t);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Zone-param toggles (play mode / pitch engine / pitch-env enable):
|
||||
// materialize the one-zone site, apply, commit.
|
||||
const int zi = ensureSampleZone();
|
||||
if (zi >= 0) {
|
||||
applyZoneControl(zi, hit.id, 0.0, hit.segment);
|
||||
selectedZone_ = zi;
|
||||
commitAndReload();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hit.kind == DeckHitKind::Knob) {
|
||||
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off.
|
||||
const bool pitchEnvKnob =
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||||
if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return;
|
||||
if (hit.id == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
hit.id == static_cast<int>(ParamControl::kMasterGain)) {
|
||||
// Processor-side knobs: transient live writes, no map edit, no reload.
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragParamZone_ = -1;
|
||||
dragKnobStartValue_ = deckControlNorm(hit.id, probeZone);
|
||||
} else {
|
||||
// Zone-param knobs: live-drag the map, commit on release.
|
||||
const int zi = ensureSampleZone();
|
||||
if (zi < 0) return;
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragParamZone_ = zi;
|
||||
selectedZone_ = zi;
|
||||
dragStartMap_ = map_;
|
||||
dragKnobStartValue_ =
|
||||
deckControlNorm(hit.id, map_.zones[static_cast<std::size_t>(zi)]);
|
||||
}
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
const Rect waveArea = bands.hero;
|
||||
if (frames > 0) {
|
||||
const double rate = liveSampleRate();
|
||||
if (rate > 0.0) {
|
||||
const PerformanceZone zone = effectiveSampleZone();
|
||||
const std::int64_t startFrame = zone.startPoint.value_or(0);
|
||||
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y);
|
||||
if (nh.hit) {
|
||||
drag_ = DragKind::kEnvNode;
|
||||
envNode_ = nh.node;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
dragStartEnv_ = env;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartFrame_ = startFrame;
|
||||
dragStartMap_ = map_;
|
||||
return; // node moves once the cursor drags
|
||||
}
|
||||
}
|
||||
const SetupMarkers m = pickedMarkers(frames);
|
||||
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||
const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y);
|
||||
if (hit >= 0) {
|
||||
drag_ = DragKind::kWaveMarker;
|
||||
waveMarker_ = static_cast<WaveMarker>(hit);
|
||||
dragStartX_ = x;
|
||||
dragStartMarkers_ = m;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartMap_ = map_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fenced root strip: grab the root marker (remainder-width 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<DeckGroupDesc> deckDescs = deckGroupDescs(effectiveSampleZone().play);
|
||||
const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
|
||||
if (drag_ == DragKind::kRootMarker) {
|
||||
// The fenced root strip on the Sample cluster band. Setting the root materializes a
|
||||
// full-keyboard zone carrying the override on the picked id (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<int>(map_.zones.size()); ++i) {
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
if (z.sampleId == selectedId_) {
|
||||
z.rootOverride = note;
|
||||
selectedZone_ = i;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = selectedId_;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
z.rootOverride = note;
|
||||
map_.zones.push_back(z);
|
||||
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
|
||||
}
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kEnvNode) {
|
||||
// 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<double>(frames) / rate;
|
||||
const int dy = y - dragStartY_;
|
||||
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero,
|
||||
totalSeconds, envClampBounds(), dx, dy);
|
||||
const int zi = ensureSampleZone();
|
||||
if (zi >= 0) {
|
||||
unpackEnvelope(edited, frames, dragStartFrame_,
|
||||
map_.zones[static_cast<std::size_t>(zi)].play);
|
||||
selectedZone_ = zi;
|
||||
}
|
||||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kCurveNode) {
|
||||
// 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<int>(map_.zones.size())) return;
|
||||
if (curvePointIndex_ < 0) return;
|
||||
const int dy = y - dragStartY_;
|
||||
map_.zones[static_cast<std::size_t>(dragCurveZone_)].velocityCurve =
|
||||
VelocityCurve::resolvePointDrag(dragStartCurve_,
|
||||
static_cast<std::size_t>(curvePointIndex_),
|
||||
curveBoxFromRect(dragCurveRect_), dx, dy);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kWaveMarker) {
|
||||
// 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<int>(waveMarker_);
|
||||
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
|
||||
dragStartMarkers_.loopEnd};
|
||||
std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx);
|
||||
|
||||
// Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware
|
||||
// requirement). Pure over the cached mono frames — no host types, no file I/O.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
if (!pcm.empty()) {
|
||||
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
|
||||
newFrame);
|
||||
}
|
||||
|
||||
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
|
||||
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
|
||||
SetupMarkers m = dragStartMarkers_;
|
||||
if (waveMarker_ == WaveMarker::kStart) {
|
||||
m.start = newFrame;
|
||||
} else if (waveMarker_ == WaveMarker::kLoopStart) {
|
||||
m.loopStart = (std::min)(newFrame, m.loopEnd);
|
||||
m.hasLoop = true;
|
||||
} else { // kLoopEnd
|
||||
m.loopEnd = (std::max)(newFrame, m.loopStart);
|
||||
m.hasLoop = true;
|
||||
}
|
||||
if (m.start < 0) m.start = 0;
|
||||
if (m.start > frames - 1) m.start = frames - 1;
|
||||
|
||||
// Upsert the override on the picked id (mirror of the root-marker path); commit lands on
|
||||
// release, this is live feedback. Set selectedZone_ so the control panel stays visible
|
||||
// after the zone is materialized (fix: without this, selectedZone_==-1 with a non-empty
|
||||
// map hides controls after the first marker drag on the single-capture face).
|
||||
selectedZone_ = upsertPickedOverride(m);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kScrollThumb) {
|
||||
// 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<int>(visible_.size()),
|
||||
dragStartScrollOffset_, dyThumb);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached
|
||||
// in the Zone surface where selectedZone_ is set + the strip lives under its content area.
|
||||
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
const Rect stripArea = zonesStripArea(zoneContentArea(w, h));
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
if (drag_ == DragKind::kZoneLow) {
|
||||
z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote);
|
||||
} else if (drag_ == DragKind::kZoneHigh) {
|
||||
z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote);
|
||||
} else if (drag_ == DragKind::kZoneBody) {
|
||||
// Move the whole span: apply the SAME delta to both edges so the span is preserved,
|
||||
// clamping so neither edge escapes [0,127] (the span shifts, never shrinks).
|
||||
const int newLow = resolveDragNote(sl, dragStartLow_, dx);
|
||||
const int newHigh = resolveDragNote(sl, dragStartHigh_, dx);
|
||||
const int span = dragStartHigh_ - dragStartLow_;
|
||||
if (newLow < 0) { z.lowNote = 0; z.highNote = span; }
|
||||
else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; }
|
||||
else { z.lowNote = newLow; z.highNote = newHigh; }
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
// Release a held preview note first (the preview button is a momentary key: note-off on up).
|
||||
// This runs regardless of drag state — the preview press does not start a drag.
|
||||
if (previewingNote_ >= 0) {
|
||||
if (processor_) processor_->previewNoteOff(previewingNote_);
|
||||
previewingNote_ = -1;
|
||||
invalidate();
|
||||
}
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
const DragKind kind = drag_;
|
||||
const int paramId = dragParamId_;
|
||||
const int curveIdx = curvePointIndex_;
|
||||
const int curveZone = dragCurveZone_;
|
||||
const Rect curveRect = dragCurveRect_;
|
||||
drag_ = DragKind::kNone;
|
||||
dragParamId_ = -1;
|
||||
dragParamZone_ = -1;
|
||||
curvePointIndex_ = -1;
|
||||
dragCurveZone_ = -1;
|
||||
// A scrollbar drag is transient UI (no map change), and the processor-side knobs (the
|
||||
// preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that
|
||||
// don't reload the instrument via the map path. Master gain is an atomic the audio thread
|
||||
// reads directly. Voice count: the label/needle tracks live during the drag but the engine
|
||||
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
|
||||
const bool deckTransient =
|
||||
kind == DragKind::kDeckKnob &&
|
||||
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
paramId == static_cast<int>(ParamControl::kMasterGain));
|
||||
if (kind == DragKind::kScrollThumb || deckTransient) {
|
||||
// Commit the voice count now that the drag is complete (one rebuild per full drag).
|
||||
if (deckTransient && processor_ &&
|
||||
paramId == static_cast<int>(ParamControl::kVoiceCount))
|
||||
processor_->setVoiceCount(voiceCount_);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// 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<int>(map_.zones.size())) {
|
||||
const bool off = x < curveRect.x - kCurveDragOffMargin ||
|
||||
x > curveRect.right() + kCurveDragOffMargin ||
|
||||
y < curveRect.y - kCurveDragOffMargin ||
|
||||
y > curveRect.bottom() + kCurveDragOffMargin;
|
||||
if (off) {
|
||||
map_.zones[static_cast<std::size_t>(curveZone)].velocityCurve.deletePoint(
|
||||
static_cast<std::size_t>(curveIdx));
|
||||
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node on next paint
|
||||
}
|
||||
}
|
||||
commitAndReload();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseRDown(int x, int y) {
|
||||
// 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<std::size_t>(zi)];
|
||||
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||||
selectedZone_ = zi;
|
||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||||
commitAndReload();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,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 <algorithm>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<instrument::map::SampleChoice>& 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<float>(kg.centerX);
|
||||
const float cy = static_cast<float>(kg.centerY);
|
||||
const float rOuter = static_cast<float>(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<float>((arc.startDeg - 360.0) * kDegToRad);
|
||||
const float a1 = static_cast<float>(
|
||||
(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<float>(
|
||||
(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<float>((tip.x - kg.centerX) * 0.35);
|
||||
const float iy = cy + static_cast<float>((tip.y - kg.centerY) * 0.35);
|
||||
const ui::Role needleRole = disabled ? ui::Role::TextDim : ui::Role::TextPrimary;
|
||||
LICE_Line(bmp, static_cast<int>(ix + 0.5f), static_cast<int>(iy + 0.5f),
|
||||
static_cast<int>(tip.x + 0.5f), static_cast<int>(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<double>(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
|
||||
@@ -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 <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<int>(visible_.size()), scrollOffset_);
|
||||
|
||||
const int tabCount = static_cast<int>(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<std::size_t>(i - 1)].displayName;
|
||||
const bool active = (i == 0) ? activeFilterBankId_.empty()
|
||||
: (banks_[static_cast<std::size_t>(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<int>(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<std::size_t>(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<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
Rect bar = zoneBarRect(sl, z.lowNote, z.highNote);
|
||||
const int bw = (std::max)(2, bar.width);
|
||||
const bool sel = (i == selectedZone_);
|
||||
if (sel) {
|
||||
// Static glow halo behind the live zone, then the crisp accent-primary bar.
|
||||
LICE_FillRect(bmp, bar.x + sx - 2, sy, bw + 4, stripArea.height,
|
||||
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
|
||||
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
|
||||
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
|
||||
} else {
|
||||
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
|
||||
toLice(roleColor(Role::AccentSecondary)), 0.55f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
|
||||
// entry fields (low / high / root) — 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<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
kitText(bmp, Rect::ltrb(infoR.x, infoR.y, infoR.x + 120, infoR.bottom()),
|
||||
sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{},
|
||||
z.sampleId)
|
||||
.c_str(),
|
||||
Font::Label, Role::TextPrimary);
|
||||
// Three fields laid out left-to-right after the sample label. A focused field lifts to
|
||||
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
const char* names[3] = {"Low", "High", "Root"};
|
||||
const std::string vals[3] = {
|
||||
noteLabel(z.lowNote), noteLabel(z.highNote),
|
||||
z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")};
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
const Rect fr = noteEntryFieldRect(fields, f);
|
||||
const bool editing = (entryField_ == f);
|
||||
fillSurface(bmp, toKitBox(fr), Role::BgCell,
|
||||
editing ? InteractionState::Focus : InteractionState::Rest);
|
||||
const KitColor border =
|
||||
editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline);
|
||||
LICE_DrawRect(bmp, fr.x, fr.y, fr.width - 1, fr.height - 1,
|
||||
toLice(border), 1.0f, 0);
|
||||
std::string cap = std::string(names[f]) + ": " +
|
||||
(editing ? (entryText_ + "_") : vals[f]);
|
||||
kitText(bmp, Rect::ltrb(fr.x + 4, fr.y, fr.right() - 2, fr.bottom()), cap.c_str(),
|
||||
Font::ValueMono, Role::TextPrimary);
|
||||
}
|
||||
} else if (map_.zones.empty()) {
|
||||
kitText(bmp, infoR,
|
||||
"No zones. Add Zone maps the picked capture across the keyboard.",
|
||||
Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// The per-zone parameter surface for the selected zone. 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<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play));
|
||||
paintCurveButton(bmp, zonesCurveButton(content), z);
|
||||
}
|
||||
|
||||
// The curve popup (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
|
||||
@@ -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 <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (hero waveform binning)
|
||||
#include "core/instrument/ui/curve_popup.h" // 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<DeckGroupDesc> 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<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
const Rect waveArea = bands.hero;
|
||||
fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
|
||||
if (frames > 0 && waveArea.width > 0) {
|
||||
// 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::int64_t>((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) *
|
||||
kWaveformOversample;
|
||||
const std::size_t bins =
|
||||
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
|
||||
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins);
|
||||
drawEnvelope(bmp, waveArea, env);
|
||||
|
||||
const SetupMarkers m = pickedMarkers(frames);
|
||||
if (m.hasLoop && m.loopEnd > m.loopStart) {
|
||||
const int lx = frameToX(waveArea, frames, m.loopStart);
|
||||
const int rx = frameToX(waveArea, frames, m.loopEnd);
|
||||
if (rx > lx) {
|
||||
LICE_FillRect(bmp, lx, waveArea.y, rx - lx, waveArea.height,
|
||||
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
|
||||
}
|
||||
}
|
||||
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const int mx = frameToX(waveArea, frames, markerFrames[i]);
|
||||
const bool loopMarker = (i != 0);
|
||||
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
|
||||
LICE_FillRect(bmp, mx - 1, waveArea.y, 2, waveArea.height,
|
||||
toLice(roleColor(markerRoles[i])), alpha, 0);
|
||||
}
|
||||
|
||||
// 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<int>(previewVelocity01() * 127.0 + 0.5));
|
||||
kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim);
|
||||
} else {
|
||||
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
// The mini curve-preview button (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<double>(frames) / rate;
|
||||
const std::int64_t startFrame = zone.startPoint.value_or(0);
|
||||
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
|
||||
|
||||
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
|
||||
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
|
||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||
for (std::size_t i = 1; i < poly.size(); ++i) {
|
||||
const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].x));
|
||||
const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x));
|
||||
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
|
||||
}
|
||||
// Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw-
|
||||
// only). Lit accent-hot when this node is the grabbed one. 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<int>(i));
|
||||
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
|
||||
// A grabbed node in drag-off territory draws warn to signal "release will delete."
|
||||
const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn
|
||||
: (hot ? handleHot : handle);
|
||||
const int nr = 3;
|
||||
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
const PerformanceZone& zone,
|
||||
const std::vector<DeckGroupDesc>& descs) {
|
||||
if (deckArea.width <= 0 || deckArea.height <= 0) return;
|
||||
const DeckLayout dl = layoutDeck(descs, deckArea.x, deckArea.y, deckArea.width);
|
||||
const ZonePlaySeconds& play = zone.play;
|
||||
const bool isMono = (voiceMode_ == VoiceMode::Mono);
|
||||
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
|
||||
|
||||
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
|
||||
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
|
||||
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
|
||||
bool seg1Active, bool disabled) {
|
||||
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
|
||||
const InteractionState st0 =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (!seg1Active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
const InteractionState st1 =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (seg1Active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
|
||||
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
|
||||
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
|
||||
disabled ? Role::TextDim
|
||||
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
|
||||
disabled ? Role::TextDim
|
||||
: (seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
};
|
||||
|
||||
// The knob's short name label (swapped for the live value during hover/drag — 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<ParamControl>(g.captionToggle.id)) {
|
||||
case ParamControl::kPlayMode:
|
||||
drawToggle(g.captionToggle, "Gate", "Trigger",
|
||||
play.playMode == PlayMode::Trigger, false);
|
||||
break;
|
||||
case ParamControl::kPitchEngine:
|
||||
drawToggle(g.captionToggle, "Varisp", "Presrv",
|
||||
play.pitchEngine == PitchEngine::Preserve, false);
|
||||
break;
|
||||
case ParamControl::kPitchEnvEnable:
|
||||
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
|
||||
break;
|
||||
case ParamControl::kVoiceMode:
|
||||
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
// The row toggle (VOICE group's Retrig|Legato) — live only in Mono.
|
||||
if (g.rowToggle.id >= 0) {
|
||||
drawToggle(g.rowToggle, "Retrig", "Legato",
|
||||
monoTrigger_ == MonoTrigger::Legato, !isMono);
|
||||
}
|
||||
|
||||
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
|
||||
// stable geometry (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<ParamControl>(c.id)));
|
||||
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r,
|
||||
const PerformanceZone& zone) {
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
// The mini curve-preview button (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
|
||||
@@ -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 <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
|
||||
#include <shellapi.h> // 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<HWND>(systemWindow);
|
||||
if (!parent) return;
|
||||
|
||||
HINSTANCE hInst =
|
||||
reinterpret_cast<HINSTANCE>(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<LONG_PTR>(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<ReaSamplerEditor*>(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<unsigned int>(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<int>(ParamControl::kVoiceCount) ||
|
||||
self->dragParamId_ == static_cast<int>(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<HDROP>(wParam);
|
||||
const UINT count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0);
|
||||
DragFinish(drop);
|
||||
if (self) self->onFilesDropped(static_cast<int>(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
|
||||
@@ -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 <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#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<SampleChoice>{};
|
||||
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
|
||||
selectedId_ = processor_->selectedSampleId();
|
||||
const auto prevZoneCount = static_cast<int>(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<int>(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<int>(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<SelectedSample> 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<int>(map_.zones.size()); ++i) {
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
if (z.sampleId == selectedId_) {
|
||||
z.loopOverride = loop;
|
||||
z.startPoint = m.start;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
PerformanceZone z;
|
||||
z.sampleId = selectedId_;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
z.loopOverride = loop;
|
||||
z.startPoint = m.start;
|
||||
map_.zones.push_back(z);
|
||||
return static_cast<int>(map_.zones.size()) - 1;
|
||||
}
|
||||
|
||||
PerformanceZone ReaSamplerEditor::effectiveSampleZone() const {
|
||||
// The picked id's one-zone override, if the map already carries one; else a product-default
|
||||
// zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes
|
||||
// it via ensureSampleZone). 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<int>(map_.zones.size()); ++i) {
|
||||
if (map_.zones[static_cast<std::size_t>(i)].sampleId == selectedId_) return i;
|
||||
}
|
||||
PerformanceZone z;
|
||||
z.sampleId = selectedId_;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
map_.zones.push_back(z);
|
||||
return static_cast<int>(map_.zones.size()) - 1;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
|
||||
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert,
|
||||
// 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<AudioSample>& 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<AudioSample> 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<std::uint8_t> bytes = readFileBytes(abs);
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (layout.valid) {
|
||||
std::vector<AudioSample> 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<AudioSample>& 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::size_t>((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
|
||||
@@ -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 <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <random>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#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<std::uint64_t>(rd()) << 32) ^ rd());
|
||||
std::uniform_int_distribution<std::uint64_t> dist;
|
||||
char buf[33] = {0};
|
||||
std::snprintf(buf, sizeof(buf), "%016llx%016llx",
|
||||
static_cast<unsigned long long>(dist(gen)),
|
||||
static_cast<unsigned long long>(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<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
const std::string& relativePath,
|
||||
ChannelMode mode) {
|
||||
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||
if (abs.empty()) return std::nullopt;
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (!layout.valid) return std::nullopt;
|
||||
std::vector<AudioSample> interleaved =
|
||||
extractFloatFrames(bytes, layout, 0, layout.frameCount());
|
||||
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
|
||||
static_cast<int>(layout.sampleRate));
|
||||
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<std::mutex> 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<std::string> ids = referencedSampleIds(selId, map);
|
||||
SampleRefs refs;
|
||||
{
|
||||
std::optional<std::string> banksJson =
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey);
|
||||
std::lock_guard<std::mutex> 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<std::mutex> vp(voiceParamsMutex_);
|
||||
builtVoiceCount = voiceCount_;
|
||||
builtVoiceMode = voiceMode_;
|
||||
builtMonoTrigger = monoTrigger_;
|
||||
}
|
||||
|
||||
std::string resolvedId;
|
||||
std::unique_ptr<LoadedInstrument> 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<DecodedZonePcm> decoded;
|
||||
std::vector<ResolvedZone> kept;
|
||||
decoded.reserve(resolved.zones.size());
|
||||
kept.reserve(resolved.zones.size());
|
||||
for (const ResolvedZone& rz : resolved.zones) {
|
||||
std::optional<DecodedZonePcm> pcm =
|
||||
decodeRelative(projectDir, rz.relativePath, mode);
|
||||
if (!pcm) continue; // unreadable/missing WAV -> drop this zone
|
||||
kept.push_back(rz);
|
||||
decoded.push_back(std::move(*pcm));
|
||||
}
|
||||
km = buildZonedKeymap(kept, decoded);
|
||||
haveKeymap = !km.zones.empty();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Single-capture fast path (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<std::mutex> cm(channelModeMutex_);
|
||||
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
||||
channelModeExplicit_);
|
||||
mode = channelMode_;
|
||||
}
|
||||
std::optional<DecodedZonePcm> pcm =
|
||||
decodeRelative(projectDir, sel->relativePath, mode);
|
||||
if (pcm) {
|
||||
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
|
||||
sel->rootNote, sel->loop,
|
||||
std::move(pcm->framesR));
|
||||
haveKeymap = true;
|
||||
resolvedId = selId; // the concrete pick that resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (haveKeymap) {
|
||||
// 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<std::int64_t>(
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
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<std::string>& 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<std::mutex> 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<std::string> 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<LoadedInstrument> 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<LoadedInstrument>& 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<LoadedInstrument>(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<std::mutex> 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<std::mutex> 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<std::int64_t>(
|
||||
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<LoadedInstrument>(
|
||||
std::move(km), static_cast<std::size_t>(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<std::mutex> 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<LoadedInstrument>(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<LoadedInstrument>& 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<AssignmentRequest> 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<std::mutex> 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
|
||||
@@ -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 <cstdint>
|
||||
#include <mutex>
|
||||
#include <vector>
|
||||
|
||||
#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<std::uint8_t> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(channelModeMutex_);
|
||||
state_out.channelMode = channelMode_;
|
||||
state_out.channelModeExplicit = channelModeExplicit_;
|
||||
}
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lock(usageMutex_);
|
||||
state_out.instanceGuid = instanceGuid_;
|
||||
}
|
||||
const std::vector<std::uint8_t> bytes = serializeComponentState(state_out);
|
||||
if (!bytes.empty()) {
|
||||
const tresult wr = state->write(const_cast<std::uint8_t*>(bytes.data()),
|
||||
static_cast<int32>(bytes.size()), nullptr);
|
||||
if (wr != kResultOk) return wr;
|
||||
}
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
std::string ReaSamplerProcessor::selectedSampleId() {
|
||||
std::lock_guard<std::mutex> lock(selectionMutex_);
|
||||
return selectedSampleId_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
|
||||
std::lock_guard<std::mutex> lock(selectionMutex_);
|
||||
selectedSampleId_ = id;
|
||||
}
|
||||
|
||||
PerformanceMap ReaSamplerProcessor::performanceMap() {
|
||||
std::lock_guard<std::mutex> lock(performanceMutex_);
|
||||
return performanceMap_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
|
||||
std::lock_guard<std::mutex> lock(performanceMutex_);
|
||||
performanceMap_ = map;
|
||||
}
|
||||
|
||||
SampleRefs ReaSamplerProcessor::sampleRefs() {
|
||||
std::lock_guard<std::mutex> lock(refsMutex_);
|
||||
return sampleRefs_;
|
||||
}
|
||||
|
||||
ChannelMode ReaSamplerProcessor::channelMode() {
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
return channelMode_;
|
||||
}
|
||||
|
||||
std::uint8_t ReaSamplerProcessor::previewVelocity() {
|
||||
std::lock_guard<std::mutex> 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<std::mutex> lock(previewMutex_);
|
||||
previewVelocity_ = velocity;
|
||||
}
|
||||
|
||||
int ReaSamplerProcessor::voiceCount() {
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> lock(voiceParamsMutex_);
|
||||
return voiceMode_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setVoiceMode(VoiceMode mode) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
if (voiceMode_ == mode) return;
|
||||
voiceMode_ = mode;
|
||||
}
|
||||
rebuildVoiceEngine();
|
||||
}
|
||||
|
||||
MonoTrigger ReaSamplerProcessor::monoTrigger() {
|
||||
std::lock_guard<std::mutex> lock(voiceParamsMutex_);
|
||||
return monoTrigger_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setMonoTrigger(MonoTrigger trigger) {
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<float>(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<std::uint32_t>(seq) << 16) |
|
||||
(static_cast<std::uint32_t>(vel) << 8) |
|
||||
static_cast<std::uint32_t>(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<std::uint32_t>(seq) << 16) |
|
||||
static_cast<std::uint32_t>(note & 0xFF);
|
||||
previewOffRequest_.store(packed, std::memory_order_release);
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setChannelMode(ChannelMode mode) {
|
||||
{
|
||||
std::lock_guard<std::mutex> 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
|
||||
@@ -22,7 +22,6 @@
|
||||
// to create/destroy the child window and onSize to resize it.
|
||||
|
||||
#pragma once
|
||||
#include "core/namespaces.h"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -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
|
||||
@@ -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 <windows.h> on Windows), which
|
||||
|
||||
@@ -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 <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
|
||||
#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<IAudioProcessor*>(new ReaSamplerProcessor());
|
||||
}
|
||||
|
||||
// Out-of-line so unique_ptr<ReaSamplerEmbed> 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<ReaSamplerEmbed>(this);
|
||||
embed_->addRef();
|
||||
*obj = static_cast<IReaperUIEmbedInterface*>(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<std::mutex> 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<std::mutex> 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<float>(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<int>(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<int>(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<std::uint16_t>(on >> 16);
|
||||
if (onSeq != 0 && onSeq != previewOnConsumed_) {
|
||||
previewOnConsumed_ = onSeq;
|
||||
if (inst) {
|
||||
const int vel = static_cast<int>((on >> 8) & 0xFF);
|
||||
const int note = static_cast<int>(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<std::uint16_t>(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<int>(off & 0xFF));
|
||||
if (drain) drain->engine.noteOff(static_cast<int>(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<std::size_t>(frames));
|
||||
if (drain) drain->engine.render(ch0, ch1, static_cast<std::size_t>(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<std::size_t>(frames));
|
||||
if (drain) drain->engine.render(ch0, static_cast<std::size_t>(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
|
||||
@@ -25,7 +25,6 @@
|
||||
// single atomic pointer swap. See the LoadedInstrument handoff below.
|
||||
|
||||
#pragma once
|
||||
#include "core/namespaces.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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 <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::uint8_t> 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<std::uint8_t> 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<std::uint8_t> v1 = serializeSelection("old-pick");
|
||||
const ComponentState out = deserializeComponentState(v1, 48000.0);
|
||||
CHECK(out.selectionId == "old-pick");
|
||||
CHECK(out.map.zones.size() == 1);
|
||||
if (out.map.zones.size() == 1) {
|
||||
CHECK(out.map.zones[0].sampleId == "old-pick");
|
||||
CHECK(out.map.zones[0].lowNote == 0);
|
||||
CHECK(out.map.zones[0].highNote == 127);
|
||||
}
|
||||
}
|
||||
|
||||
// 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<std::uint8_t> bytes = serializeComponentState(in);
|
||||
for (std::size_t cut = 0; cut < bytes.size(); ++cut) {
|
||||
const std::vector<std::uint8_t> part(bytes.begin(),
|
||||
bytes.begin() + static_cast<long>(cut));
|
||||
const ComponentState out = deserializeComponentState(part, 48000.0);
|
||||
(void)out; // reaching here without UB/throw is the contract under test
|
||||
}
|
||||
CHECK(true);
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) { \
|
||||
|
||||
@@ -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 <cmath>
|
||||
#include <cstdio>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<std::uint8_t> out;
|
||||
wire::putLE(out, static_cast<std::uint32_t>(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<std::uint64_t>(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<std::uint8_t> out;
|
||||
wire::putLE(out, static_cast<std::uint32_t>(7));
|
||||
wire::putLE(out, static_cast<std::uint64_t>(wire::doubleToBits(-2.5)));
|
||||
wire::putLE(out, static_cast<std::uint8_t>(0xAB));
|
||||
wire::putLE(out,
|
||||
static_cast<std::uint64_t>(static_cast<std::int64_t>(-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<std::uint8_t> out;
|
||||
wire::putLE(out, static_cast<std::uint32_t>(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();
|
||||
|
||||
Reference in New Issue
Block a user