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:
2026-07-29 10:56:09 -04:00
parent 9d5783453c
commit ea86f540b8
37 changed files with 6202 additions and 5352 deletions
+62 -31
View File
@@ -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"
+5 -170
View File
@@ -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
+192
View File
@@ -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 v1v5 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 v1v6 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
+5 -573
View File
@@ -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
+9 -301
View File
@@ -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 v1v5 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 v1v6 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
+23
View File
@@ -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
+17
View File
@@ -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
+146
View File
@@ -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
+82
View File
@@ -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
+110
View File
@@ -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
+13 -19
View File
@@ -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;
}
+397
View File
@@ -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
+239
View File
@@ -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
+271
View File
@@ -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
+371
View File
@@ -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
+489
View File
@@ -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
+311
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+21
View File
@@ -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;
+179
View File
@@ -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;
}
+75
View File
@@ -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;
+2 -1
View File
@@ -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)) { \
+3 -1
View File
@@ -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;
+52
View File
@@ -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();