Merge ps-w8-t2-waveform: S11 waveform view + draggable loop points

This commit is contained in:
2026-07-26 22:06:42 -04:00
12 changed files with 1092 additions and 38 deletions
+238 -16
View File
@@ -21,6 +21,7 @@
#include "reasampler_vst.h" // kPluginName (the editor title band)
#include "sample_map.h"
#include "wav_trim.h" // parseWavLayout, extractFloatFrames
#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11)
#ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
@@ -43,8 +44,9 @@ constexpr const wchar_t* kChildClassName = L"ReaSampler9000VstEditor";
// capture face) hosts the keyboard strip + level readout under the browser.
constexpr int kTitleHeight = 24;
constexpr int kToggleHeight = 22;
constexpr int kSetupHeight = 96; // the single-capture setup surface (strip + labels)
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
// Palette — house style, mirrored from bank_panel's dark theme so the instrument reads as
// the same tool. (Phase L's L1 kit replaces these flat fills later; not gated on it.)
@@ -60,6 +62,11 @@ const LICE_pixel kColThumb = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColStripBg = LICE_RGBA(36, 36, 40, 255);
const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255);
const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColWaveBg = LICE_RGBA(24, 24, 26, 255);
const LICE_pixel kColWaveform = LICE_RGBA(120, 200, 160, 255);
const LICE_pixel kColStartMarker = LICE_RGBA(230, 200, 120, 255); // start point (amber)
const LICE_pixel kColLoopMarker = LICE_RGBA(120, 170, 230, 255); // loop start/end (blue)
const LICE_pixel kColLoopRegion = LICE_RGBA(120, 170, 230, 60); // loop span fill (faint)
const LICE_pixel kColZoneBar = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255);
const COLORREF kRgbText = RGB(210, 230, 220);
@@ -132,6 +139,7 @@ ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
void ReaSamplerEditor::refreshFromBank() {
// Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER).
thumbCache_.clear(); // a bank edit may have re-captured/removed a sample; drop stale peaks
pcmCache_.clear(); // and its decoded PCM (the S11 waveform + snap source)
if (!processor_) {
samples_.clear();
banks_.clear();
@@ -177,17 +185,92 @@ void ReaSamplerEditor::commitAndReload() {
#endif
}
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
const std::string key = sampleId + "|" + std::to_string(binCount);
auto it = thumbCache_.find(key);
if (it != thumbCache_.end()) return it->second;
ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
SetupMarkers m;
// Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
// for the picked id win (the instrument's performance choice, D-B). Read the loop intrinsic
// from the live bank blob (the same path selectSample uses); the override lives in map_.
if (processor_) {
auto banksJson =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
if (banksJson) {
if (auto sel = selectSample(*banksJson, selectedId_)) {
if (sel->loop.hasLoop) {
m.hasLoop = true;
m.loopStart = sel->loop.start;
m.loopEnd = sel->loop.end;
}
}
}
}
// The override (loop + start) on a zone for the picked id supersedes the intrinsic.
for (const PerformanceZone& z : map_.zones) {
if (z.sampleId != selectedId_) continue;
if (z.loopOverride) {
m.hasLoop = z.loopOverride->hasLoop;
m.loopStart = z.loopOverride->start;
m.loopEnd = z.loopOverride->end;
}
if (z.startPoint) m.start = *z.startPoint;
break;
}
// Default an unset loop's end to the sample length so the loop markers have somewhere sane
// to sit before the user drags (loopStart stays 0). The "no loop" state is m.hasLoop==false;
// the markers are still drawn (drag one to CREATE a loop).
if (!m.hasLoop && m.loopEnd == 0) m.loopEnd = frames > 0 ? frames : 0;
return m;
}
void ReaSamplerEditor::upsertPickedOverride(const SetupMarkers& m) {
// Find-or-append the zone for selectedId_ and write the loop/start override fields.
// The bank intrinsic is NEVER written (read-only bank consumer, D-B). selectedId_ must
// be non-empty; callers are responsible for that guard.
SampleLoop loop;
loop.hasLoop = m.hasLoop;
loop.start = m.loopStart;
loop.end = m.loopEnd;
bool found = false;
for (PerformanceZone& z : map_.zones) {
if (z.sampleId == selectedId_) {
z.loopOverride = loop;
z.startPoint = m.start;
found = true;
break;
}
}
if (!found) {
PerformanceZone z;
z.sampleId = selectedId_;
z.lowNote = 0;
z.highNote = 127;
z.loopOverride = loop;
z.startPoint = m.start;
map_.zones.push_back(z);
}
}
void ReaSamplerEditor::commitPickedMarkers(const SetupMarkers& m) {
// Materialize the edited markers as a per-zone loop/start override on the picked id (upsert,
// mirror of the root-marker path): a full-keyboard zone carrying the override. This plays
// identically to the un-zoned single capture (one chromatic zone) and round-trips through
// the component state; the zone becomes visible if the user opens the Zones panel. The bank
// intrinsic is NEVER written (read-only bank consumer, D-B).
if (selectedId_.empty()) return;
upsertPickedOverride(m);
commitAndReload();
}
const std::vector<AudioSample>& ReaSamplerEditor::monoPcmFor(const std::string& sampleId) {
auto it = pcmCache_.find(sampleId);
if (it != pcmCache_.end()) return it->second;
// SampleChoice is the browser's metadata projection and does NOT carry the WAV path, so
// resolve the path from the live bank blob (selectSample) and decode via the shared WAV
// parse — the mirror of the processor's decodeRelative. Every failure path caches an
// empty envelope so a broken/missing file is not re-decoded on every paint.
// parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY
// vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width-
// independent) — the thumbnail bins this at whatever width, the snap scans it directly.
std::string relativePath;
Envelope env;
std::vector<AudioSample> mono;
if (processor_) {
auto banksJson =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
@@ -211,13 +294,27 @@ const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int
if (layout.valid) {
std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount());
std::vector<AudioSample> mono =
downmixToMono(interleaved, layout.channelCount);
env = computeEnvelope(mono, 1, mono.size(),
static_cast<std::size_t>((std::max)(1, binCount)));
mono = downmixToMono(interleaved, layout.channelCount);
}
}
}
auto ins = pcmCache_.emplace(sampleId, std::move(mono));
return ins.first->second;
}
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) {
const std::string key = sampleId + "|" + std::to_string(binCount);
auto it = thumbCache_.find(key);
if (it != thumbCache_.end()) return it->second;
// Bin the (cached) decoded mono PCM at the requested width — one decode per id, reused by
// every thumbnail width AND the S11 waveform surface + snap.
const std::vector<AudioSample>& mono = monoPcmFor(sampleId);
Envelope env;
if (!mono.empty()) {
env = computeEnvelope(mono, 1, mono.size(),
static_cast<std::size_t>((std::max)(1, binCount)));
}
auto ins = thumbCache_.emplace(key, std::move(env));
return ins.first->second;
}
@@ -325,6 +422,17 @@ Rect setupStripArea(const Rect& area) {
return Rect{area.left + pad, stripTop, area.right - pad, area.bottom - 4};
}
// 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 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,
@@ -511,7 +619,48 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) {
drawTextCentered(bmp, chan.stereo, "Stereo", kRgbText);
Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16};
drawText(bmp, hintR, "Drag on the keyboard to set the root note.", kRgbDim);
drawText(bmp, hintR,
"Drag the waveform markers to set start + loop; drag the keyboard to set root.",
kRgbDim);
// --- S11 waveform surface: the picked capture's envelope + draggable markers ----------
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(pcm.size());
const Rect waveArea = setupWaveformArea(area);
LICE_FillRect(bmp, waveArea.left, waveArea.top, waveArea.width(), waveArea.height(),
kColWaveBg, 1.0f, 0);
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<std::size_t>(bins));
drawEnvelope(bmp, waveArea, env); // reuses the thumbnail envelope draw (kColThumb)
const SetupMarkers m = pickedMarkers(frames);
// Faint loop-region fill between the loop markers (only when a loop is set).
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(),
kColLoopRegion, 1.0f, 0);
}
}
// The three markers: start (amber), loop start + loop end (blue). Drawn as 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 LICE_pixel markerCols[3] = {kColStartMarker, kColLoopMarker, kColLoopMarker};
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(), markerCols[i],
alpha, 0);
}
} else {
drawTextCentered(bmp, waveArea, "(decoding…)", kRgbDim);
}
// Keyboard strip with the root marker.
const Rect stripArea = setupStripArea(area);
@@ -626,7 +775,8 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
commitAndReload(); // publishes the pick + reloads; process() plays it repitched
return;
}
// The setup band: the mono/stereo toggle (header row), then the root-marker strip.
// 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
@@ -645,6 +795,29 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
invalidate();
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<AudioSample>& pcm = monoPcmFor(selectedId_);
const std::int64_t frames = static_cast<std::int64_t>(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<WaveMarker>(hit);
dragStartX_ = x;
dragStartMarkers_ = m;
dragSampleFrames_ = frames;
dragStartMap_ = map_;
return; // no immediate set — the marker only 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);
@@ -652,6 +825,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
drag_ = DragKind::kRootMarker;
dragStartX_ = x;
dragStartRoot_ = note;
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
@@ -718,6 +892,7 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
dragStartX_ = x;
dragStartLow_ = z.lowNote;
dragStartHigh_ = z.highNote;
dragStartMap_ = map_;
switch (hit.grab) {
case ZoneGrab::kLowEdge: drag_ = DragKind::kZoneLow; break;
case ZoneGrab::kHighEdge: drag_ = DragKind::kZoneHigh; break;
@@ -776,6 +951,51 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
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 std::int64_t frames = dragSampleFrames_;
if (frames <= 0) return;
// Grabbed frame at grab time, from the snapshot (so the delta is measured from grab).
const int idx = static_cast<int>(waveMarker_);
const std::int64_t startVals[3] = {dragStartMarkers_.start, dragStartMarkers_.loopStart,
dragStartMarkers_.loopEnd};
std::int64_t newFrame = resolveDragFrame(waveArea, frames, startVals[idx], dx);
// Snap to the nearest zero crossing in the decoded PCM (the S2 zero-crossing-aware
// requirement). Pure over the cached mono frames — no host types, no file I/O.
const std::vector<AudioSample>& pcm = monoPcmFor(selectedId_);
if (!pcm.empty()) {
newFrame = nearestZeroCrossing(pcm.data(), static_cast<std::int64_t>(pcm.size()),
newFrame);
}
// Build the edited marker set from the snapshot, moving only the grabbed marker, then
// clamp: loopStart <= loopEnd, start in [0, frames-1]. Dragging a loop marker MAKES a loop.
SetupMarkers m = dragStartMarkers_;
if (waveMarker_ == WaveMarker::kStart) {
m.start = newFrame;
} else if (waveMarker_ == WaveMarker::kLoopStart) {
m.loopStart = (std::min)(newFrame, m.loopEnd);
m.hasLoop = true;
} else { // kLoopEnd
m.loopEnd = (std::max)(newFrame, m.loopStart);
m.hasLoop = true;
}
if (m.start < 0) m.start = 0;
if (m.start > frames - 1) m.start = frames - 1;
// Upsert the override on the picked id (mirror of the root-marker path); commit lands on
// release, this is live feedback.
upsertPickedOverride(m);
invalidate();
return;
}
// Zone edits: recompute the grabbed field(s) against the pure resolver, live.
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
const Rect stripArea = zonesStripArea(bands);
@@ -833,10 +1053,12 @@ LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
}
return 0;
case WM_CAPTURECHANGED:
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — reset the drag
// state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
// Capture stolen mid-drag (modal dialog, alt-tab, etc.) — restore map_ to its
// pre-grab snapshot so the in-flight live-drag mutation is rolled back, then reset
// the drag state machine so stale capture-less WM_MOUSEMOVEs don't keep editing.
// Mirror of bank_panel.cpp's WM_CAPTURECHANGED handler.
if (self && self->drag_ != DragKind::kNone) {
self->map_ = self->dragStartMap_;
self->drag_ = DragKind::kNone;
self->invalidate();
}