Files
reasampler/src/vst/reasampler_editor.cpp
T

2275 lines
113 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// reasampler_editor.cpp — see reasampler_editor.h. The IPlugView<->LICE bridge for the
// ReaSampler 9000 capture-first editor (Phase S10). Windows-only (D5); the whole file is
// guarded so a non-Windows build (not a target) degrades to the CPluginView defaults.
#include "reasampler_editor.h"
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <string>
#include <vector>
#include "browser_scroll.h" // S12 scroll-window + thumb + type-to-filter search geometry
#include "capture_browser.h"
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
#include "component_geometry.h" // KitBox — the kit text/fill draw box (Phase L, L3)
#include "draw_kit.h" // the L1 draw kit: fillSurface/drawButton/text/drawWaveform (L3)
#include "editor_geometry.h" // Rect, contains
#include "ext_keys.h"
#include "keyboard_strip.h"
#include "theme.h" // Role / InteractionState / KitColor / spectralColor (L3)
#include "note_entry.h" // S12 direct numeric note-entry parse
#include "param_slider.h" // S12/S15/S16 control-surface layout + value<->pixel mapping
#include "peaks.h" // computeEnvelope
#include "reaper_bridge.h"
#include "reasampler_processor.h"
#include "app_version.h" // vstPluginName (channel-derived editor title band, S18)
#include "sample_map.h"
#include "wav_trim.h" // parseWavLayout, extractFloatFrames
#include "trigger_seam.h" // triggerPlayLength / framesToFadeFraction / fadeFractionToFrames (S-VIEW-3)
#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11)
#ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
#include <shellapi.h> // DragAcceptFiles / DragQueryFile / DragFinish — S13 editor drop-accept
#include "wdltypes.h"
#include "lice/lice.h"
#endif
using namespace Steinberg;
namespace reasampler::vst {
namespace {
#ifdef _WIN32
constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// The S9/S8 change-detection poll (WM_TIMER on the child window). A low-frequency UI-thread
// timer: responsive enough that a recapture/ingest/assign refreshes "within a bounded cadence"
// (the S9 verify criterion) yet cheap — three small ext-state reads per tick, coalescing many
// bumps between ticks into one reload. 500 ms is a deliberate build-time residual: fast enough
// to feel hands-free, slow enough to be free. The id is a per-window SetTimer id (any nonzero).
constexpr UINT_PTR kSyncTimerId = 1;
constexpr UINT kSyncTimerIntervalMs = 500;
// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry is in
// capture_browser / keyboard_strip). S-VIEW-2 Sample face (top->bottom): a TITLE band (name +
// Browse/Zone nav buttons), a HERO WAVEFORM band (enlarged — the Simpler/Phase-Plant hero, with
// the S11 markers + the S-VIEW-3 envelope overlay traced over it), a ROOT + PREVIEW cluster
// (fenced root strip + preview-trigger + velocity knob + Mono/Stereo), and the CONTROL STRIP (the
// param panel + keyTrack). Browse + Zone reuse the browser grid / zone strip machinery.
constexpr int kTitleHeight = 26;
constexpr int kHeroWaveformHeight = 150; // the enlarged Sample-face hero (was a 72px strip)
constexpr int kClusterHeight = 52; // root strip + preview + channel toggle
constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip)
constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons
// Marker roles (Phase L, L3) — semantic, drawn through the kit's palette. The waveform's
// start point + the sustain-loop ends are CATEGORICAL kinds (a distinct affordance class,
// §2.1), not the live/active layer, so they take the categorical accents: start = teal
// (secondary), loop start/end = purple (tertiary). The loop-span fill is a faint purple.
constexpr Role kRoleStartMarker = Role::AccentSecondary;
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
// --- Rect <-> kit adapters (Phase L, L3) -------------------------------------
//
// The editor's own sub-rect type is `Rect` (editor_geometry); the kit draws against `KitBox`
// (component_geometry). This is the single boundary that bridges them so every draw routes
// through the L1 kit (theme roles + draw_kit), retiring the shell's raw LICE_RGBA palette +
// GDI DrawTextA path.
KitBox toKitBox(const Rect& r) {
return KitBox{r.left, r.top, r.width(), r.height()};
}
// Kit text in a palette ROLE (the common case). Left/Right/Center via Align.
void kitText(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role,
Align align = Align::Left) {
text(bmp, toKitBox(r), s, font, role, align);
}
void kitTextCentered(LICE_IBitmap* bmp, const Rect& r, const char* s, Font font, Role role) {
text(bmp, toKitBox(r), s, font, role, Align::Center);
}
// A short MIDI-note label ("C4", "F#3") for the root badge. Middle C (60) is C4 (the
// common DAW convention REAPER uses).
std::string noteLabel(int note) {
static const char* kNames[12] = {"C", "C#", "D", "D#", "E", "F",
"F#", "G", "G#", "A", "A#", "B"};
if (note < 0) note = 0;
if (note > 127) note = 127;
const int octave = note / 12 - 1; // MIDI 0 = C-1; 60 = C4
return std::string(kNames[note % 12]) + std::to_string(octave);
}
// Draw a peak envelope in `r` through the kit's shared waveform primitive (Phase L, L3):
// midline + accent-primary min/max columns with the same dB display compression the dock
// panel thumbnail uses, so a waveform reads identically wherever it is drawn. The caller has
// already filled the surface behind it (bg/panel), matching drawWaveform's contract.
void drawEnvelope(LICE_IBitmap* bmp, const Rect& r, const Envelope& env) {
drawWaveform(bmp, toKitBox(r), env);
}
// A display name for a bank sample id from the snapshotted list ("?" if the id no longer
// resolves — e.g. a zone naming a deleted sample).
std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::string& id) {
for (const SampleChoice& c : samples) {
if (c.id == id) return c.displayName.empty() ? c.id : c.displayName;
}
return "?";
}
// The bin count a card's thumbnail is computed at: the card thumbnail width, so one bin
// per horizontal pixel.
int thumbBins(const BrowserLayout& layout) {
return (std::max)(1, cardThumbnailRect(layout, 0).width());
}
#endif
} // namespace
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
: CPluginView(nullptr), processor_(processor) {
// Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample
// home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate
// mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll
// on a 1080p screen with headroom. Wide enough that the control strip's label + value columns
// read comfortably.
ViewRect r(0, 0, 840, 620);
setRect(r);
}
void ReaSamplerEditor::refreshFromBank() {
// Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER).
thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks
pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source)
if (!processor_) {
samples_.clear();
banks_.clear();
visible_.clear();
selectedId_.clear();
map_.zones.clear();
selectedZone_ = -1;
return;
}
auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
selectedId_ = processor_->selectedSampleId();
map_ = processor_->performanceMap();
channelMode_ = processor_->channelMode();
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
// 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 (reloadFromBank bakes them into the live Keymap).
if (!processor_) return;
processor_->setSelectedSampleId(selectedId_);
processor_->setPerformanceMap(map_);
processor_->reloadFromBank();
#ifdef _WIN32
invalidate();
#endif
}
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); the override lives in map_.
if (processor_) {
auto banksJson =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
if (banksJson) {
if (auto sel = selectSample(*banksJson, selectedId_)) {
if (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;
}
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)
// span [0, kFadeMaxFrames] SOURCE frames (a source-timeline quantity, PLAN.md §S15 — never a
// wall-clock second). 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 kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames)
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%)
double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); }
} // namespace
std::vector<ControlDesc> ReaSamplerEditor::controlDescs(const ZonePlaySeconds& play) const {
std::vector<ControlDesc> out;
// Always: the two mode toggles.
out.push_back({static_cast<int>(ParamControl::kPlayMode), ControlKind::Toggle});
out.push_back({static_cast<int>(ParamControl::kPitchEngine), ControlKind::Toggle});
// Mode-relevant amplitude sliders.
if (play.playMode == PlayMode::Gate) {
out.push_back({static_cast<int>(ParamControl::kAttack), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kHold), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kDecay), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kSustain), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kRelease), ControlKind::Slider});
} else { // Trigger
out.push_back({static_cast<int>(ParamControl::kTrigLength), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kTrigFadeIn), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kTrigFadeOut), ControlKind::Slider});
}
// The AD pitch envelope: an enable toggle + its three sliders (drawn always; inert until on).
out.push_back({static_cast<int>(ParamControl::kPitchEnvEnable), ControlKind::Toggle});
out.push_back({static_cast<int>(ParamControl::kPitchEnvAttack), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kPitchEnvDecay), ControlKind::Slider});
out.push_back({static_cast<int>(ParamControl::kPitchEnvDepth), ControlKind::Slider});
// S-VIEW-6 key-tracking (0..200%). Lives on PerformanceZone, not ZonePlaySeconds — the shell
// reads/writes it against the zone directly (controlValue/applyControl ignore it). Always shown.
out.push_back({static_cast<int>(ParamControl::kKeyTrack), ControlKind::Slider});
return out;
}
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
// the frames ceiling. Two domains, kept explicit so neither leaks a rate.
const auto secToNorm = [](double s) { return clamp01(s / kEnvTimeMaxSeconds); };
const auto framesToNorm = [](std::int64_t f) {
return clamp01(static_cast<double>(f) / kFadeMaxFrames);
};
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 auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
const auto normToFrames = [](double v) {
return static_cast<std::int64_t>(clamp01(v) * kFadeMaxFrames + 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::previewVelocity01() const {
if (!processor_) return static_cast<double>(kPreviewVelocityDefault) / 127.0;
return static_cast<double>(processor_->previewVelocity()) / 127.0;
}
EnvClampBounds ReaSamplerEditor::envClampBounds() const {
// Match the control-panel sliders' own domains so a node drag can never produce a param a
// slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the
// Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use).
EnvClampBounds b;
b.maxAttackSeconds = kEnvTimeMaxSeconds;
b.maxHoldSeconds = kEnvTimeMaxSeconds;
b.maxDecaySeconds = kEnvTimeMaxSeconds;
b.maxReleaseSeconds = kEnvTimeMaxSeconds;
b.maxFadeInFraction = 1.0;
b.maxFadeOutFraction = 1.0;
b.maxLengthFraction = 1.0;
return b;
}
AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
std::int64_t startFrame) const {
AmpEnvelope env;
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
// AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws).
env.attackSeconds = play.adsr.attackSeconds;
env.holdSeconds = play.adsr.holdSeconds;
env.decaySeconds = play.adsr.decaySeconds;
env.sustainLevel = play.adsr.sustainLevel;
env.releaseSeconds = play.adsr.releaseSeconds;
// Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played
// span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start
// point so the fraction denominator matches the voice's actual post-start span. A zero play
// length yields 0 fractions.
env.lengthFraction = play.trigger.lengthFraction;
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen);
env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen);
return env;
}
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
std::int64_t startFrame, ZonePlaySeconds& play) const {
if (env.mode == EnvMode::Gate) {
play.adsr.attackSeconds = env.attackSeconds;
play.adsr.holdSeconds = env.holdSeconds;
play.adsr.decaySeconds = env.decaySeconds;
play.adsr.sustainLevel = env.sustainLevel;
play.adsr.releaseSeconds = env.releaseSeconds;
} else {
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
// the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's
// effective start point so the frame denominator matches the voice's actual post-start span.
// Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length
// trigger never plays nothing.
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
const std::int64_t playLen =
triggerPlayLength(play.trigger.lengthFraction, frames, startFrame);
play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen);
play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen);
}
}
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert,
// mirror of the root-marker path): a full-keyboard zone carrying the override. This plays
// identically to the un-zoned single capture (one chromatic zone) and round-trips through
// the component state; the zone becomes visible if the user opens the Zones panel. The bank
// intrinsic is NEVER written (read-only bank consumer, D-B).
if (selectedId_.empty()) return;
upsertPickedOverride(m);
commitAndReload();
}
const std::vector<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()) {
const std::string projectDir = processor_->bridge().activeProjectDir();
const std::string abs = resolveBankFile(projectDir, relativePath);
std::vector<std::uint8_t> bytes;
std::ifstream f(abs, std::ios::binary | std::ios::ate);
if (f) {
const std::streamoff size = f.tellg();
if (size > 0) {
f.seekg(0, std::ios::beg);
bytes.resize(static_cast<std::size_t>(size));
if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
}
}
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) {
const std::string key = sampleId + "|" + std::to_string(binCount);
auto it = thumbCache_.find(key);
if (it != thumbCache_.end()) return it->second;
// Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by
// every thumbnail width AND the S11 waveform surface + snap.
const std::vector<AudioSample>& mono = monoPcmFor(sampleId);
Envelope env;
if (!mono.empty()) {
env = computeEnvelope(mono, 1, mono.size(),
static_cast<std::size_t>((std::max)(1, binCount)));
}
auto ins = thumbCache_.emplace(key, std::move(env));
return ins.first->second;
}
ReaSamplerEditor::~ReaSamplerEditor() {
#ifdef _WIN32
if (childHwnd_) {
DestroyWindow(childHwnd_);
childHwnd_ = nullptr;
}
#endif
}
tresult PLUGIN_API ReaSamplerEditor::isPlatformTypeSupported(FIDString type) {
#ifdef _WIN32
if (type && std::string(type) == kPlatformTypeHWND) return kResultTrue;
#endif
return kResultFalse;
}
tresult PLUGIN_API ReaSamplerEditor::canResize() {
return kResultTrue;
}
tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) {
// Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before
// every resize; clamp the proposed rect in place and return kResultTrue so the host applies the
// (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title
// + hero waveform + cluster + a few control rows visible (the control strip clips gracefully
// below the panel bottom); anything smaller would clip essential UI. The default 840×620 is
// above this floor.
constexpr int kMinW = 560;
constexpr int kMinH = 460;
if (!rect) return kResultFalse;
if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW;
if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH;
return kResultTrue;
}
#ifdef _WIN32
void ReaSamplerEditor::invalidate() {
if (childHwnd_) InvalidateRect(childHwnd_, nullptr, FALSE);
}
void ReaSamplerEditor::attachedToParent() {
HWND parent = static_cast<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;
}
// The Sample-view (S-VIEW-2) bands. The TITLE band names the plugin + a live readout and hosts
// the Browse/Zone nav buttons at its right; the HERO band is the enlarged waveform + envelope
// overlay; the CLUSTER band is the fenced root strip + preview + channel toggle; the CONTROL band
// is the param panel. Every band is padded 8px horizontally by its consumers. Browse + Zone views
// derive their own areas from `title` + `content` below.
namespace {
constexpr int kPad = 8;
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 hero waveform + S-VIEW-3 envelope overlay
Rect cluster; // root strip + preview-trigger + velocity knob + channel toggle
Rect control; // the param control strip (Mode / Pitch / AHDSR|Trigger / AD pitch / keyTrack)
};
SampleBands computeSampleBands(int w, int h) {
SampleBands b;
const int titleH = (std::min)(kTitleHeight, h);
b.title = Rect{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{w - kPad - kNavButtonWidth, navTop, w - kPad, navBot};
const Rect browse{zone.left - 4 - kNavButtonWidth, navTop, zone.left - 4, navBot};
b.navBrowse = browse;
b.navZone = zone;
int y = titleH;
const int heroH = (std::min)(kHeroWaveformHeight, (std::max)(0, h - titleH));
b.hero = Rect{kPad, y, w - kPad, y + heroH};
y += heroH;
const int clusterH = (std::min)(kClusterHeight, (std::max)(0, h - y));
b.cluster = Rect{0, y, w, y + clusterH};
y += clusterH;
b.control = Rect{kPad, y, w - kPad, h};
return b;
}
// The fenced root keyboard-strip rect inside the cluster band (S-VIEW-2): the LEFT ~55% of the
// cluster, the fenced root affordance promoted from Browse. The preview cluster takes the right.
Rect clusterRootStrip(const Rect& cluster) {
const int stripTop = cluster.top + (cluster.height() - kStripBandHeight) / 2;
const int right = cluster.left + (cluster.width() * 55) / 100;
return Rect{cluster.left + kPad, stripTop, right - kPad, stripTop + kStripBandHeight};
}
// The preview-trigger button rect (right of the root strip, left of the channel toggle).
Rect clusterPreviewButton(const Rect& cluster) {
const Rect strip = clusterRootStrip(cluster);
const int left = strip.right + kPad;
return Rect{left, strip.top, left + 64, strip.bottom};
}
// The preview velocity knob rect (a compact horizontal slider next to the preview button).
Rect clusterVelocitySlider(const Rect& cluster) {
const Rect prev = clusterPreviewButton(cluster);
const int left = prev.right + kPad;
return Rect{left, prev.top, left + 96, prev.bottom};
}
// The Zone-view keyboard strip rect. Zone content sits below the "+ Add Zone" affordance
// (top+4, height 20) with a 12px gap, padded 8px horizontally. All call sites use this formula.
Rect zonesStripArea(const Rect& content) {
const int stripTop = content.top + 4 + 20 + 12; // addR.bottom + 12
return Rect{content.left + kPad, stripTop, content.right - kPad,
stripTop + kStripBandHeight};
}
// The S12 numeric-entry field ROW area inside the Zones legend: a band to the right of the
// sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw +
// hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom so
// the legend top tracks the strip bottom without re-inlining the strip arithmetic here.
Rect noteEntryFieldsArea(const Rect& content) {
const int stripBottom = zonesStripArea(content).bottom;
const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom + 8)
return Rect{content.left + 8 + 128, top, content.right - 8, top + 18};
}
// The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal
// segments left-to-right. An out-of-range index yields an empty rect.
Rect noteEntryFieldRect(const Rect& fields, int f) {
if (f < 0 || f > 2 || fields.width() <= 0) return Rect{};
const int segW = fields.width() / 3;
const int left = fields.left + f * segW + (f > 0 ? 4 : 0); // small inter-field gap
const int right = (f == 2) ? fields.right : fields.left + (f + 1) * segW;
return Rect{left, fields.top, right, fields.bottom};
}
// The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip +
// the one-line selected-zone legend, running to the content bottom. `bands.content` is the
// Zones mode-content area. Both draw + hit-test use this single formula so they never drift.
Rect zonesControlPanel(const Rect& content) {
const Rect strip = zonesStripArea(content);
const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap
return Rect{content.left + kPad, panelTop, content.right - kPad,
content.bottom - 4};
}
// The S7 mono/stereo toggle (S-VIEW-2: moved here from Browse to the Sample cluster band — it is
// a per-capture output-mode concern, not a choosing concern). A two-segment control right-anchored
// in `area` and vertically centered. Returns {mono-segment, stereo-segment}, each kChanSegW wide,
// kChanSegH tall, side by side.
constexpr int kChanSegW = 52;
constexpr int kChanSegH = 18;
struct ChannelToggleRects { Rect mono; Rect stereo; };
ChannelToggleRects channelToggleRects(const Rect& area) {
const int top = area.top + (area.height() - kChanSegH) / 2;
const int right = area.right - kPad;
const Rect stereo{right - kChanSegW, top, right, top + kChanSegH};
const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH};
return {mono, stereo};
}
// Draw the pastel spectral keyboard-strip background (Phase L, L3) — the signature surface.
// Fills each MIDI key column with its spectral hue (spectralColor over note/127), then draws
// faint per-octave hairline ticks for orientation. Shared by the setup face + the Zones strip
// so both read as the same spectrum. `stripArea` is the absolute strip rect.
void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) {
if (stripArea.width() <= 0 || stripArea.height() <= 0) return;
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
const int h = stripArea.height();
// A pastel spectral column per key. Each key's local x from keyRect; fill from this key's
// left to the next key's left so the sweep tiles with no gaps. Low alpha keeps it a quiet
// backdrop the root/zone marks sit over. S-VIEW-7: OVERLAY the two-tone piano-key pattern —
// naturals (white keys) keep the bright spectral fill; accidentals (C#/D#/F#/G#/A#) get a
// dark bg/base wash over the hue, so a glance reads pitch position as a keyboard without
// counting. The pattern is an OVERLAY (not a keyboard shape) per the spec.
const LICE_pixel darkKey = toLice(roleColor(Role::BgBase));
for (int n = 0; n <= 127; ++n) {
const Rect k = keyRect(sl, n);
const int x0 = k.left + sx;
const int x1 = (n < 127) ? keyRect(sl, n + 1).left + sx : stripArea.right;
const int cw = (std::max)(1, x1 - x0);
const KitColor hue = spectralColor(static_cast<double>(n) / 127.0);
LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0);
if (!isNaturalKey(n)) {
// Darken the accidental over the hue (a semi-opaque bg/base wash) so the black-key
// pattern reads while the spectral tint still shows through.
LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0);
}
}
// Faint per-octave key ticks (hairline role) for orientation.
const LICE_pixel tick = toLice(roleColor(Role::LineHairline));
for (int n = 0; n <= 127; n += 12) {
const Rect k = keyRect(sl, n);
LICE_Line(bmp, k.left + sx, sy, k.left + sx, sy + h, tick, 1.0f, 0, false);
}
}
// Draw the single-capture root marker on the strip: an accent-primary bar with a soft STATIC
// glow (a wider, lower-alpha accent bar behind it) — the "this is live" mark. Never animated.
void drawRootMarker(LICE_IBitmap* bmp, const Rect& stripArea, const StripLayout& sl, int root) {
const int sx = stripArea.left;
const int sy = stripArea.top;
const int h = stripArea.height();
const Rect marker = rootMarkerRect(sl, root);
const int mw = (std::max)(2, marker.width());
const LICE_pixel accent = toLice(roleColor(Role::AccentPrimary));
const LICE_pixel glow = toLice(roleColor(Role::AccentHot));
// Static glow: a wider low-alpha halo behind the crisp bar (a drawn state, not a pulse).
LICE_FillRect(bmp, marker.left + sx - 3, sy, mw + 6, h, glow, 0.30f, 0);
LICE_FillRect(bmp, marker.left + sx, sy, mw, h, accent, 1.0f, 0);
}
} // namespace
void ReaSamplerEditor::paint(HDC hdc) {
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
if (w <= 0 || h <= 0) return;
LICE_SysBitmap bmp(w, h);
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
// S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn
// OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the
// modal reads as a sheet layered over the home face (the "picker over the document" grammar).
if (view_ == View::kZone) {
paintZone(&bmp, w, h);
} else {
paintSample(&bmp, w, h);
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
}
// S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window.
// It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn
// LAST so it overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
if (dropHintTicks_ > 0) {
const int bannerTop = (std::min)(kTitleHeight, h);
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
Rect banner{0, bannerTop, w, bannerTop + bannerH};
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
// hue with a dark label so it reads as "attention, not action".
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
kitTextCentered(&bmp, banner,
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
Font::Label, Role::BgBase);
}
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
}
// A small helper: draw the title band with the live readout + the Browse/Zone nav buttons. Shared
// by the Sample face (nav visible) — Browse/Zone draw their own back button in place of the nav.
namespace {
void drawTitleBand(LICE_IBitmap* bmp, const Rect& title, const std::string& readout) {
fillSurface(bmp, toKitBox(title), Role::BgPanel, InteractionState::Rest);
Rect titleText{title.left + 8, title.top, title.right - 8, title.bottom};
kitText(bmp, titleText, readout.c_str(), Font::Title, Role::TextPrimary);
}
} // namespace
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
const SampleBands bands = computeSampleBands(w, h);
// Title: product name + live readout. Standard B palette — the beta channel gets NO distinct
// accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable
// signal.
std::string title = reasampler::vstPluginName(); // channel-derived (S18)
if (processor_ && processor_->bridge().isConnected()) {
if (samples_.empty()) title += " [bank empty]";
else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]";
else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
else title += " [" + sampleLabel(samples_, selectedId_) + "]";
} 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{bands.hero.left, bands.hero.top, bands.hero.right, bands.control.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.
PerformanceZone zone = effectiveSampleZone();
// --- 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) {
const int bins = (std::max)(1, waveArea.width());
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast<std::size_t>(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.top, 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.top, 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 (fenced root strip, preview button, velocity knob, channel) ---
fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest);
int root = effectiveRoot();
const Rect rootStrip = clusterRootStrip(bands.cluster);
drawSpectralStrip(bmp, rootStrip);
{
const StripLayout sl = layoutStrip(rootStrip.width(), rootStrip.height());
drawRootMarker(bmp, rootStrip, sl, root);
}
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
{
const Rect prev = clusterPreviewButton(bands.cluster);
const KitButtonBox box{toKitBox(prev)};
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest);
drawButton(bmp, box, "Preview", st, /*warn=*/false);
}
// Preview velocity knob (a compact horizontal slider bound to the persisted previewVelocity).
{
const Rect vs = clusterVelocitySlider(bands.cluster);
const double vel01 = previewVelocity01();
const Rect track = sliderTrackRect(vs);
fillSurface(bmp, toKitBox(Rect{track.left, track.top + track.height() / 2 - 1,
track.right, track.top + track.height() / 2 + 1}),
Role::BgCell, InteractionState::Pressed);
const Rect handle = sliderHandleRect(vs, vel01);
const int fillW = (std::max)(0, (handle.left + handle.width() / 2) - track.left);
if (fillW > 0) {
LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, fillW, 2,
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
}
const KitButtonBox knob{toKitBox(Rect{handle.left, handle.top + 2, handle.right,
handle.bottom - 2})};
drawButton(bmp, knob, nullptr, InteractionState::Rest, /*warn=*/false);
Rect velLbl{vs.left, vs.top - 12, vs.right, vs.top};
kitText(bmp, velLbl, "Vel", Font::Micro, Role::TextDim);
}
// Mono | Stereo output-mode toggle.
{
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
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 "Modes-and-down" control strip (S-VIEW-2: moved from Zone) --------------------
paintControls(bmp, bands.control, zone);
}
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.left, (std::min)(waveArea.right - 1, poly[i - 1].x));
const int x1 = (std::max)(waveArea.left, (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.
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;
if (v.x < waveArea.left || v.x >= waveArea.right) continue; // clipped node — no handle
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
const int r = 3;
LICE_FillRect(bmp, v.x - r, v.y - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
}
}
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{area.left, area.top, area.right, area.top + area.height() / 2};
Rect hint{area.left, primary.bottom, area.right, area.bottom};
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
kitTextCentered(bmp, hint,
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
Font::Micro, Role::TextDim);
}
// The Browse-modal (S-VIEW-5) top-level regions: a title band with a Back button, the search box,
// the browser sub-area (tabs + card grid), and a footer with Cancel / Load-confirm. The picker
// covers the full window (F3 resolved: full-window overlay). Both draw + hit-test derive from this
// single layout so they never drift. `content` is the sub-area layoutBrowser lays out over.
namespace {
struct BrowseModal {
Rect title;
Rect back; // the "Back" title-band button
Rect search; // the type-to-filter box (absolute)
Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin
Rect cancel; // footer Cancel
Rect confirm; // footer Load (confirm)
};
constexpr int kBrowseFooterH = 30;
BrowseModal computeBrowseModal(int w, int h) {
BrowseModal m;
const int titleH = (std::min)(kTitleHeight, h);
m.title = Rect{0, 0, w, titleH};
m.back = Rect{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{kPad, titleH, w - kPad, titleH + sb.height()};
const int footerTop = (std::max)(m.search.bottom, h - kBrowseFooterH);
m.content = Rect{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{kPad, fTop, kPad + 90, fBot};
m.confirm = Rect{w - kPad - 90, fTop, w - kPad, fBot};
return m;
}
} // namespace
void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
// A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying
// Sample face with a bg/base wash, then draw the picker opaque on top.
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0);
const BrowseModal bm = computeBrowseModal(w, h);
// Title band + Back button (returns to Sample, discarding any pending pick).
drawTitleBand(bmp, bm.title, "Browse - pick a capture");
{
const KitButtonBox box{toKitBox(bm.back)};
const InteractionState st =
isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "Back", st, /*warn=*/false);
}
// Search box (type-to-filter). A focused box lifts to Focus + a ring; else Rest/Hover.
const Rect searchAbs = bm.search;
const InteractionState searchState =
searchFocused_ ? InteractionState::Focus
: (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover
: InteractionState::Rest);
fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState);
if (searchFocused_) {
LICE_DrawRect(bmp, searchAbs.left, searchAbs.top, 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{searchAbs.left + 6, searchAbs.top, 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.left;
const int oy = browserArea.top;
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{t.left + ox, t.top + 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{content.left + ox, content.top + oy - scrollOffset_,
content.right + ox, content.bottom + oy - scrollOffset_};
thumb = Rect{thumb.left + ox, thumb.top + oy - scrollOffset_,
thumb.right + ox, thumb.bottom + oy - scrollOffset_};
labelR = Rect{labelR.left + ox, labelR.top + 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.left, content.top, 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{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2};
Rect badgeR{labelR.left + 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.left + ox, thumb.top + 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{0, bm.content.bottom, w, h};
fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest);
{
const KitButtonBox box{toKitBox(bm.cancel)};
const InteractionState st =
isHovered(HoverKind::kBrowseCancel, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "Cancel", st, /*warn=*/false);
}
{
const KitButtonBox box{toKitBox(bm.confirm)};
const bool armed = !browsePendingId_.empty();
const InteractionState st = armed
? (isHovered(HoverKind::kBrowseConfirm, -1) ? InteractionState::Hover : InteractionState::Active)
: InteractionState::Rest;
drawButton(bmp, box, "Load", st, /*warn=*/false);
}
}
// The Zone-view (S-VIEW-8) content area: the whole window below the title band.
namespace {
Rect zoneContentArea(int w, int h) {
const int titleH = (std::min)(kTitleHeight, h);
return Rect{0, titleH, w, h};
}
} // namespace
void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
// Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns
// to the Sample home on close.
const Rect title{0, 0, w, (std::min)(kTitleHeight, h)};
drawTitleBand(bmp, title, "Zone - keyboard map");
{
const Rect back{w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, title.bottom - 2)};
const KitButtonBox box{toKitBox(back)};
const InteractionState st =
isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "Back", st, /*warn=*/false);
}
const Rect content = zoneContentArea(w, h);
const int pad = 8;
// A single "+ Add Zone" affordance at the top of the content, then the keyboard strip
// with one bar per zone. Delete is a small × on the selected zone (keystroke also).
Rect addR{content.left + pad, content.top + 4, content.left + pad + 96,
content.top + 4 + 20};
{
const KitButtonBox box{toKitBox(addR)};
const InteractionState state =
isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest;
drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false);
}
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
if (selectedZone_ >= 0) {
const KitButtonBox box{toKitBox(delR)};
const InteractionState state =
isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest;
// Deleting a zone is not a byte-destroying act (no file removed — the bank is
// read-only here), so it is a normal button, not `warn`.
drawButton(bmp, box, "Delete", state, /*warn=*/false);
}
// The zones strip — the same PASTEL SPECTRAL surface as the Sample face, with one bar per
// zone over the spectrum. The SELECTED zone lifts to accent-primary + a static glow ("which
// zone is live"); the rest take the categorical secondary hue at low alpha.
const Rect stripArea = zonesStripArea(content);
drawSpectralStrip(bmp, stripArea);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int sx = stripArea.left;
const int sy = stripArea.top;
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.left + sx - 2, sy, bw + 4, stripArea.height(),
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
LICE_FillRect(bmp, bar.left + sx, sy, bw, stripArea.height(),
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
} else {
LICE_FillRect(bmp, bar.left + 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{stripArea.left, 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{infoR.left, infoR.top, infoR.left + 120, infoR.bottom},
sampleLabel(samples_, 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.left, fr.top, 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{fr.left + 4, fr.top, 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 S12/S15/S16 parameter surface for the selected zone (play mode + AHDSR / Trigger +
// pitch engine + AD pitch envelope). Shown for an explicit zone selection OR for the
// single-capture face when the map is empty but a capture is picked (S15-F2 lean: the
// single capture is already a one-zone map — one storage site serves both).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
paintControls(bmp, zonesControlPanel(content),
map_.zones[static_cast<std::size_t>(selectedZone_)]);
}
}
// The label + the two toggle-segment captions for a control (member so it can name the private
// ParamControl enum). Segments are only read for a ControlKind::Toggle.
namespace {
struct ControlLabels { const char* label; const char* seg0; const char* seg1; };
} // namespace
void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel,
const PerformanceZone& zone) {
// The control panel edits `zone`: its ZonePlaySeconds (Mode/Pitch/AHDSR-or-Trigger/AD-pitch)
// plus the S-VIEW-6 keyTrack scalar (which lives on PerformanceZone, not the play struct — it
// is read/written from `zone` directly). Shared by the Sample home face (single-capture, the
// effective one-zone site) and the Zone surface (an explicit zone) — one storage site.
const ZonePlaySeconds& play = zone.play;
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const auto labelsFor = [](ParamControl c) -> ControlLabels {
switch (c) {
case ParamControl::kPlayMode: return {"Mode", "Gate", "Trigger"};
case ParamControl::kPitchEngine: return {"Pitch eng", "Varisp", "Preserve"};
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::kTrigLength: return {"Length %", "", ""};
case ParamControl::kTrigFadeIn: return {"Fade in", "", ""};
case ParamControl::kTrigFadeOut: return {"Fade out", "", ""};
case ParamControl::kPitchEnvEnable: return {"Pitch env", "Off", "On"};
case ParamControl::kPitchEnvAttack: return {"P.Attack", "", ""};
case ParamControl::kPitchEnvDecay: return {"P.Decay", "", ""};
case ParamControl::kPitchEnvDepth: return {"P.Depth", "", ""};
case ParamControl::kKeyTrack: return {"Key track", "", ""};
default: return {"", "", ""};
}
};
for (const ControlRow& r : rows) {
if (r.row.top >= panel.bottom) break; // clip at the panel bottom
const ControlLabels lab = labelsFor(static_cast<ParamControl>(r.id));
kitText(bmp, r.label, lab.label, Font::Micro, Role::TextDim);
// kKeyTrack lives on the zone (0..200% over kKeyTrackMax), not in `play` — resolve it
// directly; every other control reads through controlValue against the play struct.
const double v = (r.id == static_cast<int>(ParamControl::kKeyTrack))
? clamp01(zone.keyTrack / kKeyTrackMax)
: controlValue(r.id, play);
const bool hov = isHovered(HoverKind::kControl, r.id);
if (r.kind == ControlKind::Toggle) {
const bool seg1 = (v >= 0.5);
const Rect s0 = toggleSegmentRect(r.control, 0);
const Rect s1 = toggleSegmentRect(r.control, 1);
// The lit segment carries the primary accent (Active); the unlit segment hovers
// toward accent/hot when the whole control is under the pointer.
const InteractionState s0State =
!seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest);
const InteractionState s1State =
seg1 ? InteractionState::Active : (hov ? InteractionState::Hover : InteractionState::Rest);
fillSurface(bmp, toKitBox(s0), Role::BgCell, s0State);
fillSurface(bmp, toKitBox(s1), Role::BgCell, s1State);
kitTextCentered(bmp, s0, lab.seg0, Font::Micro, !seg1 ? Role::BgBase : Role::TextPrimary);
kitTextCentered(bmp, s1, lab.seg1, Font::Micro, seg1 ? Role::BgBase : Role::TextPrimary);
} else {
// Track groove (recessed cell) + accent filled portion up to the handle + a raised
// handle. Dragging THIS control brightens the fill/handle (accent/hot).
const bool dragging = (drag_ == DragKind::kParamSlider && dragParamId_ == r.id);
const Rect track = sliderTrackRect(r.control);
fillSurface(bmp, toKitBox(Rect{track.left, track.top + track.height() / 2 - 1,
track.right, track.top + track.height() / 2 + 1}),
Role::BgCell, InteractionState::Pressed);
const Rect handle = sliderHandleRect(r.control, v);
// Filled portion: track-left to the handle center.
const int fillW = (std::max)(0, (handle.left + handle.width() / 2) - track.left);
if (fillW > 0) {
LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, fillW, 2,
toLice(roleColor(dragging ? Role::AccentHot : Role::AccentPrimary)),
1.0f, 0);
}
const KitButtonBox knob{toKitBox(Rect{handle.left, handle.top + 2, handle.right,
handle.bottom - 2})};
drawButton(bmp, knob, nullptr,
dragging ? InteractionState::Dragging
: (hov ? InteractionState::Hover : InteractionState::Rest),
/*warn=*/false);
}
}
}
// --- Hover resolution (Phase L, L3) ------------------------------------------
//
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
// idle move is free — the "sub-frame feedback, zero cost when nothing changed" discipline).
// Mirrors onMouseDown's hit-test order, but read-only: it never mutates selection/map. Only
// the frequently-touched interactive surfaces light on hover; a purely decorative region
// resolves to kNone (clearing any prior hover). Windows-only.
void ReaSamplerEditor::resolveHover(int x, int y) {
HoverTarget h; // kNone by default
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int hgt = cr.bottom - cr.top;
if (view_ == View::kBrowse) {
const BrowseModal bm = computeBrowseModal(w, hgt);
if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1};
else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1};
else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1};
else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1};
else {
const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height());
const int bx = x - bm.content.left;
const int by = y - bm.content.top;
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 (view_ == View::kZone) {
const Rect back{w - kPad - kNavButtonWidth, 2,
w - kPad, (std::max)(2, (std::min)(kTitleHeight, hgt) - 2)};
const Rect content = zoneContentArea(w, hgt);
Rect addR{content.left + kPad, content.top + 4, content.left + kPad + 96, content.top + 4 + 20};
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
if (contains(back, x, y)) {
h = {HoverKind::kBack, -1};
} else if (contains(addR, x, y)) {
h = {HoverKind::kAddZone, -1};
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
h = {HoverKind::kDeleteZone, -1};
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
const Rect panel = zonesControlPanel(content);
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const int id = controlAtPoint(rows, x, y);
if (id >= 0) h = {HoverKind::kControl, id};
}
} else { // Sample view (home)
const SampleBands bands = computeSampleBands(w, hgt);
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);
if (contains(clusterPreviewButton(bands.cluster), x, y)) h = {HoverKind::kPreview, -1};
else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
else {
const PerformanceZone zone = effectiveSampleZone();
const std::vector<ControlDesc> descs = controlDescs(zone.play);
const std::vector<ControlRow> rows = layoutControls(bands.control, descs);
const int id = controlAtPoint(rows, x, y);
if (id >= 0) h = {HoverKind::kControl, id};
}
}
}
if (h != hover_) {
hover_ = h;
invalidate();
}
}
// --- Input: the drag-state machine -------------------------------------------
void ReaSamplerEditor::onMouseDown(int x, int y) {
if (!processor_) return;
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
// ---- Browse modal (S-VIEW-5): pick + confirm/cancel over the Sample face ----
if (view_ == View::kBrowse) {
const BrowseModal bm = computeBrowseModal(w, h);
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
// Cancel/Back: discard the pending pick, return to Sample unchanged.
browsePendingId_.clear();
searchFocused_ = false;
view_ = View::kSample;
invalidate();
return;
}
if (contains(bm.confirm, x, y)) {
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
if (!browsePendingId_.empty()) {
selectedId_ = browsePendingId_;
commitAndReload();
}
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.left;
const int by = y - bm.content.top;
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{thumb.left + bm.content.left, thumb.top + bm.content.top,
thumb.right + bm.content.left, thumb.bottom + bm.content.top}, 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) {
selectedId_ = id;
commitAndReload();
browsePendingId_.clear();
lastBrowseClickCard_ = -1;
searchFocused_ = false;
view_ = View::kSample;
invalidate();
} else {
browsePendingId_ = id;
lastBrowseClickCard_ = card;
invalidate();
}
return;
}
lastBrowseClickCard_ = -1;
return;
}
// ---- Sample home (S-VIEW-2) ----
if (view_ == View::kSample) {
const SampleBands bands = computeSampleBands(w, h);
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
// 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(clusterPreviewButton(bands.cluster), x, y)) {
const int note = effectiveRoot();
if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_);
previewingNote_ = note;
processor_->previewNoteOn(note);
invalidate();
return;
}
// Preview velocity knob: grab to drag (a kParamSlider drag against the velocity domain,
// marked by dragParamId_ == -2 sentinel so onMouseMove routes it to setPreviewVelocity).
{
const Rect vs = clusterVelocitySlider(bands.cluster);
if (contains(vs, x, y)) {
drag_ = DragKind::kParamSlider;
dragParamId_ = -2; // sentinel: the preview velocity knob (not a zone param)
dragParamPanel_ = vs;
const double v = valueAtPoint(vs, x);
processor_->setPreviewVelocity(static_cast<std::uint8_t>(v * 127.0 + 0.5));
invalidate();
return;
}
}
// Channel toggle.
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
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;
}
// 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.
const Rect rootStrip = clusterRootStrip(bands.cluster);
const StripLayout sl = layoutStrip(rootStrip.width(), rootStrip.height());
const int note = keyAtPoint(sl, x - rootStrip.left, y - rootStrip.top);
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;
}
// The control strip (S-VIEW-2 moved from Zone): route via the shared handler on the
// effective one-zone site (materialize it on first interaction, mirror of the Zone path).
{
const PerformanceZone probeZone = effectiveSampleZone();
const std::vector<ControlDesc> descs = controlDescs(probeZone.play);
const std::vector<ControlRow> rows = layoutControls(bands.control, descs);
if (controlAtPoint(rows, x, y) >= 0) {
const int zi = ensureSampleZone();
if (zi >= 0) handleControlClick(zi, bands.control, x, y);
}
}
return;
}
// ---- Zone surface (S-VIEW-8) ----
const Rect back{w - kPad - kNavButtonWidth, 2,
w - kPad, (std::max)(2, (std::min)(kTitleHeight, h) - 2)};
if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; }
const Rect content = zoneContentArea(w, h);
const int pad = 8;
Rect addR{content.left + pad, content.top + 4, content.left + pad + 96,
content.top + 4 + 20};
if (contains(addR, x, y)) {
// Add a full-keyboard zone for the picked capture (or the first visible sample as a
// sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
// already exists, select it rather than appending a duplicate (mirrors the upsert the
// root-marker drag path already performs, preventing overlapping identical zones).
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;
}
}
PerformanceZone z;
z.sampleId = seed;
z.lowNote = 0;
z.highNote = 127;
map_.zones.push_back(z);
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
commitAndReload();
return;
}
Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom};
if (selectedZone_ >= 0 && contains(delR, x, y)) {
map_.zones.erase(map_.zones.begin() + selectedZone_);
selectedZone_ = -1;
commitAndReload();
return;
}
// The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the
// selected zone's root.
const Rect stripArea = zonesStripArea(content);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int lx = x - stripArea.left;
const int ly = y - stripArea.top;
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 param panel: a toggle segment flips at once (commit); a slider grab starts a live drag.
// Only when a zone is selected (the Zone surface has no single-capture fallback — that lives
// on the Sample face now).
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
handleControlClick(selectedZone_, zonesControlPanel(content), x, y);
}
}
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);
}
}
bool ReaSamplerEditor::handleControlClick(int zoneIndex, const Rect& panel, int x, int y) {
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return false;
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(zoneIndex)].play;
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(panel, descs);
const int id = controlAtPoint(rows, x, y);
if (id < 0) return false;
for (const ControlRow& r : rows) {
if (r.id != id) continue;
if (r.kind == ControlKind::Toggle) {
const int seg = toggleSegmentHitTest(r.control, x, y);
if (seg >= 0) {
applyZoneControl(zoneIndex, id, 0.0, seg);
commitAndReload(); // a toggle is a discrete, final edit
}
} else {
// Grab the slider: set the value at the grab x immediately, then live-drag.
drag_ = DragKind::kParamSlider;
dragParamId_ = id;
dragParamZone_ = zoneIndex;
dragParamPanel_ = panel;
dragStartMap_ = map_;
applyZoneControl(zoneIndex, id, valueAtPoint(r.control, x), 0);
invalidate(); // live feedback; commit on WM_LBUTTONUP
}
break;
}
return true;
}
void ReaSamplerEditor::onMouseMove(int x, int y) {
if (drag_ == DragKind::kNone) return;
RECT cr{};
GetClientRect(childHwnd_, &cr);
const int w = cr.right - cr.left;
const int h = cr.bottom - cr.top;
const SampleBands bands = computeSampleBands(w, h);
const int dx = x - dragStartX_;
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 Rect stripArea = clusterRootStrip(bands.cluster);
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::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;
}
if (drag_ == DragKind::kParamSlider) {
// The preview-velocity knob (Sample face) uses the -2 sentinel — map x->0..1 over the
// stored knob rect and write it to the processor (persisted per-instance).
if (dragParamId_ == -2) {
const double v = valueAtPoint(dragParamPanel_, x);
if (processor_) processor_->setPreviewVelocity(static_cast<std::uint8_t>(v * 127.0 + 0.5));
invalidate();
return;
}
// S12/S15/S16 + keyTrack: re-lay the panel and map x -> value against the grabbed control's
// live track rect. Uses dragParamZone_ (the Sample face has no selectedZone_ coupling).
const int zi = dragParamZone_;
if (zi < 0 || zi >= static_cast<int>(map_.zones.size())) return;
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(zi)].play;
const std::vector<ControlDesc> descs = controlDescs(play);
const std::vector<ControlRow> rows = layoutControls(dragParamPanel_, descs);
for (const ControlRow& r : rows) {
if (r.id == dragParamId_) {
applyZoneControl(zi, dragParamId_, valueAtPoint(r.control, x), 0);
break;
}
}
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_;
drag_ = DragKind::kNone;
dragParamId_ = -1;
dragParamZone_ = -1;
// A scrollbar drag is transient UI (no map change), and the preview-velocity knob (id==-2) is a
// processor-side per-instance setting already applied live — neither reloads the instrument.
// Every other drag is a coherent map edit: publish the in-flight map + reload off-thread.
if (kind == DragKind::kScrollThumb || (kind == DragKind::kParamSlider && paramId == -2)) {
invalidate();
return;
}
commitAndReload();
}
void ReaSamplerEditor::onMouseWheel(int delta) {
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A positive
// delta (wheel up) scrolls toward the top (smaller offset).
if (view_ != View::kBrowse) return;
const int rows = delta / 120;
if (rows == 0) return;
scrollOffset_ -= rows * kBrowserCardHeight;
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
invalidate();
}
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
// 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
}
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_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 preview-velocity knob (kParamSlider id==-2) 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::kParamSlider && self->dragParamId_ == -2);
if (!transient) self->map_ = self->dragStartMap_;
self->drag_ = DragKind::kNone;
self->dragParamId_ = -1;
self->dragParamZone_ = -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