Retire the zone system: one capture = one parameter set, and re-seam the engine and Sample face into bands
This commit is contained in:
@@ -8,9 +8,9 @@ two small identity/helper headers this directory owns outright
|
||||
(`reasampler_vst.h`, `editor_internal.h`).
|
||||
|
||||
The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
|
||||
`sample_map`, `component_state_io`, `zone_params.h`, `editor_geometry`,
|
||||
`keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`, `note_entry`,
|
||||
`param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
||||
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
|
||||
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
|
||||
`note_entry`, `param_slider`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
|
||||
`curve_popup`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
|
||||
`core/wire` and is documented there — this directory consumes it but does not own it.
|
||||
|
||||
@@ -78,7 +78,7 @@ scattered `#ifdef`s in the VST shell, except the one described below).
|
||||
- No cross-platform / multi-format. Windows-only, VST3-only, REAPER-only (D5). Do not
|
||||
add an AU/AAX/VST2/CLAP wrapper, a mac/Linux build, or a standalone host target.
|
||||
- The pure core stays REAPER-free *and* VST3-free — the voice engine / envelope /
|
||||
keymap / repitch module takes no VST3 or REAPER type at its boundary; the shell
|
||||
repitch module takes no VST3 or REAPER type at its boundary; the shell
|
||||
marshals. Any VST3 or REAPER type leaking into `core/instrument` is a bug.
|
||||
- Verify Steinberg SDK, bridge, embed, and LICE-view surfaces against the vendored
|
||||
headers before use.
|
||||
@@ -86,17 +86,17 @@ scattered `#ifdef`s in the VST shell, except the one described below).
|
||||
## Modules
|
||||
|
||||
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. **pS-usage:** gains `writeUsageExtState` (prefix-guarded — accepts only `rsusage_`-prefixed keys, refuses all others) so the processor can publish usage without weakening the read-only-bank invariant.
|
||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded keymap via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
|
||||
- `reasampler_editor` (`shell/instrument/`: eight face-axis TUs — `editor_session` session/bridge state, `editor_controls` parameter plumbing, `editor_paint_sample`/`editor_paint_browse_zone` paint, `editor_input_sample`/`editor_input_browse_zone` input, `editor_platform` IPlugView/Win32 window plumbing, plus the pure `editor_geometry` layout hoist as the eighth axis; shared internals in `editor_internal.h`, no TU of its own — Q-W2v, T4-11 split of the former god-TU) — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; default face is the capture browser, then single-capture setup, with opt-in zones panel. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout/hit-test to `embed_strip`.
|
||||
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
|
||||
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (parameter plumbing + the ONE `faceLayout` band resolve every paint and hit-test path shares), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
|
||||
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
|
||||
- `vst_entry` — VST3 entry point: `GetPluginFactory` export, class registration, channel-forked class UIDs.
|
||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family (Q-W2v split), included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / spectral strip / root marker / title band), label helpers, deck group ids, and the velocity-curve box derivation — the former god-TU's anonymous-namespace helpers that more than one split TU needs. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/editor_internal.h`'s own header comment and body.)*
|
||||
- `editor_internal.h` — INTERNAL shared helpers for the `reasampler_editor` TU family, included only by the editor's own shell TUs (`editor_session` / `editor_controls` / `editor_paint_*` / `editor_input_*` / `editor_platform`), never a public seam: the `Rect`↔kit adapters, small draw primitives (knob face / spectral strip / root marker / title band), label helpers, deck group ids, and the velocity-curve box derivation — the helpers more than one band TU needs.
|
||||
- `reasampler_vst.h` — shared identity constants for the ReaSampler VST3 instrument (Phase S): the plugin's class UID (the channel-selected `Steinberg::FUID`, built from the FOREVER-FROZEN macros in `core/wire/reasampler_uid.h`), vendor name/URL/email, so the processor, factory, and editor agree. A class UID is FOREVER-STABLE once shipped — minted once, never regenerated. *(Newly authored per this dispatch's brief — no existing root-CLAUDE.md bullet; verified by reading `src/shell/instrument/reasampler_vst.h` directly.)*
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `editor_internal.h` is include-only — it has no TU of its own and must never become
|
||||
a public seam; only the eight `reasampler_editor` face-axis TUs include it.
|
||||
a public seam; only the `reasampler_editor` band-axis TUs include it.
|
||||
- The two VST3 class UIDs (`core/wire/reasampler_uid.h`, consumed via
|
||||
`reasampler_vst.h`) are FOREVER-FROZEN — never regenerate an already-shipped UID.
|
||||
- The UID selection `#ifdef` in `reasampler_vst.h` is the one deliberate exception to
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the control-value domain
|
||||
// maps (controlValue / applyControl — seconds/fraction/frames <-> normalized 0..1), the
|
||||
// knob-deck group descriptors + control-id<->value binding, the envelope pack/unpack
|
||||
// (the trigger-seam converter), the curve-popup target resolution, and applyZoneControl.
|
||||
// Value logic only — no painting, no window plumbing.
|
||||
// editor_controls.cpp — the ReaSamplerEditor's parameter plumbing: the band-stack layout
|
||||
// resolve every paint/hit-test path shares, the control-value domain maps (controlValue /
|
||||
// applyControl — seconds/fraction/frames <-> normalized 0..1), the knob-deck group
|
||||
// descriptors + control-id<->value binding, and the envelope pack/unpack (the trigger-seam
|
||||
// converter). Value logic only — no painting, no window plumbing.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -14,14 +14,20 @@
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // master-gain dB<->linear<->knob taper
|
||||
#include "core/instrument/map/trigger_seam.h" // triggerPlayLength / fade fraction converters
|
||||
#include "core/instrument/ui/knob_deck.h" // deckHeight / kDeckKnobSize (the band's own height)
|
||||
#include "core/util/clamp01.h"
|
||||
#include "shell/instrument/editor_internal.h" // DeckGroup ids
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::instrument::map; // ZonePlaySeconds vocabulary + trigger_seam converters
|
||||
using namespace reasampler::instrument::map; // PlaySeconds vocabulary + trigger_seam converters
|
||||
using instrument::ui::EnvMode; // envelope_overlay's mode enum
|
||||
using instrument::ui::computeSampleBands;
|
||||
using instrument::ui::chromeRects;
|
||||
using instrument::ui::deckHeight;
|
||||
using instrument::ui::kDeckKnobSize;
|
||||
using instrument::ui::kPad;
|
||||
using instrument::engine::formatMasterGainLabel;
|
||||
using instrument::engine::masterGainLinearFromNorm;
|
||||
using instrument::engine::masterGainNormFromLinear;
|
||||
@@ -30,7 +36,7 @@ using util::clamp01;
|
||||
namespace {
|
||||
// Control-surface value domains (the shell owns these — param_slider is engine-free and maps
|
||||
// only 0..1). Wall-clock time sliders (AHDSR A/H/D/R, pitch env A/D) span [0, kEnvTimeMaxSeconds]
|
||||
// seconds — rate-free, exactly what the zone stores; the keymap build resolves seconds->frames
|
||||
// seconds — rate-free, exactly what the parameter set stores; the build resolves seconds->frames
|
||||
// at the live rate. Source-timeline fade sliders (Trigger fade-in/out) store source frames
|
||||
// (never a wall-clock second), but the knob's full-scale throw is a wall-clock intent —
|
||||
// kFadeMaxSeconds resolved against the live rate at use (fadeMaxFrames()) rather than a baked-in
|
||||
@@ -42,7 +48,18 @@ constexpr double kKeyTrackMax = 2.0; // key-track slider ceiling
|
||||
|
||||
} // namespace
|
||||
|
||||
double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const {
|
||||
ReaSamplerEditor::FaceLayout ReaSamplerEditor::faceLayout(int w, int h) const {
|
||||
// The ONE resolve every paint and hit-test path goes through, so the band stack, the
|
||||
// chrome interior, and the deck descriptors can never be derived three different ways.
|
||||
// The deck's own wrapped height is the only interior measurement the allocator needs.
|
||||
FaceLayout fl;
|
||||
fl.deckDescs = deckGroupDescs(params_.play);
|
||||
fl.bands = computeSampleBands(w, h, deckHeight(fl.deckDescs, w - 2 * kPad));
|
||||
fl.chrome = chromeRects(fl.bands.chrome, kDeckKnobSize);
|
||||
return fl;
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::controlValue(int id, const PlaySeconds& play) const {
|
||||
// Wall-clock seconds -> normalized over the seconds ceiling; source frames -> normalized over
|
||||
// the rate-resolved frames ceiling. Two domains, kept explicit so neither leaks a rate. A
|
||||
// stored fade exceeding fadeMaxFrames() at the current host rate reads as norm 1.0 (clamp01
|
||||
@@ -74,7 +91,7 @@ double ReaSamplerEditor::controlValue(int id, const ZonePlaySeconds& play) const
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value,
|
||||
void ReaSamplerEditor::applyControl(int id, PlaySeconds& play, double value,
|
||||
int segment) const {
|
||||
const double fadeMax = fadeMaxFrames(); // rate-resolved knob full-scale
|
||||
const auto normToSec = [](double v) { return clamp01(v) * kEnvTimeMaxSeconds; };
|
||||
@@ -134,12 +151,10 @@ double ReaSamplerEditor::previewVelocity01() const {
|
||||
return static_cast<double>(processor_->previewVelocity()) / 127.0;
|
||||
}
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The per-zone groups — the deck grammar both surfaces share (the Zone panel renders
|
||||
// exactly these; the Sample face appends the per-instance groups in deckGroupDescs). Group
|
||||
// widths are mode-independent: AMP ENVELOPE reserves its 5-cell Gate width (Trigger leaves
|
||||
// two blank cells), so a Gate<->Trigger flip repopulates in place and never reflows the
|
||||
// neighbouring groups.
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const PlaySeconds& play) const {
|
||||
// The deck band's groups, left to right. Group widths are mode-independent: AMP ENVELOPE
|
||||
// reserves its 5-cell Gate width (Trigger leaves two blank cells), so a Gate<->Trigger
|
||||
// flip repopulates in place and never reflows the neighbouring groups.
|
||||
std::vector<DeckGroupDesc> out;
|
||||
{
|
||||
DeckGroupDesc amp;
|
||||
@@ -179,14 +194,6 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::zoneDeckGroupDescs(const ZonePlaySe
|
||||
static_cast<int>(ParamControl::kPitchEnvDepth)};
|
||||
out.push_back(std::move(penv));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySeconds& play) const {
|
||||
// The full Sample-face deck: the shared per-zone groups + the per-instance VOICE + MASTER
|
||||
// groups. Per-instance state (ComponentState) stays off the Zone panel, so they are
|
||||
// appended here, not in zoneDeckGroupDescs.
|
||||
std::vector<DeckGroupDesc> out = zoneDeckGroupDescs(play);
|
||||
{
|
||||
DeckGroupDesc voice;
|
||||
voice.id = kGroupVoice;
|
||||
@@ -206,22 +213,22 @@ std::vector<DeckGroupDesc> ReaSamplerEditor::deckGroupDescs(const ZonePlaySecond
|
||||
return out;
|
||||
}
|
||||
|
||||
double ReaSamplerEditor::deckControlNorm(int id, const PerformanceZone& zone) const {
|
||||
if (id == -2) return previewVelocity01(); // the cluster's preview-velocity knob
|
||||
double ReaSamplerEditor::deckControlNorm(int id) const {
|
||||
if (id == -2) return previewVelocity01(); // the chrome preview-velocity knob
|
||||
switch (static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kKeyTrack:
|
||||
return clamp01(zone.keyTrack / kKeyTrackMax);
|
||||
return clamp01(params_.keyTrack / kKeyTrackMax);
|
||||
case ParamControl::kVoiceCount:
|
||||
return clamp01(static_cast<double>(voiceCount_ - kMinVoiceCount) /
|
||||
static_cast<double>(kMaxVoiceCount - kMinVoiceCount));
|
||||
case ParamControl::kMasterGain:
|
||||
return masterGainNormFromLinear(processor_ ? processor_->masterGainLinear() : 1.0);
|
||||
default:
|
||||
return controlValue(id, zone.play);
|
||||
return controlValue(id, params_.play);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
|
||||
void ReaSamplerEditor::applyDeckKnob(int id, double norm) {
|
||||
if (!processor_) return;
|
||||
norm = clamp01(norm);
|
||||
if (id == -2) {
|
||||
@@ -247,15 +254,15 @@ void ReaSamplerEditor::applyDeckKnob(int zoneIndex, int id, double norm) {
|
||||
processor_->setMasterGainLinear(masterGainLinearFromNorm(norm));
|
||||
return;
|
||||
default:
|
||||
applyZoneControl(zoneIndex, id, norm, 0);
|
||||
applyParamControl(id, norm, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone) const {
|
||||
std::string ReaSamplerEditor::deckValueLabel(int id) const {
|
||||
char buf[24];
|
||||
buf[0] = '\0';
|
||||
const ZonePlaySeconds& play = zone.play;
|
||||
const PlaySeconds& play = params_.play;
|
||||
switch (id == -2 ? ParamControl::kCount : static_cast<ParamControl>(id)) {
|
||||
case ParamControl::kAttack:
|
||||
snprintf(buf, sizeof(buf), "%.3fs", play.adsr.attackSeconds); break;
|
||||
@@ -282,13 +289,13 @@ std::string ReaSamplerEditor::deckValueLabel(int id, const PerformanceZone& zone
|
||||
case ParamControl::kPitchEnvDepth:
|
||||
snprintf(buf, sizeof(buf), "%+.1fst", play.pitchEnv.peakSemitones); break;
|
||||
case ParamControl::kKeyTrack:
|
||||
snprintf(buf, sizeof(buf), "%.0f%%", zone.keyTrack * 100.0); break;
|
||||
snprintf(buf, sizeof(buf), "%.0f%%", params_.keyTrack * 100.0); break;
|
||||
case ParamControl::kVoiceCount:
|
||||
snprintf(buf, sizeof(buf), "%d", voiceCount_); break;
|
||||
case ParamControl::kMasterGain:
|
||||
formatMasterGainLabel(deckControlNorm(id, zone), buf, sizeof(buf)); break;
|
||||
formatMasterGainLabel(deckControlNorm(id), buf, sizeof(buf)); break;
|
||||
default:
|
||||
// -2 (preview velocity) is labeled at its cluster call site; nothing else here.
|
||||
// -2 (preview velocity) is labeled at its chrome call site; nothing else here.
|
||||
break;
|
||||
}
|
||||
return std::string(buf);
|
||||
@@ -309,7 +316,7 @@ EnvClampBounds ReaSamplerEditor::envClampBounds() const {
|
||||
return b;
|
||||
}
|
||||
|
||||
AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
|
||||
AmpEnvelope ReaSamplerEditor::packEnvelope(const PlaySeconds& play, std::int64_t frames,
|
||||
std::int64_t startFrame) const {
|
||||
AmpEnvelope env;
|
||||
env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate;
|
||||
@@ -320,7 +327,7 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
|
||||
env.sustainLevel = play.adsr.sustainLevel;
|
||||
env.releaseSeconds = play.adsr.releaseSeconds;
|
||||
// Trigger: lengthFraction copies 1-to-1; the fades are derived — source frames over the played
|
||||
// span (the trigger-seam converter, pack direction). startFrame is the zone's effective start
|
||||
// span (the trigger-seam converter, pack direction). startFrame is the effective start
|
||||
// point so the fraction denominator matches the voice's actual post-start span. A zero play
|
||||
// length yields 0 fractions.
|
||||
env.lengthFraction = play.trigger.lengthFraction;
|
||||
@@ -332,7 +339,7 @@ AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames,
|
||||
std::int64_t startFrame, ZonePlaySeconds& play) const {
|
||||
std::int64_t startFrame, PlaySeconds& play) const {
|
||||
if (env.mode == EnvMode::Gate) {
|
||||
play.adsr.attackSeconds = env.attackSeconds;
|
||||
play.adsr.holdSeconds = env.holdSeconds;
|
||||
@@ -342,7 +349,7 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
||||
} else {
|
||||
// Trigger: lengthFraction copies back; the fades convert fractions -> source frames over
|
||||
// the played span (the trigger-seam converter, unpack direction). startFrame is the
|
||||
// zone's effective start point so the frame denominator matches the voice's actual
|
||||
// effective start point so the frame denominator matches the voice's actual
|
||||
// post-start span. Keep the same (0,1] floor on lengthFraction the slider path enforces
|
||||
// so a zero-length trigger never plays nothing.
|
||||
play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction);
|
||||
@@ -353,39 +360,13 @@ void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frame
|
||||
}
|
||||
}
|
||||
|
||||
PerformanceZone ReaSamplerEditor::popupZone() const {
|
||||
// The zone the popup displays: the Zone surface's selected zone, else the Sample face's
|
||||
// one-zone site (a read-only resolve — an edit materializes via popupZoneIndex).
|
||||
if (view_ == View::kZone && selectedZone_ >= 0 &&
|
||||
selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
return map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
}
|
||||
return effectiveSampleZone();
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::popupZoneIndex() {
|
||||
// The map_.zones index a popup edit lands on, or -1 when there is no valid target. The
|
||||
// Zone surface never materializes (the button only shows for an explicit selection); the
|
||||
// Sample face finds-or-materializes the picked id's one-zone site.
|
||||
if (view_ == View::kZone) {
|
||||
return (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size()))
|
||||
? selectedZone_
|
||||
: -1;
|
||||
}
|
||||
return ensureSampleZone();
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) {
|
||||
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
|
||||
void ReaSamplerEditor::applyParamControl(int id, double value, int segment) {
|
||||
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;
|
||||
// keyTrack sits beside the play bundle (0..200% over kKeyTrackMax); the knob maps 0..1.
|
||||
params_.keyTrack = clamp01(value) * kKeyTrackMax;
|
||||
} else {
|
||||
applyControl(id, z.play, value, segment);
|
||||
applyControl(id, params_.play, value, segment);
|
||||
}
|
||||
}
|
||||
#endif // _WIN32
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// editor_input.cpp — the ReaSamplerEditor's input dispatch and drag-state machine: the
|
||||
// mouse-down routing (curve popup first, then Browse or the three bands in order), the
|
||||
// onMouseMove drag router, the release commit, and the hover resolver. The per-band
|
||||
// branches live in the editor_input_<band> TUs; this TU only sequences them.
|
||||
// Windows-only. All hit-test math is pure; this family routes and mutates editor state.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
if (!processor_) return;
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
|
||||
if (view_ == View::kBrowse) {
|
||||
mouseDownBrowse(w, h, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
// The curve popup is modal over the face — while open it owns every left-click.
|
||||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||||
|
||||
// Band order matters only where bands can overlap on a degenerate window; each branch
|
||||
// reports whether it consumed the click so the next band gets a clean shot.
|
||||
const FaceLayout fl = faceLayout(w, h);
|
||||
if (mouseDownChrome(fl, x, y)) return;
|
||||
if (selectedId_.empty()) return; // empty state — chrome nav only
|
||||
if (mouseDownDeck(fl, x, y)) return;
|
||||
mouseDownWaveform(fl, x, y);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn)
|
||||
dragCurY_ = y;
|
||||
|
||||
// The three band-free drags resolve without a layout pass at all.
|
||||
switch (drag_) {
|
||||
case DragKind::kDeckKnob: dragDeck(x, y); return;
|
||||
case DragKind::kScrollThumb: dragBrowse(x, y); return;
|
||||
case DragKind::kCurveNode: dragCurve(x, y); return;
|
||||
default: break;
|
||||
}
|
||||
|
||||
RECT rc{};
|
||||
GetClientRect(childHwnd_, &rc);
|
||||
const FaceLayout fl = faceLayout(rc.right - rc.left, rc.bottom - rc.top);
|
||||
if (drag_ == DragKind::kRootMarker) {
|
||||
dragChrome(fl, x, y);
|
||||
} else {
|
||||
dragWaveform(fl, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
// Release a held preview note first (the preview button is a momentary key: note-off on up).
|
||||
// This runs regardless of drag state — the preview press does not start a drag.
|
||||
if (previewingNote_ >= 0) {
|
||||
if (processor_) processor_->previewNoteOff(previewingNote_);
|
||||
previewingNote_ = -1;
|
||||
invalidate();
|
||||
}
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
const DragKind kind = drag_;
|
||||
const int paramId = dragParamId_;
|
||||
const int curveIdx = curvePointIndex_;
|
||||
const Rect curveRect = dragCurveRect_;
|
||||
drag_ = DragKind::kNone;
|
||||
dragParamId_ = -1;
|
||||
curvePointIndex_ = -1;
|
||||
// A scrollbar drag is transient UI (no parameter change), and the processor-side knobs
|
||||
// (the preview-velocity -2 sentinel, voice count, master gain) are per-instance settings
|
||||
// that don't reload the instrument. Master gain is an atomic the audio thread reads
|
||||
// directly. Voice count: the label/needle tracks live during the drag but the engine
|
||||
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
|
||||
const bool deckTransient =
|
||||
kind == DragKind::kDeckKnob &&
|
||||
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
paramId == static_cast<int>(ParamControl::kMasterGain));
|
||||
if (kind == DragKind::kScrollThumb || deckTransient) {
|
||||
// Commit the voice count now that the drag is complete (one rebuild per full drag).
|
||||
if (deckTransient && processor_ &&
|
||||
paramId == static_cast<int>(ParamControl::kVoiceCount))
|
||||
processor_->setVoiceCount(voiceCount_);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged
|
||||
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move —
|
||||
// its amp keeps the last clamped drag value).
|
||||
if (kind == DragKind::kCurveNode && curveIdx >= 0) {
|
||||
const bool off = x < curveRect.x - kCurveDragOffMargin ||
|
||||
x > curveRect.right() + kCurveDragOffMargin ||
|
||||
y < curveRect.y - kCurveDragOffMargin ||
|
||||
y > curveRect.bottom() + kCurveDragOffMargin;
|
||||
if (off) {
|
||||
params_.velocityCurve.deletePoint(static_cast<std::size_t>(curveIdx));
|
||||
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node
|
||||
}
|
||||
}
|
||||
commitAndReload();
|
||||
}
|
||||
|
||||
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
|
||||
// idle move is free). Mirrors onMouseDown's routing order, but read-only.
|
||||
void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int hgt = cr.bottom - cr.top;
|
||||
|
||||
HoverTarget h; // kNone by default
|
||||
if (view_ == View::kBrowse) {
|
||||
h = hoverBrowse(w, hgt, x, y);
|
||||
} else if (curvePopupOpen_) { // modal over the face
|
||||
h = hoverCurvePopup(w, hgt, x, y);
|
||||
} else {
|
||||
const FaceLayout fl = faceLayout(w, hgt);
|
||||
h = hoverChrome(fl, x, y);
|
||||
if (h.kind == HoverKind::kNone && !selectedId_.empty()) h = hoverDeck(fl, x, y);
|
||||
}
|
||||
|
||||
if (h != hover_) {
|
||||
hover_ = h;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,174 @@
|
||||
// editor_input_browse.cpp — the Browse modal's input: the click branch (tabs, cards,
|
||||
// select-then-confirm, scroll-thumb grab, search focus), the thumb drag, the wheel scroll,
|
||||
// the type-to-filter keystrokes, and the modal's hover. Also carries the degraded
|
||||
// drop affordance (an OS drop is never ingested). Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "shell/instrument/editor_internal.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
|
||||
// Cancel/Back: discard the pending pick, return to Sample unchanged.
|
||||
browsePendingId_.clear();
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bm.confirm, x, y)) {
|
||||
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
|
||||
if (!browsePendingId_.empty()) loadSelection(browsePendingId_);
|
||||
browsePendingId_.clear();
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; }
|
||||
searchFocused_ = false;
|
||||
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
const int bx = x - bm.content.x;
|
||||
const int by = y - bm.content.y;
|
||||
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||
if (tab >= 0) {
|
||||
activeFilterBankId_ = (tab == 0) ? std::string()
|
||||
: banks_[static_cast<std::size_t>(tab - 1)].id;
|
||||
rebuildVisible();
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
|
||||
if (thumb.height > 0 &&
|
||||
contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y,
|
||||
thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) {
|
||||
drag_ = DragKind::kScrollThumb;
|
||||
dragStartY_ = y;
|
||||
dragStartScrollOffset_ = scrollOffset_;
|
||||
return;
|
||||
}
|
||||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (card >= 0) {
|
||||
// Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card
|
||||
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
|
||||
const std::string id = visible_[static_cast<std::size_t>(card)].id;
|
||||
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
|
||||
loadSelection(id);
|
||||
browsePendingId_.clear();
|
||||
lastBrowseClickCard_ = -1;
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
} else {
|
||||
browsePendingId_ = id;
|
||||
lastBrowseClickCard_ = card;
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastBrowseClickCard_ = -1;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::dragBrowse(int x, int y) {
|
||||
// Map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card
|
||||
// window recomputes at paint from scrollOffset_.
|
||||
(void)x;
|
||||
RECT rc{};
|
||||
GetClientRect(childHwnd_, &rc);
|
||||
const BrowseModal bm = computeBrowseModal(rc.right - rc.left, rc.bottom - rc.top);
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
|
||||
dragStartScrollOffset_, y - dragStartY_);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverBrowse(int w, int h, int x,
|
||||
int y) const {
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
if (contains(bm.back, x, y)) return {HoverKind::kBack, -1};
|
||||
if (contains(bm.cancel, x, y)) return {HoverKind::kBrowseCancel, -1};
|
||||
if (contains(bm.confirm, x, y)) return {HoverKind::kBrowseConfirm, -1};
|
||||
if (contains(bm.search, x, y)) return {HoverKind::kSearchBox, -1};
|
||||
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
const int bx = x - bm.content.x;
|
||||
const int by = y - bm.content.y;
|
||||
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||
if (tab >= 0) return {HoverKind::kFilterTab, tab};
|
||||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (card >= 0) return {HoverKind::kCard, card};
|
||||
return {};
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
|
||||
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A
|
||||
// positive delta (wheel up) scrolls toward the top (smaller offset).
|
||||
if (view_ != View::kBrowse) return;
|
||||
const int rows = delta / 120;
|
||||
if (rows == 0) return;
|
||||
scrollOffset_ -= rows * kBrowserCardHeight;
|
||||
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
// The curve popup: Esc dismisses (checked first — the popup is modal over the face, and
|
||||
// the Browse search cannot hold focus under it).
|
||||
if (curvePopupOpen_ && ch == 27) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Type-to-filter search. Only when the search box has focus (a click focuses it).
|
||||
// Backspace deletes; a printable ASCII char appends; the visible list recomposes (bank
|
||||
// filter, then search).
|
||||
if (view_ != View::kBrowse || !searchFocused_) return;
|
||||
if (ch == 8) { // backspace
|
||||
if (!searchQuery_.empty()) searchQuery_.pop_back();
|
||||
} else if (ch == 27) { // escape clears + defocuses
|
||||
searchQuery_.clear();
|
||||
searchFocused_ = false;
|
||||
} else if (ch >= 32 && ch < 127) {
|
||||
searchQuery_.push_back(static_cast<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) {
|
||||
// The instrument is a read-only bank consumer and the cross-artifact ingest relay (editor
|
||||
// drop -> extension) is not shipped, so we do not ingest the dropped files and — load-
|
||||
// bearing — never insert a timeline item. Instead of silently swallowing the drop, flash a
|
||||
// clear affordance pointing at the shipped ingest gesture. dropHintTicks_ counts sync ticks
|
||||
// (kSyncTimerIntervalMs each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer
|
||||
// decays it to 0.
|
||||
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
|
||||
dropHintTicks_ = 6;
|
||||
invalidate();
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -1,437 +0,0 @@
|
||||
// editor_input_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
|
||||
// input + the hover resolver: hover resolution across all three faces, the Browse picker's
|
||||
// click branch (tabs, cards, select-then-confirm, scroll-thumb grab, search focus), the
|
||||
// Zone surface's click branch (add/delete, strip drags, numeric-entry focus, per-zone deck
|
||||
// + curve button), the browser wheel scroll, the type-to-filter / note-entry keystrokes,
|
||||
// and the degraded drop affordance. Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup (popup hover)
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
|
||||
#include "core/instrument/map/note_entry.h" // parseNoteEntry (numeric entry)
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect (popup node hover)
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
// Resolve the interactive element under (x, y) into hover_ and repaint only on change (an
|
||||
// idle move is free). Mirrors onMouseDown's hit-test order, but read-only. Windows-only.
|
||||
void ReaSamplerEditor::resolveHover(int x, int y) {
|
||||
HoverTarget h; // kNone by default
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int hgt = cr.bottom - cr.top;
|
||||
|
||||
if (view_ == View::kBrowse) {
|
||||
const BrowseModal bm = computeBrowseModal(w, hgt);
|
||||
if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1};
|
||||
else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1};
|
||||
else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1};
|
||||
else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1};
|
||||
else {
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
const int bx = x - bm.content.x;
|
||||
const int by = y - bm.content.y;
|
||||
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||
const int card = (tab >= 0)
|
||||
? -1
|
||||
: cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (tab >= 0) h = {HoverKind::kFilterTab, tab};
|
||||
else if (card >= 0) h = {HoverKind::kCard, card};
|
||||
}
|
||||
} else if (curvePopupOpen_) { // the curve popup — modal over Sample and Zone
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, hgt);
|
||||
if (contains(pl.close, x, y)) {
|
||||
h = {HoverKind::kPopupClose, -1};
|
||||
} else if (contains(pl.curveBox, x, y)) {
|
||||
// A curve node under the pointer lights accent-hot.
|
||||
const int idx =
|
||||
popupZone().velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
|
||||
if (idx >= 0) h = {HoverKind::kCurveNode, idx};
|
||||
}
|
||||
} else if (view_ == View::kZone) {
|
||||
const Rect back = zoneBackRect(w, hgt);
|
||||
const Rect content = zoneContentArea(w, hgt);
|
||||
Rect addR = zoneAddRect(content);
|
||||
Rect delR = zoneDeleteRect(addR);
|
||||
if (contains(back, x, y)) {
|
||||
h = {HoverKind::kBack, -1};
|
||||
} else if (contains(addR, x, y)) {
|
||||
h = {HoverKind::kAddZone, -1};
|
||||
} else if (selectedZone_ >= 0 && contains(delR, x, y)) {
|
||||
h = {HoverKind::kDeleteZone, -1};
|
||||
} else if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
// The per-zone knob deck + the mini curve-preview button (the Sample deck's hover
|
||||
// grammar — knobs light + swap label->value).
|
||||
if (contains(zonesCurveButton(content), x, y)) {
|
||||
h = {HoverKind::kCurveButton, -1};
|
||||
} else {
|
||||
const ZonePlaySeconds& play =
|
||||
map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||||
const Rect deckArea = zonesDeckArea(content);
|
||||
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x,
|
||||
deckArea.y, deckArea.width);
|
||||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||||
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
|
||||
}
|
||||
}
|
||||
} else { // Sample view (home)
|
||||
const PerformanceZone zone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> descs = deckGroupDescs(zone.play);
|
||||
const SampleBands bands =
|
||||
computeSampleBands(w, hgt, deckHeight(descs, w - 2 * kPad));
|
||||
if (contains(bands.navBrowse, x, y)) {
|
||||
h = {HoverKind::kNavBrowse, -1};
|
||||
} else if (contains(bands.navZone, x, y)) {
|
||||
h = {HoverKind::kNavZone, -1};
|
||||
} else if (selectedId_.empty() && map_.zones.empty()) {
|
||||
// Empty state — no interactive surfaces beyond the nav.
|
||||
} else {
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
|
||||
if (contains(cr.preview, x, y)) h = {HoverKind::kPreview, -1};
|
||||
else if (contains(cr.velCell, x, y)) h = {HoverKind::kVelKnob, -1};
|
||||
else if (contains(cr.curveBtn, x, y)) h = {HoverKind::kCurveButton, -1};
|
||||
else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1};
|
||||
else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1};
|
||||
else if (contains(bands.deck, x, y)) {
|
||||
// A deck knob/toggle under the pointer: knobs light + swap label->value.
|
||||
const DeckLayout dl =
|
||||
layoutDeck(descs, bands.deck.x, bands.deck.y, bands.deck.width);
|
||||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||||
if (dh.kind != DeckHitKind::None) h = {HoverKind::kControl, dh.id};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (h != hover_) {
|
||||
hover_ = h;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
// The Browse-modal branch of the mouse-down dispatch (see editor_input_sample.cpp for the
|
||||
// dispatch).
|
||||
void ReaSamplerEditor::mouseDownBrowse(int w, int h, int x, int y) {
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) {
|
||||
// Cancel/Back: discard the pending pick, return to Sample unchanged.
|
||||
browsePendingId_.clear();
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bm.confirm, x, y)) {
|
||||
// Load: commit the pending pick (if any) into the loaded selection + reload, then Sample.
|
||||
if (!browsePendingId_.empty()) {
|
||||
loadSelection(browsePendingId_);
|
||||
}
|
||||
browsePendingId_.clear();
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; }
|
||||
searchFocused_ = false;
|
||||
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
const int bx = x - bm.content.x;
|
||||
const int by = y - bm.content.y;
|
||||
const int tabCount = static_cast<int>(banks_.size()) + 1;
|
||||
const int tab = filterTabHitTest(bl, tabCount, bx, by);
|
||||
if (tab >= 0) {
|
||||
activeFilterBankId_ = (tab == 0) ? std::string()
|
||||
: banks_[static_cast<std::size_t>(tab - 1)].id;
|
||||
rebuildVisible();
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const Rect thumb = scrollThumbRect(bl, static_cast<int>(visible_.size()), scrollOffset_);
|
||||
if (thumb.height > 0 &&
|
||||
contains(Rect::ltrb(thumb.x + bm.content.x, thumb.y + bm.content.y,
|
||||
thumb.right() + bm.content.x, thumb.bottom() + bm.content.y), x, y)) {
|
||||
drag_ = DragKind::kScrollThumb;
|
||||
dragStartY_ = y;
|
||||
dragStartScrollOffset_ = scrollOffset_;
|
||||
return;
|
||||
}
|
||||
const int card = cardHitTest(bl, static_cast<int>(visible_.size()), bx, by + scrollOffset_);
|
||||
if (card >= 0) {
|
||||
// Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card
|
||||
// is the load accelerator (commit + dismiss). Browse never loads on a single click.
|
||||
const std::string id = visible_[static_cast<std::size_t>(card)].id;
|
||||
if (lastBrowseClickCard_ == card && browsePendingId_ == id) {
|
||||
loadSelection(id);
|
||||
browsePendingId_.clear();
|
||||
lastBrowseClickCard_ = -1;
|
||||
searchFocused_ = false;
|
||||
view_ = View::kSample;
|
||||
invalidate();
|
||||
} else {
|
||||
browsePendingId_ = id;
|
||||
lastBrowseClickCard_ = card;
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
lastBrowseClickCard_ = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
// The Zone-surface branch of the mouse-down dispatch (the curve popup is modal over the
|
||||
// Zone surface too).
|
||||
void ReaSamplerEditor::mouseDownZone(int w, int h, int x, int y) {
|
||||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||||
const Rect back = zoneBackRect(w, h);
|
||||
if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; }
|
||||
const Rect content = zoneContentArea(w, h);
|
||||
Rect addR = zoneAddRect(content);
|
||||
if (contains(addR, x, y)) {
|
||||
// Add a narrow default zone for the picked capture (or the first visible sample as a
|
||||
// sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id
|
||||
// already exists, select it rather than appending a duplicate (mirrors the upsert the
|
||||
// root-marker drag path already performs). Narrow default: seed [root-6, root+5] (one
|
||||
// octave centred on the bank root, clamped to [0,127]) so the new zone is immediately
|
||||
// "authored" (narrow) and survives reconcileSingleCaptureZones without being treated
|
||||
// as a Sample-face full-range zone.
|
||||
std::string seed = !selectedId_.empty() ? selectedId_
|
||||
: (!visible_.empty() ? visible_.front().id : std::string());
|
||||
if (seed.empty()) return;
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
if (z.sampleId == seed && z.lowNote == 0 && z.highNote == 127) {
|
||||
selectedZone_ = i;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Look up the seed's root note from the browser list (absent root defaults to 60).
|
||||
int seedRoot = 60;
|
||||
for (const SampleChoice& sc : samples_) {
|
||||
if (sc.id == seed) { if (sc.rootNote.has_value()) seedRoot = *sc.rootNote; break; }
|
||||
}
|
||||
const int lo = (std::max)(0, seedRoot - 6);
|
||||
const int hi = (std::min)(127, seedRoot + 5);
|
||||
PerformanceZone z;
|
||||
z.sampleId = seed;
|
||||
z.lowNote = lo;
|
||||
z.highNote = hi;
|
||||
map_.zones.push_back(z);
|
||||
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
|
||||
commitAndReload();
|
||||
return;
|
||||
}
|
||||
Rect delR = zoneDeleteRect(addR);
|
||||
if (selectedZone_ >= 0 && contains(delR, x, y)) {
|
||||
map_.zones.erase(map_.zones.begin() + selectedZone_);
|
||||
selectedZone_ = -1;
|
||||
commitAndReload();
|
||||
return;
|
||||
}
|
||||
|
||||
// The zones strip: hit-test a bar edge/body to start a drag, or a bare key to set the
|
||||
// selected zone's root.
|
||||
const Rect stripArea = zonesStripArea(content);
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
const int lx = x - stripArea.x;
|
||||
const int ly = y - stripArea.y;
|
||||
|
||||
std::vector<int> lows, highs;
|
||||
lows.reserve(map_.zones.size());
|
||||
highs.reserve(map_.zones.size());
|
||||
for (const PerformanceZone& z : map_.zones) { lows.push_back(z.lowNote); highs.push_back(z.highNote); }
|
||||
const ZoneBarHit hit = zoneBarAtPoint(sl, lows.empty() ? nullptr : lows.data(),
|
||||
highs.empty() ? nullptr : highs.data(),
|
||||
static_cast<int>(map_.zones.size()), lx, ly);
|
||||
if (hit.zoneIndex >= 0) {
|
||||
selectedZone_ = hit.zoneIndex;
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(hit.zoneIndex)];
|
||||
dragStartX_ = x;
|
||||
dragStartLow_ = z.lowNote;
|
||||
dragStartHigh_ = z.highNote;
|
||||
dragStartMap_ = map_;
|
||||
switch (hit.grab) {
|
||||
case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break;
|
||||
case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break;
|
||||
case ZoneGrab::kBody: drag_ = DragKind::kZoneBody; break;
|
||||
default: drag_ = DragKind::kNone; break;
|
||||
}
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// A bare key-click inside the strip sets the selected zone's root override.
|
||||
if (contains(stripArea, x, y) && selectedZone_ >= 0 &&
|
||||
selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const int note = keyAtPoint(sl, lx, ly);
|
||||
if (note >= 0) {
|
||||
map_.zones[static_cast<std::size_t>(selectedZone_)].rootOverride = note;
|
||||
commitAndReload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a
|
||||
// zone is selected. entryText_ starts empty (the user types the full value).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
if (contains(noteEntryFieldRect(fields, f), x, y)) {
|
||||
entryField_ = f;
|
||||
entryText_.clear();
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry
|
||||
|
||||
// The per-zone param surface: the knob deck + the mini curve-preview button — the same
|
||||
// grammar and hit-test machinery as the Sample face. Only when a zone is selected (the
|
||||
// Zone surface has no single-capture fallback — that lives on the Sample face).
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
if (contains(zonesCurveButton(content), x, y)) {
|
||||
curvePopupOpen_ = true;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
const ZonePlaySeconds& play = map_.zones[static_cast<std::size_t>(selectedZone_)].play;
|
||||
const Rect deckArea = zonesDeckArea(content);
|
||||
const DeckLayout dl = layoutDeck(zoneDeckGroupDescs(play), deckArea.x, deckArea.y,
|
||||
deckArea.width);
|
||||
const DeckHit hit = hitTestDeck(dl, x, y);
|
||||
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
|
||||
// Zone-param toggles (play mode / pitch engine / pitch-env enable): a discrete,
|
||||
// final edit committed at once (the deck precedent). No per-instance ids reach
|
||||
// here — VOICE/MASTER are not in the zone group set.
|
||||
applyZoneControl(selectedZone_, hit.id, 0.0, hit.segment);
|
||||
commitAndReload();
|
||||
return;
|
||||
}
|
||||
if (hit.kind == DeckHitKind::Knob) {
|
||||
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off — the
|
||||
// Sample deck's guard, mirrored.
|
||||
const bool pitchEnvKnob =
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||||
if (pitchEnvKnob && !play.pitchEnv.enabled) return;
|
||||
// Grab-anchored vertical drag: live-drag the map, commit on release.
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragParamZone_ = selectedZone_;
|
||||
dragStartMap_ = map_;
|
||||
dragKnobStartValue_ = deckControlNorm(
|
||||
hit.id, map_.zones[static_cast<std::size_t>(selectedZone_)]);
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseWheel(int delta) {
|
||||
// Browser scroll (only in the Browse modal — the sole card grid). One wheel notch
|
||||
// (WHEEL_DELTA==120) scrolls roughly one card row; the offset is clamped at paint. A
|
||||
// positive delta (wheel up) scrolls toward the top (smaller offset).
|
||||
if (view_ != View::kBrowse) return;
|
||||
const int rows = delta / 120;
|
||||
if (rows == 0) return;
|
||||
scrollOffset_ -= rows * kBrowserCardHeight;
|
||||
if (scrollOffset_ < 0) scrollOffset_ = 0; // paint clamps the upper bound to the content
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onSearchChar(unsigned int ch) {
|
||||
// The curve popup: Esc dismisses (checked first — the popup is modal over the Sample face
|
||||
// or the Zone surface; opening it clears any note-entry focus, and the Browse search
|
||||
// cannot hold focus under it).
|
||||
if (curvePopupOpen_ && ch == 27) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes
|
||||
// and commits via parseNoteEntry on Enter. Handled before the search box (a field, when
|
||||
// focused, owns the keystrokes).
|
||||
if (view_ == View::kZone && entryField_ >= 0) {
|
||||
if (ch == 13) { // Enter: parse + commit
|
||||
if (auto note = parseNoteEntry(entryText_)) {
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<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;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// The instrument is a read-only bank consumer and the cross-artifact ingest relay (editor
|
||||
// drop -> extension) is not shipped, so we do not ingest the dropped files and — load-
|
||||
// bearing — never insert a timeline item. Instead of silently swallowing the drop, flash a
|
||||
// clear affordance pointing at the shipped ingest gesture. dropHintTicks_ counts sync ticks
|
||||
// (kSyncTimerIntervalMs each); ~6 ticks keeps the banner up a few seconds, then onSyncTimer
|
||||
// decays it to 0.
|
||||
(void)droppedCount; // count is informational; the banner text is drop-count-agnostic
|
||||
dropHintTicks_ = 6;
|
||||
#ifdef _WIN32
|
||||
invalidate();
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,115 @@
|
||||
// editor_input_chrome.cpp — the CHROME band's input: the Browse nav, the preview trigger,
|
||||
// the preview-velocity knob grab, the curve-button summon, the channel toggle, and the
|
||||
// root-marker grab plus its live drag. Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "core/instrument/ui/keyboard_strip.h" // keyAtPoint / resolveDragNote (root marker)
|
||||
#include "shell/instrument/editor_internal.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
bool ReaSamplerEditor::mouseDownChrome(const FaceLayout& fl, int x, int y) {
|
||||
const ChromeRects& cr = fl.chrome;
|
||||
|
||||
if (contains(cr.navBrowse, x, y)) {
|
||||
// Open the Browse modal; seed its pending pick from the loaded id so the current
|
||||
// capture reads as pre-selected.
|
||||
browsePendingId_ = selectedId_;
|
||||
lastBrowseClickCard_ = -1;
|
||||
view_ = View::kBrowse;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (selectedId_.empty()) return false; // empty state — nav only
|
||||
|
||||
// Preview-trigger button: fire the loaded capture at its root through the voice engine
|
||||
// (momentary — note-on on press, note-off on release).
|
||||
if (contains(cr.preview, x, y)) {
|
||||
const int note = effectiveRoot();
|
||||
if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_);
|
||||
previewingNote_ = note;
|
||||
processor_->previewNoteOn(note);
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
|
||||
// jumps the value; the delta from the grab point maps via knobDragValue.
|
||||
if (contains(cr.velCell, x, y)) {
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
|
||||
dragKnobStartValue_ = previewVelocity01();
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
// The mini curve-preview button: summon the popup editor.
|
||||
if (contains(cr.curveBtn, x, y)) {
|
||||
curvePopupOpen_ = true;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (contains(cr.chanMono, x, y)) {
|
||||
channelMode_ = ChannelMode::Mono;
|
||||
processor_->setChannelMode(ChannelMode::Mono);
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (contains(cr.chanStereo, x, y)) {
|
||||
channelMode_ = ChannelMode::Stereo;
|
||||
processor_->setChannelMode(ChannelMode::Stereo);
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
|
||||
// The root strip: grab the root marker. A plain click sets the root to the clicked key
|
||||
// (applied below as the first delta==0 move).
|
||||
if (cr.rootStrip.width > 0) {
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y);
|
||||
if (note >= 0) {
|
||||
drag_ = DragKind::kRootMarker;
|
||||
dragStartX_ = x;
|
||||
dragStartRoot_ = note;
|
||||
dragStartParams_ = params_;
|
||||
onMouseMove(x, y); // apply the click as the first delta==0 set
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// A click on the control row's background is consumed so it can't fall through to a
|
||||
// band the user cannot see under the chrome.
|
||||
return contains(cr.controls, x, y);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::dragChrome(const FaceLayout& fl, int x, int y) {
|
||||
(void)y;
|
||||
const Rect& stripArea = fl.chrome.rootStrip;
|
||||
if (stripArea.width <= 0) return;
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
params_.rootOverride = resolveDragNote(sl, dragStartRoot_, x - dragStartX_);
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
}
|
||||
|
||||
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverChrome(const FaceLayout& fl, int x,
|
||||
int y) const {
|
||||
const ChromeRects& cr = fl.chrome;
|
||||
if (contains(cr.navBrowse, x, y)) return {HoverKind::kNavBrowse, -1};
|
||||
if (selectedId_.empty()) return {}; // empty state — no interactive surfaces beyond nav
|
||||
if (contains(cr.preview, x, y)) return {HoverKind::kPreview, -1};
|
||||
if (contains(cr.velCell, x, y)) return {HoverKind::kVelKnob, -1};
|
||||
if (contains(cr.curveBtn, x, y)) return {HoverKind::kCurveButton, -1};
|
||||
if (contains(cr.chanMono, x, y)) return {HoverKind::kChanMono, -1};
|
||||
if (contains(cr.chanStereo, x, y)) return {HoverKind::kChanStereo, -1};
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,127 @@
|
||||
// editor_input_curve.cpp — the velocity-curve popup's input: the modal click routing,
|
||||
// node grab/add/Alt-delete inside the curve box, the live node drag, the right-click
|
||||
// delete, and the popup's hover. Band-independent (the sheet floats over the whole face).
|
||||
// Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
|
||||
// While open the sheet is modal over the face — it owns every left-click. Close click /
|
||||
// outside-wash click dismiss (outside only when no drag is in flight); in-box clicks
|
||||
// route to the curve machinery; anything else on the sheet is swallowed.
|
||||
if (!curvePopupOpen_) return false;
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
if (contains(pl.close, x, y)) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (contains(pl.curveBox, x, y)) {
|
||||
handleCurveMouseDown(pl.curveBox, x, y);
|
||||
return true;
|
||||
}
|
||||
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int x, int y) {
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
|
||||
int idx = params_.velocityCurve.pointAtPixel(box, x, y);
|
||||
|
||||
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
|
||||
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
|
||||
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
|
||||
if (params_.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) commitAndReload();
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot BEFORE any mutation so a capture-loss rollback also cancels an in-flight ADD
|
||||
// (mirror of the other parameter-editing drags' dragStartParams_ contract).
|
||||
dragStartParams_ = params_;
|
||||
|
||||
// Empty-space click inside the mapping box: add a control point via the pure inverse map,
|
||||
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a
|
||||
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an
|
||||
// endpoint. A ring click can still grab an existing node (handled above).
|
||||
if (idx < 0) {
|
||||
const bool inBox = (x >= box.left && x < box.left + box.width &&
|
||||
y >= box.top && y < box.top + box.height);
|
||||
if (inBox) {
|
||||
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
|
||||
idx = static_cast<int>(params_.velocityCurve.addPoint(p.velocity, p.amp));
|
||||
}
|
||||
}
|
||||
|
||||
if (idx < 0) return; // ring click with no node hit — nothing to grab
|
||||
|
||||
drag_ = DragKind::kCurveNode;
|
||||
curvePointIndex_ = idx;
|
||||
dragStartCurve_ = params_.velocityCurve; // AFTER the add — resolvePointDrag's delta base
|
||||
dragCurveRect_ = r;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::dragCurve(int x, int y) {
|
||||
// Resolve the grabbed control point from the pixel delta through the pure inverse map
|
||||
// (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box (absolute
|
||||
// delta — the mirror of the envelope-node drag). Live feedback only; commit on release.
|
||||
if (curvePointIndex_ < 0) return;
|
||||
params_.velocityCurve = VelocityCurve::resolvePointDrag(
|
||||
dragStartCurve_, static_cast<std::size_t>(curvePointIndex_),
|
||||
curveBoxFromRect(dragCurveRect_), x - dragStartX_, y - dragStartY_);
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseRDown(int x, int y) {
|
||||
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click
|
||||
// and drag-off remain as landed alternates. Commits immediately through the same path as
|
||||
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
|
||||
// Right-clicks act only while the popup is open, and never during an in-flight left drag.
|
||||
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
|
||||
if (drag_ != DragKind::kNone) return;
|
||||
RECT rc{};
|
||||
GetClientRect(childHwnd_, &rc);
|
||||
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
||||
if (!contains(pl.curveBox, x, y)) return;
|
||||
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
|
||||
const int idx = params_.velocityCurve.pointAtPixel(box, x, y);
|
||||
if (idx < 0) return;
|
||||
if (params_.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||||
commitAndReload();
|
||||
}
|
||||
}
|
||||
|
||||
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverCurvePopup(int w, int h, int x,
|
||||
int y) const {
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
if (contains(pl.close, x, y)) return {HoverKind::kPopupClose, -1};
|
||||
if (!contains(pl.curveBox, x, y)) return {};
|
||||
// A curve node under the pointer lights accent-hot.
|
||||
const int idx =
|
||||
params_.velocityCurve.pointAtPixel(curveBoxFromRect(pl.curveBox), x, y);
|
||||
if (idx < 0) return {};
|
||||
return {HoverKind::kCurveNode, idx};
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,99 @@
|
||||
// editor_input_deck.cpp — the DECKS band's input: toggles (committed at once, a discrete
|
||||
// final edit), knob grabs (grab-anchored vertical drag, committed on release), the live
|
||||
// knob-drag resolution, and the band's hover. Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / layoutDeck
|
||||
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
|
||||
#include "shell/instrument/editor_internal.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
bool ReaSamplerEditor::mouseDownDeck(const FaceLayout& fl, int x, int y) {
|
||||
const Rect& band = fl.bands.decks;
|
||||
if (!contains(band, x, y)) return false;
|
||||
|
||||
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
|
||||
const DeckHit hit = hitTestDeck(dl, x, y);
|
||||
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
|
||||
switch (static_cast<ParamControl>(hit.id)) {
|
||||
case ParamControl::kVoiceMode: {
|
||||
// Processor-side per-instance param: live setter (engine rebuild via the
|
||||
// drain-slot swap — tails survive), local snapshot in step.
|
||||
const VoiceMode m = (hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
||||
if (m != voiceMode_) {
|
||||
voiceMode_ = m;
|
||||
processor_->setVoiceMode(m);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
case ParamControl::kMonoTrigger: {
|
||||
if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly
|
||||
const MonoTrigger t =
|
||||
(hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
||||
if (t != monoTrigger_) {
|
||||
monoTrigger_ = t;
|
||||
processor_->setMonoTrigger(t);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Parameter-set toggles (play mode / pitch engine / pitch-env enable).
|
||||
applyParamControl(hit.id, 0.0, hit.segment);
|
||||
commitAndReload();
|
||||
break;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (hit.kind == DeckHitKind::Knob) {
|
||||
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off.
|
||||
const bool pitchEnvKnob =
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||||
if (pitchEnvKnob && !params_.play.pitchEnv.enabled) return true;
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragKnobStartValue_ = deckControlNorm(hit.id);
|
||||
// Processor-side knobs (voice count / master gain) are transient live writes with no
|
||||
// parameter-set mutation, so they need no rollback snapshot.
|
||||
dragStartParams_ = params_;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
}
|
||||
// The deck band swallows its own clicks either way — no fall-through to the waveform.
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::dragDeck(int x, int y) {
|
||||
// Radial knob: grab-anchored vertical drag — knobDragValue maps the y delta from the
|
||||
// value at grab (up = increase), so the value tracks relative motion and never jumps on
|
||||
// grab. Live feedback; parameter-set commits land on WM_LBUTTONUP.
|
||||
(void)x;
|
||||
applyDeckKnob(dragParamId_, knobDragValue(dragKnobStartValue_, y - dragStartY_));
|
||||
invalidate();
|
||||
}
|
||||
|
||||
ReaSamplerEditor::HoverTarget ReaSamplerEditor::hoverDeck(const FaceLayout& fl, int x,
|
||||
int y) const {
|
||||
const Rect& band = fl.bands.decks;
|
||||
if (!contains(band, x, y)) return {};
|
||||
const DeckLayout dl = layoutDeck(fl.deckDescs, band.x, band.y, band.width);
|
||||
const DeckHit dh = hitTestDeck(dl, x, y);
|
||||
if (dh.kind == DeckHitKind::None) return {};
|
||||
return {HoverKind::kControl, dh.id};
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -1,583 +0,0 @@
|
||||
// editor_input_sample.cpp — the ReaSamplerEditor's sample-face input + the drag-state
|
||||
// machine: the mouse-down dispatch (the Sample-face branch inline; Browse/Zone branches
|
||||
// delegate to editor_input_browse_zone), the curve-popup/curve-box click machinery, the
|
||||
// live drag resolution (onMouseMove — deck knobs, root marker, envelope nodes, curve
|
||||
// nodes, wave markers, scroll thumb, zone edges), the release commit (onMouseUp), and the
|
||||
// popup right-click delete. Windows-only. All hit-test math is pure; this TU routes and
|
||||
// mutates editor state only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + thumbDragToOffset (scroll drag)
|
||||
#include "core/instrument/ui/curve_popup.h" // computeCurvePopup / popupOutsideSheet
|
||||
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
|
||||
#include "core/instrument/ui/knob_deck.h" // hitTestDeck / kDeckKnobSize
|
||||
#include "core/instrument/ui/param_slider.h" // knobDragValue (grab-anchored drag)
|
||||
#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap
|
||||
#include "shell/instrument/editor_internal.h" // curveBoxFromRect + kCurveDragOffMargin
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
using namespace reasampler::instrument::map;
|
||||
|
||||
bool ReaSamplerEditor::handlePopupMouseDown(int w, int h, int x, int y) {
|
||||
// The curve popup: while open the sheet is modal over its host face — the Sample home or
|
||||
// the Zone surface — it owns every left-click. Close click / outside-wash click dismiss
|
||||
// (outside only when no drag is in flight); in-box clicks route to the shared curve
|
||||
// machinery against popupZoneIndex(); anything else on the sheet is swallowed.
|
||||
if (!curvePopupOpen_) return false;
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
if (contains(pl.close, x, y)) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
return true;
|
||||
}
|
||||
if (contains(pl.curveBox, x, y)) {
|
||||
const int zi = popupZoneIndex();
|
||||
if (zi >= 0) handleCurveMouseDown(pl.curveBox, zi, x, y);
|
||||
return true;
|
||||
}
|
||||
if (popupOutsideSheet(pl, x, y) && drag_ == DragKind::kNone) {
|
||||
curvePopupOpen_ = false;
|
||||
invalidate();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y) {
|
||||
if (zoneIndex < 0 || zoneIndex >= static_cast<int>(map_.zones.size())) return;
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zoneIndex)];
|
||||
|
||||
int idx = z.velocityCurve.pointAtPixel(box, x, y);
|
||||
|
||||
// Modifier-click (Alt) deletes an interior node — a discrete, final edit committed at once
|
||||
// (deletePoint refuses the two endpoints, so an Alt-click on them is a safe no-op).
|
||||
if (idx >= 0 && (GetKeyState(VK_MENU) & 0x8000) != 0) {
|
||||
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||||
selectedZone_ = zoneIndex;
|
||||
commitAndReload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the map BEFORE any mutation so a capture-loss rollback also cancels an in-flight
|
||||
// ADD (mirror of the other map-editing drags' dragStartMap_ contract).
|
||||
dragStartMap_ = map_;
|
||||
|
||||
// Empty-space click inside the mapping box: add a control point via the pure inverse map,
|
||||
// then grab it. Box-gated (not just contains(r,x,y)) because the inset ring must not add a
|
||||
// point — it would clamp to velocity 0/127, stacking an undeletable duplicate on an endpoint.
|
||||
// A ring click can still grab an existing node (handled above); only add is box-gated.
|
||||
if (idx < 0) {
|
||||
const bool inBox = (x >= box.left && x < box.left + box.width &&
|
||||
y >= box.top && y < box.top + box.height);
|
||||
if (inBox) {
|
||||
const VelocityPoint p = VelocityCurve::pointFromPixel(box, x, y);
|
||||
idx = static_cast<int>(z.velocityCurve.addPoint(p.velocity, p.amp));
|
||||
}
|
||||
}
|
||||
|
||||
if (idx < 0) return; // ring click with no node hit — nothing to grab
|
||||
|
||||
drag_ = DragKind::kCurveNode;
|
||||
curvePointIndex_ = idx;
|
||||
dragStartCurve_ = z.velocityCurve; // AFTER the add — resolvePointDrag's absolute-delta base
|
||||
dragCurveRect_ = r;
|
||||
dragCurveZone_ = zoneIndex;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
selectedZone_ = zoneIndex;
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
}
|
||||
|
||||
// --- Input: the drag-state machine -------------------------------------------
|
||||
|
||||
void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
if (!processor_) return;
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
|
||||
// Browse modal: the face branch lives in editor_input_browse_zone.
|
||||
if (view_ == View::kBrowse) {
|
||||
mouseDownBrowse(w, h, x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sample home.
|
||||
if (view_ == View::kSample) {
|
||||
// The curve popup: while open the sheet is modal — it owns every left-click.
|
||||
if (handlePopupMouseDown(w, h, x, y)) return;
|
||||
|
||||
const PerformanceZone probeZone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(probeZone.play);
|
||||
const SampleBands bands =
|
||||
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
if (contains(bands.navBrowse, x, y)) {
|
||||
// Open the Browse modal; seed its pending pick from the loaded id so the current
|
||||
// capture reads as pre-selected.
|
||||
browsePendingId_ = selectedId_;
|
||||
lastBrowseClickCard_ = -1;
|
||||
view_ = View::kBrowse;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; }
|
||||
if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only
|
||||
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
|
||||
|
||||
// Preview-trigger button: fire the loaded capture at its root through the voice engine
|
||||
// (momentary — note-on on press, note-off on release).
|
||||
if (contains(cr.preview, x, y)) {
|
||||
const int note = effectiveRoot();
|
||||
if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_);
|
||||
previewingNote_ = note;
|
||||
processor_->previewNoteOn(note);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Radial preview-velocity knob: grab-anchored vertical drag — the grab itself never
|
||||
// jumps the value; the delta from the grab point maps via knobDragValue.
|
||||
if (contains(cr.velCell, x, y)) {
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = -2; // sentinel: the preview velocity knob (a processor param)
|
||||
dragParamZone_ = -1;
|
||||
dragKnobStartValue_ = previewVelocity01();
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// The mini curve-preview button: summon the popup editor.
|
||||
if (contains(cr.curveBtn, x, y)) {
|
||||
curvePopupOpen_ = true;
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Channel toggle.
|
||||
if (contains(chan.mono, x, y)) {
|
||||
channelMode_ = ChannelMode::Mono;
|
||||
processor_->setChannelMode(ChannelMode::Mono);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
if (contains(chan.stereo, x, y)) {
|
||||
channelMode_ = ChannelMode::Stereo;
|
||||
processor_->setChannelMode(ChannelMode::Stereo);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// The knob deck: toggles commit at once (a discrete, final edit); knobs start a
|
||||
// grab-anchored vertical drag. The deck band swallows its clicks (no fall-through to
|
||||
// the hero/markers).
|
||||
if (contains(bands.deck, x, y)) {
|
||||
const DeckLayout dl = layoutDeck(deckDescs, bands.deck.x, bands.deck.y,
|
||||
bands.deck.width);
|
||||
const DeckHit hit = hitTestDeck(dl, x, y);
|
||||
if (hit.kind == DeckHitKind::CaptionToggle || hit.kind == DeckHitKind::RowToggle) {
|
||||
switch (static_cast<ParamControl>(hit.id)) {
|
||||
case ParamControl::kVoiceMode: {
|
||||
// Processor-side per-instance param: live setter (engine rebuild via
|
||||
// the drain-slot swap — tails survive), local snapshot in step.
|
||||
const VoiceMode m =
|
||||
(hit.segment == 1) ? VoiceMode::Mono : VoiceMode::Poly;
|
||||
if (m != voiceMode_) {
|
||||
voiceMode_ = m;
|
||||
processor_->setVoiceMode(m);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
case ParamControl::kMonoTrigger: {
|
||||
if (voiceMode_ != VoiceMode::Mono) break; // Disabled (inert) in Poly
|
||||
const MonoTrigger t =
|
||||
(hit.segment == 1) ? MonoTrigger::Legato : MonoTrigger::Retrigger;
|
||||
if (t != monoTrigger_) {
|
||||
monoTrigger_ = t;
|
||||
processor_->setMonoTrigger(t);
|
||||
}
|
||||
invalidate();
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Zone-param toggles (play mode / pitch engine / pitch-env enable):
|
||||
// materialize the one-zone site, apply, commit.
|
||||
const int zi = ensureSampleZone();
|
||||
if (zi >= 0) {
|
||||
applyZoneControl(zi, hit.id, 0.0, hit.segment);
|
||||
selectedZone_ = zi;
|
||||
commitAndReload();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (hit.kind == DeckHitKind::Knob) {
|
||||
// PITCH ENV knobs are Disabled (drawn, inert) while the envelope is off.
|
||||
const bool pitchEnvKnob =
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvAttack) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDecay) ||
|
||||
hit.id == static_cast<int>(ParamControl::kPitchEnvDepth);
|
||||
if (pitchEnvKnob && !probeZone.play.pitchEnv.enabled) return;
|
||||
if (hit.id == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
hit.id == static_cast<int>(ParamControl::kMasterGain)) {
|
||||
// Processor-side knobs: transient live writes, no map edit, no reload.
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragParamZone_ = -1;
|
||||
dragKnobStartValue_ = deckControlNorm(hit.id, probeZone);
|
||||
} else {
|
||||
// Zone-param knobs: live-drag the map, commit on release.
|
||||
const int zi = ensureSampleZone();
|
||||
if (zi < 0) return;
|
||||
drag_ = DragKind::kDeckKnob;
|
||||
dragParamId_ = hit.id;
|
||||
dragParamZone_ = zi;
|
||||
selectedZone_ = zi;
|
||||
dragStartMap_ = map_;
|
||||
dragKnobStartValue_ =
|
||||
deckControlNorm(hit.id, map_.zones[static_cast<std::size_t>(zi)]);
|
||||
}
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
invalidate();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Hero waveform: envelope nodes first, then the wave markers.
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
const Rect waveArea = bands.hero;
|
||||
if (frames > 0) {
|
||||
const double rate = liveSampleRate();
|
||||
if (rate > 0.0) {
|
||||
const PerformanceZone zone = effectiveSampleZone();
|
||||
const std::int64_t startFrame = zone.startPoint.value_or(0);
|
||||
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y);
|
||||
if (nh.hit) {
|
||||
drag_ = DragKind::kEnvNode;
|
||||
envNode_ = nh.node;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
dragStartEnv_ = env;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartFrame_ = startFrame;
|
||||
dragStartMap_ = map_;
|
||||
return; // node moves once the cursor drags
|
||||
}
|
||||
}
|
||||
const SetupMarkers m = pickedMarkers(frames);
|
||||
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||
const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y);
|
||||
if (hit >= 0) {
|
||||
drag_ = DragKind::kWaveMarker;
|
||||
waveMarker_ = static_cast<WaveMarker>(hit);
|
||||
dragStartX_ = x;
|
||||
dragStartMarkers_ = m;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartMap_ = map_;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fenced root strip: grab the root marker (remainder-width).
|
||||
if (cr.rootStrip.width > 0) {
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
const int note = keyAtPoint(sl, x - cr.rootStrip.x, y - cr.rootStrip.y);
|
||||
if (note >= 0) {
|
||||
drag_ = DragKind::kRootMarker;
|
||||
dragStartX_ = x;
|
||||
dragStartRoot_ = note;
|
||||
dragStartMap_ = map_;
|
||||
onMouseMove(x, y); // apply the click as the first delta==0 set
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Zone surface: the face branch lives in editor_input_browse_zone.
|
||||
mouseDownZone(w, h, x, y);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseMove(int x, int y) {
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
dragCurX_ = x; // keep the live cursor position for drag-state draw cues (e.g. drag-off warn)
|
||||
dragCurY_ = y;
|
||||
RECT rc{};
|
||||
GetClientRect(childHwnd_, &rc);
|
||||
const int w = rc.right - rc.left;
|
||||
const int h = rc.bottom - rc.top;
|
||||
const int dx = x - dragStartX_;
|
||||
|
||||
if (drag_ == DragKind::kDeckKnob) {
|
||||
// Radial knob: grab-anchored vertical drag — knobDragValue maps the y delta from the
|
||||
// value at grab (up = increase), so the value tracks relative motion and never jumps
|
||||
// on grab. Live feedback; zone-param commits land on WM_LBUTTONUP.
|
||||
const int dy = y - dragStartY_;
|
||||
applyDeckKnob(dragParamZone_, dragParamId_, knobDragValue(dragKnobStartValue_, dy));
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// The Sample bands derive from the deck height (mode-independent width math). Hoisted
|
||||
// below the kDeckKnob early-return — that branch uses neither deckDescs nor bands.
|
||||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(effectiveSampleZone().play);
|
||||
const SampleBands bands = computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
|
||||
if (drag_ == DragKind::kRootMarker) {
|
||||
// The fenced root strip on the Sample cluster band. Setting the root materializes a
|
||||
// full-keyboard zone carrying the override on the picked id — upsert by id so a
|
||||
// repeated drag edits the same zone rather than stacking duplicates.
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const Rect stripArea = clusterRects(bands.cluster, chan.mono, kDeckKnobSize).rootStrip;
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
const int note = resolveDragNote(sl, dragStartRoot_, dx);
|
||||
bool found = false;
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
if (z.sampleId == selectedId_) {
|
||||
z.rootOverride = note;
|
||||
selectedZone_ = i;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
PerformanceZone z;
|
||||
z.sampleId = selectedId_;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
z.rootOverride = note;
|
||||
map_.zones.push_back(z);
|
||||
selectedZone_ = static_cast<int>(map_.zones.size()) - 1;
|
||||
}
|
||||
invalidate(); // live feedback; the commit lands on WM_LBUTTONUP
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kEnvNode) {
|
||||
// Resolve the grabbed envelope node's new params from the pixel delta (through the
|
||||
// pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the
|
||||
// picked id's one-zone play params. The AmpEnvelope was snapshotted at grab
|
||||
// (dragStartEnv_) so the delta is absolute. Materialize the zone if needed (mirror of
|
||||
// the marker path).
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
const double rate = liveSampleRate();
|
||||
if (frames <= 0 || rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const int dy = y - dragStartY_;
|
||||
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero,
|
||||
totalSeconds, envClampBounds(), dx, dy);
|
||||
const int zi = ensureSampleZone();
|
||||
if (zi >= 0) {
|
||||
unpackEnvelope(edited, frames, dragStartFrame_,
|
||||
map_.zones[static_cast<std::size_t>(zi)].play);
|
||||
selectedZone_ = zi;
|
||||
}
|
||||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kCurveNode) {
|
||||
// Resolve the grabbed control point from the pixel delta through the pure inverse map
|
||||
// (box + neighbour-X + endpoint-pin clamps), against the grab-time curve + box
|
||||
// (absolute delta — the mirror of the envelope-node drag). Live feedback only; the
|
||||
// commit lands on WM_LBUTTONUP.
|
||||
if (dragCurveZone_ < 0 || dragCurveZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
if (curvePointIndex_ < 0) return;
|
||||
const int dy = y - dragStartY_;
|
||||
map_.zones[static_cast<std::size_t>(dragCurveZone_)].velocityCurve =
|
||||
VelocityCurve::resolvePointDrag(dragStartCurve_,
|
||||
static_cast<std::size_t>(curvePointIndex_),
|
||||
curveBoxFromRect(dragCurveRect_), dx, dy);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (drag_ == DragKind::kWaveMarker) {
|
||||
// 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. 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) {
|
||||
// Map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag only
|
||||
// happens in the Browse modal (the sole card grid). The visible-card window recomputes
|
||||
// at paint from scrollOffset_.
|
||||
const int dyThumb = y - dragStartY_;
|
||||
const BrowseModal bm = computeBrowseModal(w, h);
|
||||
const BrowserLayout bl = layoutBrowser(bm.content.width, bm.content.height);
|
||||
scrollOffset_ = thumbDragToOffset(bl, static_cast<int>(visible_.size()),
|
||||
dragStartScrollOffset_, dyThumb);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
|
||||
// Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached
|
||||
// in the Zone surface where selectedZone_ is set + the strip lives under its content area.
|
||||
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
|
||||
const Rect stripArea = zonesStripArea(zoneContentArea(w, h));
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
if (drag_ == DragKind::kZoneLow) {
|
||||
z.lowNote = (std::min)(resolveDragNote(sl, dragStartLow_, dx), z.highNote);
|
||||
} else if (drag_ == DragKind::kZoneHigh) {
|
||||
z.highNote = (std::max)(resolveDragNote(sl, dragStartHigh_, dx), z.lowNote);
|
||||
} else if (drag_ == DragKind::kZoneBody) {
|
||||
// Move the whole span: apply the SAME delta to both edges so the span is preserved,
|
||||
// clamping so neither edge escapes [0,127] (the span shifts, never shrinks).
|
||||
const int newLow = resolveDragNote(sl, dragStartLow_, dx);
|
||||
const int newHigh = resolveDragNote(sl, dragStartHigh_, dx);
|
||||
const int span = dragStartHigh_ - dragStartLow_;
|
||||
if (newLow < 0) { z.lowNote = 0; z.highNote = span; }
|
||||
else if (newHigh > 127) { z.highNote = 127; z.lowNote = 127 - span; }
|
||||
else { z.lowNote = newLow; z.highNote = newHigh; }
|
||||
}
|
||||
invalidate();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseUp(int x, int y) {
|
||||
// Release a held preview note first (the preview button is a momentary key: note-off on up).
|
||||
// This runs regardless of drag state — the preview press does not start a drag.
|
||||
if (previewingNote_ >= 0) {
|
||||
if (processor_) processor_->previewNoteOff(previewingNote_);
|
||||
previewingNote_ = -1;
|
||||
invalidate();
|
||||
}
|
||||
if (drag_ == DragKind::kNone) return;
|
||||
const DragKind kind = drag_;
|
||||
const int paramId = dragParamId_;
|
||||
const int curveIdx = curvePointIndex_;
|
||||
const int curveZone = dragCurveZone_;
|
||||
const Rect curveRect = dragCurveRect_;
|
||||
drag_ = DragKind::kNone;
|
||||
dragParamId_ = -1;
|
||||
dragParamZone_ = -1;
|
||||
curvePointIndex_ = -1;
|
||||
dragCurveZone_ = -1;
|
||||
// A scrollbar drag is transient UI (no map change), and the processor-side knobs (the
|
||||
// preview-velocity -2 sentinel, voice count, master gain) are per-instance settings that
|
||||
// don't reload the instrument via the map path. Master gain is an atomic the audio thread
|
||||
// reads directly. Voice count: the label/needle tracks live during the drag but the engine
|
||||
// rebuild (setVoiceCount) fires ONCE here on release — not per integer step.
|
||||
const bool deckTransient =
|
||||
kind == DragKind::kDeckKnob &&
|
||||
(paramId == -2 || paramId == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
paramId == static_cast<int>(ParamControl::kMasterGain));
|
||||
if (kind == DragKind::kScrollThumb || deckTransient) {
|
||||
// Commit the voice count now that the drag is complete (one rebuild per full drag).
|
||||
if (deckTransient && processor_ &&
|
||||
paramId == static_cast<int>(ParamControl::kVoiceCount))
|
||||
processor_->setVoiceCount(voiceCount_);
|
||||
invalidate();
|
||||
return;
|
||||
}
|
||||
// Drag-off delete: releasing a curve-node drag well outside the box removes the dragged
|
||||
// point (deletePoint refuses the two endpoints, so an endpoint drag-off is a plain move —
|
||||
// its amp keeps the last clamped drag value).
|
||||
if (kind == DragKind::kCurveNode && curveIdx >= 0 && curveZone >= 0 &&
|
||||
curveZone < static_cast<int>(map_.zones.size())) {
|
||||
const bool off = x < curveRect.x - kCurveDragOffMargin ||
|
||||
x > curveRect.right() + kCurveDragOffMargin ||
|
||||
y < curveRect.y - kCurveDragOffMargin ||
|
||||
y > curveRect.bottom() + kCurveDragOffMargin;
|
||||
if (off) {
|
||||
map_.zones[static_cast<std::size_t>(curveZone)].velocityCurve.deletePoint(
|
||||
static_cast<std::size_t>(curveIdx));
|
||||
hover_ = HoverTarget{}; // stale kCurveNode index would light a shifted node on next paint
|
||||
}
|
||||
}
|
||||
commitAndReload();
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onMouseRDown(int x, int y) {
|
||||
// Right-click on a popup curve node deletes it — the primary delete affordance; Alt-click
|
||||
// and drag-off remain as landed alternates. Commits immediately through the same path as
|
||||
// Alt-click; deletePoint's endpoint guard makes an endpoint right-click a safe no-op.
|
||||
// Right-clicks act only while the popup is open — over the Sample face or the Zone
|
||||
// surface (nothing else in the editor consumes them) — and never during an in-flight left
|
||||
// drag.
|
||||
if (!processor_ || view_ == View::kBrowse || !curvePopupOpen_) return;
|
||||
if (drag_ != DragKind::kNone) return;
|
||||
RECT rc{};
|
||||
GetClientRect(childHwnd_, &rc);
|
||||
const CurvePopupLayout pl = computeCurvePopup(rc.right - rc.left, rc.bottom - rc.top);
|
||||
if (!contains(pl.curveBox, x, y)) return;
|
||||
// Hit-test first (read-only, via popupZone) so a right-click that lands between nodes
|
||||
// does not materialize an uncommitted zone in map_. Materialize only on an actual hit.
|
||||
const VelocityCurve::Box box = curveBoxFromRect(pl.curveBox);
|
||||
const int idx = popupZone().velocityCurve.pointAtPixel(box, x, y);
|
||||
if (idx < 0) return;
|
||||
const int zi = popupZoneIndex();
|
||||
if (zi < 0) return;
|
||||
PerformanceZone& z = map_.zones[static_cast<std::size_t>(zi)];
|
||||
if (z.velocityCurve.deletePoint(static_cast<std::size_t>(idx))) {
|
||||
selectedZone_ = zi;
|
||||
hover_ = HoverTarget{}; // a stale kCurveNode index would light a shifted node
|
||||
commitAndReload();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,124 @@
|
||||
// editor_input_waveform.cpp — the WAVEFORM band's input: grabbing an envelope node or a
|
||||
// start/loop marker, and resolving both drags live against the pure inverse maps
|
||||
// (envelope_edit, waveform_view). Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/envelope_edit.h" // nodeAtPoint / resolveNodeDrag
|
||||
#include "core/instrument/ui/waveform_view.h" // markerAtPoint / resolveDragFrame / snap
|
||||
#include "shell/instrument/editor_internal.h"
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui;
|
||||
using namespace reasampler::instrument::ui;
|
||||
|
||||
bool ReaSamplerEditor::mouseDownWaveform(const FaceLayout& fl, int x, int y) {
|
||||
const Rect& band = fl.bands.waveform;
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
if (frames <= 0) return false;
|
||||
|
||||
// Envelope nodes first (they sit on top of the markers), then the wave markers.
|
||||
const double rate = liveSampleRate();
|
||||
if (rate > 0.0) {
|
||||
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
||||
const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame);
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const NodeHit nh = nodeAtPoint(env, band, totalSeconds, x, y);
|
||||
if (nh.hit) {
|
||||
drag_ = DragKind::kEnvNode;
|
||||
envNode_ = nh.node;
|
||||
dragStartX_ = x;
|
||||
dragStartY_ = y;
|
||||
dragStartEnv_ = env;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartFrame_ = startFrame;
|
||||
dragStartParams_ = params_;
|
||||
return true; // node moves once the cursor drags
|
||||
}
|
||||
}
|
||||
const SetupMarkers m = pickedMarkers(frames);
|
||||
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||
const int hit = markerAtPoint(band, frames, markerFrames, 3, x, y);
|
||||
if (hit >= 0) {
|
||||
drag_ = DragKind::kWaveMarker;
|
||||
waveMarker_ = static_cast<WaveMarker>(hit);
|
||||
dragStartX_ = x;
|
||||
dragStartMarkers_ = m;
|
||||
dragSampleFrames_ = frames;
|
||||
dragStartParams_ = params_;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::dragWaveform(const FaceLayout& fl, int x, int y) {
|
||||
const Rect& band = fl.bands.waveform;
|
||||
const int dx = x - dragStartX_;
|
||||
|
||||
if (drag_ == DragKind::kEnvNode) {
|
||||
// Resolve the grabbed envelope node's new params from the pixel delta (through the
|
||||
// pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto
|
||||
// the parameter set. The AmpEnvelope was snapshotted at grab (dragStartEnv_) so the
|
||||
// delta is absolute.
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
const double rate = liveSampleRate();
|
||||
if (frames <= 0 || rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, band, totalSeconds,
|
||||
envClampBounds(), dx, y - dragStartY_);
|
||||
unpackEnvelope(edited, frames, dragStartFrame_, params_.play);
|
||||
invalidate(); // live feedback; commit on WM_LBUTTONUP
|
||||
return;
|
||||
}
|
||||
|
||||
// kWaveMarker: resolve the grabbed marker's new frame from the pixel delta,
|
||||
// zero-crossing-snap it against the decoded PCM, apply the inter-marker clamps, and write
|
||||
// the override live.
|
||||
const std::int64_t frames = dragSampleFrames_;
|
||||
if (frames <= 0) return;
|
||||
|
||||
// Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
|
||||
const int idx = static_cast<int>(waveMarker_);
|
||||
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
|
||||
dragStartMarkers_.loopEnd};
|
||||
std::int64_t newFrame = resolveDragFrame(band, frames, startVals[idx], dx);
|
||||
|
||||
// Snap to the nearest zero crossing in the decoded PCM. Pure over the cached mono
|
||||
// frames — no host types, no file I/O.
|
||||
const std::vector<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;
|
||||
|
||||
applyMarkers(m);
|
||||
invalidate(); // live feedback; the commit lands on release
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -115,7 +115,7 @@ inline int thumbBins(const instrument::ui::BrowserLayout& layout) {
|
||||
instrument::ui::cardThumbnailRect(layout, 0))));
|
||||
}
|
||||
|
||||
// Draws the title band with the live readout. Browse/Zone draw their own back button in
|
||||
// Draws the title band with the live readout. The Browse modal draws its own back button in
|
||||
// place of the nav.
|
||||
inline void drawTitleBand(LICE_IBitmap* bmp, const instrument::ui::Rect& title,
|
||||
const std::string& readout) {
|
||||
@@ -172,7 +172,7 @@ inline void drawKnobFace(LICE_IBitmap* bmp, const instrument::ui::Rect& knobRect
|
||||
|
||||
// Draws the pastel spectral keyboard-strip background: each MIDI key column filled with
|
||||
// its spectral hue, accidentals darkened with an overlay wash so pitch position reads as
|
||||
// a keyboard at a glance. Shared by the setup face + the Zones strip.
|
||||
// a keyboard at a glance.
|
||||
inline void drawSpectralStrip(LICE_IBitmap* bmp, const instrument::ui::Rect& stripArea) {
|
||||
using instrument::ui::StripLayout;
|
||||
if (stripArea.width <= 0 || stripArea.height <= 0) return;
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// editor_paint.cpp — the ReaSamplerEditor's paint dispatch: the WM_PAINT entry, the Sample
|
||||
// face's band composition (chrome / waveform / decks, each drawn by its own TU), the empty
|
||||
// state, and the drop-affordance banner. Windows-only; draws through the shared kit by
|
||||
// palette role. All layout math is pure (sample_bands) — this TU only sequences.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
|
||||
using namespace reasampler::instrument::ui; // pure geometry (bands / chrome)
|
||||
|
||||
void ReaSamplerEditor::paint(HDC hdc) {
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
LICE_SysBitmap bmp(w, h);
|
||||
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
|
||||
|
||||
// Sample is home; Browse is a full-window modal overlay drawn over it, so the Sample
|
||||
// face draws first and the modal reads as a sheet layered on top.
|
||||
paintSample(&bmp, w, h);
|
||||
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
|
||||
|
||||
// A transient banner flashed after a file was dropped on this window. It reiterates the
|
||||
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
|
||||
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||
if (dropHintTicks_ > 0) {
|
||||
const int bannerTop = (std::min)(kTitleHeight, h);
|
||||
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
|
||||
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
|
||||
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
|
||||
// hue with a dark label so it reads as "attention, not action".
|
||||
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
|
||||
kitTextCentered(&bmp, banner,
|
||||
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
|
||||
Font::Label, Role::BgBase);
|
||||
}
|
||||
|
||||
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
const FaceLayout fl = faceLayout(w, h);
|
||||
const bool empty = selectedId_.empty();
|
||||
|
||||
paintChrome(bmp, fl, empty);
|
||||
|
||||
// Nothing loaded: the lower bands carry the "pick a capture" prompt pointing at Browse
|
||||
// (which the chrome lit above), and there is nothing to deck.
|
||||
if (empty) {
|
||||
Rect body = Rect::ltrb(fl.bands.waveform.x, fl.bands.waveform.y,
|
||||
fl.bands.waveform.right(), fl.bands.decks.bottom());
|
||||
paintEmptyState(bmp, body);
|
||||
return;
|
||||
}
|
||||
|
||||
paintWaveform(bmp, fl.bands.waveform);
|
||||
paintDeck(bmp, fl);
|
||||
|
||||
// The curve popup: a centered sheet over the whole face, drawn last.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
|
||||
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
|
||||
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
|
||||
const char* msg = samples_.empty()
|
||||
? "No captures in this project yet - capture audio into the bank to play it here."
|
||||
: "No captures in this bank filter. Choose another bank tab above.";
|
||||
// Split the area so the primary line sits centered and the ingest affordance sits just
|
||||
// below it. The affordance is the shipped ingest gesture (drop onto the docked panel) —
|
||||
// kept discoverable here regardless of whether a drop ever lands on this window.
|
||||
Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2);
|
||||
Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom());
|
||||
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
|
||||
kitTextCentered(bmp, hint,
|
||||
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
|
||||
Font::Micro, Role::TextDim);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
+7
-124
@@ -1,9 +1,7 @@
|
||||
// editor_paint_browse_zone.cpp — the ReaSamplerEditor's browse-modal and zone-surface
|
||||
// painting: the full-window select-then-confirm picker (wash, search box, filter tabs, card
|
||||
// grid, scrollbar, footer) and the Zone keymap surface (add/delete, the spectral zones
|
||||
// strip, the numeric-entry legend, the per-zone knob deck + curve button). Windows-only.
|
||||
// Shares the Sample face's painters (title band / empty state / deck / curve button /
|
||||
// popup) via the class + editor_internal.h.
|
||||
// editor_paint_browse.cpp — the Browse modal's painter: the full-window
|
||||
// select-then-confirm picker (wash, search box, filter tabs, card grid, scrollbar, footer).
|
||||
// Windows-only. Shares the Sample face's title-band + empty-state painters via the class +
|
||||
// editor_internal.h.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -15,15 +13,14 @@
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/browser_scroll.h" // BrowseModal + scroll/search geometry
|
||||
#include "core/instrument/ui/knob_deck.h" // the per-zone deck layout
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + spectral strip + labels
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + labels
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // browser/strip/deck/zone-surface geometry
|
||||
using namespace reasampler::instrument::map; // SampleChoice / BankChoice / SampleRefs
|
||||
using namespace reasampler::instrument::ui; // browser geometry
|
||||
using namespace reasampler::instrument::map; // SampleChoice / BankChoice
|
||||
|
||||
void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||||
// A full-window modal sheet over the Sample face. Dim the underlying Sample face with a
|
||||
@@ -157,120 +154,6 @@ void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) {
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintZone(LICE_IBitmap* bmp, int w, int h) {
|
||||
// Title band + Back button (returns to Sample). The Zone surface is button-summoned and returns
|
||||
// to the Sample home on close.
|
||||
const Rect title = Rect::ltrb(0, 0, w, (std::min)(kTitleHeight, h));
|
||||
drawTitleBand(bmp, title, "Zone - keyboard map");
|
||||
{
|
||||
const Rect back = zoneBackRect(w, h);
|
||||
const KitButtonBox box{toKitBox(back)};
|
||||
const InteractionState st =
|
||||
isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
drawButton(bmp, box, "Back", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
const Rect content = zoneContentArea(w, h);
|
||||
|
||||
// A single "+ Add Zone" affordance at the top of the content, then the keyboard strip
|
||||
// with one bar per zone. Delete is a small × on the selected zone (keystroke also).
|
||||
Rect addR = zoneAddRect(content);
|
||||
{
|
||||
const KitButtonBox box{toKitBox(addR)};
|
||||
const InteractionState state =
|
||||
isHovered(HoverKind::kAddZone, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
drawButton(bmp, box, "+ Add Zone", state, /*warn=*/false);
|
||||
}
|
||||
|
||||
Rect delR = zoneDeleteRect(addR);
|
||||
if (selectedZone_ >= 0) {
|
||||
const KitButtonBox box{toKitBox(delR)};
|
||||
const InteractionState state =
|
||||
isHovered(HoverKind::kDeleteZone, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
// Deleting a zone is not a byte-destroying act (no file removed — the bank is
|
||||
// read-only here), so it is a normal button, not `warn`.
|
||||
drawButton(bmp, box, "Delete", state, /*warn=*/false);
|
||||
}
|
||||
|
||||
// The zones strip — the same pastel spectral surface as the Sample face, with one bar per
|
||||
// zone over the spectrum. The selected zone lifts to accent-primary + a static glow ("which
|
||||
// zone is live"); the rest take the categorical secondary hue at low alpha.
|
||||
const Rect stripArea = zonesStripArea(content);
|
||||
drawSpectralStrip(bmp, stripArea);
|
||||
const StripLayout sl = layoutStrip(stripArea.width, stripArea.height);
|
||||
const int sx = stripArea.x;
|
||||
const int sy = stripArea.y;
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(i)];
|
||||
Rect bar = zoneBarRect(sl, z.lowNote, z.highNote);
|
||||
const int bw = (std::max)(2, bar.width);
|
||||
const bool sel = (i == selectedZone_);
|
||||
if (sel) {
|
||||
// Static glow halo behind the live zone, then the crisp accent-primary bar.
|
||||
LICE_FillRect(bmp, bar.x + sx - 2, sy, bw + 4, stripArea.height,
|
||||
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
|
||||
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
|
||||
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
|
||||
} else {
|
||||
LICE_FillRect(bmp, bar.x + sx, sy, bw, stripArea.height,
|
||||
toLice(roleColor(Role::AccentSecondary)), 0.55f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// A one-line legend of the selected zone below the strip, with three click-to-type numeric
|
||||
// entry fields (low / high / root). Clicking a field focuses it (entryField_) and typed
|
||||
// text commits via parseNoteEntry on Enter.
|
||||
const int legendTop = stripArea.bottom() + 8;
|
||||
Rect infoR = Rect::ltrb(stripArea.x, legendTop, stripArea.right(), legendTop + 18);
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
kitText(bmp, Rect::ltrb(infoR.x, infoR.y, infoR.x + 120, infoR.bottom()),
|
||||
sampleLabel(samples_, processor_ ? processor_->sampleRefs() : SampleRefs{},
|
||||
z.sampleId)
|
||||
.c_str(),
|
||||
Font::Label, Role::TextPrimary);
|
||||
// Three fields laid out left-to-right after the sample label. A focused field lifts to
|
||||
// the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter.
|
||||
const Rect fields = noteEntryFieldsArea(content);
|
||||
const char* names[3] = {"Low", "High", "Root"};
|
||||
const std::string vals[3] = {
|
||||
noteLabel(z.lowNote), noteLabel(z.highNote),
|
||||
z.rootOverride ? noteLabel(*z.rootOverride) : std::string("(bank)")};
|
||||
for (int f = 0; f < 3; ++f) {
|
||||
const Rect fr = noteEntryFieldRect(fields, f);
|
||||
const bool editing = (entryField_ == f);
|
||||
fillSurface(bmp, toKitBox(fr), Role::BgCell,
|
||||
editing ? InteractionState::Focus : InteractionState::Rest);
|
||||
const KitColor border =
|
||||
editing ? roleColor(Role::TextPrimary) : roleColor(Role::LineHairline);
|
||||
LICE_DrawRect(bmp, fr.x, fr.y, fr.width - 1, fr.height - 1,
|
||||
toLice(border), 1.0f, 0);
|
||||
std::string cap = std::string(names[f]) + ": " +
|
||||
(editing ? (entryText_ + "_") : vals[f]);
|
||||
kitText(bmp, Rect::ltrb(fr.x + 4, fr.y, fr.right() - 2, fr.bottom()), cap.c_str(),
|
||||
Font::ValueMono, Role::TextPrimary);
|
||||
}
|
||||
} else if (map_.zones.empty()) {
|
||||
kitText(bmp, infoR,
|
||||
"No zones. Add Zone maps the picked capture across the keyboard.",
|
||||
Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// The per-zone parameter surface for the selected zone: the same knob deck +
|
||||
// curve-preview-button/popup grammar as the Sample face — one control language over the
|
||||
// one storage site. Only the per-zone groups render here; VOICE/MASTER are per-instance
|
||||
// (ComponentState) and live on the Sample deck only.
|
||||
if (selectedZone_ >= 0 && selectedZone_ < static_cast<int>(map_.zones.size())) {
|
||||
const PerformanceZone& z = map_.zones[static_cast<std::size_t>(selectedZone_)];
|
||||
paintKnobDeck(bmp, zonesDeckArea(content), z, zoneDeckGroupDescs(z.play));
|
||||
paintCurveButton(bmp, zonesCurveButton(content), z);
|
||||
}
|
||||
|
||||
// The curve popup: a centered sheet over the whole Zone surface, drawn last — the same
|
||||
// modal grammar as the Sample face.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,118 @@
|
||||
// editor_paint_chrome.cpp — the CHROME band's painter: the toolbar row (product title +
|
||||
// live readout + Browse) and the control row (root/piano strip with its root marker, the
|
||||
// preview trigger, the preview-velocity knob cell, the curve-preview button, and the
|
||||
// Mono|Stereo toggle). Windows-only; all rects come from the pure sample_chrome interior.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "core/instrument/ui/knob_deck.h" // kDeckKnobSize (the shared knob square)
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived title band)
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face / spectral strip / root marker
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // chrome geometry + keyboard strip
|
||||
using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback)
|
||||
|
||||
void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty) {
|
||||
const ChromeRects& cr = fl.chrome;
|
||||
|
||||
// Toolbar: product name + live readout. The beta channel gets no distinct accent; the
|
||||
// channel-derived vstPluginName is the only beta-vs-stable signal.
|
||||
std::string title = version::vstPluginName();
|
||||
if (processor_ && processor_->bridge().isConnected()) {
|
||||
// The instance's own loaded state outranks bank availability (the bank is a browser
|
||||
// source, not the instrument's identity) — a self-contained instance names its sound
|
||||
// (refs displayName fallback) even when the bank snapshot is empty.
|
||||
if (!selectedId_.empty())
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
|
||||
else if (samples_.empty()) title += " [bank empty]";
|
||||
else title += " [pick a capture]";
|
||||
} else {
|
||||
title += " [host: no bridge]";
|
||||
}
|
||||
drawTitleBand(bmp, cr.toolbar, title);
|
||||
|
||||
// Browse: the picker. When nothing is loaded it is the empty state's dominant
|
||||
// call-to-action — draw it Active (accent-primary) so it reads as "start here".
|
||||
{
|
||||
const KitButtonBox box{toKitBox(cr.navBrowse)};
|
||||
const InteractionState st = empty ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawButton(bmp, box, "Browse", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
// The control row draws only once a capture is loaded — with nothing picked there is no
|
||||
// root, no preview and no channel decision to make.
|
||||
if (empty || cr.controls.empty()) return;
|
||||
|
||||
fillSurface(bmp, toKitBox(cr.controls), Role::BgPanel, InteractionState::Rest);
|
||||
|
||||
// Root strip: the full 128-key spectral band with the root marked. The loaded capture
|
||||
// responds across the whole strip, repitched from that root.
|
||||
if (cr.rootStrip.width > 0) {
|
||||
drawSpectralStrip(bmp, cr.rootStrip);
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
drawRootMarker(bmp, cr.rootStrip, sl, effectiveRoot());
|
||||
}
|
||||
|
||||
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
|
||||
{
|
||||
const KitButtonBox box{toKitBox(cr.preview)};
|
||||
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawButton(bmp, box, "Preview", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
|
||||
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
|
||||
{
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
|
||||
const bool hov = isHovered(HoverKind::kVelKnob, -1);
|
||||
const InteractionState st = dragging ? InteractionState::Dragging
|
||||
: (hov ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st);
|
||||
if (dragging || hov) {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%d",
|
||||
static_cast<int>(previewVelocity01() * 127.0 + 0.5));
|
||||
kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim);
|
||||
} else {
|
||||
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
|
||||
// The mini curve-preview button: opens the popup editor.
|
||||
paintCurveButton(bmp, cr.curveBtn);
|
||||
|
||||
// Mono | Stereo output-mode toggle.
|
||||
{
|
||||
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
|
||||
const InteractionState monoState = !isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
const InteractionState stereoState = isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
fillSurface(bmp, toKitBox(cr.chanMono), Role::BgCell, monoState);
|
||||
fillSurface(bmp, toKitBox(cr.chanStereo), Role::BgCell, stereoState);
|
||||
kitTextCentered(bmp, cr.chanMono, "Mono", Font::Label,
|
||||
!isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
kitTextCentered(bmp, cr.chanStereo, "Stereo", Font::Label,
|
||||
isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,125 @@
|
||||
// editor_paint_curve.cpp — the velocity->amp curve surfaces: the chrome band's mini
|
||||
// preview button and the modal popup sheet that hosts the full editor. Band-independent
|
||||
// (the popup floats over the whole face). Windows-only.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + curveBoxFromRect
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // popup geometry
|
||||
|
||||
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r) {
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
// A hairline-bordered bg/cell square with the live velocity curve traced in miniature
|
||||
// (no node markers at this scale). Hover lifts it; it draws Active (accent-primary
|
||||
// border) while its popup is open, and re-renders live as the popup edits the curve.
|
||||
const bool hov = isHovered(HoverKind::kCurveButton, -1);
|
||||
fillSurface(bmp, toKitBox(r), Role::BgCell,
|
||||
hov ? InteractionState::Hover : InteractionState::Rest);
|
||||
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
|
||||
: roleColor(Role::LineHairline);
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0);
|
||||
const VelocityCurve& curve = params_.velocityCurve;
|
||||
const int inset = 3;
|
||||
const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset,
|
||||
r.height - 2 * inset};
|
||||
if (mini.width > 1 && mini.height > 1) {
|
||||
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
|
||||
int prevX = 0, prevY = 0;
|
||||
for (int px = 0; px <= mini.width; ++px) {
|
||||
const int mx = mini.left + px;
|
||||
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
|
||||
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
|
||||
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
|
||||
prevX = mx;
|
||||
prevY = my;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
|
||||
// The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the
|
||||
// face stays legible behind it), then the centered sheet.
|
||||
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0);
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1,
|
||||
pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim);
|
||||
{
|
||||
const KitButtonBox box{toKitBox(pl.close)};
|
||||
const InteractionState st = isHovered(HoverKind::kPopupClose, -1)
|
||||
? InteractionState::Hover
|
||||
: InteractionState::Rest;
|
||||
drawButton(bmp, box, "x", st, /*warn=*/false);
|
||||
}
|
||||
// The full-size editor: one draw path + the one curveBoxFromRect mapping formula, so
|
||||
// trace/handles/drag-off cues cannot drift from the hit-test.
|
||||
paintVelocityCurve(bmp, pl.curveBox);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r) {
|
||||
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
|
||||
|
||||
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
|
||||
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup
|
||||
// is the only host).
|
||||
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
const VelocityCurve& curve = params_.velocityCurve;
|
||||
|
||||
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
|
||||
// secondary accent (the same grammar as the envelope trace over the waveform). The x ->
|
||||
// velocity and amp -> y mappings both go through the pure module so the trace, the node
|
||||
// handles, and the hit-test all share one coordinate system.
|
||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||
int prevX = 0, prevY = 0;
|
||||
for (int px = 0; px <= box.width; ++px) {
|
||||
const int cx = box.left + px;
|
||||
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
|
||||
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
|
||||
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
}
|
||||
|
||||
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
|
||||
// to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor
|
||||
// has passed kCurveDragOffMargin outside the box — release will delete the node).
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
const LICE_pixel handleWarn = toLice(roleColor(Role::Warn));
|
||||
// Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin?
|
||||
const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x &&
|
||||
dragCurveRect_.y == r.y) &&
|
||||
(dragCurX_ < r.x - kCurveDragOffMargin ||
|
||||
dragCurX_ > r.right() + kCurveDragOffMargin ||
|
||||
dragCurY_ < r.y - kCurveDragOffMargin ||
|
||||
dragCurY_ > r.bottom() + kCurveDragOffMargin);
|
||||
for (std::size_t i = 0; i < curve.points().size(); ++i) {
|
||||
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
|
||||
const bool grabbed = (drag_ == DragKind::kCurveNode &&
|
||||
curvePointIndex_ == static_cast<int>(i));
|
||||
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
|
||||
// A grabbed node in drag-off territory draws warn to signal "release will delete."
|
||||
const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn
|
||||
: (hot ? handleHot : handle);
|
||||
const int nr = 3;
|
||||
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,139 @@
|
||||
// editor_paint_deck.cpp — the DECKS band's painter: the fenced control groups (AMP
|
||||
// ENVELOPE / PITCH / PITCH ENV / VOICE / MASTER), their captions, the compact caption and
|
||||
// row toggles, and the radial knobs with the label<->value swap on hover/drag.
|
||||
// Windows-only; the deck's cell geometry is the pure knob_deck layout.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face + DeckGroup ids
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // deck geometry
|
||||
|
||||
void ReaSamplerEditor::paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl) {
|
||||
const Rect& deckArea = fl.bands.decks;
|
||||
if (deckArea.width <= 0 || deckArea.height <= 0) return;
|
||||
const DeckLayout dl = layoutDeck(fl.deckDescs, deckArea.x, deckArea.y, deckArea.width);
|
||||
const PlaySeconds& play = params_.play;
|
||||
const bool isMono = (voiceMode_ == VoiceMode::Mono);
|
||||
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
|
||||
|
||||
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
|
||||
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
|
||||
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
|
||||
bool seg1Active, bool disabled) {
|
||||
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
|
||||
const InteractionState st0 =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (!seg1Active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
const InteractionState st1 =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (seg1Active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
|
||||
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
|
||||
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
|
||||
disabled ? Role::TextDim
|
||||
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
|
||||
disabled ? Role::TextDim
|
||||
: (seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
};
|
||||
|
||||
// The knob's short name label (swapped for the live value during hover/drag — no third
|
||||
// line, no permanent value clutter).
|
||||
const auto knobName = [](ParamControl c) -> const char* {
|
||||
switch (c) {
|
||||
case ParamControl::kAttack: return "Attack";
|
||||
case ParamControl::kHold: return "Hold";
|
||||
case ParamControl::kDecay: return "Decay";
|
||||
case ParamControl::kSustain: return "Sustain";
|
||||
case ParamControl::kRelease: return "Release";
|
||||
case ParamControl::kTrigFadeIn: return "Fade In";
|
||||
case ParamControl::kTrigLength: return "Len %";
|
||||
case ParamControl::kTrigFadeOut: return "Fade Out";
|
||||
case ParamControl::kKeyTrack: return "Key Trk";
|
||||
case ParamControl::kPitchEnvAttack: return "P.Att";
|
||||
case ParamControl::kPitchEnvDecay: return "P.Dec";
|
||||
case ParamControl::kPitchEnvDepth: return "P.Depth";
|
||||
case ParamControl::kVoiceCount: return "Voices";
|
||||
case ParamControl::kMasterGain: return "Gain";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
for (const DeckGroupLayout& g : dl.groups) {
|
||||
// The fence: a bg/panel box with a hairline border, caption micro-caps left.
|
||||
fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1,
|
||||
hairline, 1.0f, 0);
|
||||
const char* caption = "";
|
||||
switch (g.id) {
|
||||
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
|
||||
case kGroupPitch: caption = "PITCH"; break;
|
||||
case kGroupPitchEnv: caption = "PITCH ENV"; break;
|
||||
case kGroupVoice: caption = "VOICE"; break;
|
||||
case kGroupMaster: caption = "MASTER"; break;
|
||||
default: break;
|
||||
}
|
||||
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
|
||||
|
||||
// The compact caption toggle (right-anchored in the caption row, never full-width).
|
||||
if (g.captionToggle.id >= 0) {
|
||||
switch (static_cast<ParamControl>(g.captionToggle.id)) {
|
||||
case ParamControl::kPlayMode:
|
||||
drawToggle(g.captionToggle, "Gate", "Trigger",
|
||||
play.playMode == PlayMode::Trigger, false);
|
||||
break;
|
||||
case ParamControl::kPitchEngine:
|
||||
drawToggle(g.captionToggle, "Varisp", "Presrv",
|
||||
play.pitchEngine == PitchEngine::Preserve, false);
|
||||
break;
|
||||
case ParamControl::kPitchEnvEnable:
|
||||
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
|
||||
break;
|
||||
case ParamControl::kVoiceMode:
|
||||
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
// The row toggle (VOICE group's Retrig|Legato) — live only in Mono.
|
||||
if (g.rowToggle.id >= 0) {
|
||||
drawToggle(g.rowToggle, "Retrig", "Legato",
|
||||
monoTrigger_ == MonoTrigger::Legato, !isMono);
|
||||
}
|
||||
|
||||
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
|
||||
// stable geometry.
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
|
||||
const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled);
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
|
||||
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
|
||||
const InteractionState st =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (dragging ? InteractionState::Dragging
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
drawKnobFace(bmp, c.knob, deckControlNorm(c.id), st);
|
||||
const std::string label = (dragging || hov)
|
||||
? deckValueLabel(c.id)
|
||||
: std::string(knobName(static_cast<ParamControl>(c.id)));
|
||||
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -1,516 +0,0 @@
|
||||
// editor_paint_sample.cpp — the ReaSamplerEditor's sample-face painting: the WM_PAINT
|
||||
// dispatch, the Sample home face (title band + elastic hero waveform + root/preview cluster
|
||||
// + bottom-anchored knob deck), the envelope overlay, the velocity-curve editor + mini
|
||||
// preview button + popup sheet (shared painters the Zone surface reuses), and the empty
|
||||
// state. Windows-only; draws through the shared kit by palette role. All layout math is
|
||||
// pure (editor_geometry / knob_deck / curve_popup) — this TU only draws.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (hero waveform binning)
|
||||
#include "core/instrument/ui/curve_popup.h" // centered curve-popup sheet geometry
|
||||
#include "core/instrument/ui/knob_deck.h" // deck layout + kDeckKnobSize
|
||||
#include "core/instrument/ui/waveform_view.h" // frameToX (waveform markers)
|
||||
#include "core/version/app_version.h" // vstPluginName (channel-derived title band)
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters + knob face/spectral strip/root marker
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary (Role / InteractionState / KitBox / …)
|
||||
using namespace reasampler::instrument::ui; // pure geometry (bands / cluster / deck / popup / strip)
|
||||
using namespace reasampler::instrument::map; // SampleRefs / findRef (title readout fallback)
|
||||
using audio::computeEnvelope;
|
||||
|
||||
namespace {
|
||||
// Marker roles — semantic, drawn through the kit's palette: start = teal (secondary), loop
|
||||
// start/end = purple (tertiary). The loop-span fill is a faint purple.
|
||||
constexpr Role kRoleStartMarker = Role::AccentSecondary;
|
||||
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerEditor::paint(HDC hdc) {
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const int w = cr.right - cr.left;
|
||||
const int h = cr.bottom - cr.top;
|
||||
if (w <= 0 || h <= 0) return;
|
||||
|
||||
LICE_SysBitmap bmp(w, h);
|
||||
LICE_Clear(&bmp, toLice(roleColor(Role::BgBase)));
|
||||
|
||||
// Three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn over
|
||||
// Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the
|
||||
// modal reads as a sheet layered over the home face.
|
||||
if (view_ == View::kZone) {
|
||||
paintZone(&bmp, w, h);
|
||||
} else {
|
||||
paintSample(&bmp, w, h);
|
||||
if (view_ == View::kBrowse) paintBrowse(&bmp, w, h);
|
||||
}
|
||||
|
||||
// A transient banner flashed after a file was dropped on this window. It reiterates the
|
||||
// shipped ingest gesture rather than swallowing the drop silently. Drawn last so it
|
||||
// overlays whatever view is up; decays via onSyncTimer (dropHintTicks_).
|
||||
if (dropHintTicks_ > 0) {
|
||||
const int bannerTop = (std::min)(kTitleHeight, h);
|
||||
const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop));
|
||||
Rect banner = Rect::ltrb(0, bannerTop, w, bannerTop + bannerH);
|
||||
// A transient notice, not the live layer — draw it on the accent-tertiary categorical
|
||||
// hue with a dark label so it reads as "attention, not action".
|
||||
fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest);
|
||||
kitTextCentered(&bmp, banner,
|
||||
"Dropped here isn't loaded yet - drop files onto the ReaSampler bank panel to add them.",
|
||||
Font::Label, Role::BgBase);
|
||||
}
|
||||
|
||||
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
|
||||
// The deck height comes from the pure knob_deck wrap (mode-independent — the AMP ENVELOPE
|
||||
// group reserves its 5-cell Gate width, so Gate<->Trigger never changes it).
|
||||
const PerformanceZone deckZone = effectiveSampleZone();
|
||||
const std::vector<DeckGroupDesc> deckDescs = deckGroupDescs(deckZone.play);
|
||||
const SampleBands bands =
|
||||
computeSampleBands(w, h, deckHeight(deckDescs, w - 2 * kPad));
|
||||
|
||||
// Title: product name + live readout. The beta channel gets no distinct accent; the
|
||||
// channel-derived vstPluginName is the only beta-vs-stable signal.
|
||||
std::string title = version::vstPluginName();
|
||||
if (processor_ && processor_->bridge().isConnected()) {
|
||||
// The instance's own loaded state outranks bank availability (the bank is a browser
|
||||
// source, not the instrument's identity) — a self-contained instance names its sound
|
||||
// (refs displayName fallback) even when the bank snapshot is empty.
|
||||
if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]";
|
||||
else if (!selectedId_.empty())
|
||||
title += " [" + sampleLabel(samples_, processor_->sampleRefs(), selectedId_) + "]";
|
||||
else if (samples_.empty()) title += " [bank empty]";
|
||||
else title += " [pick a capture]";
|
||||
} else {
|
||||
title += " [host: no bridge]";
|
||||
}
|
||||
drawTitleBand(bmp, bands.title, title);
|
||||
|
||||
// Browse + Zone nav buttons (right of the title). Browse is the picker; Zone opens the keymap
|
||||
// surface. When nothing is loaded, Browse is the empty state's dominant call-to-action — draw
|
||||
// it Active (accent-primary) so it reads as "start here".
|
||||
const bool empty = selectedId_.empty() && map_.zones.empty();
|
||||
{
|
||||
const KitButtonBox box{toKitBox(bands.navBrowse)};
|
||||
const InteractionState st = empty ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||||
drawButton(bmp, box, "Browse", st, /*warn=*/false);
|
||||
}
|
||||
{
|
||||
const KitButtonBox box{toKitBox(bands.navZone)};
|
||||
const InteractionState st =
|
||||
isHovered(HoverKind::kNavZone, -1) ? InteractionState::Hover : InteractionState::Rest;
|
||||
drawButton(bmp, box, "Zone", st, /*warn=*/false);
|
||||
}
|
||||
|
||||
// Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing
|
||||
// at Browse (which is lit above). No hero waveform / controls to draw.
|
||||
if (empty) {
|
||||
Rect body = Rect::ltrb(bands.hero.x, bands.hero.y, bands.hero.right(), bands.deck.bottom());
|
||||
paintEmptyState(bmp, body);
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve the effective single-capture zone: the picked id's one-zone override when present,
|
||||
// else the product-default play params (the single capture is a one-zone map). This is the
|
||||
// one storage site both Sample and Zone edit.
|
||||
const PerformanceZone& zone = deckZone;
|
||||
|
||||
// Hero waveform band: envelope + markers + envelope overlay.
|
||||
const std::vector<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) {
|
||||
// Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
|
||||
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
|
||||
// partition — extra bins produce no visible change. Clamped to frame count below.
|
||||
const std::int64_t wantBins =
|
||||
static_cast<std::int64_t>((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) *
|
||||
kWaveformOversample;
|
||||
const std::size_t bins =
|
||||
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
|
||||
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins);
|
||||
drawEnvelope(bmp, waveArea, env);
|
||||
|
||||
const SetupMarkers m = pickedMarkers(frames);
|
||||
if (m.hasLoop && m.loopEnd > m.loopStart) {
|
||||
const int lx = frameToX(waveArea, frames, m.loopStart);
|
||||
const int rx = frameToX(waveArea, frames, m.loopEnd);
|
||||
if (rx > lx) {
|
||||
LICE_FillRect(bmp, lx, waveArea.y, rx - lx, waveArea.height,
|
||||
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
|
||||
}
|
||||
}
|
||||
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const int mx = frameToX(waveArea, frames, markerFrames[i]);
|
||||
const bool loopMarker = (i != 0);
|
||||
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
|
||||
LICE_FillRect(bmp, mx - 1, waveArea.y, 2, waveArea.height,
|
||||
toLice(roleColor(markerRoles[i])), alpha, 0);
|
||||
}
|
||||
|
||||
// Trace the amp-envelope overlay + its draggable node handles over the hero.
|
||||
paintEnvelopeOverlay(bmp, waveArea, zone, frames);
|
||||
} else {
|
||||
kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim);
|
||||
}
|
||||
|
||||
// Root + preview cluster: remainder-width root strip, preview button, radial velocity
|
||||
// knob, mini curve-preview button, channel toggle.
|
||||
fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest);
|
||||
const ChannelToggleRects chan = channelToggleRects(bands.cluster);
|
||||
const ClusterRects cr = clusterRects(bands.cluster, chan.mono, kDeckKnobSize);
|
||||
int root = effectiveRoot();
|
||||
if (cr.rootStrip.width > 0) {
|
||||
drawSpectralStrip(bmp, cr.rootStrip);
|
||||
const StripLayout sl = layoutStrip(cr.rootStrip.width, cr.rootStrip.height);
|
||||
drawRootMarker(bmp, cr.rootStrip, sl, root);
|
||||
}
|
||||
|
||||
// Preview-trigger button (fires the loaded capture at root through the live voice engine).
|
||||
{
|
||||
const KitButtonBox box{toKitBox(cr.preview)};
|
||||
const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||||
drawButton(bmp, box, "Preview", st, /*warn=*/false);
|
||||
}
|
||||
// Preview velocity: a radial knob cell (the deck cell grammar), bound to the same
|
||||
// persisted previewVelocity seam. Label swaps to the live value during hover/drag.
|
||||
{
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == -2);
|
||||
const bool hov = isHovered(HoverKind::kVelKnob, -1);
|
||||
const InteractionState st = dragging ? InteractionState::Dragging
|
||||
: (hov ? InteractionState::Hover
|
||||
: InteractionState::Rest);
|
||||
drawKnobFace(bmp, cr.velKnob, previewVelocity01(), st);
|
||||
if (dragging || hov) {
|
||||
char buf[8];
|
||||
snprintf(buf, sizeof(buf), "%d",
|
||||
static_cast<int>(previewVelocity01() * 127.0 + 0.5));
|
||||
kitTextCentered(bmp, cr.velLabel, buf, Font::Micro, Role::TextDim);
|
||||
} else {
|
||||
kitTextCentered(bmp, cr.velLabel, "Vel", Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
// The mini curve-preview button: opens the popup editor. Shared painter with the Zone
|
||||
// panel's button — one grammar on both surfaces.
|
||||
paintCurveButton(bmp, cr.curveBtn, zone);
|
||||
// Mono | Stereo output-mode toggle.
|
||||
{
|
||||
const bool isStereo = (channelMode_ == ChannelMode::Stereo);
|
||||
const InteractionState monoState = !isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||||
const InteractionState stereoState = isStereo ? InteractionState::Active
|
||||
: (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover : InteractionState::Rest);
|
||||
fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState);
|
||||
fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState);
|
||||
kitTextCentered(bmp, chan.mono, "Mono", Font::Label, !isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary);
|
||||
}
|
||||
|
||||
// The knob deck: the fenced control groups, bottom-anchored.
|
||||
paintKnobDeck(bmp, bands.deck, zone, deckDescs);
|
||||
|
||||
// The curve popup: a centered sheet over the whole Sample face, drawn last.
|
||||
if (curvePopupOpen_) paintCurvePopup(bmp, w, h);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea,
|
||||
const PerformanceZone& zone, std::int64_t frames) {
|
||||
if (frames <= 0 || waveArea.width <= 0 || waveArea.height <= 0) return;
|
||||
const double rate = liveSampleRate();
|
||||
if (rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const std::int64_t startFrame = zone.startPoint.value_or(0);
|
||||
const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame);
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
|
||||
|
||||
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
|
||||
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
|
||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||
for (std::size_t i = 1; i < poly.size(); ++i) {
|
||||
const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].x));
|
||||
const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x));
|
||||
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
|
||||
}
|
||||
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are
|
||||
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is
|
||||
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles);
|
||||
// the handle square is additionally clamped inside the hero rect so a 6px box on an edge
|
||||
// node never overhangs into the neighbouring bands.
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
|
||||
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
|
||||
const int r = 3;
|
||||
const int hx = (std::max)(waveArea.x + r, (std::min)(waveArea.right() - 1 - r, v.x));
|
||||
const int hy = (std::max)(waveArea.y + r, (std::min)(waveArea.bottom() - 1 - r, v.y));
|
||||
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r,
|
||||
const PerformanceZone& zone) {
|
||||
if (r.width <= 0 || r.height <= 0) return; // defensive (degenerate rect)
|
||||
|
||||
// The bordered box: a panel surface + hairline border, drawn by palette role. No corner
|
||||
// caption — the popup sheet's own "VELOCITY -> AMP" title labels this context (the popup
|
||||
// is the only host).
|
||||
fillSurface(bmp, toKitBox(r), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
|
||||
const VelocityCurve::Box box = curveBoxFromRect(r);
|
||||
if (box.width <= 0 || box.height <= 1) return;
|
||||
const VelocityCurve& curve = zone.velocityCurve;
|
||||
|
||||
// Trace the monotone spline — ONE eval per x column over the mapping box, in the categorical
|
||||
// secondary accent (the same grammar as the envelope trace over the hero). The x -> velocity
|
||||
// and amp -> y mappings both go through the pure module so the trace, the node handles, and
|
||||
// the hit-test all share one coordinate system.
|
||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||
int prevX = 0, prevY = 0;
|
||||
for (int px = 0; px <= box.width; ++px) {
|
||||
const int cx = box.left + px;
|
||||
const double vel = VelocityCurve::pointFromPixel(box, cx, box.top).velocity;
|
||||
const int cy = VelocityCurve::pixelFromPoint(box, {vel, curve.eval(vel)}).y;
|
||||
if (px > 0) LICE_Line(bmp, prevX, prevY, cx, cy, line, 1.0f, 0, true);
|
||||
prevX = cx;
|
||||
prevY = cy;
|
||||
}
|
||||
|
||||
// Draggable node handles (mirror of the envelope overlay's): accent-primary squares lifted
|
||||
// to accent-hot when grabbed or hovered, or warn when a drag-off delete is armed (cursor
|
||||
// has passed kCurveDragOffMargin outside the box — release will delete the node).
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
const LICE_pixel handleWarn = toLice(roleColor(Role::Warn));
|
||||
// Drag-off check: during a kCurveNode drag on THIS box, is the live cursor beyond the margin?
|
||||
const bool dragOffArmed = (drag_ == DragKind::kCurveNode && dragCurveRect_.x == r.x &&
|
||||
dragCurveRect_.y == r.y) &&
|
||||
(dragCurX_ < r.x - kCurveDragOffMargin ||
|
||||
dragCurX_ > r.right() + kCurveDragOffMargin ||
|
||||
dragCurY_ < r.y - kCurveDragOffMargin ||
|
||||
dragCurY_ > r.bottom() + kCurveDragOffMargin);
|
||||
for (std::size_t i = 0; i < curve.points().size(); ++i) {
|
||||
const auto np = VelocityCurve::pixelFromPoint(box, curve.points()[i]);
|
||||
const bool grabbed = (drag_ == DragKind::kCurveNode &&
|
||||
curvePointIndex_ == static_cast<int>(i));
|
||||
const bool hot = grabbed || isHovered(HoverKind::kCurveNode, static_cast<int>(i));
|
||||
// A grabbed node in drag-off territory draws warn to signal "release will delete."
|
||||
const LICE_pixel col = (grabbed && dragOffArmed) ? handleWarn
|
||||
: (hot ? handleHot : handle);
|
||||
const int nr = 3;
|
||||
LICE_FillRect(bmp, np.x - nr, np.y - nr, 2 * nr, 2 * nr, col, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea,
|
||||
const PerformanceZone& zone,
|
||||
const std::vector<DeckGroupDesc>& descs) {
|
||||
if (deckArea.width <= 0 || deckArea.height <= 0) return;
|
||||
const DeckLayout dl = layoutDeck(descs, deckArea.x, deckArea.y, deckArea.width);
|
||||
const ZonePlaySeconds& play = zone.play;
|
||||
const bool isMono = (voiceMode_ == VoiceMode::Mono);
|
||||
const LICE_pixel hairline = toLice(roleColor(Role::LineHairline));
|
||||
|
||||
// One compact-toggle draw (the Mono/Stereo segment grammar at Micro scale). Disabled
|
||||
// segments draw inert so the dependency (Retrig|Legato needs Mono) reads at a glance.
|
||||
const auto drawToggle = [&](const DeckToggleLayout& t, const char* s0, const char* s1,
|
||||
bool seg1Active, bool disabled) {
|
||||
const bool hov = !disabled && isHovered(HoverKind::kControl, t.id);
|
||||
const InteractionState st0 =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (!seg1Active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
const InteractionState st1 =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (seg1Active ? InteractionState::Active
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
fillSurface(bmp, toKitBox(t.seg0), Role::BgCell, st0);
|
||||
fillSurface(bmp, toKitBox(t.seg1), Role::BgCell, st1);
|
||||
kitTextCentered(bmp, t.seg0, s0, Font::Micro,
|
||||
disabled ? Role::TextDim
|
||||
: (!seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
kitTextCentered(bmp, t.seg1, s1, Font::Micro,
|
||||
disabled ? Role::TextDim
|
||||
: (seg1Active ? Role::BgBase : Role::TextPrimary));
|
||||
};
|
||||
|
||||
// The knob's short name label (swapped for the live value during hover/drag — no third
|
||||
// line, no permanent value clutter).
|
||||
const auto knobName = [](ParamControl c) -> const char* {
|
||||
switch (c) {
|
||||
case ParamControl::kAttack: return "Attack";
|
||||
case ParamControl::kHold: return "Hold";
|
||||
case ParamControl::kDecay: return "Decay";
|
||||
case ParamControl::kSustain: return "Sustain";
|
||||
case ParamControl::kRelease: return "Release";
|
||||
case ParamControl::kTrigFadeIn: return "Fade In";
|
||||
case ParamControl::kTrigLength: return "Len %";
|
||||
case ParamControl::kTrigFadeOut: return "Fade Out";
|
||||
case ParamControl::kKeyTrack: return "Key Trk";
|
||||
case ParamControl::kPitchEnvAttack: return "P.Att";
|
||||
case ParamControl::kPitchEnvDecay: return "P.Dec";
|
||||
case ParamControl::kPitchEnvDepth: return "P.Depth";
|
||||
case ParamControl::kVoiceCount: return "Voices";
|
||||
case ParamControl::kMasterGain: return "Gain";
|
||||
default: return "";
|
||||
}
|
||||
};
|
||||
|
||||
for (const DeckGroupLayout& g : dl.groups) {
|
||||
// The fence: a bg/panel box with a hairline border, caption micro-caps left.
|
||||
fillSurface(bmp, toKitBox(g.box), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, g.box.x, g.box.y, g.box.width - 1, g.box.height - 1,
|
||||
hairline, 1.0f, 0);
|
||||
const char* caption = "";
|
||||
switch (g.id) {
|
||||
case kGroupAmpEnv: caption = "AMP ENVELOPE"; break;
|
||||
case kGroupPitch: caption = "PITCH"; break;
|
||||
case kGroupPitchEnv: caption = "PITCH ENV"; break;
|
||||
case kGroupVoice: caption = "VOICE"; break;
|
||||
case kGroupMaster: caption = "MASTER"; break;
|
||||
default: break;
|
||||
}
|
||||
kitText(bmp, g.caption, caption, Font::Micro, Role::TextDim);
|
||||
|
||||
// The compact caption toggle (right-anchored in the caption row, never full-width).
|
||||
if (g.captionToggle.id >= 0) {
|
||||
switch (static_cast<ParamControl>(g.captionToggle.id)) {
|
||||
case ParamControl::kPlayMode:
|
||||
drawToggle(g.captionToggle, "Gate", "Trigger",
|
||||
play.playMode == PlayMode::Trigger, false);
|
||||
break;
|
||||
case ParamControl::kPitchEngine:
|
||||
drawToggle(g.captionToggle, "Varisp", "Presrv",
|
||||
play.pitchEngine == PitchEngine::Preserve, false);
|
||||
break;
|
||||
case ParamControl::kPitchEnvEnable:
|
||||
drawToggle(g.captionToggle, "Off", "On", play.pitchEnv.enabled, false);
|
||||
break;
|
||||
case ParamControl::kVoiceMode:
|
||||
drawToggle(g.captionToggle, "Poly", "Mono", isMono, false);
|
||||
break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
// The row toggle (VOICE group's Retrig|Legato) — live only in Mono.
|
||||
if (g.rowToggle.id >= 0) {
|
||||
drawToggle(g.rowToggle, "Retrig", "Legato",
|
||||
monoTrigger_ == MonoTrigger::Legato, !isMono);
|
||||
}
|
||||
|
||||
// The knobs. PITCH ENV knobs draw Disabled (not hidden) while the envelope is off —
|
||||
// stable geometry.
|
||||
for (const DeckCellLayout& c : g.cells) {
|
||||
if (c.id < 0) continue; // reserved blank cell (the Trigger face's two spares)
|
||||
const bool disabled = (g.id == kGroupPitchEnv && !play.pitchEnv.enabled);
|
||||
const bool dragging = (drag_ == DragKind::kDeckKnob && dragParamId_ == c.id);
|
||||
const bool hov = !disabled && isHovered(HoverKind::kControl, c.id);
|
||||
const InteractionState st =
|
||||
disabled ? InteractionState::Disabled
|
||||
: (dragging ? InteractionState::Dragging
|
||||
: (hov ? InteractionState::Hover : InteractionState::Rest));
|
||||
drawKnobFace(bmp, c.knob, deckControlNorm(c.id, zone), st);
|
||||
const std::string label = (dragging || hov)
|
||||
? deckValueLabel(c.id, zone)
|
||||
: std::string(knobName(static_cast<ParamControl>(c.id)));
|
||||
kitTextCentered(bmp, c.label, label.c_str(), Font::Micro, Role::TextDim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintCurveButton(LICE_IBitmap* bmp, const Rect& r,
|
||||
const PerformanceZone& zone) {
|
||||
if (r.width <= 0 || r.height <= 0) return;
|
||||
// The mini curve-preview button (shared by the Sample cluster and the Zone panel): a
|
||||
// hairline-bordered bg/cell square with the zone's live velocity curve traced in
|
||||
// miniature (no node markers at this scale). Hover lifts it; it draws Active
|
||||
// (accent-primary border) while its popup is open, and re-renders live as the popup edits
|
||||
// the curve (same zone, re-read each paint).
|
||||
const bool hov = isHovered(HoverKind::kCurveButton, -1);
|
||||
fillSurface(bmp, toKitBox(r), Role::BgCell,
|
||||
hov ? InteractionState::Hover : InteractionState::Rest);
|
||||
const KitColor border = curvePopupOpen_ ? roleColor(Role::AccentPrimary)
|
||||
: roleColor(Role::LineHairline);
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1, toLice(border), 1.0f, 0);
|
||||
const VelocityCurve& curve = zone.velocityCurve;
|
||||
const int inset = 3;
|
||||
const VelocityCurve::Box mini{r.x + inset, r.y + inset, r.width - 2 * inset,
|
||||
r.height - 2 * inset};
|
||||
if (mini.width > 1 && mini.height > 1) {
|
||||
const LICE_pixel trace = toLice(roleColor(Role::AccentSecondary));
|
||||
int prevX = 0, prevY = 0;
|
||||
for (int px = 0; px <= mini.width; ++px) {
|
||||
const int mx = mini.left + px;
|
||||
const double vel = VelocityCurve::pointFromPixel(mini, mx, mini.top).velocity;
|
||||
const int my = VelocityCurve::pixelFromPoint(mini, {vel, curve.eval(vel)}).y;
|
||||
if (px > 0) LICE_Line(bmp, prevX, prevY, mx, my, trace, 1.0f, 0, true);
|
||||
prevX = mx;
|
||||
prevY = my;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintCurvePopup(LICE_IBitmap* bmp, int w, int h) {
|
||||
// The 0.50-alpha bg/base wash (lighter than Browse's 0.82 — a focused sub-editor; the
|
||||
// Sample face stays legible behind it), then the centered sheet.
|
||||
LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.50f, 0);
|
||||
const CurvePopupLayout pl = computeCurvePopup(w, h);
|
||||
fillSurface(bmp, toKitBox(pl.sheet), Role::BgPanel, InteractionState::Rest);
|
||||
LICE_DrawRect(bmp, pl.sheet.x, pl.sheet.y, pl.sheet.width - 1,
|
||||
pl.sheet.height - 1, toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
kitText(bmp, pl.title, "VELOCITY -> AMP", Font::Micro, Role::TextDim);
|
||||
{
|
||||
const KitButtonBox box{toKitBox(pl.close)};
|
||||
const InteractionState st = isHovered(HoverKind::kPopupClose, -1)
|
||||
? InteractionState::Hover
|
||||
: InteractionState::Rest;
|
||||
drawButton(bmp, box, "x", st, /*warn=*/false);
|
||||
}
|
||||
// The full-size editor: one draw path + the one curveBoxFromRect mapping formula, so
|
||||
// trace/handles/drag-off cues cannot drift between hosts. The popup edits popupZone() —
|
||||
// the picked capture's one-zone site on the Sample face, the selected zone on the Zone
|
||||
// surface.
|
||||
paintVelocityCurve(bmp, pl.curveBox, popupZone());
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) {
|
||||
// Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from
|
||||
// a bank filter that hides everything. Either way it is the "pick a capture" empty state.
|
||||
const char* msg = samples_.empty()
|
||||
? "No captures in this project yet - capture audio into the bank to play it here."
|
||||
: "No captures in this bank filter. Choose another bank tab above.";
|
||||
// Split the area so the primary line sits centered and the ingest affordance sits just
|
||||
// below it. The affordance is the shipped ingest gesture (drop onto the docked panel) —
|
||||
// kept discoverable here regardless of whether a drop ever lands on this window.
|
||||
Rect primary = Rect::ltrb(area.x, area.y, area.right(), area.y + area.height / 2);
|
||||
Rect hint = Rect::ltrb(area.x, primary.bottom(), area.right(), area.bottom());
|
||||
kitTextCentered(bmp, primary, msg, Font::Label, Role::TextDim);
|
||||
kitTextCentered(bmp, hint,
|
||||
"To add a sample: drop a file onto the ReaSampler bank panel (the docked window).",
|
||||
Font::Micro, Role::TextDim);
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -0,0 +1,124 @@
|
||||
// editor_paint_waveform.cpp — the WAVEFORM band's painter: the channel lane(s), the loop
|
||||
// span + start/loop markers, and the amp-envelope overlay. Windows-only.
|
||||
//
|
||||
// Overlays that ride the waveform (the envelope trace, its node handles, the markers) draw
|
||||
// ONCE across the full band height, never per lane — the landed contract the stacked-lane
|
||||
// work consumes.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "core/audio/peaks.h" // computeEnvelope (waveform binning)
|
||||
#include "core/instrument/ui/sample_bands.h" // waveformLanes (the band's lane inventory)
|
||||
#include "core/instrument/ui/waveform_view.h" // frameToX (waveform markers)
|
||||
#include "shell/instrument/editor_internal.h" // kit adapters
|
||||
#include "shell/instrument/reasampler_processor.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using namespace reasampler::ui; // kit vocabulary
|
||||
using namespace reasampler::instrument::ui; // lanes + waveform geometry
|
||||
using audio::computeEnvelope;
|
||||
|
||||
namespace {
|
||||
// Marker roles — semantic, drawn through the kit's palette: start = teal (secondary), loop
|
||||
// start/end = purple (tertiary). The loop-span fill is a faint purple.
|
||||
constexpr Role kRoleStartMarker = Role::AccentSecondary;
|
||||
constexpr Role kRoleLoopMarker = Role::AccentTertiary;
|
||||
} // namespace
|
||||
|
||||
void ReaSamplerEditor::paintWaveform(LICE_IBitmap* bmp, const Rect& band) {
|
||||
fillSurface(bmp, toKitBox(band), Role::BgBase, InteractionState::Rest);
|
||||
if (band.empty()) return;
|
||||
|
||||
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
|
||||
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
|
||||
if (frames <= 0) {
|
||||
kitTextCentered(bmp, band, "(decoding...)", Font::Label, Role::TextDim);
|
||||
return;
|
||||
}
|
||||
|
||||
// One lane today: the cached PCM is a mono downmix, so there is no second channel to
|
||||
// draw. The band is already sized for two, and the second lane lights up when the
|
||||
// per-channel decode lands.
|
||||
const WaveformLanes lanes = waveformLanes(band, /*stereo=*/false);
|
||||
const Rect& lane = lanes.upper;
|
||||
if (lane.width > 0) {
|
||||
// Gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this
|
||||
// multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact
|
||||
// partition — extra bins produce no visible change. Clamped to frame count below.
|
||||
const std::int64_t wantBins =
|
||||
static_cast<std::int64_t>((std::max)(1, waveformColumnCount(toKitBox(lane)))) *
|
||||
kWaveformOversample;
|
||||
const std::size_t bins =
|
||||
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
|
||||
drawEnvelope(bmp, lane, computeEnvelope(pcm, 1, pcm.size(), bins));
|
||||
}
|
||||
|
||||
// Markers and the loop span run the FULL band height (both lanes), so a stacked view
|
||||
// reads one loop region rather than two.
|
||||
const SetupMarkers m = pickedMarkers(frames);
|
||||
if (m.hasLoop && m.loopEnd > m.loopStart) {
|
||||
const int lx = frameToX(band, frames, m.loopStart);
|
||||
const int rx = frameToX(band, frames, m.loopEnd);
|
||||
if (rx > lx) {
|
||||
LICE_FillRect(bmp, lx, band.y, rx - lx, band.height,
|
||||
toLice(roleColor(kRoleLoopMarker)), 0.20f, 0);
|
||||
}
|
||||
}
|
||||
const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd};
|
||||
const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker};
|
||||
for (int i = 0; i < 3; ++i) {
|
||||
const int mx = frameToX(band, frames, markerFrames[i]);
|
||||
const bool loopMarker = (i != 0);
|
||||
const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f;
|
||||
LICE_FillRect(bmp, mx - 1, band.y, 2, band.height,
|
||||
toLice(roleColor(markerRoles[i])), alpha, 0);
|
||||
}
|
||||
|
||||
paintEnvelopeOverlay(bmp, band, frames);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea,
|
||||
std::int64_t frames) {
|
||||
if (frames <= 0 || waveArea.width <= 0 || waveArea.height <= 0) return;
|
||||
const double rate = liveSampleRate();
|
||||
if (rate <= 0.0) return;
|
||||
const double totalSeconds = static_cast<double>(frames) / rate;
|
||||
const std::int64_t startFrame = params_.startPoint.value_or(0);
|
||||
const AmpEnvelope env = packEnvelope(params_.play, frames, startFrame);
|
||||
const std::vector<EnvVertex> poly = buildEnvelopePolyline(env, waveArea, totalSeconds);
|
||||
|
||||
// Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct
|
||||
// curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right).
|
||||
const LICE_pixel line = toLice(roleColor(Role::AccentSecondary));
|
||||
for (std::size_t i = 1; i < poly.size(); ++i) {
|
||||
const int x0 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i - 1].x));
|
||||
const int x1 = (std::max)(waveArea.x, (std::min)(waveArea.right() - 1, poly[i].x));
|
||||
LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true);
|
||||
}
|
||||
// Draggable node handles: a small square per draggable node (Origin + ReleaseStart are
|
||||
// draw-only). Lit accent-hot when this node is the grabbed one. Every vertex is
|
||||
// guaranteed in-bounds (edge nodes like ReleaseEnd at area.right()-1 must get handles);
|
||||
// the handle square is additionally clamped inside the band so a 6px box on an edge
|
||||
// node never overhangs into the neighbouring bands.
|
||||
const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary));
|
||||
const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot));
|
||||
for (const EnvVertex& v : poly) {
|
||||
if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue;
|
||||
const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node);
|
||||
const int r = 3;
|
||||
const int hx = (std::max)(waveArea.x + r, (std::min)(waveArea.right() - 1 - r, v.x));
|
||||
const int hy = (std::max)(waveArea.y + r, (std::min)(waveArea.bottom() - 1 - r, v.y));
|
||||
LICE_FillRect(bmp, hx - r, hy - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
#endif // _WIN32
|
||||
@@ -219,20 +219,18 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
}
|
||||
if (self->drag_ != DragKind::kNone) {
|
||||
// A scrollbar drag + the processor-side deck knobs (preview velocity -2 /
|
||||
// voice count / master gain) are transient (no map mutation; dragStartMap_
|
||||
// not snapshotted) — reset drag state only, never touch map_. Every
|
||||
// map-editing drag rolls its live mutation back to the snapshot.
|
||||
// voice count / master gain) are transient (they mutate no parameter, so
|
||||
// dragStartParams_ is not a rollback target) — reset drag state only.
|
||||
// Every parameter-editing drag rolls its live mutation back to the snapshot.
|
||||
const bool transient = self->drag_ == DragKind::kScrollThumb ||
|
||||
(self->drag_ == DragKind::kDeckKnob &&
|
||||
(self->dragParamId_ == -2 ||
|
||||
self->dragParamId_ == static_cast<int>(ParamControl::kVoiceCount) ||
|
||||
self->dragParamId_ == static_cast<int>(ParamControl::kMasterGain)));
|
||||
if (!transient) self->map_ = self->dragStartMap_;
|
||||
if (!transient) self->params_ = self->dragStartParams_;
|
||||
self->drag_ = DragKind::kNone;
|
||||
self->dragParamId_ = -1;
|
||||
self->dragParamZone_ = -1;
|
||||
self->curvePointIndex_ = -1; // curve-node drag state (peer reset)
|
||||
self->dragCurveZone_ = -1;
|
||||
self->invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// editor_session.cpp — the ReaSamplerEditor's session/bridge state: construction, the
|
||||
// live-bank snapshot (refreshFromBank / rebuildVisible), the sync tick, the
|
||||
// commit-and-reload seam, selection loading, the picked-capture marker resolution/upsert
|
||||
// helpers, and the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits
|
||||
// off the audio thread via the processor's reloadInstrument.
|
||||
// commit-and-reload seam, selection loading, the loaded capture's marker resolution, and
|
||||
// the decoded-PCM + peak thumbnail caches. UI thread only; every edit commits off the audio
|
||||
// thread via the processor's reloadInstrument.
|
||||
|
||||
#include "shell/instrument/reasampler_editor.h"
|
||||
|
||||
@@ -38,8 +38,8 @@ using util::readFileBytes;
|
||||
|
||||
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
||||
: CPluginView(nullptr), processor_(processor) {
|
||||
// Default view size, tuned to the Sample-face band heights: title + hero waveform +
|
||||
// cluster + control strip. 840x620 clears the full face without scroll on 1080p.
|
||||
// Default view size, tuned to the three band heights: chrome + two-lane waveform +
|
||||
// deck row. 840x620 clears the full face without scroll on 1080p.
|
||||
ViewRect r(0, 0, 840, 620);
|
||||
setRect(r);
|
||||
}
|
||||
@@ -53,30 +53,21 @@ void ReaSamplerEditor::refreshFromBank() {
|
||||
banks_.clear();
|
||||
visible_.clear();
|
||||
selectedId_.clear();
|
||||
map_.zones.clear();
|
||||
selectedZone_ = -1;
|
||||
params_ = InstrumentParams{};
|
||||
return;
|
||||
}
|
||||
auto banksJson = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||
samples_ = banksJson ? listSamples(*banksJson) : std::vector<SampleChoice>{};
|
||||
banks_ = banksJson ? listBanks(*banksJson) : std::vector<BankChoice>{};
|
||||
selectedId_ = processor_->selectedSampleId();
|
||||
const auto prevZoneCount = static_cast<int>(map_.zones.size());
|
||||
map_ = processor_->performanceMap();
|
||||
params_ = processor_->instrumentParams();
|
||||
channelMode_ = processor_->channelMode();
|
||||
voiceCount_ = processor_->voiceCount();
|
||||
voiceMode_ = processor_->voiceMode();
|
||||
monoTrigger_ = processor_->monoTrigger();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
// A refresh that emptied the selection closes the curve popup — an open-but-invisible
|
||||
// modal would otherwise swallow clicks on the empty state.
|
||||
if (selectedId_.empty() && map_.zones.empty()) curvePopupOpen_ = false;
|
||||
// On the Zone surface, close the popup if the zone count changed at all — a mid-list
|
||||
// deletion can leave selectedZone_ in range but silently naming a different zone.
|
||||
if (view_ == View::kZone && curvePopupOpen_) {
|
||||
const auto newZoneCount = static_cast<int>(map_.zones.size());
|
||||
if (selectedZone_ < 0 || newZoneCount != prevZoneCount) curvePopupOpen_ = false;
|
||||
}
|
||||
if (selectedId_.empty()) curvePopupOpen_ = false;
|
||||
// Drop a filter that names a bank no longer present.
|
||||
if (!activeFilterBankId_.empty()) {
|
||||
bool found = false;
|
||||
@@ -127,13 +118,13 @@ void ReaSamplerEditor::onSyncTimer() {
|
||||
#endif // _WIN32
|
||||
|
||||
void ReaSamplerEditor::commitAndReload() {
|
||||
// UI thread only. Publishes the edited selection + zones, then rebuilds off the audio
|
||||
// thread. The reload also copies the picked capture's file ref + intrinsics into the
|
||||
// instance-owned refs table — a browser load is the moment the instance becomes
|
||||
// UI thread only. Publishes the edited selection + parameter set, then rebuilds off the
|
||||
// audio thread. The reload also copies the loaded capture's file ref + intrinsics into
|
||||
// the instance-owned refs table — a browser load is the moment the instance becomes
|
||||
// self-contained for that sample.
|
||||
if (!processor_) return;
|
||||
processor_->setSelectedSampleId(selectedId_);
|
||||
processor_->setPerformanceMap(map_);
|
||||
processor_->setInstrumentParams(params_);
|
||||
processor_->reloadInstrument();
|
||||
// The reload may have auto-defaulted the channel mode (implicit only) — re-read so the
|
||||
// toggle draws what the engine actually decoded with.
|
||||
@@ -144,23 +135,25 @@ void ReaSamplerEditor::commitAndReload() {
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::loadSelection(const std::string& id) {
|
||||
// A Sample-face load REPLACES the loaded sound: the previous sample's materialized
|
||||
// full-range zone must not linger, or first-match resolve would keep playing it.
|
||||
// Authored Zone-view maps (narrow key ranges) are left untouched.
|
||||
// A load REPLACES the loaded sound. The shaping parameters (play mode, envelopes, pitch
|
||||
// engine, key-track, velocity curve) are NOT reset — the one set governs whatever is
|
||||
// loaded, so a load swaps the sound and keeps the settings. The three CAPTURE-ANCHORED
|
||||
// overrides are: a root, a loop span and a start frame all name positions in the
|
||||
// OUTGOING capture and mean nothing in the new one, so they clear and the new capture
|
||||
// plays from its own bank intrinsics.
|
||||
selectedId_ = id;
|
||||
if (reconcileSingleCaptureZones(map_, selectedId_)) {
|
||||
selectedZone_ = map_.zones.empty() ? -1 : 0;
|
||||
}
|
||||
params_.rootOverride.reset();
|
||||
params_.loopOverride.reset();
|
||||
params_.startPoint.reset();
|
||||
commitAndReload();
|
||||
}
|
||||
|
||||
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
|
||||
SetupMarkers m;
|
||||
// Seed from the bank's intrinsic loop (fact about the file), then let a per-zone override
|
||||
// for the picked id win (the instrument's performance choice). Read the loop intrinsic from
|
||||
// the live bank blob (the same path selectSample uses); when that is not readable (extension
|
||||
// absent / not yet parsed) the instance-owned ref carries the same intrinsics. The override
|
||||
// lives in map_.
|
||||
// Seed from the bank's intrinsic loop (fact about the file), then let the parameter set's
|
||||
// override win (the instrument's performance choice). Read the loop intrinsic from the
|
||||
// live bank blob (the same path selectSample uses); when that is not readable (extension
|
||||
// absent / not yet parsed) the instance-owned ref carries the same intrinsics.
|
||||
if (processor_) {
|
||||
std::optional<SelectedSample> sel;
|
||||
auto banksJson =
|
||||
@@ -176,17 +169,13 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
|
||||
m.loopEnd = sel->loop.end;
|
||||
}
|
||||
}
|
||||
// The override (loop + start) on a zone for the picked id supersedes the intrinsic.
|
||||
for (const PerformanceZone& z : map_.zones) {
|
||||
if (z.sampleId != selectedId_) continue;
|
||||
if (z.loopOverride) {
|
||||
m.hasLoop = z.loopOverride->hasLoop;
|
||||
m.loopStart = z.loopOverride->start;
|
||||
m.loopEnd = z.loopOverride->end;
|
||||
}
|
||||
if (z.startPoint) m.start = *z.startPoint;
|
||||
break;
|
||||
// The parameter set's override (loop + start) supersedes the intrinsic.
|
||||
if (params_.loopOverride) {
|
||||
m.hasLoop = params_.loopOverride->hasLoop;
|
||||
m.loopStart = params_.loopOverride->start;
|
||||
m.loopEnd = params_.loopOverride->end;
|
||||
}
|
||||
if (params_.startPoint) m.start = *params_.startPoint;
|
||||
// Default an unset loop's end to the sample length so the loop markers have somewhere sane
|
||||
// to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false;
|
||||
// the markers are still drawn (drag one to CREATE a loop).
|
||||
@@ -194,79 +183,23 @@ ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t fram
|
||||
return m;
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
|
||||
// Find-or-append the zone for selectedId_ and write the loop/start override fields. The
|
||||
// bank intrinsic is never written (read-only bank consumer). selectedId_ must be
|
||||
// non-empty; callers are responsible for that guard. Returns the zone index (0-based) so
|
||||
// callers can update selectedZone_.
|
||||
void ReaSamplerEditor::applyMarkers(const SetupMarkers& m) {
|
||||
// Write the edited markers into the parameter set as the loop/start override. The bank
|
||||
// intrinsic is never written (read-only bank consumer).
|
||||
SampleLoop loop;
|
||||
loop.hasLoop = m.hasLoop;
|
||||
loop.start = m.loopStart;
|
||||
loop.end = m.loopEnd;
|
||||
for (int i = 0; i < static_cast<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).
|
||||
for (const PerformanceZone& z : map_.zones) {
|
||||
if (z.sampleId == selectedId_) return z;
|
||||
}
|
||||
PerformanceZone z;
|
||||
z.sampleId = selectedId_;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
return z;
|
||||
params_.loopOverride = loop;
|
||||
params_.startPoint = m.start;
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::effectiveRoot() const {
|
||||
int root = 60;
|
||||
if (params_.rootOverride) return *params_.rootOverride;
|
||||
for (const SampleChoice& s : samples_) {
|
||||
if (s.id == selectedId_ && s.rootNote) root = *s.rootNote;
|
||||
if (s.id == selectedId_ && s.rootNote) return *s.rootNote;
|
||||
}
|
||||
for (const PerformanceZone& z : map_.zones) {
|
||||
if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
int ReaSamplerEditor::ensureSampleZone() {
|
||||
if (selectedId_.empty()) return -1;
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
if (map_.zones[static_cast<std::size_t>(i)].sampleId == selectedId_) return i;
|
||||
}
|
||||
PerformanceZone z;
|
||||
z.sampleId = selectedId_;
|
||||
z.lowNote = 0;
|
||||
z.highNote = 127;
|
||||
map_.zones.push_back(z);
|
||||
return static_cast<int>(map_.zones.size()) - 1;
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
|
||||
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert):
|
||||
// a full-keyboard zone carrying the override. This plays identically to the un-zoned single
|
||||
// capture (one chromatic zone) and round-trips through the component state; the zone becomes
|
||||
// visible if the user opens the Zones panel. The bank intrinsic is never written.
|
||||
if (selectedId_.empty()) return;
|
||||
upsertPickedOverride(m);
|
||||
commitAndReload();
|
||||
return 60;
|
||||
}
|
||||
|
||||
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// processor_reload.cpp — ReaSamplerProcessor's off-audio-thread instrument lifecycle:
|
||||
// reloadInstrument (self-contained refs resolve + WAV decode + keymap build), the
|
||||
// reloadInstrument (self-contained refs resolve + WAV decode + SampleData build), the
|
||||
// safety-critical publishBuiltLocked drain-slot swap, the voice-param light rebuild,
|
||||
// idle-drain retirement, the pre-v10 legacy-lift gate, the bank-sync poll, and the
|
||||
// usage publish. Nothing here runs on the audio thread — process() only touches the
|
||||
@@ -19,7 +19,7 @@
|
||||
#include "core/capture/capture_paths.h" // resolveBankFile (shared path resolution)
|
||||
#include "core/capture/wav_codec.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
#include "core/instrument/map/bank_sync.h" // pure decisions: parseBankGeneration, consumeDecision
|
||||
#include "core/instrument/map/sample_map.h" // refs resolve, buildZonedKeymap (self-contained)
|
||||
#include "core/instrument/map/sample_map.h" // refs resolve, buildSampleData (self-contained)
|
||||
#include "core/util/file_bytes.h" // shared whole-file loader
|
||||
#include "core/wire/assignment_request.h" // decodeAssignmentRequest (request wire parse)
|
||||
#include "core/wire/sample_usage.h" // usage publish plan + wire (prune-protection seam)
|
||||
@@ -60,11 +60,10 @@ std::string mintUsageInstanceGuid() {
|
||||
// Resolves a project-relative WAV path, reads + decodes it (file I/O, off-thread only),
|
||||
// and applies the cross-mode channel policy for `mode` (mono downmix; stereo -> dual-mono
|
||||
// for a mono source, L/R for a stereo source — see decodeChannels). Returns nullopt on any
|
||||
// resolve/read/decode failure — the caller drops the zone or plays silence. Shared by the
|
||||
// zoned build and the single-capture path.
|
||||
std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
const std::string& relativePath,
|
||||
ChannelMode mode) {
|
||||
// resolve/read/decode failure — the caller plays silence.
|
||||
std::optional<DecodedPcm> decodeRelative(const std::string& projectDir,
|
||||
const std::string& relativePath,
|
||||
ChannelMode mode) {
|
||||
const std::string abs = resolveBankFile(projectDir, relativePath);
|
||||
if (abs.empty()) return std::nullopt;
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
||||
@@ -72,8 +71,8 @@ std::optional<DecodedZonePcm> decodeRelative(const std::string& projectDir,
|
||||
if (!layout.valid) return std::nullopt;
|
||||
std::vector<AudioSample> interleaved =
|
||||
extractFloatFrames(bytes, layout, 0, layout.frameCount());
|
||||
DecodedZonePcm out = decodeChannels(interleaved, layout.channelCount, mode,
|
||||
static_cast<int>(layout.sampleRate));
|
||||
DecodedPcm out = decodeChannels(interleaved, layout.channelCount, mode,
|
||||
static_cast<int>(layout.sampleRate));
|
||||
if (out.monoFrames.empty()) return std::nullopt;
|
||||
return out;
|
||||
}
|
||||
@@ -95,8 +94,8 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
// nothing below — a project restored before PROJEXTSTATE parses (or with the
|
||||
// extension absent) resolves + plays from the persisted refs.
|
||||
const std::string selId = selectedSampleId();
|
||||
const PerformanceMap map = performanceMap();
|
||||
const std::vector<std::string> ids = referencedSampleIds(selId, map);
|
||||
const InstrumentParams params = instrumentParams();
|
||||
const std::vector<std::string> ids = referencedSampleIds(selId);
|
||||
SampleRefs refs;
|
||||
{
|
||||
std::optional<std::string> banksJson =
|
||||
@@ -110,8 +109,8 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
refs = sampleRefs_; // snapshot for the decode below (outside the refs lock)
|
||||
}
|
||||
const std::string projectDir = bridge_.activeProjectDir();
|
||||
// Governs how each WAV decodes (mono downmix vs 2-channel); the single-capture branch
|
||||
// below may auto-default it before its decode.
|
||||
// Governs how the WAV decodes (mono downmix vs 2-channel); auto-defaulted from the
|
||||
// capture's own channel count below, before the decode.
|
||||
ChannelMode mode = channelMode();
|
||||
// Snapshot the voice-system parameters once — baked into the built engine's
|
||||
// construction (immutable config; a later change rebuilds).
|
||||
@@ -127,60 +126,33 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
|
||||
std::string resolvedId;
|
||||
std::unique_ptr<LoadedInstrument> built;
|
||||
Keymap km;
|
||||
bool haveKeymap = false;
|
||||
SampleData sample;
|
||||
bool havePlayable = false;
|
||||
|
||||
// 2. Zoned build: if the performance map is non-empty, resolve its zones against the
|
||||
// owned refs (an id with no ref drops cleanly), decode each zone's WAV off-thread,
|
||||
// and build the keymap. A zone whose WAV fails to decode is dropped, not the whole
|
||||
// map — the defined no-play, no crash, no retry loop.
|
||||
if (!map.empty()) {
|
||||
const ResolvedPerformance resolved = resolvePerformanceFromRefs(refs, map);
|
||||
if (!resolved.zones.empty()) {
|
||||
std::vector<DecodedZonePcm> decoded;
|
||||
std::vector<ResolvedZone> kept;
|
||||
decoded.reserve(resolved.zones.size());
|
||||
kept.reserve(resolved.zones.size());
|
||||
for (const ResolvedZone& rz : resolved.zones) {
|
||||
std::optional<DecodedZonePcm> pcm =
|
||||
decodeRelative(projectDir, rz.relativePath, mode);
|
||||
if (!pcm) continue; // unreadable/missing WAV -> drop this zone
|
||||
kept.push_back(rz);
|
||||
decoded.push_back(std::move(*pcm));
|
||||
}
|
||||
km = buildZonedKeymap(kept, decoded);
|
||||
haveKeymap = !km.zones.empty();
|
||||
// 2. Resolve + decode the ONE loaded capture, which plays across the whole keyboard
|
||||
// repitched from its effective root. No first-sample fallback: an empty selection
|
||||
// (or one with no ref) resolves to nothing, so an un-picked instrument stays silent
|
||||
// rather than auto-playing sample #1. A missing/unreadable WAV is the same defined
|
||||
// no-play — no crash, no retry loop.
|
||||
if (const SelectedSample* sel = findRef(refs, selId)) {
|
||||
// Auto-default: channelModeFor computes the mode from the loaded capture's channel
|
||||
// count (always 2 for extension captures; mono only for ingest-imported mono files).
|
||||
// An unknown count (0) or explicit user choice keeps the mode.
|
||||
{
|
||||
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
||||
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
||||
channelModeExplicit_);
|
||||
mode = channelMode_;
|
||||
}
|
||||
std::optional<DecodedPcm> pcm = decodeRelative(projectDir, sel->relativePath, mode);
|
||||
if (pcm) {
|
||||
sample = buildSampleData(resolveCapture(*sel, params), std::move(*pcm));
|
||||
havePlayable = sample.playable();
|
||||
if (havePlayable) resolvedId = selId; // the concrete pick that resolved
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Single-capture fast path: an empty performance map plays the one selected capture
|
||||
// chromatically across the whole keyboard. No first-sample fallback: an empty
|
||||
// selection (or one with no ref) resolves to nothing, so an un-picked instrument
|
||||
// stays silent rather than auto-playing sample #1.
|
||||
if (!haveKeymap) {
|
||||
if (const SelectedSample* sel = findRef(refs, selId)) {
|
||||
// Auto-default: channelModeFor computes the mode from the loaded capture's
|
||||
// channel count (always 2 for extension captures; mono only for ingest-imported
|
||||
// mono files). An unknown count (0) or explicit user choice keeps the mode.
|
||||
{
|
||||
std::lock_guard<std::mutex> cm(channelModeMutex_);
|
||||
channelMode_ = channelModeFor(sel->channelCount, channelMode_,
|
||||
channelModeExplicit_);
|
||||
mode = channelMode_;
|
||||
}
|
||||
std::optional<DecodedZonePcm> pcm =
|
||||
decodeRelative(projectDir, sel->relativePath, mode);
|
||||
if (pcm) {
|
||||
km = buildTier0Keymap(std::move(pcm->monoFrames), pcm->sampleRate,
|
||||
sel->rootNote, sel->loop,
|
||||
std::move(pcm->framesR));
|
||||
haveKeymap = true;
|
||||
resolvedId = selId; // the concrete pick that resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (haveKeymap) {
|
||||
if (havePlayable) {
|
||||
// Preserve OLA window in output frames from the host rate (kPreserveWindowMs),
|
||||
// pre-sized here so process()-time note-on never allocates. Floored at 2 so a
|
||||
// valid window is always a real ring, covering a pathological host rate <= 0 too.
|
||||
@@ -188,16 +160,16 @@ std::string ReaSamplerProcessor::reloadInstrument() {
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||
}
|
||||
|
||||
// 4. Publish: atomically install the new instrument via the drain-slot swap (see the
|
||||
// 3. Publish: atomically install the new instrument via the drain-slot swap (see the
|
||||
// header). A null `built` (no ref / unreadable WAV) installs silence while any
|
||||
// displaced tails still ring out via the drain.
|
||||
publishBuiltLocked(std::move(built));
|
||||
|
||||
// 5. Publish this instance's held captures so the extension's prune can never reclaim
|
||||
// 4. Publish this instance's held captures so the extension's prune can never reclaim
|
||||
// them. Regardless of decode success: the holds are the refs the instance retains
|
||||
// (its play-set), not what decoded — a transiently unreadable WAV stays protected.
|
||||
publishUsage(refs, ids);
|
||||
@@ -282,12 +254,12 @@ void ReaSamplerProcessor::rebuildVoiceEngine() {
|
||||
kPreserveWindowMs * sampleRate_ / 1000.0 + 0.5);
|
||||
if (preserveWindow < 2) preserveWindow = 2;
|
||||
|
||||
// Deep-copy the decoded PCM + zones: safe to read concurrently with process() because
|
||||
// the keymap is immutable after construction and reloadMutex_ prevents `cur` from
|
||||
// being freed.
|
||||
Keymap km = cur->keymap;
|
||||
// Deep-copy the decoded sample: safe to read concurrently with process() because the
|
||||
// SampleData is immutable after construction and reloadMutex_ prevents `cur` from being
|
||||
// freed.
|
||||
SampleData sample = cur->sample;
|
||||
auto built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
std::move(sample), static_cast<std::size_t>(builtVoiceCount), gen,
|
||||
kPreserveVoiceCap, preserveWindow, builtVoiceMode, builtMonoTrigger);
|
||||
publishBuiltLocked(std::move(built));
|
||||
}
|
||||
@@ -322,7 +294,7 @@ bool ReaSamplerProcessor::legacyLiftShouldRun() {
|
||||
if (legacyLiftConcluded_.load(std::memory_order_relaxed)) return false;
|
||||
const LegacyLiftDecision decision = legacyLiftDecision(
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey),
|
||||
referencedSampleIds(selectedSampleId(), performanceMap()));
|
||||
referencedSampleIds(selectedSampleId()));
|
||||
if (decision == LegacyLiftDecision::Stale) {
|
||||
// Provably stale: give up permanently. A later bank change that re-introduces an
|
||||
// id bumps the generation, and genChanged refreshes the refs without this latch.
|
||||
@@ -379,15 +351,10 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
|
||||
if (decision.apply) {
|
||||
// Apply as this instance's own selection (the instrument updates its own state,
|
||||
// never the bank); reloadInstrument below rebuilds against it.
|
||||
// never the bank); reloadInstrument below rebuilds against it. The parameter set
|
||||
// carries over to the new capture — there is only one, and it governs whatever is
|
||||
// loaded (the peer of the editor's Browse Load).
|
||||
setSelectedSampleId(decision.sampleId);
|
||||
// Peer of the editor's Browse Load: a stale full-range zone from the previous
|
||||
// sample would shadow the assigned pick under first-match resolve. Authored maps
|
||||
// (narrow key ranges) are untouched.
|
||||
PerformanceMap reconciled = performanceMap();
|
||||
if (reconcileSingleCaptureZones(reconciled, decision.sampleId)) {
|
||||
setPerformanceMap(reconciled);
|
||||
}
|
||||
result.applied = true;
|
||||
}
|
||||
|
||||
@@ -412,8 +379,7 @@ ReaSamplerProcessor::pollBankSync(bool isFocusedTarget) {
|
||||
// dependency (a v10 blob plays from its refs with no poll at all).
|
||||
bool legacyLift = false;
|
||||
if (!genChanged && !result.applied && sampleRefs().empty()) {
|
||||
const bool hasIntent = !selectedSampleId().empty() || !performanceMap().empty();
|
||||
legacyLift = hasIntent && legacyLiftShouldRun();
|
||||
legacyLift = !selectedSampleId().empty() && legacyLiftShouldRun();
|
||||
}
|
||||
|
||||
if (genChanged || result.applied || legacyLift) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// processor_state.cpp — ReaSamplerProcessor's component-state I/O (setState/getState
|
||||
// against the component_state_io codec) and its UI-thread parameter accessors/setters
|
||||
// (selection, performance map, channel mode, preview velocity, voice-system params,
|
||||
// (selection, the one parameter set, channel mode, preview velocity, voice-system params,
|
||||
// master gain, preview-note mailbox posts). Everything here runs off the audio thread;
|
||||
// setters hand work to the reload family (processor_reload.cpp) or store atomics
|
||||
// process() picks up at block start.
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (post-mixer gain clamp)
|
||||
#include "core/instrument/map/component_state_io.h" // the ComponentState codec
|
||||
#include "core/instrument/map/sample_map.h" // reconcileSingleCaptureZones / retainRefs / referencedSampleIds
|
||||
#include "core/instrument/map/sample_map.h" // retainRefs / referencedSampleIds
|
||||
|
||||
using namespace Steinberg;
|
||||
using namespace Steinberg::Vst;
|
||||
@@ -34,18 +34,14 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
|
||||
bytes.insert(bytes.end(), chunk, chunk + got);
|
||||
}
|
||||
// Component state is {single-capture selection id, opt-in zones}, restored explicitly
|
||||
// since they're distinct (default face vs. a demoted overlay). deserializeComponentState
|
||||
// lifts older blobs cleanly (no first-sample fallback in reloadInstrument). sampleRate_
|
||||
// is the real host rate here — REAPER calls setupProcessing before setState on load.
|
||||
// Component state is {loaded capture id, one parameter set}. deserializeComponentState
|
||||
// lifts older blobs cleanly — including the retired zone payloads, which adopt zone
|
||||
// one's capture into cs.selectionId. sampleRate_ is the real host rate here (REAPER
|
||||
// calls setupProcessing before setState on load), which the legacy v3 payload's
|
||||
// frames->seconds conversion needs.
|
||||
const ComponentState cs = deserializeComponentState(bytes, sampleRate_);
|
||||
setSelectedSampleId(cs.selectionId);
|
||||
// Heal-on-load: a blob saved under the pre-fix editor may carry stale full-range zones
|
||||
// (one per sample ever browsed), the oldest shadowing the saved selection under
|
||||
// first-match resolve. Authored Zone-view maps (narrow key ranges) pass through untouched.
|
||||
PerformanceMap restored = cs.map;
|
||||
reconcileSingleCaptureZones(restored, cs.selectionId); // bool return ignored: reload below runs unconditionally
|
||||
setPerformanceMap(restored);
|
||||
setInstrumentParams(cs.params);
|
||||
// Restore the last-consumed assignment generation so a re-open does not re-apply a
|
||||
// stale assign_request.
|
||||
{
|
||||
@@ -95,11 +91,11 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Persists the full instance state — never written to the "reasampler" bank ext-state.
|
||||
// No pick + no zones serializes to {"", no zones}, restoring as silence (never
|
||||
// auto-playing sample #1).
|
||||
// No pick serializes to {"", default params}, restoring as silence (never auto-playing
|
||||
// sample #1).
|
||||
ComponentState state_out;
|
||||
state_out.selectionId = selectedSampleId();
|
||||
state_out.map = performanceMap();
|
||||
state_out.params = instrumentParams();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(channelModeMutex_);
|
||||
state_out.channelMode = channelMode_;
|
||||
@@ -121,8 +117,7 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
// present. Filtered (snapshot copy only) to what the instance currently plays, so the
|
||||
// table cannot grow with browsing history.
|
||||
state_out.sampleRefs = sampleRefs();
|
||||
retainRefs(state_out.sampleRefs,
|
||||
referencedSampleIds(state_out.selectionId, state_out.map));
|
||||
retainRefs(state_out.sampleRefs, referencedSampleIds(state_out.selectionId));
|
||||
// Persist the publish identity so the usage key is stable across sessions.
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(usageMutex_);
|
||||
@@ -147,14 +142,14 @@ void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
|
||||
selectedSampleId_ = id;
|
||||
}
|
||||
|
||||
PerformanceMap ReaSamplerProcessor::performanceMap() {
|
||||
std::lock_guard<std::mutex> lock(performanceMutex_);
|
||||
return performanceMap_;
|
||||
InstrumentParams ReaSamplerProcessor::instrumentParams() {
|
||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||
return params_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setPerformanceMap(const PerformanceMap& map) {
|
||||
std::lock_guard<std::mutex> lock(performanceMutex_);
|
||||
performanceMap_ = map;
|
||||
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
|
||||
std::lock_guard<std::mutex> lock(paramsMutex_);
|
||||
params_ = params;
|
||||
}
|
||||
|
||||
SampleRefs ReaSamplerProcessor::sampleRefs() {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// reasampler_editor.h — VST3 IPlugView LICE editor for the ReaSampler 9000 UI. Thin shell:
|
||||
// hosts a LICE child window, routing host paint/mouse into the pure geometry modules
|
||||
// (capture_browser, keyboard_strip, sample_map) — default face is the capture browser, then
|
||||
// single-capture setup, with an opt-in zones panel. All layout/hit-test/drag math lives in
|
||||
// the pure modules; every edit commits off the audio thread via reloadInstrument.
|
||||
// (sample_bands, sample_chrome, capture_browser, keyboard_strip, sample_map). The Sample
|
||||
// face is a three-band stack — chrome, waveform, decks — and the shell TUs split on that
|
||||
// same axis; Browse is a modal picker over it. All layout/hit-test/drag math lives in the
|
||||
// pure modules; every edit commits off the audio thread via reloadInstrument.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -16,9 +17,11 @@
|
||||
#include "core/instrument/ui/editor_geometry.h" // Rect (shared sub-rect type)
|
||||
#include "core/instrument/ui/envelope_edit.h" // EnvClampBounds / NodeHit (envelope node hit-test/edit)
|
||||
#include "core/instrument/ui/envelope_overlay.h" // AmpEnvelope / EnvNode (envelope overlay draw seam)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (Sample + Zone knob deck)
|
||||
#include "core/instrument/ui/knob_deck.h" // DeckGroupDesc / DeckLayout (the deck band)
|
||||
#include "core/instrument/ui/sample_bands.h" // SampleBands (the band-stack allocator)
|
||||
#include "core/instrument/ui/sample_chrome.h" // ChromeRects (chrome-band interior)
|
||||
#include "core/audio/peaks.h" // Envelope (the cached peak thumbnail)
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot)
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice, BankChoice, InstrumentParams
|
||||
#include "core/instrument/engine/velocity_curve.h" // VelocityCurve (transfer-curve editor state)
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -32,25 +35,26 @@ namespace reasampler::vst {
|
||||
using audio::AudioSample;
|
||||
using audio::Envelope;
|
||||
using instrument::map::BankChoice;
|
||||
using instrument::map::PerformanceMap;
|
||||
using instrument::map::PerformanceZone;
|
||||
using instrument::map::InstrumentParams;
|
||||
using instrument::map::PlaySeconds;
|
||||
using instrument::map::SampleChoice;
|
||||
using instrument::map::SampleRefEntry;
|
||||
using instrument::map::SampleRefs;
|
||||
using instrument::map::ZonePlaySeconds;
|
||||
using instrument::ui::AmpEnvelope;
|
||||
using instrument::ui::ChromeRects;
|
||||
using instrument::ui::DeckGroupDesc;
|
||||
using instrument::ui::EnvClampBounds;
|
||||
using instrument::ui::EnvNode;
|
||||
using instrument::ui::Rect;
|
||||
using instrument::ui::SampleBands;
|
||||
|
||||
class ReaSamplerProcessor;
|
||||
|
||||
class ReaSamplerEditor : public Steinberg::CPluginView {
|
||||
public:
|
||||
// `processor` outlives this editor; the editor reads the live bank through it and drives
|
||||
// selection/zone edits + reload on user input. May be null (defensive; a real host always
|
||||
// supplies one).
|
||||
// selection/parameter edits + reload on user input. May be null (defensive; a real host
|
||||
// always supplies one).
|
||||
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
||||
~ReaSamplerEditor() override;
|
||||
|
||||
@@ -65,18 +69,18 @@ protected:
|
||||
Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override;
|
||||
|
||||
private:
|
||||
// Sample is the home/default face. Browse is a full-window modal picker overlaid on
|
||||
// Sample. Zone is the dedicated multi-zone keymap surface, button-summoned.
|
||||
enum class View { kSample, kBrowse, kZone };
|
||||
// Sample is the home/default face (the three-band stack). Browse is a full-window modal
|
||||
// picker overlaid on it.
|
||||
enum class View { kSample, kBrowse };
|
||||
|
||||
// What a mouse drag is currently editing. kWaveMarker/kEnvNode/kCurveNode track their
|
||||
// grabbed item in waveMarker_/envNode_/curvePointIndex_; kDeckKnob is a grab-anchored
|
||||
// knob drag (control in dragParamId_, grab value in dragKnobStartValue_).
|
||||
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker,
|
||||
kScrollThumb, kEnvNode, kCurveNode, kDeckKnob };
|
||||
enum class DragKind { kNone, kRootMarker, kWaveMarker, kScrollThumb, kEnvNode,
|
||||
kCurveNode, kDeckKnob };
|
||||
|
||||
// Controls on the setup surface. The int value is the opaque control id the pure
|
||||
// knob_deck hit-test returns; the shell maps it to the zone's play params or a
|
||||
// knob_deck hit-test returns; the shell maps it to the one parameter set or a
|
||||
// processor-side per-instance setter.
|
||||
enum class ParamControl {
|
||||
kPlayMode = 0, // Gate | Trigger toggle
|
||||
@@ -93,9 +97,9 @@ private:
|
||||
kPitchEnvAttack, // AD pitch attack
|
||||
kPitchEnvDecay, // AD pitch decay
|
||||
kPitchEnvDepth, // AD pitch depth in +/- semitones
|
||||
kKeyTrack, // key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds)
|
||||
// Deck-only controls: processor-side per-instance params, NOT zone params — routed to
|
||||
// the processor setters, never through applyZoneControl / the map.
|
||||
kKeyTrack, // key-tracking 0..200% (lives on InstrumentParams, not PlaySeconds)
|
||||
// Deck-only controls: processor-side per-instance params — routed to the processor
|
||||
// setters, never through applyParamControl.
|
||||
kVoiceCount, // polyphony bound (1..32) — a stepped knob in the VOICE group
|
||||
kVoiceMode, // Poly | Mono caption toggle (VOICE group)
|
||||
kMonoTrigger, // Retrig | Legato row toggle (VOICE group; live only in Mono)
|
||||
@@ -103,8 +107,8 @@ private:
|
||||
kCount
|
||||
};
|
||||
|
||||
// The waveform markers on the single-capture setup surface: start-point + the sustain
|
||||
// loop's two ends, in draw + hit order.
|
||||
// The waveform markers on the waveform band: start-point + the sustain loop's two ends,
|
||||
// in draw + hit order.
|
||||
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
|
||||
|
||||
// The interactive element under the pointer, resolved live in WM_MOUSEMOVE. `index`
|
||||
@@ -112,9 +116,8 @@ private:
|
||||
// not applicable.
|
||||
enum class HoverKind {
|
||||
kNone,
|
||||
kNavBrowse, // the Sample-view "Browse" title-band button (opens the Browse modal)
|
||||
kNavZone, // the Sample-view "Zone" title-band button (opens the Zone surface)
|
||||
kBack, // the Browse/Zone "back" affordance (returns to Sample)
|
||||
kNavBrowse, // the chrome "Browse" toolbar button (opens the Browse modal)
|
||||
kBack, // the Browse "back" affordance (returns to Sample)
|
||||
kSearchBox, // the browser search box
|
||||
kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All)
|
||||
kCard, // a capture card (index = visible_ index)
|
||||
@@ -122,13 +125,11 @@ private:
|
||||
kBrowseCancel, // the Browse modal "Cancel" button
|
||||
kChanMono, // the mono channel-mode segment
|
||||
kChanStereo, // the stereo channel-mode segment
|
||||
kPreview, // the Sample-view preview-trigger button
|
||||
kAddZone, // the "+ Add Zone" button
|
||||
kDeleteZone, // the "Delete" zone button
|
||||
kPreview, // the preview-trigger button
|
||||
kControl, // a knob-deck element (index = control id)
|
||||
kCurveNode, // a velocity-curve control point (index = point index)
|
||||
kVelKnob, // the cluster preview-velocity radial knob
|
||||
kCurveButton, // the cluster mini curve-preview button (opens the popup)
|
||||
kVelKnob, // the chrome preview-velocity radial knob
|
||||
kCurveButton, // the chrome mini curve-preview button (opens the popup)
|
||||
kPopupClose, // the curve popup's Close (x) button
|
||||
};
|
||||
struct HoverTarget {
|
||||
@@ -138,60 +139,82 @@ private:
|
||||
bool operator!=(const HoverTarget& o) const { return !(*this == o); }
|
||||
};
|
||||
|
||||
// The three-band stack for the current client size, plus the chrome interior. Every
|
||||
// paint/hit-test path derives both through this one call so draw and hit-test can never
|
||||
// disagree about where a band is.
|
||||
struct FaceLayout {
|
||||
SampleBands bands;
|
||||
ChromeRects chrome;
|
||||
std::vector<DeckGroupDesc> deckDescs;
|
||||
};
|
||||
FaceLayout faceLayout(int w, int h) const;
|
||||
|
||||
#ifdef _WIN32
|
||||
void paint(HDC hdc);
|
||||
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face
|
||||
void paintSample(LICE_IBitmap* bmp, int w, int h); // home face (band composition)
|
||||
void paintBrowse(LICE_IBitmap* bmp, int w, int h); // modal picker overlay
|
||||
void paintZone(LICE_IBitmap* bmp, int w, int h); // zone surface
|
||||
void paintEmptyState(LICE_IBitmap* bmp, const Rect& area);
|
||||
|
||||
// The knob deck: group fence + caption + compact caption toggles + radial knobs with
|
||||
// label<->value swap on hover/drag. `descs` picks the group set (Sample's deckGroupDescs
|
||||
// or the Zone panel's zoneDeckGroupDescs); caller anchors (Sample bottom, Zone top).
|
||||
void paintKnobDeck(LICE_IBitmap* bmp, const Rect& deckArea, const PerformanceZone& zone,
|
||||
const std::vector<DeckGroupDesc>& descs);
|
||||
// The mini curve-preview button shared by the Sample cluster + the Zone panel.
|
||||
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
|
||||
// The centered curve-popup sheet. Edits popupZone() — the Sample face's one-zone site
|
||||
// or the Zone surface's selected zone.
|
||||
// --- Band painters (one TU each, mirroring the input side) ---
|
||||
// Chrome: title band + Browse nav + the control row (root strip, preview, velocity knob,
|
||||
// curve button, channel toggle).
|
||||
void paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool empty);
|
||||
// Waveform: the channel lane(s), the loop/start markers, and the envelope overlay.
|
||||
void paintWaveform(LICE_IBitmap* bmp, const Rect& band);
|
||||
// Decks: the group fence + caption + compact caption toggles + radial knobs with
|
||||
// label<->value swap on hover/drag.
|
||||
void paintDeck(LICE_IBitmap* bmp, const FaceLayout& fl);
|
||||
|
||||
// The mini curve-preview button (chrome) and the modal curve editor it summons.
|
||||
void paintCurveButton(LICE_IBitmap* bmp, const Rect& r);
|
||||
void paintCurvePopup(LICE_IBitmap* bmp, int w, int h);
|
||||
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`.
|
||||
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone,
|
||||
std::int64_t frames);
|
||||
// The velocity->amp transfer-curve editor (X = velocity 0-127, Y = amp 0-1); its only
|
||||
// host is the popup sheet. `r` empty -> draws nothing.
|
||||
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r, const PerformanceZone& zone);
|
||||
|
||||
// Mouse-down inside curve-editor box `r` editing map_.zones[zoneIndex]: a node grab
|
||||
// starts a kCurveNode drag; Alt-click on an interior node deletes it at once; an
|
||||
// empty-space click adds a point and grabs it. `zoneIndex` must be valid (callers
|
||||
// materialize first).
|
||||
void handleCurveMouseDown(const Rect& r, int zoneIndex, int x, int y);
|
||||
|
||||
// Left-click while the curve popup is open (modal over both faces): Close /
|
||||
// outside-wash dismiss, in-box clicks route to the curve machinery, else swallowed.
|
||||
// Returns true whenever the popup is open (it consumed the click).
|
||||
bool handlePopupMouseDown(int w, int h, int x, int y);
|
||||
void paintVelocityCurve(LICE_IBitmap* bmp, const Rect& r);
|
||||
// Traces the amp-envelope overlay + its draggable node handles over `waveArea`, ONCE at
|
||||
// full band height (never per lane).
|
||||
void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, std::int64_t frames);
|
||||
|
||||
// --- Input: the mouse-down dispatch and its per-band branches ---
|
||||
void onMouseDown(int x, int y);
|
||||
// The Browse-modal and Zone-surface halves of the mouse-down dispatch (bodies in
|
||||
// editor_input_browse_zone.cpp).
|
||||
// Each returns true when it consumed the click. Called in band order by onMouseDown.
|
||||
bool mouseDownChrome(const FaceLayout& fl, int x, int y);
|
||||
bool mouseDownWaveform(const FaceLayout& fl, int x, int y);
|
||||
bool mouseDownDeck(const FaceLayout& fl, int x, int y);
|
||||
void mouseDownBrowse(int w, int h, int x, int y);
|
||||
void mouseDownZone(int w, int h, int x, int y);
|
||||
|
||||
// Live drag resolution, split on the same axis; each handles only its own DragKind
|
||||
// values and is called from onMouseMove's router.
|
||||
void dragChrome(const FaceLayout& fl, int x, int y); // kRootMarker
|
||||
void dragWaveform(const FaceLayout& fl, int x, int y); // kEnvNode / kWaveMarker
|
||||
void dragDeck(int x, int y); // kDeckKnob
|
||||
void dragBrowse(int x, int y); // kScrollThumb
|
||||
void dragCurve(int x, int y); // kCurveNode
|
||||
|
||||
void onMouseMove(int x, int y);
|
||||
void onMouseUp(int x, int y);
|
||||
// Right-click is the curve popup's primary node-delete affordance; only acts while the
|
||||
// popup is open (deletePoint's endpoint guard makes an endpoint right-click a no-op).
|
||||
void onMouseRDown(int x, int y);
|
||||
|
||||
// Applies a knob/toggle interaction to map_.zones[zoneIndex] for control `id`: ordinary
|
||||
// controls route through applyControl; kKeyTrack writes the zone's keyTrack scalar
|
||||
// (0..200% over the knob's 0..1).
|
||||
void applyZoneControl(int zoneIndex, int id, double value, int segment);
|
||||
// Mouse-down inside curve-editor box `r`: a node grab starts a kCurveNode drag;
|
||||
// Alt-click on an interior node deletes it at once; an empty-space click adds a point
|
||||
// and grabs it.
|
||||
void handleCurveMouseDown(const Rect& r, int x, int y);
|
||||
|
||||
// Left-click while the curve popup is open (modal over the Sample face): Close /
|
||||
// outside-wash dismiss, in-box clicks route to the curve machinery, else swallowed.
|
||||
// Returns true whenever the popup is open (it consumed the click).
|
||||
bool handlePopupMouseDown(int w, int h, int x, int y);
|
||||
|
||||
// Resolves the interactive element under (x, y) into hover_, called from WM_MOUSEMOVE.
|
||||
// Repaints only on change, so an idle move is free. Windows-only.
|
||||
// Repaints only on change, so an idle move is free. The per-band resolvers mirror the
|
||||
// mouse-down branches but are read-only. Windows-only.
|
||||
void resolveHover(int x, int y);
|
||||
HoverTarget hoverChrome(const FaceLayout& fl, int x, int y) const;
|
||||
HoverTarget hoverDeck(const FaceLayout& fl, int x, int y) const;
|
||||
HoverTarget hoverBrowse(int w, int h, int x, int y) const;
|
||||
HoverTarget hoverCurvePopup(int w, int h, int x, int y) const;
|
||||
bool isHovered(HoverKind kind, int index) const {
|
||||
return hover_.kind == kind && hover_.index == index;
|
||||
}
|
||||
@@ -215,16 +238,15 @@ private:
|
||||
#endif
|
||||
|
||||
// Re-read the bank (samples + banks) from the live bridge and snapshot the instrument's
|
||||
// selection + performance map. Main/UI thread only. Called on attach and after any edit.
|
||||
// selection + parameter set. Main/UI thread only. Called on attach and after any edit.
|
||||
void refreshFromBank();
|
||||
|
||||
// Publishes the edited zones/selection to the processor, then rebuilds the instrument
|
||||
// off the audio thread. UI thread only.
|
||||
// Publishes the edited selection + parameters to the processor, then rebuilds the
|
||||
// instrument off the audio thread. UI thread only.
|
||||
void commitAndReload();
|
||||
|
||||
// Commits `id` as the loaded single-capture selection. Runs reconcileSingleCaptureZones
|
||||
// first so the previous sample's materialized full-range zone cannot linger and shadow
|
||||
// the new pick under first-match resolve, then publishes + reloads.
|
||||
// Commits `id` as the loaded capture. The one parameter set carries over — it governs
|
||||
// whatever is loaded, so a load swaps the sound, not the settings.
|
||||
void loadSelection(const std::string& id);
|
||||
|
||||
// Recomputes the visible capture cards (samples_ narrowed by activeFilterBankId_ then
|
||||
@@ -240,8 +262,8 @@ private:
|
||||
// thread only (file I/O); cleared with the thumbnail cache on refresh.
|
||||
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
|
||||
|
||||
// The effective loop + start markers for the picked capture: the per-zone override when
|
||||
// one exists in map_, else the bank's loop intrinsic / frame 0. Absent loop ->
|
||||
// The effective loop + start markers for the loaded capture: the parameter set's
|
||||
// override when one is set, else the bank's loop intrinsic / frame 0. Absent loop ->
|
||||
// loopStart==loopEnd==0. `frames` defaults loopEnd when the bank left the loop empty.
|
||||
struct SetupMarkers {
|
||||
std::int64_t start = 0;
|
||||
@@ -251,94 +273,74 @@ private:
|
||||
};
|
||||
SetupMarkers pickedMarkers(std::int64_t frames) const;
|
||||
|
||||
// Commits an edited marker set for the picked capture as a per-zone loop/start override
|
||||
// (upsert on the picked id), then reloads off-thread.
|
||||
void commitPickedMarkers(const SetupMarkers& m);
|
||||
// Writes `m` into params_ as the loop/start override. Does NOT call commitAndReload —
|
||||
// callers decide live-drag vs final commit.
|
||||
void applyMarkers(const SetupMarkers& m);
|
||||
|
||||
// Writes `m` as a loop/start override upsert into map_ for selectedId_ (find-or-append).
|
||||
// Does NOT call commitAndReload — callers decide live-drag vs final commit. selectedId_
|
||||
// must be non-empty. Returns the updated/appended zone index.
|
||||
int upsertPickedOverride(const SetupMarkers& m);
|
||||
|
||||
// Deck knobs edit a zone's ZonePlaySeconds (play mode + AHDSR; pitch engine + AD pitch
|
||||
// envelope) — wall-clock seconds, rate-free; the keymap build resolves to frames.
|
||||
// Deck knobs edit the parameter set's PlaySeconds (play mode + AHDSR; pitch engine + AD
|
||||
// pitch envelope) — wall-clock seconds, rate-free; the build resolves to frames.
|
||||
|
||||
// The normalized [0,1] display value for control `id` given `play` (seconds -> 0..1 over
|
||||
// a fixed ceiling, sustain 0..1 as-is, %-length/fade frames -> 0..1, semitone depth
|
||||
// centered at 0.5).
|
||||
double controlValue(int id, const ZonePlaySeconds& play) const;
|
||||
double controlValue(int id, const PlaySeconds& play) const;
|
||||
|
||||
// Applies a committed control interaction to `play`: a knob's normalized `value` or a
|
||||
// toggle's `segment` (0/1). Mutates `play` in place.
|
||||
void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const;
|
||||
void applyControl(int id, PlaySeconds& play, double value, int segment) const;
|
||||
|
||||
// Applies a knob/toggle interaction to the ONE parameter set for control `id`: ordinary
|
||||
// controls route through applyControl; kKeyTrack writes the keyTrack scalar (0..200%
|
||||
// over the knob's 0..1).
|
||||
void applyParamControl(int id, double value, int segment);
|
||||
|
||||
// The Trigger fade-in/out knob full-scale, in source frames: kFadeMaxSeconds resolved
|
||||
// against the live rate — never a baked-in rate. 44.1 kHz fallback pre-setupProcessing.
|
||||
// against the live rate — never a baked-in rate. Returns 0 when the rate is unknown.
|
||||
double fadeMaxFrames() const;
|
||||
|
||||
// envelope_overlay's AmpEnvelope stores Trigger fades as fractions of the played span,
|
||||
// while the zone stores source frames — pack/unpack own that conversion (see
|
||||
// while the parameter set stores source frames — pack/unpack own that conversion (see
|
||||
// envelope_overlay.h's trigger-seam note). `frames` is total source frames; AHDSR
|
||||
// seconds are rate-free and copy 1-to-1.
|
||||
|
||||
// PACK (draw): zone play params -> AmpEnvelope. `startFrame` is the zone's effective
|
||||
// start point (zone.startPoint.value_or(0)).
|
||||
AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames,
|
||||
// PACK (draw): play params -> AmpEnvelope. `startFrame` is the effective start point.
|
||||
AmpEnvelope packEnvelope(const PlaySeconds& play, std::int64_t frames,
|
||||
std::int64_t startFrame) const;
|
||||
|
||||
// UNPACK (commit): an edited AmpEnvelope -> the zone's play params, in place.
|
||||
// UNPACK (commit): an edited AmpEnvelope -> the play params, in place.
|
||||
void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame,
|
||||
ZonePlaySeconds& play) const;
|
||||
PlaySeconds& play) const;
|
||||
|
||||
// Clamp bounds envelope_edit uses, matching the sliders' own domains so a node drag can
|
||||
// never produce a param a slider couldn't.
|
||||
EnvClampBounds envClampBounds() const;
|
||||
|
||||
// The Sample face and the Zone surface read/write the same one-zone map site.
|
||||
// effectiveSampleZone returns the picked id's override if present in map_, else a
|
||||
// product-default zone (not yet materialized — a control edit does that).
|
||||
PerformanceZone effectiveSampleZone() const;
|
||||
// The effective root: rootOverride, else the bank intrinsic, else middle C.
|
||||
// The effective root: params_.rootOverride, else the bank intrinsic, else middle C.
|
||||
int effectiveRoot() const;
|
||||
// The live sample rate from the bridge, or 0 when unavailable (caller guards).
|
||||
double liveSampleRate() const;
|
||||
// Persisted preview velocity as a 0..1 slider value (MIDI 1..127 -> [0,1]).
|
||||
double previewVelocity01() const;
|
||||
|
||||
// Find-or-materializes the one-zone override for the picked id, appending a
|
||||
// product-default zone if none exists. Mirror of upsertPickedOverride for a control
|
||||
// edit. Returns -1 if selectedId_ is empty.
|
||||
int ensureSampleZone();
|
||||
// The deck groups: AMP ENVELOPE (Gate A/H/D/S/R; Trigger Fade In/Length %/Fade Out + two
|
||||
// reserved blanks so a mode flip never reflows neighbours) / PITCH (Key Track) / PITCH
|
||||
// ENV (P.Attack/P.Decay/P.Depth) / VOICE (Voices knob + Poly|Mono + Retrig|Legato) /
|
||||
// MASTER (Gain knob).
|
||||
std::vector<DeckGroupDesc> deckGroupDescs(const PlaySeconds& play) const;
|
||||
|
||||
// The popup edits ONE zone per open: the Zone surface's selected zone or the Sample
|
||||
// face's picked site. popupZone is the read-only resolve; popupZoneIndex is the edit
|
||||
// target — materializes on the Sample face via ensureSampleZone, never on the Zone
|
||||
// surface (button only shows for an explicit selection). -1 = no valid target.
|
||||
PerformanceZone popupZone() const;
|
||||
int popupZoneIndex();
|
||||
|
||||
// The per-zone deck groups both surfaces share: AMP ENVELOPE (Gate A/H/D/S/R; Trigger
|
||||
// Fade In/Length %/Fade Out + two reserved blanks so a mode flip never reflows
|
||||
// neighbours) / PITCH (Key Track) / PITCH ENV (P.Attack/P.Decay/P.Depth).
|
||||
std::vector<DeckGroupDesc> zoneDeckGroupDescs(const ZonePlaySeconds& play) const;
|
||||
|
||||
// The full Sample-face deck: the shared groups + the per-instance VOICE (Voices knob +
|
||||
// Poly|Mono + Retrig|Legato) and MASTER (Gain knob) groups.
|
||||
std::vector<DeckGroupDesc> deckGroupDescs(const ZonePlaySeconds& play) const;
|
||||
|
||||
// The normalized [0,1] value a deck knob shows for `zone` — zone params route through
|
||||
// The normalized [0,1] value a deck knob shows — parameter-set ids route through
|
||||
// controlValue/keyTrack; processor-side ids (voice count, master gain, preview velocity
|
||||
// via the -2 sentinel) read the processor's live value.
|
||||
double deckControlNorm(int id, const PerformanceZone& zone) const;
|
||||
double deckControlNorm(int id) const;
|
||||
|
||||
// Applies a deck-knob value: zone params write map_.zones[zoneIndex] (commit on
|
||||
// release); processor params write through the processor setters immediately
|
||||
// (transient — no map edit, no reload). zoneIndex ignored for processor-side ids.
|
||||
void applyDeckKnob(int zoneIndex, int id, double norm);
|
||||
// Applies a deck-knob value: parameter-set ids write params_ (commit on release);
|
||||
// processor params write through the processor setters immediately (transient — no
|
||||
// params edit, no reload).
|
||||
void applyDeckKnob(int id, double norm);
|
||||
|
||||
// The knob's live value label shown during hover/drag: seconds, percents, source
|
||||
// frames, signed semitones, a voice count, or the master-gain dB.
|
||||
std::string deckValueLabel(int id, const PerformanceZone& zone) const;
|
||||
std::string deckValueLabel(int id) const;
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
|
||||
@@ -346,8 +348,8 @@ private:
|
||||
std::vector<SampleChoice> samples_; // every bank sample, bank order
|
||||
std::vector<BankChoice> banks_; // the named banks, for the filter tab strip
|
||||
std::vector<SampleChoice> visible_; // samples_ narrowed by the active bank filter
|
||||
std::string selectedId_; // the single-capture pick ("" = empty state)
|
||||
PerformanceMap map_; // the opt-in zones (empty = no zones)
|
||||
std::string selectedId_; // the loaded capture ("" = empty state)
|
||||
InstrumentParams params_; // the ONE parameter set governing it
|
||||
ChannelMode channelMode_ = ChannelMode::Mono; // mono/stereo toggle snapshot
|
||||
|
||||
// Mirrors of the processor's persisted voice-system params, refreshed with the rest of the
|
||||
@@ -357,10 +359,9 @@ private:
|
||||
VoiceMode voiceMode_ = VoiceMode::Poly;
|
||||
MonoTrigger monoTrigger_ = MonoTrigger::Retrigger;
|
||||
|
||||
// Transient UI state (not persisted; component state carries selection + zones).
|
||||
// Transient UI state (not persisted; component state carries selection + parameters).
|
||||
View view_ = View::kSample; // default face is the loaded-sample home
|
||||
std::string activeFilterBankId_; // "" = All; else a bank id from banks_
|
||||
int selectedZone_ = -1; // highlighted zone in the Zone surface; -1 = none
|
||||
|
||||
// The Browse overlay is a select-then-confirm picker: a click marks a pending pick;
|
||||
// Confirm/double-click commits it + reloads; Cancel discards it. "" = nothing picked.
|
||||
@@ -381,11 +382,6 @@ private:
|
||||
std::string searchQuery_; // type-to-filter narrow; "" = no search
|
||||
bool searchFocused_ = false; // whether the search box has keyboard focus
|
||||
|
||||
// When >= 0, a low/high/root field is being typed (0=low,1=high,2=root); entryText_
|
||||
// accumulates keystrokes and commits via parseNoteEntry on Enter. -1 = no field editing.
|
||||
int entryField_ = -1;
|
||||
std::string entryText_;
|
||||
|
||||
// Hover state (transient, never persisted).
|
||||
HoverTarget hover_; // the interactive element under the pointer
|
||||
#ifdef _WIN32
|
||||
@@ -398,10 +394,8 @@ private:
|
||||
int dragStartY_ = 0; // grab y (px), for the vertical scrollbar-thumb drag
|
||||
int dragCurX_ = 0; // live cursor x (px) during a drag — updated in onMouseMove
|
||||
int dragCurY_ = 0; // live cursor y (px) during a drag — updated in onMouseMove
|
||||
int dragStartLow_ = 0; // the grabbed field's note at grab time
|
||||
int dragStartHigh_ = 0;
|
||||
int dragStartRoot_ = 60;
|
||||
PerformanceMap dragStartMap_; // map_ snapshotted at grab; restored on capture-loss
|
||||
int dragStartRoot_ = 60; // the root note at grab time
|
||||
InstrumentParams dragStartParams_; // params_ snapshotted at grab; restored on capture-loss
|
||||
|
||||
// Waveform-marker drag: which marker + the marker set snapshotted at grab time, so the
|
||||
// pixel-delta resolver shifts from the grab-time value and inter-marker clamps use the
|
||||
@@ -409,12 +403,11 @@ private:
|
||||
WaveMarker waveMarker_ = WaveMarker::kStart;
|
||||
SetupMarkers dragStartMarkers_;
|
||||
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
|
||||
std::int64_t dragStartFrame_ = 0; // zone startPoint at grab time (0 if absent); for env-node drag
|
||||
std::int64_t dragStartFrame_ = 0; // effective start point at grab time; for env-node drag
|
||||
|
||||
// Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id + zone.
|
||||
// Scrollbar-thumb drag: the offset at grab time. kDeckKnob drag: which control id.
|
||||
int dragStartScrollOffset_ = 0;
|
||||
int dragParamId_ = -1; // control id under a kDeckKnob drag; -2 = preview-vel knob
|
||||
int dragParamZone_ = -1; // the zone index a kDeckKnob drag edits; -1 = processor-side
|
||||
|
||||
// Envelope-node drag: which node + the AmpEnvelope snapshotted at grab (absolute-delta
|
||||
// contract, per envelope_edit's grabEnv).
|
||||
@@ -422,19 +415,16 @@ private:
|
||||
AmpEnvelope dragStartEnv_{};
|
||||
|
||||
// Velocity-curve node drag: which point, the curve snapshotted at grab
|
||||
// (resolvePointDrag's absolute-delta contract), the grab-time box rect (Sample and Zone
|
||||
// place the editor differently), and which zone the edit lands on.
|
||||
// (resolvePointDrag's absolute-delta contract), and the grab-time box rect.
|
||||
int curvePointIndex_ = -1;
|
||||
VelocityCurve dragStartCurve_ = VelocityCurve::flat();
|
||||
Rect dragCurveRect_{};
|
||||
int dragCurveZone_ = -1;
|
||||
|
||||
// Deck-knob drag: the normalized value at grab — knobDragValue maps the vertical pixel
|
||||
// delta from this anchor, so a grab never jumps the value.
|
||||
double dragKnobStartValue_ = 0.0;
|
||||
|
||||
// Curve popup open flag, never persisted. Edits popupZone(), re-resolved each paint so a
|
||||
// sync-tick refresh mid-open stays coherent (a refresh that drops the target closes it).
|
||||
// Curve popup open flag, never persisted.
|
||||
bool curvePopupOpen_ = false;
|
||||
|
||||
// Peak-thumbnail cache (mirror of bank_panel), keyed by "id|binCount" so a resize
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "shell/instrument/reasampler_embed.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -62,15 +63,6 @@ std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::str
|
||||
}
|
||||
#endif
|
||||
|
||||
// Projects the performance map into the strip's minimal zone shape (key ranges only).
|
||||
// Kept shell-side because it reads PerformanceMap; embed_strip stays free of it.
|
||||
std::vector<EmbedZone> toEmbedZones(const PerformanceMap& map) {
|
||||
std::vector<EmbedZone> out;
|
||||
out.reserve(map.zones.size());
|
||||
for (const PerformanceZone& z : map.zones) out.push_back(EmbedZone{z.lowNote, z.highNote});
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) {
|
||||
@@ -80,26 +72,37 @@ tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) {
|
||||
return kNoInterface;
|
||||
}
|
||||
|
||||
// The loaded capture id + effective root, both cheap in-process accessors.
|
||||
void ReaSamplerEmbed::refreshLoaded() {
|
||||
loadedId_ = processor_->selectedSampleId();
|
||||
const InstrumentParams params = processor_->instrumentParams();
|
||||
if (params.rootOverride) {
|
||||
rootNote_ = *params.rootOverride;
|
||||
} else if (const SelectedSample* ref = findRef(processor_->sampleRefs(), loadedId_)) {
|
||||
rootNote_ = ref->rootNote;
|
||||
} else {
|
||||
rootNote_ = 60;
|
||||
}
|
||||
}
|
||||
|
||||
void ReaSamplerEmbed::refresh() {
|
||||
if (!processor_) {
|
||||
samples_.clear();
|
||||
map_.zones.clear();
|
||||
selectedZone_ = -1;
|
||||
loadedId_.clear();
|
||||
rootNote_ = 60;
|
||||
return;
|
||||
}
|
||||
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
|
||||
map_ = processor_->performanceMap();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
refreshLoaded();
|
||||
}
|
||||
|
||||
void ReaSamplerEmbed::maybeRefresh() {
|
||||
if (!processor_) { refresh(); return; } // clears state; cheap
|
||||
|
||||
// The performance map is a cheap in-process accessor, and the editor may edit zones
|
||||
// with no bank-content change — always re-snapshot it so an edit reflects immediately.
|
||||
map_ = processor_->performanceMap();
|
||||
if (selectedZone_ >= static_cast<int>(map_.zones.size())) selectedZone_ = -1;
|
||||
// The loaded capture + root are cheap in-process accessors, and the editor may change
|
||||
// either with no bank-content change — always re-snapshot so an edit reflects at once.
|
||||
refreshLoaded();
|
||||
|
||||
// The expensive part is the bank-blob bridge read: gate it on the bank-generation
|
||||
// stamp, re-reading only when it changed (or on the first paint). A project with no
|
||||
@@ -157,9 +160,6 @@ TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) {
|
||||
#ifdef _WIN32
|
||||
case REAPER_FXEMBED_WM_PAINT:
|
||||
return paint(parm2, parm3) ? 1 : 0;
|
||||
case REAPER_FXEMBED_WM_LBUTTONDOWN:
|
||||
// Selection at most: map the click to a zone; force a redraw if it changed.
|
||||
return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0;
|
||||
#endif
|
||||
default:
|
||||
return 0; // unhandled messages (cursor, wheel, hittest) fall through
|
||||
@@ -186,45 +186,35 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
|
||||
const EmbedLayout layout = layoutEmbed(w, h);
|
||||
|
||||
if (map_.zones.empty()) {
|
||||
// No opt-in zones authored: a faint band so the strip reads as "present, no zones"
|
||||
// — the default single-capture face lives in the editor.
|
||||
if (loadedId_.empty()) {
|
||||
// Nothing loaded: a faint band so the strip reads as "present, silent" — the pick
|
||||
// affordance lives in the editor.
|
||||
LICE_FillRect(bmp, layout.keymap.x, layout.keymap.y, layout.keymap.width,
|
||||
layout.keymap.height, toLice(roleColor(Role::BgCell)), 0.5f, 0);
|
||||
const std::string label = version::vstPluginName() + // channel-derived
|
||||
(samples_.empty() ? " (bank empty)" : " (no zones)");
|
||||
(samples_.empty() ? " (bank empty)" : " (pick a capture)");
|
||||
const Rect labelR = Rect::ltrb(layout.keymap.x + 4, layout.keymap.y, layout.keymap.right(),
|
||||
layout.keymap.bottom());
|
||||
text(bmp, toKitBox(labelR), label.c_str(), Font::Label, Role::TextPrimary, Align::Left);
|
||||
} else {
|
||||
// Each zone draws as a segment (first-match order, matching selection/playback),
|
||||
// colored by its key span's spectral hue so it reads as the same spectrum as the
|
||||
// editor's keyboard strip. The selected zone lifts to accent-primary + a static glow.
|
||||
for (int i = 0; i < static_cast<int>(map_.zones.size()); ++i) {
|
||||
const PerformanceZone& z = map_.zones[i];
|
||||
const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote);
|
||||
if (r.width <= 0) continue;
|
||||
const bool sel = (i == selectedZone_);
|
||||
if (sel) {
|
||||
// Static glow halo, then the crisp accent-primary fill.
|
||||
LICE_FillRect(bmp, r.x - 2, r.y, r.width + 4, r.height,
|
||||
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
|
||||
LICE_FillRect(bmp, r.x, r.y, r.width, r.height,
|
||||
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
|
||||
} else {
|
||||
const double t = ((z.lowNote + z.highNote) * 0.5) / 127.0;
|
||||
LICE_FillRect(bmp, r.x, r.y, r.width, r.height,
|
||||
toLice(spectralColor(t)), 0.65f, 0);
|
||||
}
|
||||
LICE_DrawRect(bmp, r.x, r.y, r.width - 1, r.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
// Label when wide enough to read; the selected (accent-fill) segment labels in
|
||||
// bg/base for contrast, the rest in text/primary.
|
||||
if (r.width >= 24) {
|
||||
const Rect lr = Rect::ltrb(r.x + 3, r.y, r.right() - 2, r.bottom());
|
||||
text(bmp, toKitBox(lr), sampleLabel(samples_, z.sampleId).c_str(),
|
||||
Font::Label, sel ? Role::BgBase : Role::TextPrimary, Align::Left);
|
||||
}
|
||||
// The loaded capture spans the whole keyboard, drawn in its root's spectral hue so
|
||||
// the strip reads as the same spectrum as the editor's keyboard strip; the root key
|
||||
// lifts to accent-primary with a static glow ("this is where it plays at unity").
|
||||
const Rect span = keySpanRect(layout, 0, 127);
|
||||
LICE_FillRect(bmp, span.x, span.y, span.width, span.height,
|
||||
toLice(spectralColor(rootNote_ / 127.0)), 0.65f, 0);
|
||||
LICE_DrawRect(bmp, span.x, span.y, span.width - 1, span.height - 1,
|
||||
toLice(roleColor(Role::LineHairline)), 1.0f, 0);
|
||||
const Rect root = keySpanRect(layout, rootNote_, rootNote_);
|
||||
const int rw = (std::max)(2, root.width);
|
||||
LICE_FillRect(bmp, root.x - 2, root.y, rw + 4, root.height,
|
||||
toLice(roleColor(Role::AccentHot)), 0.30f, 0);
|
||||
LICE_FillRect(bmp, root.x, root.y, rw, root.height,
|
||||
toLice(roleColor(Role::AccentPrimary)), 1.0f, 0);
|
||||
if (span.width >= 24) {
|
||||
const Rect lr = Rect::ltrb(span.x + 3, span.y, span.right() - 2, span.bottom());
|
||||
text(bmp, toKitBox(lr), sampleLabel(samples_, loadedId_).c_str(), Font::Label,
|
||||
Role::TextPrimary, Align::Left);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,19 +233,6 @@ bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ReaSamplerEmbed::onMouseDown(TPtrInt drawInfo) {
|
||||
auto* di = reinterpret_cast<const REAPER_FXEMBED_DrawInfo*>(drawInfo);
|
||||
if (!di || di->width <= 0 || di->height <= 0) return false;
|
||||
refresh();
|
||||
const EmbedLayout layout = layoutEmbed(di->width, di->height);
|
||||
const std::vector<EmbedZone> zones = toEmbedZones(map_);
|
||||
const int hit = zoneAtPoint(layout, zones.data(), static_cast<int>(zones.size()),
|
||||
di->mouse_x, di->mouse_y);
|
||||
if (hit == selectedZone_) return false; // no change -> no redraw
|
||||
selectedZone_ = hit;
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // _WIN32
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// IReaperUIEmbedInterface so the instrument draws a compact keymap/level strip inline in
|
||||
// the track/mixer control panel. All embed messages arrive on REAPER's UI thread; nothing
|
||||
// here runs in process(). Windows-only, guarded so a non-Windows build stays compilable.
|
||||
// The strip's layout + hit-test is pure (embed_strip.h, unit-tested); this shell marshals
|
||||
// REAPER's messages to/from it.
|
||||
// The strip's layout is pure (embed_strip.h, unit-tested); this shell marshals REAPER's
|
||||
// messages to/from it. The strip is a read-only readout — the loaded capture across the
|
||||
// keyboard with its root marked, plus the activity level.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -13,7 +14,7 @@
|
||||
|
||||
#include "pluginterfaces/base/funknown.h"
|
||||
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects)
|
||||
#include "core/instrument/map/sample_map.h" // SampleChoice (the state the strip reflects)
|
||||
|
||||
// REAPER's VST3-side embed interface (vendored). Uses UNQUALIFIED Steinberg types, so it is
|
||||
// pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host
|
||||
@@ -26,7 +27,6 @@ namespace reasampler::vst {
|
||||
|
||||
class ReaSamplerProcessor;
|
||||
|
||||
using instrument::map::PerformanceMap;
|
||||
using instrument::map::SampleChoice;
|
||||
|
||||
// Implements IReaperUIEmbedInterface. Lifetime is owned by the processor (sole unique_ptr,
|
||||
@@ -59,17 +59,19 @@ private:
|
||||
#ifdef _WIN32
|
||||
// Draws the current strip into REAPER's supplied LICE bitmap. Returns true if it drew.
|
||||
bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo);
|
||||
// A mouse-down inside the strip: maps to a zone and selects it (no new editing
|
||||
// semantics). Returns true if the selection changed (caller then invalidates).
|
||||
bool onMouseDown(Steinberg::TPtrInt drawInfo);
|
||||
#endif
|
||||
|
||||
// Snapshots the live bank + the instrument's performance map for the next paint.
|
||||
// The loaded capture id + effective root (cheap in-process accessors, no bridge read).
|
||||
// `processor_` must be non-null.
|
||||
void refreshLoaded();
|
||||
|
||||
// Snapshots the live bank + the instrument's loaded capture and effective root for the
|
||||
// next paint.
|
||||
void refresh();
|
||||
|
||||
// Dirty-guard over refresh(): re-reads the bank blob only when the (cheap) generation
|
||||
// stamp changed since the last paint. The performance map is always refreshed (cheap
|
||||
// in-process accessor) so a zone edit reflects immediately. UI thread only.
|
||||
// stamp changed since the last paint. The loaded capture + root are always refreshed
|
||||
// (cheap in-process accessors) so an edit reflects immediately. UI thread only.
|
||||
void maybeRefresh();
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
@@ -77,10 +79,8 @@ private:
|
||||
// from a real generation 0, forcing the first maybeRefresh() to do a full read.
|
||||
std::int64_t lastSeenBankGeneration_ = -1;
|
||||
std::vector<SampleChoice> samples_;
|
||||
PerformanceMap map_;
|
||||
// The zone the last click selected (local/visual only); -1 = none. Drives the strip's
|
||||
// highlight.
|
||||
int selectedZone_ = -1;
|
||||
std::string loadedId_; // the loaded capture's bank id; "" = nothing loaded
|
||||
int rootNote_ = 60; // effective root: override, else the capture's own intrinsic
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -17,37 +17,37 @@
|
||||
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
|
||||
|
||||
#include "shell/instrument/reaper_bridge.h"
|
||||
#include "core/instrument/map/sample_map.h" // PerformanceMap (the instrument's owned zoned keymap)
|
||||
#include "core/instrument/map/component_state_io.h" // ComponentState codec (Q-W2v split)
|
||||
#include "core/instrument/engine/sampler_core.h"
|
||||
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set)
|
||||
#include "core/instrument/map/component_state_io.h" // ComponentState codec
|
||||
#include "core/instrument/engine/voice_engine.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
using instrument::map::ComponentState;
|
||||
using instrument::map::PerformanceMap;
|
||||
using instrument::map::InstrumentParams;
|
||||
using instrument::map::SampleRefs;
|
||||
using instrument::map::kPreviewVelocityDefault;
|
||||
|
||||
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
|
||||
|
||||
// Decoded keymap + the voice engine playing it. The engine holds references into the
|
||||
// keymap, so both must live/die together at a stable address — heap-allocated,
|
||||
// The decoded capture + the voice engine playing it. The engine holds a reference to the
|
||||
// sample, so both must live/die together at a stable address — heap-allocated,
|
||||
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
|
||||
struct LoadedInstrument {
|
||||
Keymap keymap;
|
||||
SampleData sample;
|
||||
VoiceEngine engine;
|
||||
std::uint64_t installedAt = 0; // reloadGeneration_ at which this was installed into live_
|
||||
|
||||
// Takeover declick is on by default here (product default; the pure core defaults it
|
||||
// off): any voice restart (mono retrigger, legato, poly steal, preview) ramps instead
|
||||
// of clicking.
|
||||
LoadedInstrument(Keymap km, std::size_t maxVoices,
|
||||
LoadedInstrument(SampleData sd, std::size_t maxVoices,
|
||||
std::uint64_t gen, std::size_t preserveVoiceCap = 0,
|
||||
std::int64_t preserveWindowFrames = 0,
|
||||
VoiceMode voiceMode = VoiceMode::Poly,
|
||||
MonoTrigger monoTrigger = MonoTrigger::Retrigger)
|
||||
: keymap(std::move(km)),
|
||||
engine(maxVoices, keymap, preserveVoiceCap, preserveWindowFrames,
|
||||
: sample(std::move(sd)),
|
||||
engine(maxVoices, sample, preserveVoiceCap, preserveWindowFrames,
|
||||
voiceMode, monoTrigger, /*takeoverDeclick=*/true),
|
||||
installedAt(gen) {}
|
||||
|
||||
@@ -111,7 +111,7 @@ public:
|
||||
return static_cast<double>(embedPeak_.load(std::memory_order_relaxed));
|
||||
}
|
||||
|
||||
// Resolves selection/zones against the instance-owned SampleRefs, decodes each WAV
|
||||
// Resolves the selection against the instance-owned SampleRefs, decodes its WAV
|
||||
// off-thread, and publishes the built instrument via atomic swap — no bank read
|
||||
// required. When the bank blob is readable it's first folded into the refs table
|
||||
// (refreshRefsFromBank; the browser's copy-the-ref-in + recapture-sync mechanism). A
|
||||
@@ -140,16 +140,16 @@ public:
|
||||
// The live host sample rate latched from setupProcessing; the editor's envelope overlay
|
||||
// shares this time base. 0.0 before setupProcessing runs.
|
||||
double sampleRate() const { return sampleRate_; }
|
||||
// The single-capture selection id (guarded by selectionMutex_, never read on the audio
|
||||
// thread): the default face's pick when the performance map is empty; a non-empty map
|
||||
// supersedes it. Empty id -> silence, no first-sample fallback.
|
||||
// The loaded capture's id (guarded by selectionMutex_, never read on the audio thread).
|
||||
// Empty id -> silence, no first-sample fallback.
|
||||
std::string selectedSampleId();
|
||||
void setSelectedSampleId(const std::string& id);
|
||||
|
||||
// The performance map (zoned keymap). UI thread, guarded by performanceMutex_; never
|
||||
// read on the audio thread — reloadInstrument bakes it into the Keymap off-thread.
|
||||
PerformanceMap performanceMap();
|
||||
void setPerformanceMap(const PerformanceMap& map);
|
||||
// The one parameter set governing that capture. UI thread, guarded by paramsMutex_;
|
||||
// never read on the audio thread — reloadInstrument bakes it into the SampleData
|
||||
// off-thread.
|
||||
InstrumentParams instrumentParams();
|
||||
void setInstrumentParams(const InstrumentParams& params);
|
||||
|
||||
// Per-instance channel mode (mono | stereo), guarded by channelModeMutex_, never read
|
||||
// on the audio thread. Decode policy only (downmix vs L/R split) — the output bus is
|
||||
@@ -203,7 +203,7 @@ private:
|
||||
void retireIdleDrain();
|
||||
|
||||
// Light voice-param rebuild: rebuilds the engine around a copy of the live instrument's
|
||||
// already-decoded Keymap (no bridge/disk) and publishes through the same drain-slot
|
||||
// already-decoded SampleData (no bridge/disk) and publishes through the same drain-slot
|
||||
// swap as a full reload. No-op when nothing is loaded. Off the audio thread only.
|
||||
void rebuildVoiceEngine();
|
||||
|
||||
@@ -253,15 +253,15 @@ private:
|
||||
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // drained on reclaim + setActive(false) + terminate
|
||||
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
||||
|
||||
// The single-capture selection id ("" = no pick -> silence). Off-thread only, not read
|
||||
// on the audio thread.
|
||||
// The loaded capture's id ("" = no pick -> silence). Off-thread only, not read on the
|
||||
// audio thread.
|
||||
std::mutex selectionMutex_;
|
||||
std::string selectedSampleId_;
|
||||
|
||||
// The performance map (zoned keymap). Off-thread only; reloadInstrument bakes it into
|
||||
// the Keymap under the reload lock, never read directly on the audio thread.
|
||||
std::mutex performanceMutex_;
|
||||
PerformanceMap performanceMap_;
|
||||
// The one parameter set. Off-thread only; reloadInstrument bakes it into the SampleData
|
||||
// under the reload lock, never read directly on the audio thread.
|
||||
std::mutex paramsMutex_;
|
||||
InstrumentParams params_;
|
||||
|
||||
// Instance-owned sample refs: path + intrinsics per referenced sample. Refreshed
|
||||
// opportunistically from the bank blob when readable; never a bank dependency for
|
||||
|
||||
Reference in New Issue
Block a user