diff --git a/CMakeLists.txt b/CMakeLists.txt index 5de3a92..d4c6f95 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -762,6 +762,13 @@ add_library(param_slider STATIC src/vst/param_slider.cpp) target_include_directories(param_slider PUBLIC src/vst) target_link_libraries(param_slider PUBLIC editor_geometry) +# trigger_seam (Phase S-VIEW-3) — PURE Trigger-mode frames<->fraction converter for the TRIGGER +# SEAM documented in envelope_overlay.h: triggerPlayLength / framesToFadeFraction / +# fadeFractionToFrames. Owns the one formula so pack (draw) and unpack (commit) are provably +# consistent. No shell/LICE/REAPER types — only . NEITHER SDK. +add_library(trigger_seam STATIC src/vst/trigger_seam.cpp) +target_include_directories(trigger_seam PUBLIC src/vst) + # envelope_overlay (Phase S-VIEW-3) — PURE amp-envelope -> polyline geometry for the Sample-view # envelope overlay: AHDSR (Gate) / fade+%-length (Trigger) params + the sample's wall-clock # duration -> a breakpoint polyline in the waveform rect, at the same time base waveform_view maps. @@ -835,6 +842,12 @@ add_executable(param_slider_tests tests/test_param_slider.cpp) target_link_libraries(param_slider_tests PRIVATE param_slider) add_test(NAME param_slider_tests COMMAND param_slider_tests) +# trigger_seam (S-VIEW-3): the pure Trigger-mode frames<->fraction converter. +# Links ONLY trigger_seam — no editor_geometry dep, the plainest data-boundary proof possible. +add_executable(trigger_seam_tests tests/test_trigger_seam.cpp) +target_link_libraries(trigger_seam_tests PRIVATE trigger_seam) +add_test(NAME trigger_seam_tests COMMAND trigger_seam_tests) + # envelope_overlay (S-VIEW-3): the pure amp-envelope -> polyline geometry (Gate AHDSR + Trigger # fade/%-length) at the waveform time base. Links ONLY envelope_overlay (+ its editor_geometry # dep) — NEITHER SDK — the plain-data-boundary proof. @@ -1050,10 +1063,14 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp") # module above): theme supplies role->KitColor + spectralColor, component_geometry the # KitBox/button/slider geometry, bank_grid the shared dB display-compression the waveform # primitive uses. All REAPER/SWELL-free. + # envelope_overlay + envelope_edit (S-VIEW-3): the pure amp-envelope -> polyline forward map + # and the node hit-test + pixel-delta -> clamped-param inverse map the Sample-view envelope + # overlay draws + drags against; envelope_edit links envelope_overlay transitively (shared + # node vocabulary + timeToX/levelToY). Both engine-free, DAW-verified in the shell. target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal sample_map capture_paths embed_strip app_version capture_browser keyboard_strip waveform_view bank_sync browser_scroll note_entry param_slider - theme component_geometry bank_grid) + theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index dfbbc4e..77507c3 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -27,6 +27,7 @@ #include "app_version.h" // vstPluginName (channel-derived editor title band, S18) #include "sample_map.h" #include "wav_trim.h" // parseWavLayout, extractFloatFrames +#include "trigger_seam.h" // triggerPlayLength / framesToFadeFraction / fadeFractionToFrames (S-VIEW-3) #include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11) #ifdef _WIN32 @@ -53,15 +54,17 @@ constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor"; constexpr UINT_PTR kSyncTimerId = 1; constexpr UINT kSyncTimerIntervalMs = 500; -// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry -// is in capture_browser / keyboard_strip). The title band names the plugin + a live -// readout; the toggle band carries the Browser/Zones switch; the setup band (single- -// capture face) hosts the keyboard strip + level readout under the browser. -constexpr int kTitleHeight = 24; -constexpr int kToggleHeight = 22; -constexpr int kSetupHeight = 176; // the single-capture setup surface (labels + waveform + strip) -constexpr int kStripBandHeight = 40; -constexpr int kWaveformHeight = 72; // the S11 waveform band inside the setup surface +// Top-level band metrics (shell arithmetic — the load-bearing card/tab/key/zone geometry is in +// capture_browser / keyboard_strip). S-VIEW-2 Sample face (top->bottom): a TITLE band (name + +// Browse/Zone nav buttons), a HERO WAVEFORM band (enlarged — the Simpler/Phase-Plant hero, with +// the S11 markers + the S-VIEW-3 envelope overlay traced over it), a ROOT + PREVIEW cluster +// (fenced root strip + preview-trigger + velocity knob + Mono/Stereo), and the CONTROL STRIP (the +// param panel + keyTrack). Browse + Zone reuse the browser grid / zone strip machinery. +constexpr int kTitleHeight = 26; +constexpr int kHeroWaveformHeight = 150; // the enlarged Sample-face hero (was a 72px strip) +constexpr int kClusterHeight = 52; // root strip + preview + channel toggle +constexpr int kStripBandHeight = 40; // the keyboard-strip band height (root strip + zone strip) +constexpr int kNavButtonWidth = 62; // Browse / Zone / Back title-band buttons // Marker roles (Phase L, L3) — semantic, drawn through the kit's palette. The waveform's // start point + the sustain-loop ends are CATEGORICAL kinds (a distinct affordance class, @@ -128,9 +131,12 @@ int thumbBins(const BrowserLayout& layout) { ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor) : CPluginView(nullptr), processor_(processor) { - // Default view size — 840×560 gives comfortable room for the three-band Sample face - // on a 1080p screen (an interim canvas; Wave 2 tunes final band-height numbers). - ViewRect r(0, 0, 840, 560); + // Default view size (S-VIEW-SIZE-1 tuned to the concrete Sample-face band heights). The Sample + // home stacks: title (26) + hero waveform (150) + cluster (52) + the control strip, whose Gate + // mode shows 12 rows at ~26px ≈ 312px. 840×620 clears the full three-band face without scroll + // on a 1080p screen with headroom. Wide enough that the control strip's label + value columns + // read comfortably. + ViewRect r(0, 0, 840, 620); setRect(r); } @@ -288,6 +294,44 @@ int ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) { return static_cast(map_.zones.size()) - 1; } +PerformanceZone ReaSamplerEditor::effectiveSampleZone() const { + // The picked id's one-zone override, if the map already carries one; else a product-default + // zone bound to the picked id (NOT appended — a read-only resolve; a control edit materializes + // it via ensureSampleZone). Mirrors the S15-F2 single-storage-site lean. + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_) return z; + } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + return z; +} + +int ReaSamplerEditor::effectiveRoot() const { + int root = 60; + for (const SampleChoice& s : samples_) { + if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; + } + for (const PerformanceZone& z : map_.zones) { + if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride; + } + return root; +} + +int ReaSamplerEditor::ensureSampleZone() { + if (selectedId_.empty()) return -1; + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + if (map_.zones[static_cast(i)].sampleId == selectedId_) return i; + } + PerformanceZone z; + z.sampleId = selectedId_; + z.lowNote = 0; + z.highNote = 127; + map_.zones.push_back(z); + return static_cast(map_.zones.size()) - 1; +} + namespace { // The S12/S15/S16 control-surface value DOMAINS (the shell owns these — param_slider is // engine-free and maps only 0..1). WALL-CLOCK time sliders (AHDSR A/H/D/R, pitch env A/D) span @@ -298,6 +342,7 @@ namespace { constexpr double kEnvTimeMaxSeconds = 2.0; // AHDSR A/H/D/R + pitch A/D throw ceiling (seconds) constexpr double kFadeMaxFrames = 88200.0; // Trigger fade throw ceiling (source frames) constexpr double kPitchDepthMaxSemis = 24.0; // AD pitch depth throw: +/-24 st, centered +constexpr double kKeyTrackMax = 2.0; // S-VIEW-6 key-track slider ceiling (0..200%) double clamp01(double v) { return v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v); } } // namespace @@ -324,6 +369,9 @@ std::vector ReaSamplerEditor::controlDescs(const ZonePlaySeconds& p out.push_back({static_cast(ParamControl::kPitchEnvAttack), ControlKind::Slider}); out.push_back({static_cast(ParamControl::kPitchEnvDecay), ControlKind::Slider}); out.push_back({static_cast(ParamControl::kPitchEnvDepth), ControlKind::Slider}); + // S-VIEW-6 key-tracking (0..200%). Lives on PerformanceZone, not ZonePlaySeconds — the shell + // reads/writes it against the zone directly (controlValue/applyControl ignore it). Always shown. + out.push_back({static_cast(ParamControl::kKeyTrack), ControlKind::Slider}); return out; } @@ -391,6 +439,74 @@ void ReaSamplerEditor::applyControl(int id, ZonePlaySeconds& play, double value, } } +double ReaSamplerEditor::liveSampleRate() const { + return processor_ ? processor_->sampleRate() : 0.0; +} + +double ReaSamplerEditor::previewVelocity01() const { + if (!processor_) return static_cast(kPreviewVelocityDefault) / 127.0; + return static_cast(processor_->previewVelocity()) / 127.0; +} + +EnvClampBounds ReaSamplerEditor::envClampBounds() const { + // Match the control-panel sliders' own domains so a node drag can never produce a param a + // slider couldn't (the S-VIEW-F2 invariant). AHDSR seconds cap at kEnvTimeMaxSeconds; the + // Trigger fade/length fractions cap at 1.0 (the natural full-span bound the sliders use). + EnvClampBounds b; + b.maxAttackSeconds = kEnvTimeMaxSeconds; + b.maxHoldSeconds = kEnvTimeMaxSeconds; + b.maxDecaySeconds = kEnvTimeMaxSeconds; + b.maxReleaseSeconds = kEnvTimeMaxSeconds; + b.maxFadeInFraction = 1.0; + b.maxFadeOutFraction = 1.0; + b.maxLengthFraction = 1.0; + return b; +} + +AmpEnvelope ReaSamplerEditor::packEnvelope(const ZonePlaySeconds& play, std::int64_t frames, + std::int64_t startFrame) const { + AmpEnvelope env; + env.mode = (play.playMode == PlayMode::Trigger) ? EnvMode::Trigger : EnvMode::Gate; + // AHDSR seconds copy 1-to-1 (rate-free, the same domain the overlay draws). + env.attackSeconds = play.adsr.attackSeconds; + env.holdSeconds = play.adsr.holdSeconds; + env.decaySeconds = play.adsr.decaySeconds; + env.sustainLevel = play.adsr.sustainLevel; + env.releaseSeconds = play.adsr.releaseSeconds; + // Trigger: lengthFraction copies 1-to-1; the fades are DERIVED — source frames over the played + // span (the TRIGGER SEAM converter, PACK direction). startFrame is the zone's effective start + // point so the fraction denominator matches the voice's actual post-start span. A zero play + // length yields 0 fractions. + env.lengthFraction = play.trigger.lengthFraction; + const std::int64_t playLen = + triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); + env.fadeInFraction = framesToFadeFraction(play.trigger.fadeInFrames, playLen); + env.fadeOutFraction = framesToFadeFraction(play.trigger.fadeOutFrames, playLen); + return env; +} + +void ReaSamplerEditor::unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, + std::int64_t startFrame, ZonePlaySeconds& play) const { + if (env.mode == EnvMode::Gate) { + play.adsr.attackSeconds = env.attackSeconds; + play.adsr.holdSeconds = env.holdSeconds; + play.adsr.decaySeconds = env.decaySeconds; + play.adsr.sustainLevel = env.sustainLevel; + play.adsr.releaseSeconds = env.releaseSeconds; + } else { + // Trigger: lengthFraction copies back; the fades convert fractions -> source frames over + // the played span (the TRIGGER SEAM converter, UNPACK direction). startFrame is the zone's + // effective start point so the frame denominator matches the voice's actual post-start span. + // Keep the same (0,1] floor on lengthFraction the slider path enforces so a zero-length + // trigger never plays nothing. + play.trigger.lengthFraction = (std::max)(0.01, env.lengthFraction); + const std::int64_t playLen = + triggerPlayLength(play.trigger.lengthFraction, frames, startFrame); + play.trigger.fadeInFrames = fadeFractionToFrames(env.fadeInFraction, playLen); + play.trigger.fadeOutFrames = fadeFractionToFrames(env.fadeOutFraction, playLen); + } +} + void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) { // Materialize the edited markers as a per-zone loop/start override on the picked id (upsert, // mirror of the root-marker path): a full-keyboard zone carrying the override. This plays @@ -482,14 +598,14 @@ tresult PLUGIN_API ReaSamplerEditor::canResize() { } tresult PLUGIN_API ReaSamplerEditor::checkSizeConstraint(ViewRect* rect) { - // Enforce a minimum usable floor: at least 560 wide and 380 tall. The host calls this - // before every resize; clamp the proposed rect in place and return kResultTrue so the - // host applies the (possibly adjusted) rect rather than the raw user drag. - // 560×380 keeps the title band + toggle band + a couple of card rows + the setup strip - // visible; anything smaller would clip essential UI. The default 840×560 is above this - // floor — Wave 2 tunes final numbers once the three-band layout is in. + // Enforce a minimum usable floor: at least 560 wide and 460 tall. The host calls this before + // every resize; clamp the proposed rect in place and return kResultTrue so the host applies the + // (possibly adjusted) rect rather than the raw user drag. 560×460 keeps the Sample face's title + // + hero waveform + cluster + a few control rows visible (the control strip clips gracefully + // below the panel bottom); anything smaller would clip essential UI. The default 840×620 is + // above this floor. constexpr int kMinW = 560; - constexpr int kMinH = 380; + constexpr int kMinH = 460; if (!rect) return kResultFalse; if (rect->getWidth() < kMinW) rect->right = rect->left + kMinW; if (rect->getHeight() < kMinH) rect->bottom = rect->top + kMinH; @@ -569,56 +685,72 @@ tresult PLUGIN_API ReaSamplerEditor::onSize(ViewRect* newSize) { return res; } -// The client bands: title (top), toggle (below title), then the mode content. In the -// browser view the content is the browser grid on top of the single-capture setup band -// (when a capture is picked); in the zones view the content is the zones strip + list. +// The Sample-view (S-VIEW-2) bands. The TITLE band names the plugin + a live readout and hosts +// the Browse/Zone nav buttons at its right; the HERO band is the enlarged waveform + envelope +// overlay; the CLUSTER band is the fenced root strip + preview + channel toggle; the CONTROL band +// is the param panel. Every band is padded 8px horizontally by its consumers. Browse + Zone views +// derive their own areas from `title` + `content` below. namespace { -struct EditorBands { - Rect title; - Rect toggleBrowser; // left half of the toggle band - Rect toggleZones; // right half - Rect content; // below the toggle band: the mode's own area +constexpr int kPad = 8; + +struct SampleBands { + Rect title; // top: name + Browse/Zone nav buttons + Rect navBrowse; // the "Browse" title-band button + Rect navZone; // the "Zone" title-band button + Rect hero; // the hero waveform + S-VIEW-3 envelope overlay + Rect cluster; // root strip + preview-trigger + velocity knob + channel toggle + Rect control; // the param control strip (Mode / Pitch / AHDSR|Trigger / AD pitch / keyTrack) }; -EditorBands computeBands(int w, int h) { - EditorBands b; +SampleBands computeSampleBands(int w, int h) { + SampleBands b; const int titleH = (std::min)(kTitleHeight, h); b.title = Rect{0, 0, w, titleH}; - const int toggleTop = titleH; - const int toggleBot = (std::min)(h, toggleTop + kToggleHeight); - b.toggleBrowser = Rect{0, toggleTop, w / 2, toggleBot}; - b.toggleZones = Rect{w / 2, toggleTop, w, toggleBot}; - b.content = Rect{0, toggleBot, w, h}; + // Two nav buttons right-anchored in the title band (Browse then Zone). + const int navTop = 2; + const int navBot = (std::max)(navTop, titleH - 2); + const Rect zone{w - kPad - kNavButtonWidth, navTop, w - kPad, navBot}; + const Rect browse{zone.left - 4 - kNavButtonWidth, navTop, zone.left - 4, navBot}; + b.navBrowse = browse; + b.navZone = zone; + + int y = titleH; + const int heroH = (std::min)(kHeroWaveformHeight, (std::max)(0, h - titleH)); + b.hero = Rect{kPad, y, w - kPad, y + heroH}; + y += heroH; + const int clusterH = (std::min)(kClusterHeight, (std::max)(0, h - y)); + b.cluster = Rect{0, y, w, y + clusterH}; + y += clusterH; + b.control = Rect{kPad, y, w - kPad, h}; return b; } -// The keyboard strip rectangle inside the setup area (single-capture root-drag face). -// `area` is the full setup Rect; the strip is anchored at the bottom with an 8px horizontal -// pad. All three call sites (paintSetup, onMouseDown, onMouseMove) use this single formula. -Rect setupStripArea(const Rect& area) { - constexpr int pad = 8; - const int stripTop = area.bottom - kStripBandHeight; - return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4}; +// The fenced root keyboard-strip rect inside the cluster band (S-VIEW-2): the LEFT ~55% of the +// cluster, the fenced root affordance promoted from Browse. The preview cluster takes the right. +Rect clusterRootStrip(const Rect& cluster) { + const int stripTop = cluster.top + (cluster.height() - kStripBandHeight) / 2; + const int right = cluster.left + (cluster.width() * 55) / 100; + return Rect{cluster.left + kPad, stripTop, right - kPad, stripTop + kStripBandHeight}; } -// The S11 waveform rectangle inside the setup area: a band above the keyboard strip, below the -// header/hint labels. `area` is the full setup Rect; the waveform is padded 8px horizontally and -// anchored above the strip band. All call sites (paintSetup, onMouseDown, onMouseMove) use this -// single formula so the draw and the hit-test never drift. -Rect setupWaveformArea(const Rect& area) { - constexpr int pad = 8; - const int waveBottom = area.bottom - kStripBandHeight - 6; // 6px gap above the strip - const int waveTop = waveBottom - kWaveformHeight; - return Rect{area.left + pad, waveTop, area.right - pad, waveBottom}; +// The preview-trigger button rect (right of the root strip, left of the channel toggle). +Rect clusterPreviewButton(const Rect& cluster) { + const Rect strip = clusterRootStrip(cluster); + const int left = strip.right + kPad; + return Rect{left, strip.top, left + 64, strip.bottom}; } -// The keyboard strip rectangle inside the Zones panel content area. `bands.content` is the -// mode-content Rect; the strip sits below the "+ Add Zone" affordance (top+4, height 20) -// with a 12px gap, padded 8px horizontally. All three call sites (paintZones, onMouseDown, -// onMouseMove) use this single formula — the inline arithmetic in onMouseMove was the drift. -Rect zonesStripArea(const EditorBands& bands) { - constexpr int pad = 8; - const int stripTop = bands.content.top + 4 + 20 + 12; // addR.bottom + 12 - return Rect{bands.content.left + pad, stripTop, bands.content.right - pad, +// The preview velocity knob rect (a compact horizontal slider next to the preview button). +Rect clusterVelocitySlider(const Rect& cluster) { + const Rect prev = clusterPreviewButton(cluster); + const int left = prev.right + kPad; + return Rect{left, prev.top, left + 96, prev.bottom}; +} + +// The Zone-view keyboard strip rect. Zone content sits below the "+ Add Zone" affordance +// (top+4, height 20) with a 12px gap, padded 8px horizontally. All call sites use this formula. +Rect zonesStripArea(const Rect& content) { + const int stripTop = content.top + 4 + 20 + 12; // addR.bottom + 12 + return Rect{content.left + kPad, stripTop, content.right - kPad, stripTop + kStripBandHeight}; } @@ -626,10 +758,10 @@ Rect zonesStripArea(const EditorBands& bands) { // sample label on the legend row. Three equal fields (low/high/root) tile it. Both draw + // hit-test use this single formula so they never drift. Anchored off zonesStripArea.bottom so // the legend top tracks the strip bottom without re-inlining the strip arithmetic here. -Rect noteEntryFieldsArea(const EditorBands& bands) { - const int stripBottom = zonesStripArea(bands).bottom; +Rect noteEntryFieldsArea(const Rect& content) { + const int stripBottom = zonesStripArea(content).bottom; const int top = stripBottom + 8; // legendTop (== zonesStripArea.bottom + 8) - return Rect{bands.content.left + 8 + 128, top, bands.content.right - 8, top + 18}; + return Rect{content.left + 8 + 128, top, content.right - 8, top + 18}; } // The rect of note-entry field `f` (0=low, 1=high, 2=root) within the fields area: three equal @@ -645,25 +777,23 @@ Rect noteEntryFieldRect(const Rect& fields, int f) { // The S12/S15/S16 parameter-control panel rect inside the Zones content: below the strip + // the one-line selected-zone legend, running to the content bottom. `bands.content` is the // Zones mode-content area. Both draw + hit-test use this single formula so they never drift. -Rect zonesControlPanel(const EditorBands& bands) { - constexpr int pad = 8; - const Rect strip = zonesStripArea(bands); +Rect zonesControlPanel(const Rect& content) { + const Rect strip = zonesStripArea(content); const int panelTop = strip.bottom + 8 + 18 + 8; // strip + the 18px legend row + gap - return Rect{bands.content.left + pad, panelTop, bands.content.right - pad, - bands.content.bottom - 4}; + return Rect{content.left + kPad, panelTop, content.right - kPad, + content.bottom - 4}; } -// The S7 mono/stereo toggle, a two-segment control anchored to the RIGHT of the setup band's -// header row (same y as the sample-name header, so it reads as "this capture's output mode"). -// `area` is the full setup Rect. Returns {mono-segment, stereo-segment}; each is kSegW wide, -// kSegH tall, side by side. Kept to a small fenced block (S11 owns the waveform region). +// The S7 mono/stereo toggle (S-VIEW-2: moved here from Browse to the Sample cluster band — it is +// a per-capture output-mode concern, not a choosing concern). A two-segment control right-anchored +// in `area` and vertically centered. Returns {mono-segment, stereo-segment}, each kChanSegW wide, +// kChanSegH tall, side by side. constexpr int kChanSegW = 52; constexpr int kChanSegH = 18; struct ChannelToggleRects { Rect mono; Rect stereo; }; ChannelToggleRects channelToggleRects(const Rect& area) { - constexpr int pad = 8; - const int top = area.top + 4; - const int right = area.right - pad; + const int top = area.top + (area.height() - kChanSegH) / 2; + const int right = area.right - kPad; const Rect stereo{right - kChanSegW, top, right, top + kChanSegH}; const Rect mono{stereo.left - kChanSegW, top, stereo.left, top + kChanSegH}; return {mono, stereo}; @@ -681,7 +811,11 @@ void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) { const int h = stripArea.height(); // A pastel spectral column per key. Each key's local x from keyRect; fill from this key's // left to the next key's left so the sweep tiles with no gaps. Low alpha keeps it a quiet - // backdrop the root/zone marks sit over. + // backdrop the root/zone marks sit over. S-VIEW-7: OVERLAY the two-tone piano-key pattern — + // naturals (white keys) keep the bright spectral fill; accidentals (C#/D#/F#/G#/A#) get a + // dark bg/base wash over the hue, so a glance reads pitch position as a keyboard without + // counting. The pattern is an OVERLAY (not a keyboard shape) per the spec. + const LICE_pixel darkKey = toLice(roleColor(Role::BgBase)); for (int n = 0; n <= 127; ++n) { const Rect k = keyRect(sl, n); const int x0 = k.left + sx; @@ -689,6 +823,11 @@ void drawSpectralStrip(LICE_IBitmap* bmp, const Rect& stripArea) { const int cw = (std::max)(1, x1 - x0); const KitColor hue = spectralColor(static_cast(n) / 127.0); LICE_FillRect(bmp, x0, sy, cw, h, toLice(hue), 0.55f, 0); + if (!isNaturalKey(n)) { + // Darken the accidental over the hue (a semi-opaque bg/base wash) so the black-key + // pattern reads while the spectral tint still shows through. + LICE_FillRect(bmp, x0, sy, cw, h, darkKey, 0.55f, 0); + } } // Faint per-octave key ticks (hairline role) for orientation. const LICE_pixel tick = toLice(roleColor(Role::LineHairline)); @@ -724,57 +863,23 @@ void ReaSamplerEditor::paint(HDC hdc) { LICE_SysBitmap bmp(w, h); LICE_Clear(&bmp, toLice(roleColor(Role::BgBase))); - const EditorBands bands = computeBands(w, h); - - // Title band: product name + live readout. Standard B palette — the beta channel gets NO - // distinct accent (settled 2026-07-27); the channel-derived vstPluginName is the only - // beta-vs-stable signal. bg/panel one step up from the canvas, primary-role title text. - fillSurface(&bmp, toKitBox(bands.title), Role::BgPanel, InteractionState::Rest); - std::string title = reasampler::vstPluginName(); // channel-derived (S18) - if (processor_ && processor_->bridge().isConnected()) { - if (samples_.empty()) title += " [bank empty]"; - else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]"; - else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; - else title += " [" + sampleLabel(samples_, selectedId_) + "]"; + // S-VIEW-1 three-view dispatch. Sample is home; Browse is a full-window modal overlay drawn + // OVER Sample; Zone is the dedicated surface. In the Browse view we draw Sample first so the + // modal reads as a sheet layered over the home face (the "picker over the document" grammar). + if (view_ == View::kZone) { + paintZone(&bmp, w, h); } else { - title += " [host: no bridge]"; - } - Rect titleText{bands.title.left + 8, bands.title.top, bands.title.right - 8, - bands.title.bottom}; - kitText(&bmp, titleText, title.c_str(), Font::Title, Role::TextPrimary); - - // Toggle band: Browser | Zones — two segmented switches. Active = accent-primary fill - // ("this is live"); hover lightens the inactive segment toward accent/hot. - const bool inZones = (view_ == View::kZones); - const InteractionState browserState = - !inZones ? InteractionState::Active - : (isHovered(HoverKind::kToggleBrowser, -1) ? InteractionState::Hover - : InteractionState::Rest); - const InteractionState zonesState = - inZones ? InteractionState::Active - : (isHovered(HoverKind::kToggleZones, -1) ? InteractionState::Hover - : InteractionState::Rest); - fillSurface(&bmp, toKitBox(bands.toggleBrowser), Role::BgCell, browserState); - fillSurface(&bmp, toKitBox(bands.toggleZones), Role::BgCell, zonesState); - // Active segment's label sits on the accent fill — draw it in bg/base for contrast - // (the tight text-on-pastel-fill pair, §4); the inactive label stays text/primary. - kitTextCentered(&bmp, bands.toggleBrowser, "Browser", Font::Label, - !inZones ? Role::BgBase : Role::TextPrimary); - kitTextCentered(&bmp, bands.toggleZones, "Zones", Font::Label, - inZones ? Role::BgBase : Role::TextPrimary); - - if (view_ == View::kZones) { - paintZones(&bmp, w, h); - } else { - paintBrowser(&bmp, w, h); + paintSample(&bmp, w, h); + if (view_ == View::kBrowse) paintBrowse(&bmp, w, h); } // S13 (relay degraded): a transient banner flashed after a file was dropped ON THIS window. // It reiterates the shipped ingest gesture rather than swallowing the drop silently. Drawn - // LAST so it overlays the mode content; decays via onSyncTimer (dropHintTicks_). + // LAST so it overlays whatever view is up; decays via onSyncTimer (dropHintTicks_). if (dropHintTicks_ > 0) { - const int bannerH = (std::min)(kTitleHeight + 8, h); - Rect banner{0, bands.toggleZones.bottom, w, bands.toggleZones.bottom + bannerH}; + const int bannerTop = (std::min)(kTitleHeight, h); + const int bannerH = (std::min)(kTitleHeight + 8, (std::max)(0, h - bannerTop)); + Rect banner{0, bannerTop, w, bannerTop + bannerH}; // A transient notice, not the live layer — draw it on the accent-tertiary categorical // hue with a dark label so it reads as "attention, not action". fillSurface(&bmp, toKitBox(banner), Role::AccentTertiary, InteractionState::Rest); @@ -786,6 +891,185 @@ void ReaSamplerEditor::paint(HDC hdc) { BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); } +// A small helper: draw the title band with the live readout + the Browse/Zone nav buttons. Shared +// by the Sample face (nav visible) — Browse/Zone draw their own back button in place of the nav. +namespace { +void drawTitleBand(LICE_IBitmap* bmp, const Rect& title, const std::string& readout) { + fillSurface(bmp, toKitBox(title), Role::BgPanel, InteractionState::Rest); + Rect titleText{title.left + 8, title.top, title.right - 8, title.bottom}; + kitText(bmp, titleText, readout.c_str(), Font::Title, Role::TextPrimary); +} +} // namespace + +void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) { + const SampleBands bands = computeSampleBands(w, h); + + // Title: product name + live readout. Standard B palette — the beta channel gets NO distinct + // accent (settled 2026-07-27); the channel-derived vstPluginName is the only beta-vs-stable + // signal. + std::string title = reasampler::vstPluginName(); // channel-derived (S18) + if (processor_ && processor_->bridge().isConnected()) { + if (samples_.empty()) title += " [bank empty]"; + else if (selectedId_.empty() && map_.zones.empty()) title += " [pick a capture]"; + else if (!map_.zones.empty()) title += " [" + std::to_string(map_.zones.size()) + " zone(s)]"; + else title += " [" + sampleLabel(samples_, selectedId_) + "]"; + } else { + title += " [host: no bridge]"; + } + drawTitleBand(bmp, bands.title, title); + + // Browse + Zone nav buttons (right of the title). Browse is the picker; Zone opens the keymap + // surface. When nothing is loaded, Browse is the empty state's dominant call-to-action — draw + // it Active (accent-primary) so it reads as "start here". + const bool empty = selectedId_.empty() && map_.zones.empty(); + { + const KitButtonBox box{toKitBox(bands.navBrowse)}; + const InteractionState st = empty ? InteractionState::Active + : (isHovered(HoverKind::kNavBrowse, -1) ? InteractionState::Hover : InteractionState::Rest); + drawButton(bmp, box, "Browse", st, /*warn=*/false); + } + { + const KitButtonBox box{toKitBox(bands.navZone)}; + const InteractionState st = + isHovered(HoverKind::kNavZone, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Zone", st, /*warn=*/false); + } + + // Nothing loaded yet: the Sample face is the empty state — a "pick a capture" prompt pointing + // at Browse (which is lit above). No hero waveform / controls to draw. + if (empty) { + Rect body{bands.hero.left, bands.hero.top, bands.hero.right, bands.control.bottom}; + paintEmptyState(bmp, body); + return; + } + + // Resolve the effective single-capture zone: the picked id's one-zone override when present, + // else the product-default play params (S15-F2 — the single capture is a one-zone map). This + // is the ONE storage site both Sample and Zone edit. + PerformanceZone zone = effectiveSampleZone(); + + // --- Hero waveform band: envelope + S11 markers + S-VIEW-3 envelope overlay ----------- + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + const Rect waveArea = bands.hero; + fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest); + if (frames > 0 && waveArea.width() > 0) { + const int bins = (std::max)(1, waveArea.width()); + const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast(bins)); + drawEnvelope(bmp, waveArea, env); + + const SetupMarkers m = pickedMarkers(frames); + if (m.hasLoop && m.loopEnd > m.loopStart) { + const int lx = frameToX(waveArea, frames, m.loopStart); + const int rx = frameToX(waveArea, frames, m.loopEnd); + if (rx > lx) { + LICE_FillRect(bmp, lx, waveArea.top, rx - lx, waveArea.height(), + toLice(roleColor(kRoleLoopMarker)), 0.20f, 0); + } + } + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; + for (int i = 0; i < 3; ++i) { + const int mx = frameToX(waveArea, frames, markerFrames[i]); + const bool loopMarker = (i != 0); + const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; + LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(), + toLice(roleColor(markerRoles[i])), alpha, 0); + } + + // S-VIEW-3: trace the amp-envelope overlay + its draggable node handles over the hero. + paintEnvelopeOverlay(bmp, waveArea, zone, frames); + } else { + kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim); + } + + // --- Root + preview cluster (fenced root strip, preview button, velocity knob, channel) --- + fillSurface(bmp, toKitBox(bands.cluster), Role::BgPanel, InteractionState::Rest); + int root = effectiveRoot(); + const Rect rootStrip = clusterRootStrip(bands.cluster); + drawSpectralStrip(bmp, rootStrip); + { + const StripLayout sl = layoutStrip(rootStrip.width(), rootStrip.height()); + drawRootMarker(bmp, rootStrip, sl, root); + } + + // Preview-trigger button (fires the loaded capture at root through the live voice engine). + { + const Rect prev = clusterPreviewButton(bands.cluster); + const KitButtonBox box{toKitBox(prev)}; + const InteractionState st = (previewingNote_ >= 0) ? InteractionState::Active + : (isHovered(HoverKind::kPreview, -1) ? InteractionState::Hover : InteractionState::Rest); + drawButton(bmp, box, "Preview", st, /*warn=*/false); + } + // Preview velocity knob (a compact horizontal slider bound to the persisted previewVelocity). + { + const Rect vs = clusterVelocitySlider(bands.cluster); + const double vel01 = previewVelocity01(); + const Rect track = sliderTrackRect(vs); + fillSurface(bmp, toKitBox(Rect{track.left, track.top + track.height() / 2 - 1, + track.right, track.top + track.height() / 2 + 1}), + Role::BgCell, InteractionState::Pressed); + const Rect handle = sliderHandleRect(vs, vel01); + const int fillW = (std::max)(0, (handle.left + handle.width() / 2) - track.left); + if (fillW > 0) { + LICE_FillRect(bmp, track.left, track.top + track.height() / 2 - 1, fillW, 2, + toLice(roleColor(Role::AccentPrimary)), 1.0f, 0); + } + const KitButtonBox knob{toKitBox(Rect{handle.left, handle.top + 2, handle.right, + handle.bottom - 2})}; + drawButton(bmp, knob, nullptr, InteractionState::Rest, /*warn=*/false); + Rect velLbl{vs.left, vs.top - 12, vs.right, vs.top}; + kitText(bmp, velLbl, "Vel", Font::Micro, Role::TextDim); + } + // Mono | Stereo output-mode toggle. + { + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + const bool isStereo = (channelMode_ == ChannelMode::Stereo); + const InteractionState monoState = !isStereo ? InteractionState::Active + : (isHovered(HoverKind::kChanMono, -1) ? InteractionState::Hover : InteractionState::Rest); + const InteractionState stereoState = isStereo ? InteractionState::Active + : (isHovered(HoverKind::kChanStereo, -1) ? InteractionState::Hover : InteractionState::Rest); + fillSurface(bmp, toKitBox(chan.mono), Role::BgCell, monoState); + fillSurface(bmp, toKitBox(chan.stereo), Role::BgCell, stereoState); + kitTextCentered(bmp, chan.mono, "Mono", Font::Label, !isStereo ? Role::BgBase : Role::TextPrimary); + kitTextCentered(bmp, chan.stereo, "Stereo", Font::Label, isStereo ? Role::BgBase : Role::TextPrimary); + } + + // --- The "Modes-and-down" control strip (S-VIEW-2: moved from Zone) -------------------- + paintControls(bmp, bands.control, zone); +} + +void ReaSamplerEditor::paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, + const PerformanceZone& zone, std::int64_t frames) { + if (frames <= 0 || waveArea.width() <= 0 || waveArea.height() <= 0) return; + const double rate = liveSampleRate(); + if (rate <= 0.0) return; + const double totalSeconds = static_cast(frames) / rate; + const std::int64_t startFrame = zone.startPoint.value_or(0); + const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); + const std::vector poly = buildEnvelopePolyline(env, waveArea, totalSeconds); + + // Trace the polyline in the categorical secondary accent (teal) so it reads as a distinct + // curve over the waveform. Clip x to the wave rect (a Gate release tail maps past the right). + const LICE_pixel line = toLice(roleColor(Role::AccentSecondary)); + for (std::size_t i = 1; i < poly.size(); ++i) { + const int x0 = (std::max)(waveArea.left, (std::min)(waveArea.right - 1, poly[i - 1].x)); + const int x1 = (std::max)(waveArea.left, (std::min)(waveArea.right - 1, poly[i].x)); + LICE_Line(bmp, x0, poly[i - 1].y, x1, poly[i].y, line, 1.0f, 0, true); + } + // Draggable node handles: a small square per DRAGGABLE node (Origin + ReleaseStart are draw- + // only). Lit accent-hot when this node is the grabbed one. + const LICE_pixel handle = toLice(roleColor(Role::AccentPrimary)); + const LICE_pixel handleHot = toLice(roleColor(Role::AccentHot)); + for (const EnvVertex& v : poly) { + if (v.node == EnvNode::Origin || v.node == EnvNode::ReleaseStart) continue; + if (v.x < waveArea.left || v.x >= waveArea.right) continue; // clipped node — no handle + const bool grabbed = (drag_ == DragKind::kEnvNode && envNode_ == v.node); + const int r = 3; + LICE_FillRect(bmp, v.x - r, v.y - r, 2 * r, 2 * r, grabbed ? handleHot : handle, 1.0f, 0); + } +} + void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { // Shown when no card is drawn (nothing to pick): distinguish a genuinely empty bank from // a bank filter that hides everything. Either way it is the "pick a capture" empty state. @@ -803,30 +1087,64 @@ void ReaSamplerEditor::paintEmptyState(LICE_IBitmap* bmp, const Rect& area) { Font::Micro, Role::TextDim); } -void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { - const EditorBands bands = computeBands(w, h); - // When a capture is picked, the setup band takes the bottom; the browser gets the rest. - const bool havePick = !selectedId_.empty(); - const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) - : bands.content.bottom; - const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; +// The Browse-modal (S-VIEW-5) top-level regions: a title band with a Back button, the search box, +// the browser sub-area (tabs + card grid), and a footer with Cancel / Load-confirm. The picker +// covers the full window (F3 resolved: full-window overlay). Both draw + hit-test derive from this +// single layout so they never drift. `content` is the sub-area layoutBrowser lays out over. +namespace { +struct BrowseModal { + Rect title; + Rect back; // the "Back" title-band button + Rect search; // the type-to-filter box (absolute) + Rect content; // the browser sub-area (tabs + grid) — layoutBrowser's origin + Rect cancel; // footer Cancel + Rect confirm; // footer Load (confirm) +}; +constexpr int kBrowseFooterH = 30; +BrowseModal computeBrowseModal(int w, int h) { + BrowseModal m; + const int titleH = (std::min)(kTitleHeight, h); + m.title = Rect{0, 0, w, titleH}; + m.back = Rect{w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, titleH - 2)}; + // Search box below the title, spanning the width (searchBoxRect lays it out from 0). + const Rect sb = searchBoxRect(w); + m.search = Rect{kPad, titleH, w - kPad, titleH + sb.height()}; + const int footerTop = (std::max)(m.search.bottom, h - kBrowseFooterH); + m.content = Rect{0, m.search.bottom, w, footerTop}; + // Footer: Cancel (left) + Load (right). + const int fTop = footerTop + 3; + const int fBot = (std::max)(fTop, h - 3); + m.cancel = Rect{kPad, fTop, kPad + 90, fBot}; + m.confirm = Rect{w - kPad - 90, fTop, w - kPad, fBot}; + return m; +} +} // namespace - // S12: reserve a type-to-filter search box at the top of the browser area; the tabs + grid - // sit below it. The search box spans the browser width. - const Rect searchBox = searchBoxRect(fullBrowserArea.width()); - const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top, - fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom}; - // A focused search box lifts to the focus state (a nudge toward the primary accent + a - // text/primary ring drawn below); hover lightens; else the resting cell surface. +void ReaSamplerEditor::paintBrowse(LICE_IBitmap* bmp, int w, int h) { + // A full-window modal sheet over the Sample face (F3: full-window overlay). Dim the underlying + // Sample face with a bg/base wash, then draw the picker opaque on top. + LICE_FillRect(bmp, 0, 0, w, h, toLice(roleColor(Role::BgBase)), 0.82f, 0); + const BrowseModal bm = computeBrowseModal(w, h); + + // Title band + Back button (returns to Sample, discarding any pending pick). + drawTitleBand(bmp, bm.title, "Browse - pick a capture"); + { + const KitButtonBox box{toKitBox(bm.back)}; + const InteractionState st = + isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Back", st, /*warn=*/false); + } + + // Search box (type-to-filter). A focused box lifts to Focus + a ring; else Rest/Hover. + const Rect searchAbs = bm.search; const InteractionState searchState = searchFocused_ ? InteractionState::Focus : (isHovered(HoverKind::kSearchBox, -1) ? InteractionState::Hover : InteractionState::Rest); fillSurface(bmp, toKitBox(searchAbs), Role::BgCell, searchState); if (searchFocused_) { - const KitColor ring = roleColor(Role::TextPrimary); LICE_DrawRect(bmp, searchAbs.left, searchAbs.top, searchAbs.width() - 1, - searchAbs.height() - 1, toLice(ring), 1.0f, 0); + searchAbs.height() - 1, toLice(roleColor(Role::TextPrimary)), 1.0f, 0); } { std::string sb = searchQuery_.empty() @@ -837,16 +1155,13 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { searchQuery_.empty() ? Role::TextDim : Role::TextPrimary); } - const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop}; - - // The browser tabs + card grid, laid out by the pure module over the browser sub-area. - // capture_browser lays out from (0,0); offset the draw by browserArea's origin. + // Tabs + card grid, laid out over the content sub-area by the pure module (origin-offset). + const Rect browserArea = bm.content; const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); const int ox = browserArea.left; const int oy = browserArea.top; scrollOffset_ = clampScrollOffset(bl, static_cast(visible_.size()), scrollOffset_); - // Filter tabs: an "All" tab (index 0) + one per named bank. The active tab highlights. const int tabCount = static_cast(banks_.size()) + 1; for (int i = 0; i < tabCount; ++i) { Rect t = filterTabRect(bl, tabCount, i); @@ -863,15 +1178,12 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { active ? Role::BgBase : Role::TextPrimary); } - // Cards: only the S12 visible window at the current scroll offset (a bank longer than the - // panel is reachable by wheel/thumb drag). scrolledCardCellRect shifts each cell up by the - // offset; we clip to the grid region so a partially-scrolled row is trimmed at the edges. + // Cards (the S12 visible window at the current scroll offset). The PENDING pick (browsePendingId_) + // is marked with the accent-primary border; the currently-loaded id gets a faint tertiary border. const int bins = thumbBins(bl); const int cardCount = static_cast(visible_.size()); const VisibleRange vr = visibleCardRange(bl, cardCount, scrollOffset_); for (int i = vr.first; i < vr.last; ++i) { - // The scrolled CELL, then the same gutter/thumbnail/label insets the pure module derives, - // shifted by the scroll offset (they share the cell's top, so subtract the offset). Rect content = cardContentRect(bl, i); Rect thumb = cardThumbnailRect(bl, i); Rect labelR = cardLabelRect(bl, i); @@ -883,21 +1195,18 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { labelR.right + ox, labelR.bottom + oy - scrollOffset_}; const SampleChoice& s = visible_[static_cast(i)]; - const bool sel = (s.id == selectedId_); - // Card surface: a plain cell (hover lightens), with the SELECTED pick marked by an - // accent-primary border (the "this is live" signal, §2.1) — the same convention the - // dock panel's L7 selection uses (normal cell + accent border, no inversion). The - // waveform thumbnail draws over bg/panel so its accent columns read against the cell. + const bool pending = (s.id == browsePendingId_); + const bool loaded = (s.id == selectedId_); const InteractionState cardState = isHovered(HoverKind::kCard, i) ? InteractionState::Hover : InteractionState::Rest; fillSurface(bmp, toKitBox(content), Role::BgCell, cardState); - const KitColor cardBorder = - sel ? roleColor(Role::AccentPrimary) : roleColor(Role::LineHairline); + const KitColor cardBorder = pending ? roleColor(Role::AccentPrimary) + : (loaded ? roleColor(Role::AccentTertiary) + : roleColor(Role::LineHairline)); LICE_DrawRect(bmp, content.left, content.top, content.width() - 1, content.height() - 1, toLice(cardBorder), 1.0f, 0); drawEnvelope(bmp, thumb, thumbnailFor(s.id, bins)); - // Name + root/key badge under the thumbnail. std::string caption = s.displayName.empty() ? s.id : s.displayName; Rect nameR{labelR.left + 3, labelR.top, labelR.right - 3, labelR.top + labelR.height() / 2}; Rect badgeR{labelR.left + 3, nameR.bottom, labelR.right - 3, labelR.bottom}; @@ -909,9 +1218,7 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { kitText(bmp, badgeR, badge.c_str(), Font::Micro, Role::TextDim); } - // S12 scrollbar: a thumb in the grid's right-edge gutter, sized/positioned by the pure - // module (empty when the content fits — the shell simply draws nothing then). Offset by the - // browser origin like every other card rect. + // Scrollbar thumb. { const Rect thumb = scrollThumbRect(bl, cardCount, scrollOffset_); if (thumb.height() > 0) { @@ -922,119 +1229,56 @@ void ReaSamplerEditor::paintBrowser(LICE_IBitmap* bmp, int w, int h) { } } - if (havePick) { - paintSetup(bmp, Rect{bands.content.left, setupTop, bands.content.right, bands.content.bottom}); - } else if (visible_.empty()) { - paintEmptyState(bmp, browserArea); + if (visible_.empty()) paintEmptyState(bmp, browserArea); + + // Footer: Cancel (discard, return to Sample) + Load (commit the pending pick). Load is inert + // (no accent) until a card is picked. Draw a footer strip so the buttons read as a modal bar. + Rect footer{0, bm.content.bottom, w, h}; + fillSurface(bmp, toKitBox(footer), Role::BgPanel, InteractionState::Rest); + { + const KitButtonBox box{toKitBox(bm.cancel)}; + const InteractionState st = + isHovered(HoverKind::kBrowseCancel, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Cancel", st, /*warn=*/false); + } + { + const KitButtonBox box{toKitBox(bm.confirm)}; + const bool armed = !browsePendingId_.empty(); + const InteractionState st = armed + ? (isHovered(HoverKind::kBrowseConfirm, -1) ? InteractionState::Hover : InteractionState::Active) + : InteractionState::Rest; + drawButton(bmp, box, "Load", st, /*warn=*/false); } } -void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) { - // The guided single-capture setup: the picked capture's name + root/level, and a - // keyboard strip with its root marker (drag to set root). A raised bg/panel region. - fillSurface(bmp, toKitBox(area), Role::BgPanel, InteractionState::Rest); - - // Effective root: the picked sample's rootNote intrinsic (or middle C when unset). - // Read from samples_ (the full unfiltered list) so a bank-filter that hides the - // picked sample's bank doesn't mask its intrinsic root with the C4 default. - int root = 60; - for (const SampleChoice& s : samples_) { - if (s.id == selectedId_ && s.rootNote) root = *s.rootNote; - } - // If a matching one-zone override exists (opt-in from Zones), prefer it as the shown root. - for (const PerformanceZone& z : map_.zones) { - if (z.sampleId == selectedId_ && z.rootOverride) root = *z.rootOverride; - } - - const int pad = 8; - // The mono/stereo toggle sits at the right of the header row; keep the name text clear of it. - const ChannelToggleRects chan = channelToggleRects(area); - Rect headerR{area.left + pad, area.top + 4, chan.mono.left - 8, area.top + 22}; - std::string header = sampleLabel(samples_, selectedId_) + " root " + noteLabel(root); - kitText(bmp, headerR, header.c_str(), Font::Label, Role::TextPrimary); - - // S7 mono | stereo output-mode toggle. Active segment = accent-primary fill (the live - // mode); inactive lightens on hover — the same visual grammar as the Browser/Zones 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); - - Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16}; - kitText(bmp, hintR, - "Drag the waveform markers to set start + loop; drag the keyboard to set root.", - Font::Micro, Role::TextDim); - - // --- S11 waveform surface: the picked capture's envelope + draggable markers ---------- - const std::vector& pcm = monoPcmFor(selectedId_); - const std::int64_t frames = static_cast(pcm.size()); - const Rect waveArea = setupWaveformArea(area); - // The waveform pane is a recessed surface (bg/base, one step DOWN from the setup panel) - // so the accent-primary envelope reads against it. - fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest); - if (frames > 0 && waveArea.width() > 0) { - // Envelope at one bin per pixel (full-res view of the decoded PCM, S10 read-only view - // reused). computeEnvelope over the cached mono frames — no new decode. - const int bins = (std::max)(1, waveArea.width()); - const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast(bins)); - drawEnvelope(bmp, waveArea, env); // kit drawWaveform — accent-primary columns - - const SetupMarkers m = pickedMarkers(frames); - // Faint loop-region fill between the loop markers (only when a loop is set) — the - // categorical loop-marker hue (tertiary purple) at low alpha. - if (m.hasLoop && m.loopEnd > m.loopStart) { - const int lx = frameToX(waveArea, frames, m.loopStart); - const int rx = frameToX(waveArea, frames, m.loopEnd); - if (rx > lx) { - LICE_FillRect(bmp, lx, waveArea.top, rx - lx, waveArea.height(), - toLice(roleColor(kRoleLoopMarker)), 0.20f, 0); - } - } - // The three markers: start (teal, secondary) + loop start/end (purple, tertiary) — - // categorical affordance hues (§2.1), 2px vertical lines the full waveform height. - // Loop markers dim when no loop is set (the "no loop" state — draggable to CREATE one). - const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; - const Role markerRoles[3] = {kRoleStartMarker, kRoleLoopMarker, kRoleLoopMarker}; - for (int i = 0; i < 3; ++i) { - const int mx = frameToX(waveArea, frames, markerFrames[i]); - const bool loopMarker = (i != 0); - const float alpha = (loopMarker && !m.hasLoop) ? 0.4f : 1.0f; - LICE_FillRect(bmp, mx - 1, waveArea.top, 2, waveArea.height(), - toLice(roleColor(markerRoles[i])), alpha, 0); - } - } else { - kitTextCentered(bmp, waveArea, "(decoding...)", Font::Label, Role::TextDim); - } - - // Keyboard strip with the root marker — the signature Direction-C PASTEL SPECTRAL surface - // (§4). Each key column is hue-mapped low->high across the accent trio (spectralColor: - // lime -> teal -> purple), so the strip reads as an extension of the accent system. The - // root marker lifts to accent-primary with a STATIC glow (never a pulse — §3.5). - const Rect stripArea = setupStripArea(area); - drawSpectralStrip(bmp, stripArea); - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); - drawRootMarker(bmp, stripArea, sl, root); +// The Zone-view (S-VIEW-8) content area: the whole window below the title band. +namespace { +Rect zoneContentArea(int w, int h) { + const int titleH = (std::min)(kTitleHeight, h); + return Rect{0, titleH, w, h}; } +} // namespace -void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { - const EditorBands bands = computeBands(w, 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{0, 0, w, (std::min)(kTitleHeight, h)}; + drawTitleBand(bmp, title, "Zone - keyboard map"); + { + const Rect back{w - kPad - kNavButtonWidth, 2, w - kPad, (std::max)(2, title.bottom - 2)}; + const KitButtonBox box{toKitBox(back)}; + const InteractionState st = + isHovered(HoverKind::kBack, -1) ? InteractionState::Hover : InteractionState::Rest; + drawButton(bmp, box, "Back", st, /*warn=*/false); + } + + const Rect content = zoneContentArea(w, h); const int pad = 8; // A single "+ Add Zone" affordance at the top of the content, then the keyboard strip // with one bar per zone. Delete is a small × on the selected zone (keystroke also). - Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, - bands.content.top + 4 + 20}; + Rect addR{content.left + pad, content.top + 4, content.left + pad + 96, + content.top + 4 + 20}; { const KitButtonBox box{toKitBox(addR)}; const InteractionState state = @@ -1052,10 +1296,10 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { drawButton(bmp, box, "Delete", state, /*warn=*/false); } - // The zones strip — the same PASTEL SPECTRAL surface as the setup face (§4), 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(bands); + // The zones strip — the same PASTEL SPECTRAL surface as the Sample face, with one bar per + // zone over the spectrum. The SELECTED zone lifts to accent-primary + a static glow ("which + // zone is live"); the rest take the categorical secondary hue at low alpha. + const Rect stripArea = zonesStripArea(content); drawSpectralStrip(bmp, stripArea); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int sx = stripArea.left; @@ -1088,7 +1332,7 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { sampleLabel(samples_, z.sampleId).c_str(), Font::Label, Role::TextPrimary); // Three fields laid out left-to-right after the sample label. A focused field lifts to // the Focus state (accent nudge + ring); values in tabular mono so digits don't jitter. - const Rect fields = noteEntryFieldsArea(bands); + const Rect fields = noteEntryFieldsArea(content); const char* names[3] = {"Low", "High", "Root"}; const std::string vals[3] = { noteLabel(z.lowNote), noteLabel(z.highNote), @@ -1117,11 +1361,9 @@ void ReaSamplerEditor::paintZones(LICE_IBitmap* bmp, int w, int h) { // pitch engine + AD pitch envelope). Shown for an explicit zone selection OR for the // single-capture face when the map is empty but a capture is picked (S15-F2 lean: the // single capture is already a one-zone map — one storage site serves both). - const bool haveControlTarget = - (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) || - (map_.zones.empty() && !selectedId_.empty()); - if (haveControlTarget) { - paintControls(bmp, zonesControlPanel(computeBands(w, h))); + if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + paintControls(bmp, zonesControlPanel(content), + map_.zones[static_cast(selectedZone_)]); } } @@ -1131,19 +1373,13 @@ namespace { struct ControlLabels { const char* label; const char* seg0; const char* seg1; }; } // namespace -void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) { - // Resolve the play params: from the selected zone when one is chosen, or from the - // PerformanceZone product defaults when the map is empty but a capture is picked - // (S15-F2 lean: the single-capture face shares the same storage site as a one-zone map; - // see paintZones for the gate that reaches here). - ZonePlaySeconds play; - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - play = map_.zones[static_cast(selectedZone_)].play; - } else if (map_.zones.empty() && !selectedId_.empty()) { - play = PerformanceZone{}.play; // product defaults (Gate + Preserve + tier-0 ADSR) - } else { - return; // no control target - } +void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel, + const PerformanceZone& zone) { + // The control panel edits `zone`: its ZonePlaySeconds (Mode/Pitch/AHDSR-or-Trigger/AD-pitch) + // plus the S-VIEW-6 keyTrack scalar (which lives on PerformanceZone, not the play struct — it + // is read/written from `zone` directly). Shared by the Sample home face (single-capture, the + // effective one-zone site) and the Zone surface (an explicit zone) — one storage site. + const ZonePlaySeconds& play = zone.play; const std::vector descs = controlDescs(play); const std::vector rows = layoutControls(panel, descs); @@ -1163,6 +1399,7 @@ void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) { case ParamControl::kPitchEnvAttack: return {"P.Attack", "", ""}; case ParamControl::kPitchEnvDecay: return {"P.Decay", "", ""}; case ParamControl::kPitchEnvDepth: return {"P.Depth", "", ""}; + case ParamControl::kKeyTrack: return {"Key track", "", ""}; default: return {"", "", ""}; } }; @@ -1171,7 +1408,11 @@ void ReaSamplerEditor::paintControls(LICE_IBitmap* bmp, const Rect& panel) { if (r.row.top >= panel.bottom) break; // clip at the panel bottom const ControlLabels lab = labelsFor(static_cast(r.id)); kitText(bmp, r.label, lab.label, Font::Micro, Role::TextDim); - const double v = controlValue(r.id, play); + // kKeyTrack lives on the zone (0..200% over kKeyTrackMax), not in `play` — resolve it + // directly; every other control reads through controlValue against the play struct. + const double v = (r.id == static_cast(ParamControl::kKeyTrack)) + ? clamp01(zone.keyTrack / kKeyTrackMax) + : controlValue(r.id, play); const bool hov = isHovered(HoverKind::kControl, r.id); if (r.kind == ControlKind::Toggle) { const bool seg1 = (v >= 0.5); @@ -1226,68 +1467,62 @@ void ReaSamplerEditor::resolveHover(int x, int y) { GetClientRect(childHwnd_, &cr); const int w = cr.right - cr.left; const int hgt = cr.bottom - cr.top; - const EditorBands bands = computeBands(w, hgt); - if (contains(bands.toggleBrowser, x, y)) { - h = {HoverKind::kToggleBrowser, -1}; - } else if (contains(bands.toggleZones, x, y)) { - h = {HoverKind::kToggleZones, -1}; - } else if (view_ == View::kBrowser) { - const bool havePick = !selectedId_.empty(); - const int setupTop = havePick - ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) - : bands.content.bottom; - const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; - const Rect searchBox = searchBoxRect(fullBrowserArea.width()); - const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top, - fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom}; - if (contains(searchAbs, x, y)) { - h = {HoverKind::kSearchBox, -1}; - } else { - const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop}; - const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); - const int bx = x - browserArea.left; - const int by = y - browserArea.top; + if (view_ == View::kBrowse) { + const BrowseModal bm = computeBrowseModal(w, hgt); + if (contains(bm.back, x, y)) h = {HoverKind::kBack, -1}; + else if (contains(bm.cancel, x, y)) h = {HoverKind::kBrowseCancel, -1}; + else if (contains(bm.confirm, x, y)) h = {HoverKind::kBrowseConfirm, -1}; + else if (contains(bm.search, x, y)) h = {HoverKind::kSearchBox, -1}; + else { + const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height()); + const int bx = x - bm.content.left; + const int by = y - bm.content.top; const int tabCount = static_cast(banks_.size()) + 1; const int tab = filterTabHitTest(bl, tabCount, bx, by); const int card = (tab >= 0) ? -1 : cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); - if (tab >= 0) { - h = {HoverKind::kFilterTab, tab}; - } else if (card >= 0) { - h = {HoverKind::kCard, card}; - } else if (havePick) { - const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; - const ChannelToggleRects chan = channelToggleRects(area); - if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1}; - else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1}; - } + if (tab >= 0) h = {HoverKind::kFilterTab, tab}; + else if (card >= 0) h = {HoverKind::kCard, card}; } - } else { // Zones view - const int pad = 8; - Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, - bands.content.top + 4 + 20}; + } else if (view_ == View::kZone) { + const Rect back{w - kPad - kNavButtonWidth, 2, + w - kPad, (std::max)(2, (std::min)(kTitleHeight, hgt) - 2)}; + const Rect content = zoneContentArea(w, hgt); + Rect addR{content.left + kPad, content.top + 4, content.left + kPad + 96, content.top + 4 + 20}; Rect delR{addR.right + 8, addR.top, addR.right + 8 + 64, addR.bottom}; - if (contains(addR, x, y)) { + if (contains(back, x, y)) { + h = {HoverKind::kBack, -1}; + } else if (contains(addR, x, y)) { h = {HoverKind::kAddZone, -1}; } else if (selectedZone_ >= 0 && contains(delR, x, y)) { h = {HoverKind::kDeleteZone, -1}; + } else if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { + const ZonePlaySeconds& play = map_.zones[static_cast(selectedZone_)].play; + const Rect panel = zonesControlPanel(content); + const std::vector descs = controlDescs(play); + const std::vector rows = layoutControls(panel, descs); + const int id = controlAtPoint(rows, x, y); + if (id >= 0) h = {HoverKind::kControl, id}; + } + } else { // Sample view (home) + const SampleBands bands = computeSampleBands(w, hgt); + if (contains(bands.navBrowse, x, y)) { + h = {HoverKind::kNavBrowse, -1}; + } else if (contains(bands.navZone, x, y)) { + h = {HoverKind::kNavZone, -1}; + } else if (selectedId_.empty() && map_.zones.empty()) { + // Empty state — no interactive surfaces beyond the nav. } else { - // The param control panel (a selected zone, or the single-capture face's defaults). - ZonePlaySeconds play; - bool haveTarget = false; - if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - play = map_.zones[static_cast(selectedZone_)].play; - haveTarget = true; - } else if (map_.zones.empty() && !selectedId_.empty()) { - play = PerformanceZone{}.play; - haveTarget = true; - } - if (haveTarget) { - const Rect panel = zonesControlPanel(bands); - const std::vector descs = controlDescs(play); - const std::vector rows = layoutControls(panel, descs); + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + if (contains(clusterPreviewButton(bands.cluster), x, y)) h = {HoverKind::kPreview, -1}; + else if (contains(chan.mono, x, y)) h = {HoverKind::kChanMono, -1}; + else if (contains(chan.stereo, x, y)) h = {HoverKind::kChanStereo, -1}; + else { + const PerformanceZone zone = effectiveSampleZone(); + const std::vector descs = controlDescs(zone.play); + const std::vector rows = layoutControls(bands.control, descs); const int id = controlAtPoint(rows, x, y); if (id >= 0) h = {HoverKind::kControl, id}; } @@ -1308,35 +1543,36 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { GetClientRect(childHwnd_, &cr); const int w = cr.right - cr.left; const int h = cr.bottom - cr.top; - const EditorBands bands = computeBands(w, h); - // Toggle band: switch views. - if (contains(bands.toggleBrowser, x, y)) { view_ = View::kBrowser; invalidate(); return; } - if (contains(bands.toggleZones, x, y)) { view_ = View::kZones; invalidate(); return; } - - if (view_ == View::kBrowser) { - const bool havePick = !selectedId_.empty(); - const int setupTop = havePick ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) - : bands.content.bottom; - const Rect fullBrowserArea{bands.content.left, bands.content.top, bands.content.right, setupTop}; - - // S12 search box (mirror of paintBrowser): a click focuses it; the browser sits below. - const Rect searchBox = searchBoxRect(fullBrowserArea.width()); - const Rect searchAbs{fullBrowserArea.left + searchBox.left, fullBrowserArea.top + searchBox.top, - fullBrowserArea.left + searchBox.right, fullBrowserArea.top + searchBox.bottom}; - if (contains(searchAbs, x, y)) { - searchFocused_ = true; + // ---- Browse modal (S-VIEW-5): pick + confirm/cancel over the Sample face ---- + if (view_ == View::kBrowse) { + const BrowseModal bm = computeBrowseModal(w, h); + if (contains(bm.back, x, y) || contains(bm.cancel, x, y)) { + // Cancel/Back: discard the pending pick, return to Sample unchanged. + browsePendingId_.clear(); + searchFocused_ = false; + view_ = View::kSample; invalidate(); return; } - searchFocused_ = false; // any other browser click defocuses the search box + if (contains(bm.confirm, x, y)) { + // Load: commit the pending pick (if any) into the loaded selection + reload, then Sample. + if (!browsePendingId_.empty()) { + selectedId_ = browsePendingId_; + commitAndReload(); + } + browsePendingId_.clear(); + searchFocused_ = false; + view_ = View::kSample; + invalidate(); + return; + } + if (contains(bm.search, x, y)) { searchFocused_ = true; invalidate(); return; } + searchFocused_ = false; - const Rect browserArea{fullBrowserArea.left, searchAbs.bottom, fullBrowserArea.right, setupTop}; - const BrowserLayout bl = layoutBrowser(browserArea.width(), browserArea.height()); - const int bx = x - browserArea.left; - const int by = y - browserArea.top; - - // Filter tabs. + const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height()); + const int bx = x - bm.content.left; + const int by = y - bm.content.top; const int tabCount = static_cast(banks_.size()) + 1; const int tab = filterTabHitTest(bl, tabCount, bx, by); if (tab >= 0) { @@ -1346,89 +1582,166 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { invalidate(); return; } - // S12 scrollbar thumb: grab to drag-scroll (checked before cards — the thumb overlays the - // grid's right gutter). scrollThumbRect is empty when the content fits, so this is inert then. const Rect thumb = scrollThumbRect(bl, static_cast(visible_.size()), scrollOffset_); if (thumb.height() > 0 && - contains(Rect{thumb.left + browserArea.left, thumb.top + browserArea.top, - thumb.right + browserArea.left, thumb.bottom + browserArea.top}, x, y)) { + contains(Rect{thumb.left + bm.content.left, thumb.top + bm.content.top, + thumb.right + bm.content.left, thumb.bottom + bm.content.top}, x, y)) { drag_ = DragKind::kScrollThumb; dragStartY_ = y; dragStartScrollOffset_ = scrollOffset_; return; } - // Cards: pick a capture -> load it (this is the whole time-to-first-note gesture). The - // hit-test adds the scroll offset back so a scrolled card maps to the right index. const int card = cardHitTest(bl, static_cast(visible_.size()), bx, by + scrollOffset_); if (card >= 0) { - selectedId_ = visible_[static_cast(card)].id; - commitAndReload(); // publishes the pick + reloads; process() plays it repitched + // Select-then-confirm: a click marks the pending pick; a DOUBLE-click on the same card + // is the load accelerator (commit + dismiss). Browse never loads on a single click. + const std::string id = visible_[static_cast(card)].id; + if (lastBrowseClickCard_ == card && browsePendingId_ == id) { + selectedId_ = id; + commitAndReload(); + browsePendingId_.clear(); + lastBrowseClickCard_ = -1; + searchFocused_ = false; + view_ = View::kSample; + invalidate(); + } else { + browsePendingId_ = id; + lastBrowseClickCard_ = card; + invalidate(); + } return; } - // The setup band: the mono/stereo toggle (header row), the S11 waveform markers, - // then the root-marker strip. - if (havePick) { - const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; - // S7: a click on a channel-mode segment sets the instance mode (setChannelMode - // re-negotiates the bus + reloads; a no-op set for the already-active mode is ignored - // by the processor). Snapshot the new mode locally so the paint reflects it at once. - const ChannelToggleRects chan = channelToggleRects(area); - 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; - } + lastBrowseClickCard_ = -1; + return; + } - // S11 waveform markers: grab start / loop-start / loop-end to drag. Hit-test the - // waveform band first (it sits above the keyboard strip). markerAtPoint resolves - // which marker under the grab; a miss falls through to the keyboard strip. - const std::vector& pcm = monoPcmFor(selectedId_); - const std::int64_t frames = static_cast(pcm.size()); - if (frames > 0) { - const Rect waveArea = setupWaveformArea(area); - const SetupMarkers m = pickedMarkers(frames); - const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; - const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y); - if (hit >= 0) { - drag_ = DragKind::kWaveMarker; - waveMarker_ = static_cast(hit); + // ---- Sample home (S-VIEW-2) ---- + if (view_ == View::kSample) { + const SampleBands bands = computeSampleBands(w, h); + if (contains(bands.navBrowse, x, y)) { + // Open the Browse modal; seed its pending pick from the loaded id so the current + // capture reads as pre-selected. + browsePendingId_ = selectedId_; + lastBrowseClickCard_ = -1; + view_ = View::kBrowse; + invalidate(); + return; + } + if (contains(bands.navZone, x, y)) { view_ = View::kZone; invalidate(); return; } + if (selectedId_.empty() && map_.zones.empty()) return; // empty state — nav only + + // Preview-trigger button: fire the loaded capture at its root through the voice engine + // (momentary — note-on on press, note-off on release). + if (contains(clusterPreviewButton(bands.cluster), x, y)) { + const int note = effectiveRoot(); + if (previewingNote_ >= 0) processor_->previewNoteOff(previewingNote_); + previewingNote_ = note; + processor_->previewNoteOn(note); + invalidate(); + return; + } + // Preview velocity knob: grab to drag (a kParamSlider drag against the velocity domain, + // marked by dragParamId_ == -2 sentinel so onMouseMove routes it to setPreviewVelocity). + { + const Rect vs = clusterVelocitySlider(bands.cluster); + if (contains(vs, x, y)) { + drag_ = DragKind::kParamSlider; + dragParamId_ = -2; // sentinel: the preview velocity knob (not a zone param) + dragParamPanel_ = vs; + const double v = valueAtPoint(vs, x); + processor_->setPreviewVelocity(static_cast(v * 127.0 + 0.5)); + invalidate(); + return; + } + } + // Channel toggle. + const ChannelToggleRects chan = channelToggleRects(bands.cluster); + if (contains(chan.mono, x, y)) { + channelMode_ = ChannelMode::Mono; + processor_->setChannelMode(ChannelMode::Mono); + invalidate(); + return; + } + if (contains(chan.stereo, x, y)) { + channelMode_ = ChannelMode::Stereo; + processor_->setChannelMode(ChannelMode::Stereo); + invalidate(); + return; + } + + // Hero waveform: envelope nodes (S-VIEW-3) first, then the S11 markers. + const std::vector& pcm = monoPcmFor(selectedId_); + const std::int64_t frames = static_cast(pcm.size()); + const Rect waveArea = bands.hero; + if (frames > 0) { + const double rate = liveSampleRate(); + if (rate > 0.0) { + const PerformanceZone zone = effectiveSampleZone(); + const std::int64_t startFrame = zone.startPoint.value_or(0); + const AmpEnvelope env = packEnvelope(zone.play, frames, startFrame); + const double totalSeconds = static_cast(frames) / rate; + const NodeHit nh = nodeAtPoint(env, waveArea, totalSeconds, x, y); + if (nh.hit) { + drag_ = DragKind::kEnvNode; + envNode_ = nh.node; dragStartX_ = x; - dragStartMarkers_ = m; + dragStartY_ = y; + dragStartEnv_ = env; dragSampleFrames_ = frames; + dragStartFrame_ = startFrame; dragStartMap_ = map_; - return; // no immediate set — the marker only moves once the cursor drags + return; // node moves once the cursor drags } } - - // The setup strip: grab the root marker (drag to set the picked capture's root). - const Rect stripArea = setupStripArea(area); - const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); - const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); - if (note >= 0) { - drag_ = DragKind::kRootMarker; + const SetupMarkers m = pickedMarkers(frames); + const std::int64_t markerFrames[3] = {m.start, m.loopStart, m.loopEnd}; + const int hit = markerAtPoint(waveArea, frames, markerFrames, 3, x, y); + if (hit >= 0) { + drag_ = DragKind::kWaveMarker; + waveMarker_ = static_cast(hit); dragStartX_ = x; - dragStartRoot_ = note; + dragStartMarkers_ = m; + dragSampleFrames_ = frames; dragStartMap_ = map_; - // A click sets the root immediately (drag then refines); the override lives on - // a one-zone map entry for the picked capture (D-B, never written to the bank). - onMouseMove(x, y); // apply the click position as the first delta==0 set return; } } + + // Fenced root strip: grab the root marker. + const Rect rootStrip = clusterRootStrip(bands.cluster); + const StripLayout sl = layoutStrip(rootStrip.width(), rootStrip.height()); + const int note = keyAtPoint(sl, x - rootStrip.left, y - rootStrip.top); + if (note >= 0) { + drag_ = DragKind::kRootMarker; + dragStartX_ = x; + dragStartRoot_ = note; + dragStartMap_ = map_; + onMouseMove(x, y); // apply the click as the first delta==0 set + return; + } + + // The control strip (S-VIEW-2 moved from Zone): route via the shared handler on the + // effective one-zone site (materialize it on first interaction, mirror of the Zone path). + { + const PerformanceZone probeZone = effectiveSampleZone(); + const std::vector descs = controlDescs(probeZone.play); + const std::vector rows = layoutControls(bands.control, descs); + if (controlAtPoint(rows, x, y) >= 0) { + const int zi = ensureSampleZone(); + if (zi >= 0) handleControlClick(zi, bands.control, x, y); + } + } return; } - // Zones view. + // ---- Zone surface (S-VIEW-8) ---- + const Rect back{w - kPad - kNavButtonWidth, 2, + w - kPad, (std::max)(2, (std::min)(kTitleHeight, h) - 2)}; + if (contains(back, x, y)) { view_ = View::kSample; invalidate(); return; } + const Rect content = zoneContentArea(w, h); const int pad = 8; - Rect addR{bands.content.left + pad, bands.content.top + 4, bands.content.left + pad + 96, - bands.content.top + 4 + 20}; + Rect addR{content.left + pad, content.top + 4, content.left + pad + 96, + content.top + 4 + 20}; if (contains(addR, x, y)) { // Add a full-keyboard zone for the picked capture (or the first visible sample as a // sensible seed). No pick -> nothing to add. If a full-keyboard zone for the seed id @@ -1464,7 +1777,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // 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(bands); + const Rect stripArea = zonesStripArea(content); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int lx = x - stripArea.left; const int ly = y - stripArea.top; @@ -1506,7 +1819,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { // S12 numeric-entry fields (low/high/root): a click focuses the field for typing. Only when a // zone is selected. entryText_ starts empty (the user types the full value). if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - const Rect fields = noteEntryFieldsArea(bands); + const Rect fields = noteEntryFieldsArea(content); for (int f = 0; f < 3; ++f) { if (contains(noteEntryFieldRect(fields, f), x, y)) { entryField_ = f; @@ -1516,88 +1829,73 @@ void ReaSamplerEditor::onMouseDown(int x, int y) { } } } - entryField_ = -1; // a click elsewhere in the Zones view cancels an in-progress entry + entryField_ = -1; // a click elsewhere in the Zone view cancels an in-progress entry - // The S12/S15/S16 parameter panel: a toggle segment flips at once (commit); a slider grab - // starts a live drag (commit on release). Reachable for an explicit zone selection OR for - // the single-capture face when the map is empty but a capture is picked (S15-F2 lean). - // In the empty-map+picked case, auto-create a full-keyboard zone for selectedId_ on first - // control interaction (same path as "+ Add Zone"), then apply the control — the zone is - // committed as part of the control edit. - if (selectedZone_ < 0 && map_.zones.empty() && !selectedId_.empty()) { - // Synthesize a probe layout with the product defaults to see if the click is in the - // panel before committing to creating the zone. - const Rect panel = zonesControlPanel(bands); - const ZonePlaySeconds defaultPlay = PerformanceZone{}.play; - const std::vector probeDescs = controlDescs(defaultPlay); - const std::vector probeRows = layoutControls(panel, probeDescs); - if (controlAtPoint(probeRows, x, y) >= 0) { - // The click lands in the control panel — materialize the zone now. - PerformanceZone z; - z.sampleId = selectedId_; - z.lowNote = 0; - z.highNote = 127; - map_.zones.push_back(z); - selectedZone_ = 0; - // Fall through to the control handler below which will process the click. - } - } + // The param panel: a toggle segment flips at once (commit); a slider grab starts a live drag. + // Only when a zone is selected (the Zone surface has no single-capture fallback — that lives + // on the Sample face now). if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { - PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - const Rect panel = zonesControlPanel(bands); - const std::vector descs = controlDescs(z.play); - const std::vector rows = layoutControls(panel, descs); - const int id = controlAtPoint(rows, x, y); - if (id >= 0) { - // Find the row to know its kind + control rect. - for (const ControlRow& r : rows) { - if (r.id != id) continue; - if (r.kind == ControlKind::Toggle) { - const int seg = toggleSegmentHitTest(r.control, x, y); - if (seg >= 0) { - applyControl(id, z.play, 0.0, seg); - commitAndReload(); // a toggle is a discrete, final edit - } - } else { - // Grab the slider: set the value at the grab x immediately, then live-drag. - drag_ = DragKind::kParamSlider; - dragParamId_ = id; - dragParamPanel_ = panel; - dragStartMap_ = map_; - applyControl(id, z.play, valueAtPoint(r.control, x), 0); - invalidate(); // live feedback; commit on WM_LBUTTONUP - } - break; - } - } + handleControlClick(selectedZone_, zonesControlPanel(content), x, y); } } +void ReaSamplerEditor::applyZoneControl(int zoneIndex, int id, double value, int segment) { + if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return; + PerformanceZone& z = map_.zones[static_cast(zoneIndex)]; + if (id == static_cast(ParamControl::kKeyTrack)) { + // keyTrack lives on the zone (0..200% over kKeyTrackMax); the slider maps 0..1. + z.keyTrack = clamp01(value) * kKeyTrackMax; + } else { + applyControl(id, z.play, value, segment); + } +} + +bool ReaSamplerEditor::handleControlClick(int zoneIndex, const Rect& panel, int x, int y) { + if (zoneIndex < 0 || zoneIndex >= static_cast(map_.zones.size())) return false; + const ZonePlaySeconds& play = map_.zones[static_cast(zoneIndex)].play; + const std::vector descs = controlDescs(play); + const std::vector rows = layoutControls(panel, descs); + const int id = controlAtPoint(rows, x, y); + if (id < 0) return false; + for (const ControlRow& r : rows) { + if (r.id != id) continue; + if (r.kind == ControlKind::Toggle) { + const int seg = toggleSegmentHitTest(r.control, x, y); + if (seg >= 0) { + applyZoneControl(zoneIndex, id, 0.0, seg); + commitAndReload(); // a toggle is a discrete, final edit + } + } else { + // Grab the slider: set the value at the grab x immediately, then live-drag. + drag_ = DragKind::kParamSlider; + dragParamId_ = id; + dragParamZone_ = zoneIndex; + dragParamPanel_ = panel; + dragStartMap_ = map_; + applyZoneControl(zoneIndex, id, valueAtPoint(r.control, x), 0); + invalidate(); // live feedback; commit on WM_LBUTTONUP + } + break; + } + return true; +} + void ReaSamplerEditor::onMouseMove(int x, int y) { if (drag_ == DragKind::kNone) return; RECT cr{}; GetClientRect(childHwnd_, &cr); const int w = cr.right - cr.left; const int h = cr.bottom - cr.top; - const EditorBands bands = computeBands(w, h); + const SampleBands bands = computeSampleBands(w, h); const int dx = x - dragStartX_; if (drag_ == DragKind::kRootMarker) { - // The single-capture root strip lives in the setup band. - const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight); - const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; - const Rect stripArea = setupStripArea(area); + // The fenced root strip on the Sample cluster band. Setting the root materializes a + // full-keyboard zone carrying the override on the picked id (the D-B override vehicle) — + // upsert by id so a repeated drag edits the same zone rather than stacking duplicates. + const Rect stripArea = clusterRootStrip(bands.cluster); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const int note = resolveDragNote(sl, dragStartRoot_, dx); - // The performance map is the ONLY D-B override vehicle (rootOverride lives on a zone), - // so setting the single capture's root materializes a full-keyboard zone carrying the - // override. This plays identically to the un-zoned single-capture path (one chromatic - // zone over the whole keyboard) and round-trips through the v3 component state; the - // zone becomes visible if the user opens the Zones panel. Upsert by the picked id so a - // repeated drag edits the same zone rather than stacking duplicates. - // Upsert the root override on the picked id; track the zone index so the control panel - // stays visible after the zone is materialized on the single-capture face (fix: without - // setting selectedZone_ here, selectedZone_==-1 with a non-empty map hides controls). bool found = false; for (int i = 0; i < static_cast(map_.zones.size()); ++i) { PerformanceZone& z = map_.zones[static_cast(i)]; @@ -1621,12 +1919,32 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { return; } + if (drag_ == DragKind::kEnvNode) { + // S-VIEW-3: resolve the grabbed envelope node's new params from the pixel delta (through + // the pure envelope_edit inverse map, clamped + monotonic), then unpack them back onto the + // picked id's one-zone play params. The AmpEnvelope was snapshotted at grab (dragStartEnv_) + // so the delta is absolute. Materialize the zone if needed (mirror of the marker path). + const std::int64_t frames = dragSampleFrames_; + const double rate = liveSampleRate(); + if (frames <= 0 || rate <= 0.0) return; + const double totalSeconds = static_cast(frames) / rate; + const int dy = y - dragStartY_; + const AmpEnvelope edited = resolveNodeDrag(dragStartEnv_, envNode_, bands.hero, + totalSeconds, envClampBounds(), dx, dy); + const int zi = ensureSampleZone(); + if (zi >= 0) { + unpackEnvelope(edited, frames, dragStartFrame_, + map_.zones[static_cast(zi)].play); + selectedZone_ = zi; + } + invalidate(); // live feedback; commit on WM_LBUTTONUP + return; + } + if (drag_ == DragKind::kWaveMarker) { // S11: resolve the grabbed marker's new frame from the pixel delta, zero-crossing-snap // it against the decoded PCM, apply the inter-marker clamps, and write the override live. - const int setupTop = (std::max)(bands.content.top, bands.content.bottom - kSetupHeight); - const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; - const Rect waveArea = setupWaveformArea(area); + const Rect waveArea = bands.hero; const std::int64_t frames = dragSampleFrames_; if (frames <= 0) return; @@ -1669,15 +1987,12 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { } if (drag_ == DragKind::kScrollThumb) { - // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The visible-card - // window recomputes at paint from scrollOffset_. The browser sub-area matches paintBrowser - // when a capture is picked (the setup band takes the bottom). + // S12: map the thumb-drag pixel delta to a new (clamped) scroll offset. The scroll drag + // only happens in the Browse modal (the sole card grid). The visible-card window recomputes + // at paint from scrollOffset_. const int dyThumb = y - dragStartY_; - const bool havePick = !selectedId_.empty(); - const int setupTop = havePick - ? (std::max)(bands.content.top, bands.content.bottom - kSetupHeight) - : bands.content.bottom; - const BrowserLayout bl = layoutBrowser(bands.content.width(), setupTop - bands.content.top); + const BrowseModal bm = computeBrowseModal(w, h); + const BrowserLayout bl = layoutBrowser(bm.content.width(), bm.content.height()); scrollOffset_ = thumbDragToOffset(bl, static_cast(visible_.size()), dragStartScrollOffset_, dyThumb); invalidate(); @@ -1685,16 +2000,24 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { } if (drag_ == DragKind::kParamSlider) { - // S12/S15/S16: re-lay the panel and map x -> value against the grabbed control's live - // track rect (the panel geometry is stable during the drag; re-laying keeps the value - // mapping exact even if a mode toggle changed the row set — it did not, mid-drag). - if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; - PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; - const std::vector descs = controlDescs(z.play); + // The preview-velocity knob (Sample face) uses the -2 sentinel — map x->0..1 over the + // stored knob rect and write it to the processor (persisted per-instance). + if (dragParamId_ == -2) { + const double v = valueAtPoint(dragParamPanel_, x); + if (processor_) processor_->setPreviewVelocity(static_cast(v * 127.0 + 0.5)); + invalidate(); + return; + } + // S12/S15/S16 + keyTrack: re-lay the panel and map x -> value against the grabbed control's + // live track rect. Uses dragParamZone_ (the Sample face has no selectedZone_ coupling). + const int zi = dragParamZone_; + if (zi < 0 || zi >= static_cast(map_.zones.size())) return; + const ZonePlaySeconds& play = map_.zones[static_cast(zi)].play; + const std::vector descs = controlDescs(play); const std::vector rows = layoutControls(dragParamPanel_, descs); for (const ControlRow& r : rows) { if (r.id == dragParamId_) { - applyControl(dragParamId_, z.play, valueAtPoint(r.control, x), 0); + applyZoneControl(zi, dragParamId_, valueAtPoint(r.control, x), 0); break; } } @@ -1702,9 +2025,10 @@ void ReaSamplerEditor::onMouseMove(int x, int y) { return; } - // Zone edits: recompute the grabbed field(s) against the pure resolver, live. + // Zone edits (kZoneLow/kZoneHigh/kZoneBody): recompute the grabbed field(s) live. Only reached + // in the Zone surface where selectedZone_ is set + the strip lives under its content area. if (selectedZone_ < 0 || selectedZone_ >= static_cast(map_.zones.size())) return; - const Rect stripArea = zonesStripArea(bands); + const Rect stripArea = zonesStripArea(zoneContentArea(w, h)); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); PerformanceZone& z = map_.zones[static_cast(selectedZone_)]; if (drag_ == DragKind::kZoneLow) { @@ -1725,12 +2049,23 @@ void ReaSamplerEditor::onMouseMove(int x, int 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_; drag_ = DragKind::kNone; - // A scrollbar drag is transient UI (no map change) — repaint but do NOT reload. Every other - // drag is a coherent map edit: publish the in-flight map + reload off-thread on release. - if (kind == DragKind::kScrollThumb) { + dragParamId_ = -1; + dragParamZone_ = -1; + // A scrollbar drag is transient UI (no map change), and the preview-velocity knob (id==-2) is a + // processor-side per-instance setting already applied live — neither reloads the instrument. + // Every other drag is a coherent map edit: publish the in-flight map + reload off-thread. + if (kind == DragKind::kScrollThumb || (kind == DragKind::kParamSlider && paramId == -2)) { invalidate(); return; } @@ -1738,10 +2073,10 @@ void ReaSamplerEditor::onMouseUp(int /*x*/, int /*y*/) { } void ReaSamplerEditor::onMouseWheel(int delta) { - // S12 browser scroll (only in the browser view). One wheel notch (WHEEL_DELTA==120) scrolls - // roughly one card row; the offset is clamped at paint (the layout/panel height is known - // there). A positive delta (wheel up) scrolls toward the top (smaller offset). - if (view_ != View::kBrowser) return; + // 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; @@ -1750,10 +2085,10 @@ void ReaSamplerEditor::onMouseWheel(int delta) { } void ReaSamplerEditor::onSearchChar(unsigned int ch) { - // S12 numeric note-entry (Zones view): a focused low/high/root field accumulates keystrokes + // S12 numeric note-entry (Zone surface): a focused low/high/root field accumulates keystrokes // and commits via parseNoteEntry on Enter. Handled before the search box (a field, when // focused, owns the keystrokes). - if (view_ == View::kZones && entryField_ >= 0) { + if (view_ == View::kZone && entryField_ >= 0) { if (ch == 13) { // Enter: parse + commit if (auto note = parseNoteEntry(entryText_)) { if (selectedZone_ >= 0 && selectedZone_ < static_cast(map_.zones.size())) { @@ -1783,7 +2118,7 @@ void ReaSamplerEditor::onSearchChar(unsigned int ch) { // S12 type-to-filter search. Only when the search box has focus (a click focuses it). Backspace // deletes; a printable ASCII char appends; the visible list recomposes (bank filter, then search). - if (view_ != View::kBrowser || !searchFocused_) return; + if (view_ != View::kBrowse || !searchFocused_) return; if (ch == 8) { // backspace if (!searchQuery_.empty()) searchQuery_.pop_back(); } else if (ch == 27) { // escape clears + defocuses @@ -1884,13 +2219,26 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam, // pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset // the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing. // Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler. - if (self && self->drag_ != DragKind::kNone) { - // A scrollbar drag is transient (no map mutation + dragStartMap_ was not - // snapshotted for it) — reset the drag state only, never touch map_. Every - // map-editing drag rolls its live mutation back to the pre-grab snapshot. - if (self->drag_ != DragKind::kScrollThumb) self->map_ = self->dragStartMap_; - self->drag_ = DragKind::kNone; - self->invalidate(); + if (self) { + // A held preview note must be released here too (peer of WM_LBUTTONUP) — capture + // loss otherwise leaves the momentary-key voice hung with no note-off. + if (self->previewingNote_ >= 0) { + if (self->processor_) self->processor_->previewNoteOff(self->previewingNote_); + self->previewingNote_ = -1; + self->invalidate(); + } + if (self->drag_ != DragKind::kNone) { + // A scrollbar drag + the preview-velocity knob (kParamSlider id==-2) are transient + // (no map mutation; dragStartMap_ not snapshotted) — reset drag state only, never + // touch map_. Every map-editing drag rolls its live mutation back to the snapshot. + const bool transient = self->drag_ == DragKind::kScrollThumb || + (self->drag_ == DragKind::kParamSlider && self->dragParamId_ == -2); + if (!transient) self->map_ = self->dragStartMap_; + self->drag_ = DragKind::kNone; + self->dragParamId_ = -1; + self->dragParamZone_ = -1; + self->invalidate(); + } } return 0; case WM_DROPFILES: { diff --git a/src/vst/reasampler_editor.h b/src/vst/reasampler_editor.h index 59f22ab..329986f 100644 --- a/src/vst/reasampler_editor.h +++ b/src/vst/reasampler_editor.h @@ -31,6 +31,8 @@ #include "public.sdk/source/common/pluginview.h" #include "editor_geometry.h" // Rect (the shell's sub-rect type, shared with the pure modules) +#include "envelope_edit.h" // EnvClampBounds / NodeHit (S-VIEW-3 envelope node hit-test/edit) +#include "envelope_overlay.h" // AmpEnvelope / EnvNode (S-VIEW-3 envelope overlay draw seam) #include "param_slider.h" // ControlRow (the S12/S15/S16 control-surface geometry) #include "peaks.h" // Envelope (the cached peak thumbnail) #include "sample_map.h" // SampleChoice, BankChoice, PerformanceMap (the shell's snapshot) @@ -64,16 +66,21 @@ protected: Steinberg::tresult PLUGIN_API onSize(Steinberg::ViewRect* newSize) override; private: - // Which face the editor shows. The browser is the default; the Zones panel is the - // demoted opt-in view reached by the toggle. Both draw over the same snapshotted bank. - enum class View { kBrowser, kZones }; + // Which face the editor shows (S-VIEW-1, three-view model). Sample is the HOME/default + // face (the loaded capture). Browse is a full-window MODAL picker overlaid on Sample + // (select + confirm/cancel changes the loaded capture, then dismisses). Zone is the + // dedicated multi-zone keymap surface, button-summoned. All three draw over the same + // snapshotted bank; Browse + Zone return to Sample when dismissed. + enum class View { kSample, kBrowse, kZone }; // What a mouse drag is currently editing (the drag-state machine). kNone = no drag in // flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the // single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop - // marker on the S11 waveform surface (which marker is in waveMarker_). + // marker on the S11 waveform surface (which marker is in waveMarker_); kEnvNode is a + // draggable envelope breakpoint on the Sample-view hero overlay (S-VIEW-3, which node in + // envNode_). enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker, - kScrollThumb, kParamSlider }; + kScrollThumb, kParamSlider, kEnvNode }; // The parameter controls on the setup surface (S12 AHDSR + the S15/S16 control surfaces). // The int value is the ControlDesc id the pure param_slider hit-test returns; the shell @@ -93,6 +100,7 @@ private: kPitchEnvAttack, // AD pitch attack (S16) kPitchEnvDecay, // AD pitch decay (S16) kPitchEnvDepth, // AD pitch depth in +/- semitones (S16) + kKeyTrack, // S-VIEW-6 key-tracking 0..200% (lives on PerformanceZone, not ZonePlaySeconds) kCount }; @@ -111,13 +119,17 @@ private: // bank_panel's L2 hover model. enum class HoverKind { kNone, - kToggleBrowser, // the Browser toggle segment - kToggleZones, // the Zones toggle segment + 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) kSearchBox, // the browser search box kFilterTab, // a bank-filter tab (index = tab ordinal, 0 = All) kCard, // a capture card (index = visible_ index) + kBrowseConfirm, // the Browse modal "Load" confirm button + 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 kControl, // a param-panel control row (index = ControlDesc id) @@ -131,16 +143,30 @@ private: #ifdef _WIN32 void paint(HDC hdc); - void paintBrowser(LICE_IBitmap* bmp, int w, int h); - void paintSetup(LICE_IBitmap* bmp, const Rect& area); - void paintZones(LICE_IBitmap* bmp, int w, int h); + void paintSample(LICE_IBitmap* bmp, int w, int h); // S-VIEW-2 home face + void paintBrowse(LICE_IBitmap* bmp, int w, int h); // S-VIEW-5 modal picker overlay + void paintZone(LICE_IBitmap* bmp, int w, int h); // S-VIEW-8 zone surface void paintEmptyState(LICE_IBitmap* bmp, const Rect& area); - void paintControls(LICE_IBitmap* bmp, const Rect& panel); // S12/S15/S16 param surface + void paintControls(LICE_IBitmap* bmp, const Rect& panel, const PerformanceZone& zone); // S12/S15/S16 + keyTrack + // Trace the S-VIEW-3 amp-envelope overlay + its draggable node handles over `waveArea` for + // `zone`'s play params, at the sample's wall-clock duration. Shared by the Sample hero band. + void paintEnvelopeOverlay(LICE_IBitmap* bmp, const Rect& waveArea, const PerformanceZone& zone, + std::int64_t frames); void onMouseDown(int x, int y); void onMouseMove(int x, int y); void onMouseUp(int x, int y); + // Route a click at (x,y) into the param control panel `panel` editing map_.zones[zoneIndex]: + // a toggle segment commits immediately, a slider grab starts a live param-drag (kParamSlider), + // the keyTrack slider likewise (against the zone's keyTrack scalar). Returns true when the + // click landed on a control (handled). `zoneIndex` must be a valid index into map_.zones. + bool handleControlClick(int zoneIndex, const Rect& panel, int x, int y); + // Apply a slider/toggle interaction to map_.zones[zoneIndex] for control `id`: routes ordinary + // controls through applyControl against the zone's play struct, and kKeyTrack against the + // zone's keyTrack scalar (0..200% over the slider's 0..1). Used by both the click + drag paths. + void applyZoneControl(int zoneIndex, int id, double value, int segment); + // Resolve the interactive element under (x, y) into hover_ (Phase L, L3). Called from // WM_MOUSEMOVE (also while a drag is in flight — the resolved element just isn't used // for a hover repaint mid-drag). Repaints only when the hovered element changed, so an @@ -244,6 +270,53 @@ private: // into the control's stored domain) or a toggle's `segment` (0/1). Mutates `play` in place. void applyControl(int id, ZonePlaySeconds& play, double value, int segment) const; + // --- S-VIEW-3 envelope overlay seam (frames <-> fraction converter) ---------- + // + // envelope_overlay's AmpEnvelope is a DERIVED VIEW, not a TriggerParams copy: it stores the + // Trigger fades as FRACTIONS of the played span, while the zone stores them as SOURCE FRAMES. + // These two members own the non-trivial conversion on BOTH paths (documented in + // envelope_overlay.h's TRIGGER SEAM note). `frames` is the sample's total source frame count; + // `rate` is the live sample rate (the wall-clock AHDSR seconds are rate-free and copy 1-to-1, + // but the Trigger played-span math needs the frame count). + + // PACK (draw): zone play params -> AmpEnvelope. Copies AHDSR seconds directly; derives the + // Trigger fade fractions from the source-frame fades over the played span. + // `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)). + AmpEnvelope packEnvelope(const ZonePlaySeconds& play, std::int64_t frames, + std::int64_t startFrame) const; + + // UNPACK (commit): an edited AmpEnvelope -> the zone's play params. Copies AHDSR seconds + // directly; converts the Trigger fade fractions back to source frames over the played span. + // `startFrame` is the zone's effective start point (zone.startPoint.value_or(0)). + // Mutates `play` in place; only the mode-relevant fields are written. + void unpackEnvelope(const AmpEnvelope& env, std::int64_t frames, std::int64_t startFrame, + ZonePlaySeconds& play) const; + + // The clamp bounds envelope_edit uses, matching the control-panel sliders' own domains (so a + // node drag can never produce a param a slider couldn't — the S-VIEW-F2 invariant). + EnvClampBounds envClampBounds() const; + + // --- Sample-view resolution helpers (the ONE storage site, S15-F2) ----------- + // + // The single-capture Sample face reads/writes the same one-zone map site as the Zone surface. + // These resolve the effective values for the picked id: effectiveSampleZone returns the picked + // id's one-zone override (found in map_) or a product-default PerformanceZone bound to the + // picked id (not yet materialized — a control edit materializes it, mirroring the Zone path). + PerformanceZone effectiveSampleZone() const; + // The effective root: the picked id's rootOverride, else its bank intrinsic, else middle C. + int effectiveRoot() const; + // The live sample rate from the bridge (for the envelope overlay's seconds<->frames time base), + // or 0 when unavailable (the caller guards). Matches the voice engine's resolution rate. + double liveSampleRate() const; + // The persisted preview velocity as a 0..1 slider value (MIDI 1..127 mapped onto [0,1]). + double previewVelocity01() const; + + // Find-or-materialize the one-zone override for the picked id and return a mutable index into + // map_.zones (appending a product-default zone if none exists). selectedId_ must be non-empty. + // The mirror of upsertPickedOverride for a control edit — used when a Sample-face control edit + // needs a concrete zone to write. Returns -1 if selectedId_ is empty. + int ensureSampleZone(); + ReaSamplerProcessor* processor_ = nullptr; // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- @@ -255,9 +328,22 @@ private: ChannelMode channelMode_ = ChannelMode::Mono; // S7 mono/stereo toggle snapshot // --- Transient UI state (not persisted; component state carries selection + zones) --- - View view_ = View::kBrowser; // default face is the browser + View view_ = View::kSample; // default face is the loaded-sample home (S-VIEW-1) std::string activeFilterBankId_; // "" = All; else a bank id from banks_ - int selectedZone_ = -1; // highlighted zone in the Zones panel; -1 = none + int selectedZone_ = -1; // highlighted zone in the Zone surface; -1 = none + + // --- S-VIEW-5 Browse modal picker (a selection PENDING confirm) --------------- + // The Browse overlay is a select-then-confirm picker: a click marks a pending pick without + // loading it; Confirm (or double-click) commits it to selectedId_ + reloads and returns to + // Sample; Cancel discards it and returns to Sample unchanged. "" = nothing picked yet. + std::string browsePendingId_; + int lastBrowseClickCard_ = -1; // for double-click-to-load detection (visible_ index) + + // --- S-VIEW-4 preview-trigger note (transient) ------------------------------- + // The MIDI note the preview button is currently sounding (a held Gate voice), or -1 when the + // button is up. Set on preview-button press (note-on posted to the processor), cleared on + // release (note-off posted). One note at a time — a fresh press releases the prior. + int previewingNote_ = -1; // --- S13 drop-to-load affordance (relay DEGRADED — transient, never persisted) ---- // S13's cross-artifact ingest relay (editor drop -> extension ingest) is NOT shipped: the @@ -303,14 +389,22 @@ 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 // S12 scrollbar-thumb drag: the offset held at grab time (the pixel-delta resolver shifts // from it). S12/S15/S16 param-slider drag: which control id + the panel it lives in (the // shell re-lays the panel each move to map x->value against the live control rect). int dragStartScrollOffset_ = 0; - int dragParamId_ = -1; + int dragParamId_ = -1; // ControlDesc id under a kParamSlider drag; -2 = preview-vel knob + int dragParamZone_ = -1; // the zone index a kParamSlider drag edits (Sample or Zone face) Rect dragParamPanel_{}; + // S-VIEW-3 envelope-node drag: which node is grabbed + the AmpEnvelope snapshotted at grab + // (so the pixel delta is absolute, per envelope_edit's grabEnv contract). The overlay rect + + // sample frame count are re-derived at move time from the live Sample-view layout. + EnvNode envNode_ = EnvNode::Origin; + AmpEnvelope dragStartEnv_{}; + // --- Peak-thumbnail cache (mirror of bank_panel; id -> envelope at a bin width) ------ // Keyed by "id|binCount" so a resize recomputes at the new width. Cleared on refresh so // a bank edit (a re-captured or deleted sample) does not show a stale thumbnail. diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 5d65efd..4fe5838 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -197,9 +197,12 @@ tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) { channelMode_ = cs.channelMode; } applyOutputArrangement(cs.channelMode); - // S-VIEW-4: restore the per-instance preview velocity. No mutex — setState is a load-time - // call serialized by the host; there is no concurrent writer before Wave 2. - previewVelocity_ = cs.previewVelocity; + // S-VIEW-4: restore the per-instance preview velocity. Guarded by previewMutex_ — since Wave 2 + // the editor's velocity knob is a concurrent UI-thread writer. + { + std::lock_guard lock(previewMutex_); + previewVelocity_ = cs.previewVelocity; + } // Rebuild from the restored state (off-thread — setState is a load-time call). reloadFromBank(); return kResultOk; @@ -220,7 +223,7 @@ tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) { std::lock_guard lock(assignMarkerMutex_); state_out.lastConsumedAssignGeneration = lastConsumedAssignGeneration_; // S8 reader marker } - state_out.previewVelocity = previewVelocity_; // S-VIEW-4: persist the preview strike velocity + state_out.previewVelocity = previewVelocity(); // S-VIEW-4: persist the preview strike velocity const std::vector bytes = serializeComponentState(state_out); if (!bytes.empty()) { const tresult wr = state->write(const_cast(bytes.data()), @@ -255,6 +258,43 @@ ChannelMode ReaSamplerProcessor::channelMode() { return channelMode_; } +std::uint8_t ReaSamplerProcessor::previewVelocity() { + std::lock_guard lock(previewMutex_); + return previewVelocity_; +} + +void ReaSamplerProcessor::setPreviewVelocity(std::uint8_t velocity) { + // Clamp to the MIDI-note range [1,127] (0 would be a note-off by convention — a preview + // strike must sound). The editor's knob maps its 0..1 domain into this range before calling. + if (velocity < 1) velocity = 1; + if (velocity > 127) velocity = 127; + std::lock_guard lock(previewMutex_); + previewVelocity_ = velocity; +} + +void ReaSamplerProcessor::previewNoteOn(int note) { + if (note < 0) note = 0; + if (note > 127) note = 127; + const std::uint8_t vel = previewVelocity(); // latch the current knob value into the request + // Advance the sequence (wrapping; process compares for inequality, so a wrap is harmless as + // long as we never land back on the exact value the audio thread last consumed in one step — + // 16 bits gives 65535 posts between collisions, unreachable at UI-click rates). + const std::uint16_t seq = ++previewOnSeq_ == 0 ? ++previewOnSeq_ : previewOnSeq_; + const std::uint32_t packed = (static_cast(seq) << 16) | + (static_cast(vel) << 8) | + static_cast(note & 0xFF); + previewOnRequest_.store(packed, std::memory_order_release); +} + +void ReaSamplerProcessor::previewNoteOff(int note) { + if (note < 0) note = 0; + if (note > 127) note = 127; + const std::uint16_t seq = ++previewOffSeq_ == 0 ? ++previewOffSeq_ : previewOffSeq_; + const std::uint32_t packed = (static_cast(seq) << 16) | + static_cast(note & 0xFF); + previewOffRequest_.store(packed, std::memory_order_release); +} + void ReaSamplerProcessor::applyOutputArrangement(ChannelMode mode) { // Set the single output bus's SpeakerArrangement to the mode's arrangement so getBusInfo / // getBusArrangement report the right channel count. The default getBusArrangement (from the @@ -525,6 +565,34 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { } } + // S-VIEW-4 preview mailbox: drain the off-thread preview-trigger requests (a single relaxed + // atomic load each — RT-safe). A request is NEW when its packed sequence differs from the last + // one we consumed; fire it once, then latch the sequence so the same request never re-fires. + // Preview note-on/off drive the SAME voice engine as host MIDI (a preview is just a note with + // no MIDI wire) — off-thread posted, audio-thread consumed, no lock, no allocation. + // Consume (advance the sequence) even when inst is null so a note-on posted while no instrument + // is loaded does not re-fire stale on the next instrument load. + { + const std::uint32_t on = previewOnRequest_.load(std::memory_order_acquire); + const std::uint16_t onSeq = static_cast(on >> 16); + if (onSeq != 0 && onSeq != previewOnConsumed_) { + previewOnConsumed_ = onSeq; + if (inst) { + const int vel = static_cast((on >> 8) & 0xFF); + const int note = static_cast(on & 0xFF); + if (vel > 0) inst->engine.noteOn(note, vel); + } + } + } + if (inst) { + const std::uint32_t off = previewOffRequest_.load(std::memory_order_acquire); + const std::uint16_t offSeq = static_cast(off >> 16); + if (offSeq != 0 && offSeq != previewOffConsumed_) { + previewOffConsumed_ = offSeq; + inst->engine.noteOff(static_cast(off & 0xFF)); + } + } + if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) { embedPeak_.store(0.f, std::memory_order_relaxed); return kResultOk; diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index f68fe7d..3df4d13 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -149,6 +149,13 @@ public: // The bridge, for the editor's live-state readout + sample list. Owned here; the // editor borrows it (outlives the editor). ReaperBridge& bridge() { return bridge_; } + + // The live host sample rate latched from setupProcessing (the SAME rate reloadFromBank + // resolves seconds->frames against). The editor's S-VIEW-3 envelope overlay reads it to place + // its wall-clock seconds on the same time base the voice engine plays them over. 0.0 before + // setupProcessing runs (the editor guards). Read on the UI thread; a plain load — sampleRate_ + // is set once by setupProcessing before any audio and does not change under the editor. + double sampleRate() const { return sampleRate_; } // The current single-capture selection id (main/UI thread reads for the editor). Guarded // by selectionMutex_ — never touched on the audio thread. Since S10 this is the ONE picked // capture the default face plays chromatically when the performance map is empty; an EMPTY @@ -173,6 +180,22 @@ public: // (same mode) does neither. UI thread only. void setChannelMode(ChannelMode mode); + // The per-instance preview-trigger velocity (S-VIEW-4, MIDI 1..127). Read/written on the + // UI thread (the Sample-view velocity knob) and by getState/setState (host load-save thread); + // guarded by previewMutex_. Persisted in component state (v6). NOT read on the audio thread. + std::uint8_t previewVelocity(); + void setPreviewVelocity(std::uint8_t velocity); + + // Fire a one-shot PREVIEW note-on / note-off through the live voice engine (S-VIEW-4), OFF + // the audio thread (the editor's preview-trigger button drives these on the UI thread). The + // request is handed to process() via a lock-free single-slot mailbox drained at block start — + // no allocation, no lock on the audio thread. previewNoteOn plays `note` at the current + // previewVelocity(); previewNoteOff releases it (Gate) — Trigger zones ignore note-off and + // play through. A momentary button (down = on, up = off) reads as a natural key press. This + // is PLAYBACK ONLY: it never captures, never inserts a timeline item. + void previewNoteOn(int note); + void previewNoteOff(int note); + private: // Apply `mode` to the output audio bus's SpeakerArrangement (kMono / kStereo). Called from // initialize (topology) and setChannelMode (runtime change). Does NOT re-negotiate — the @@ -251,11 +274,30 @@ private: std::int64_t lastSeenBankGeneration_ = -1; // S-VIEW-4 preview-trigger velocity (MIDI 1..127). Persisted in component state (v6) so the - // user's chosen strike velocity survives a project save/reload. No concurrent writer before - // Wave 2 (no editor knob yet) — setState and getState are the sole accessors, both on the - // load/save thread (host-serialized). Default kPreviewVelocityDefault (64). + // user's chosen strike velocity survives a project save/reload. Since Wave 2 the Sample-view + // velocity knob writes it on the UI thread, so it is guarded by previewMutex_; setState and + // getState (load/save thread) share the same guard. Default kPreviewVelocityDefault (64). NOT + // read on the audio thread. + std::mutex previewMutex_; std::uint8_t previewVelocity_ = kPreviewVelocityDefault; + // --- S-VIEW-4 preview-trigger mailbox (off-thread -> audio thread, lock-free) --------- + // The editor's preview-trigger button posts a note-on/off request from the UI thread; process() + // drains it at block start and drives the live engine. ONE slot per direction, each a packed + // request whose high bits are a monotonically-incrementing sequence so process() detects a NEW + // request by comparing against the last sequence it consumed (never re-firing a stale one). The + // low 8 bits carry the note (on) / note (off); the on request also carries the velocity in the + // next 8 bits, latched at post time so the audio thread reads no shared velocity field. A single + // relaxed atomic load per block on the audio thread — RT-safe (no alloc, no lock). + // packed = (seq << 16) | (velocity << 8) | note [note-on] + // packed = (seq << 16) | note [note-off] + std::atomic previewOnRequest_{0}; // 0 = no request posted yet + std::atomic previewOffRequest_{0}; + std::uint16_t previewOnSeq_ = 0; // UI-thread post counter (never 0 after first post) + std::uint16_t previewOffSeq_ = 0; + std::uint16_t previewOnConsumed_ = 0; // audio-thread: last on-seq fired + std::uint16_t previewOffConsumed_ = 0; // audio-thread: last off-seq fired + // Latched from setupProcessing so setActive/reload can size against it. Read // off-thread only. 0.0 is explicitly invalid — setupProcessing sets the real host rate // before any audio, and reloadFromBank guards on it before use. diff --git a/src/vst/trigger_seam.cpp b/src/vst/trigger_seam.cpp new file mode 100644 index 0000000..0ee61a4 --- /dev/null +++ b/src/vst/trigger_seam.cpp @@ -0,0 +1,27 @@ +// trigger_seam.cpp — PURE Trigger-mode frames↔fraction converter (see trigger_seam.h). + +#include "trigger_seam.h" + +#include + +namespace reasampler::vst { + +std::int64_t triggerPlayLength(double lengthFraction, + std::int64_t frameCount, + std::int64_t startFrame) { + const std::int64_t postStart = (std::max)(std::int64_t{0}, frameCount - startFrame); + if (postStart <= 0 || lengthFraction <= 0.0) return 0; + return static_cast(lengthFraction * static_cast(postStart) + 0.5); +} + +double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength) { + if (playLength <= 0) return 0.0; + return static_cast(fadeFrames) / static_cast(playLength); +} + +std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength) { + if (playLength <= 0) return 0; + return static_cast(fadeFraction * static_cast(playLength) + 0.5); +} + +} // namespace reasampler::vst diff --git a/src/vst/trigger_seam.h b/src/vst/trigger_seam.h new file mode 100644 index 0000000..d7ce3c3 --- /dev/null +++ b/src/vst/trigger_seam.h @@ -0,0 +1,51 @@ +// trigger_seam.h — PURE Trigger-mode frames↔fraction converter for the S-VIEW-3 envelope seam. +// NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. +// +// The TRIGGER SEAM (documented in envelope_overlay.h) converts between the two representations +// of Trigger fade lengths: +// +// ENGINE domain (TriggerParams / sampler_core): SOURCE FRAMES — int64_t absolute frame counts +// that anchor directly to the voice's source-timeline read pointer. +// +// OVERLAY domain (AmpEnvelope / envelope_overlay): FRACTIONS — doubles in [0,1] of the played +// span, where the played span is: +// playLengthFrames = round(lengthFraction * (frameCount - startFrame)) +// The overlay stores fractions so the drawn shape stays invariant across sample-rate changes; +// the engine stores frames so the voice advances correctly at the live rate. +// +// This module owns the one shared formula so the pack (frames->fractions) and unpack +// (fractions->frames) paths are provably consistent and unit-tested independently of the shell. +// The shell (reasampler_editor.cpp) calls these two functions from packEnvelope / unpackEnvelope. +// +// S-VIEW-F2 safety: the fractions produced here are in [0,1] by construction; a caller that +// clamps the fractions to [0,1] before writing the AmpEnvelope preserves the slider-range +// invariant (a drag can never produce a value a slider couldn't reach). + +#pragma once + +#include + +namespace reasampler::vst { + +// The source-frame length of the Trigger played span: +// postStart = max(0, frameCount - startFrame) +// playLength = round(lengthFraction * postStart) +// `frameCount` is the total decoded sample length in source frames. +// `startFrame` is the effective start point (zone.startPoint, or 0 when absent). +// `lengthFraction` is TriggerParams::lengthFraction — (0,1], the fraction of the post-start span. +// Returns 0 when postStart == 0 or lengthFraction <= 0. +std::int64_t triggerPlayLength(double lengthFraction, + std::int64_t frameCount, + std::int64_t startFrame); + +// Convert a source-frame fade count to a fraction of the play span (PACK direction, draw path). +// Returns 0.0 when playLength == 0 (degenerate sample or zero %-length); the fraction is +// NOT clamped — the caller clamps to [0,1] when filling AmpEnvelope so the overlay clamp logic +// stays in envelope_edit, not here. +double framesToFadeFraction(std::int64_t fadeFrames, std::int64_t playLength); + +// Convert a fade fraction to a source-frame count (UNPACK direction, commit path). +// Rounds to nearest integer frame. Returns 0 when playLength == 0. +std::int64_t fadeFractionToFrames(double fadeFraction, std::int64_t playLength); + +} // namespace reasampler::vst diff --git a/tests/test_trigger_seam.cpp b/tests/test_trigger_seam.cpp new file mode 100644 index 0000000..58cbcb5 --- /dev/null +++ b/tests/test_trigger_seam.cpp @@ -0,0 +1,161 @@ +// Standalone tests for reasampler::vst::trigger_seam — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests. +// +// Covers: triggerPlayLength (zero play length, startFrame set, startFrame past frameCount, +// rounding); framesToFadeFraction (zero play length, basic ratio); fadeFractionToFrames +// (zero play length, rounding); round-trip fidelity; the Finding 1 regression (start-point +// set — the case that was broken before this module existed). + +#include "../src/vst/trigger_seam.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- triggerPlayLength -------------------------------------------------------- + +static void testPlayLengthNoStartPoint() { + // startFrame == 0 (no start marker): postStart = frameCount. + // 1000 frames, lengthFraction 1.0 -> 1000. + CHECK(triggerPlayLength(1.0, 1000, 0) == 1000); + // 1000 frames, lengthFraction 0.5 -> round(500.0) = 500. + CHECK(triggerPlayLength(0.5, 1000, 0) == 500); + // 1000 frames, lengthFraction 0.333 -> round(333.0) = 333. + CHECK(triggerPlayLength(0.333, 1000, 0) == 333); +} + +static void testPlayLengthWithStartPoint() { + // The Finding 1 regression: startFrame set, play length must be shorter. + // frameCount=1000, startFrame=200 -> postStart=800. + // lengthFraction 1.0 -> 800 (NOT 1000 as the pre-fix code produced). + CHECK(triggerPlayLength(1.0, 1000, 200) == 800); + // lengthFraction 0.5 -> round(400.0) = 400. + CHECK(triggerPlayLength(0.5, 1000, 200) == 400); +} + +static void testPlayLengthZeroFrameCount() { + // No decoded audio -> 0. + CHECK(triggerPlayLength(1.0, 0, 0) == 0); +} + +static void testPlayLengthStartFramePastEnd() { + // startFrame >= frameCount -> postStart clamped to 0 -> play length 0. + CHECK(triggerPlayLength(1.0, 500, 500) == 0); + CHECK(triggerPlayLength(1.0, 500, 600) == 0); +} + +static void testPlayLengthRounding() { + // round(0.5) = 1 (round half up via +0.5 truncation: 0.5 + 0.5 = 1.0 -> 1). + CHECK(triggerPlayLength(0.5, 1, 0) == 1); + // round(lengthFraction * 3): 0.4 * 3 = 1.2 -> 1. + CHECK(triggerPlayLength(0.4, 3, 0) == 1); + // 0.6 * 3 = 1.8 -> 2. + CHECK(triggerPlayLength(0.6, 3, 0) == 2); +} + +// --- framesToFadeFraction ----------------------------------------------------- + +static void testFramesToFadeFractionBasic() { + // 100 frames fade over 1000 play length -> 0.1. + const double frac = framesToFadeFraction(100, 1000); + CHECK(frac > 0.0999 && frac < 0.1001); +} + +static void testFramesToFadeFractionZeroPlayLength() { + // Degenerate: zero play length -> 0.0 (no division by zero). + CHECK(framesToFadeFraction(100, 0) == 0.0); + CHECK(framesToFadeFraction(0, 0) == 0.0); +} + +static void testFramesToFadeFractionFullSpan() { + // fadeFrames == playLength -> fraction 1.0. + const double frac = framesToFadeFraction(500, 500); + CHECK(frac > 0.9999 && frac < 1.0001); +} + +// --- fadeFractionToFrames ----------------------------------------------------- + +static void testFadeFractionToFramesBasic() { + // 0.1 of 1000 play length -> round(100.0) = 100. + CHECK(fadeFractionToFrames(0.1, 1000) == 100); +} + +static void testFadeFractionToFramesZeroPlayLength() { + // Degenerate: play length 0 -> 0 frames. + CHECK(fadeFractionToFrames(0.5, 0) == 0); +} + +static void testFadeFractionToFramesRounding() { + // 0.333... of 3 -> round(1.0) = 1. + CHECK(fadeFractionToFrames(1.0 / 3.0, 3) == 1); + // 0.5 of 3 -> round(1.5) = 2. + CHECK(fadeFractionToFrames(0.5, 3) == 2); +} + +// --- Round-trip --------------------------------------------------------------- + +static void testRoundTripNoStartPoint() { + // Pack then unpack: fadeInFrames should survive (within 1 frame of rounding). + // frameCount=44100, startFrame=0, lengthFraction=1.0 -> playLength=44100. + // fadeInFrames = 2205 (5% of 44100). + const std::int64_t fadeIn = 2205; + const std::int64_t playLen = triggerPlayLength(1.0, 44100, 0); + const double frac = framesToFadeFraction(fadeIn, playLen); + const std::int64_t recovered = fadeFractionToFrames(frac, playLen); + // Should be exact (2205 / 44100 * 44100 = 2205.0). + CHECK(recovered == fadeIn); +} + +static void testRoundTripWithStartPoint() { + // The Finding 1 case: startFrame set. frameCount=44100, startFrame=8820 (20%). + // postStart=35280, lengthFraction=1.0 -> playLength=35280. + // fadeInFrames = 1764 (5% of 35280). + const std::int64_t frameCount = 44100; + const std::int64_t startFrame = 8820; + const std::int64_t fadeIn = 1764; + const std::int64_t playLen = triggerPlayLength(1.0, frameCount, startFrame); + CHECK(playLen == 35280); + const double frac = framesToFadeFraction(fadeIn, playLen); + const std::int64_t recovered = fadeFractionToFrames(frac, playLen); + CHECK(recovered == fadeIn); +} + +static void testRoundTripFadeGreaterThanSpan() { + // fadeFrames > playLength -> fraction > 1 (returned unclamped; the overlay clamps at draw). + // The shell is responsible for clamping before writing AmpEnvelope. + const std::int64_t playLen = 100; + const std::int64_t fadeIn = 150; + const double frac = framesToFadeFraction(fadeIn, playLen); + CHECK(frac > 1.0); // intentionally unclamped from this module's perspective + // The round-trip still recovers the original fade, so the shell can clamp after. + const std::int64_t recovered = fadeFractionToFrames(frac, playLen); + CHECK(recovered == fadeIn); +} + +int main() { + testPlayLengthNoStartPoint(); + testPlayLengthWithStartPoint(); + testPlayLengthZeroFrameCount(); + testPlayLengthStartFramePastEnd(); + testPlayLengthRounding(); + + testFramesToFadeFractionBasic(); + testFramesToFadeFractionZeroPlayLength(); + testFramesToFadeFractionFullSpan(); + + testFadeFractionToFramesBasic(); + testFadeFractionToFramesZeroPlayLength(); + testFadeFractionToFramesRounding(); + + testRoundTripNoStartPoint(); + testRoundTripWithStartPoint(); + testRoundTripFadeGreaterThanSpan(); + + if (g_fail == 0) std::printf("trigger_seam: all tests passed\n"); + else std::printf("trigger_seam: %d FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}