S11: waveform view with draggable start/loop markers + zero-crossing snap
Pure waveform_view module (frame<->pixel markers, zero-crossing snap); per-zone loop/start overrides on PerformanceZone with self-versioning zone payload; core start-point read offset; editor waveform surface with drag machine.
This commit is contained in:
+243
-14
@@ -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();
|
||||
@@ -176,17 +184,85 @@ 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::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;
|
||||
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);
|
||||
}
|
||||
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);
|
||||
@@ -210,13 +286,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;
|
||||
}
|
||||
@@ -324,6 +414,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,
|
||||
@@ -482,7 +583,48 @@ void ReaSamplerEditor::paintSetup(LICE_IBitmap* bmp, const Rect& area) {
|
||||
drawText(bmp, headerR, header.c_str(), 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);
|
||||
@@ -597,9 +739,30 @@ void ReaSamplerEditor::onMouseDown(int x, int y) {
|
||||
commitAndReload(); // publishes the pick + reloads; process() plays it repitched
|
||||
return;
|
||||
}
|
||||
// The setup strip: grab the root marker (drag to set the picked capture's root).
|
||||
if (havePick) {
|
||||
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom};
|
||||
|
||||
// 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;
|
||||
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);
|
||||
@@ -731,6 +894,72 @@ 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]. 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) m.start = frames;
|
||||
|
||||
// Upsert the override on the picked id (mirror of the root-marker path); commit lands on
|
||||
// release, this is live feedback.
|
||||
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);
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -68,8 +68,14 @@ private:
|
||||
|
||||
// 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.
|
||||
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody };
|
||||
// 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_).
|
||||
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody, kWaveMarker };
|
||||
|
||||
// The waveform markers on the single-capture setup surface (S11). Order is the draw + hit
|
||||
// order (start first). Named generically per the spec so S15 can repurpose the surface with
|
||||
// a different marker set; here it is start-point + the sustain loop's two ends.
|
||||
enum class WaveMarker { kStart = 0, kLoopStart = 1, kLoopEnd = 2, kCount = 3 };
|
||||
|
||||
#ifdef _WIN32
|
||||
void paint(HDC hdc);
|
||||
@@ -105,6 +111,30 @@ private:
|
||||
// an empty envelope when the WAV can't be resolved/decoded. UI thread only (file I/O).
|
||||
const Envelope& thumbnailFor(const std::string& sampleId, int binCount);
|
||||
|
||||
// The decoded MONO PCM for a bank sample id, decoded once from the WAV and cached by id.
|
||||
// Feeds the S11 waveform surface: the full-res envelope binned at view width AND the
|
||||
// zero-crossing snap (both need the raw frames, not the binned thumbnail). Returns an empty
|
||||
// vector when the WAV can't be resolved/decoded. UI thread only (file I/O). Reuses the same
|
||||
// decode path as thumbnailFor (no new WAV reader), keyed by id (not width — snap is width-
|
||||
// independent). Cleared with the thumbnail cache on refresh.
|
||||
const std::vector<AudioSample>& monoPcmFor(const std::string& sampleId);
|
||||
|
||||
// The effective loop + start markers for the picked single capture (S11): the per-zone
|
||||
// OVERRIDE for the picked id when one exists in map_, else the bank's S2 loop intrinsic
|
||||
// (loop) / frame 0 (start). Absent loop -> loopStart==loopEnd==0 (the "no loop" state).
|
||||
// frames is the decoded length (for defaulting loopEnd when the bank left the loop empty).
|
||||
struct SetupMarkers {
|
||||
std::int64_t start = 0;
|
||||
std::int64_t loopStart = 0;
|
||||
std::int64_t loopEnd = 0;
|
||||
bool hasLoop = false; // whether a sustain loop is set (drives the "no loop" affordance)
|
||||
};
|
||||
SetupMarkers pickedMarkers(std::int64_t frames) const;
|
||||
|
||||
// Commit an edited marker set for the picked capture as a per-zone loop/start override
|
||||
// (upsert on the picked id — mirror of the root-marker path), then reload off-thread.
|
||||
void commitPickedMarkers(const SetupMarkers& m);
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
|
||||
// --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) ---
|
||||
@@ -126,10 +156,23 @@ private:
|
||||
int dragStartHigh_ = 0;
|
||||
int dragStartRoot_ = 60;
|
||||
|
||||
// S11 waveform-marker drag: which marker + the marker set snapshotted at grab time (so the
|
||||
// pixel-delta resolver shifts the grabbed frame from its grab-time value, and inter-marker
|
||||
// clamps use the sibling markers).
|
||||
WaveMarker waveMarker_ = WaveMarker::kStart;
|
||||
SetupMarkers dragStartMarkers_;
|
||||
std::int64_t dragSampleFrames_ = 0; // decoded length of the sample under the drag
|
||||
|
||||
// --- 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.
|
||||
std::unordered_map<std::string, Envelope> thumbCache_;
|
||||
|
||||
// --- Decoded mono-PCM cache (S11; id -> full-res frames) ------------------------------
|
||||
// Keyed by id (width-independent, unlike thumbCache_). Feeds the waveform envelope binning
|
||||
// + the zero-crossing snap. Cleared alongside thumbCache_ on refresh so a re-captured or
|
||||
// deleted sample does not show/snap against stale PCM.
|
||||
std::unordered_map<std::string, std::vector<AudioSample>> pcmCache_;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
+75
-7
@@ -143,7 +143,11 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
|
||||
// Effective root: override beats bank intrinsic beats middle-C default.
|
||||
rz.rootNote = z.rootOverride ? *z.rootOverride
|
||||
: (found->rootNote ? *found->rootNote : 60);
|
||||
rz.loop = loopFromSample(*found);
|
||||
// Effective loop / start (S11): the instrument's per-zone override wins over the
|
||||
// bank's S2 intrinsic; absent -> the intrinsic (loop) / frame 0 (start). The bank is
|
||||
// never mutated — this only shapes what the core plays for THIS instance (D-B).
|
||||
rz.loop = z.loopOverride ? *z.loopOverride : loopFromSample(*found);
|
||||
rz.startFrame = z.startPoint ? *z.startPoint : 0;
|
||||
out.zones.push_back(std::move(rz));
|
||||
}
|
||||
return out;
|
||||
@@ -161,6 +165,7 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100;
|
||||
data.rootNote = zones[i].rootNote;
|
||||
data.loop = zones[i].loop;
|
||||
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
|
||||
const std::size_t sampleIndex = km.samples.size();
|
||||
km.samples.push_back(std::move(data));
|
||||
KeyZone zone;
|
||||
@@ -184,6 +189,14 @@ void putU32le(std::vector<std::uint8_t>& out, std::uint32_t v) {
|
||||
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFF));
|
||||
}
|
||||
|
||||
// 64-bit little-endian, for the S11 loop start/end + start frame (int64 on the wire as
|
||||
// two's-complement u64, mirroring the u32 signed-int idiom above).
|
||||
void putU64le(std::vector<std::uint8_t>& out, std::uint64_t v) {
|
||||
for (int b = 0; b < 8; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFF));
|
||||
}
|
||||
|
||||
std::uint64_t asU64(std::int64_t v) { return static_cast<std::uint64_t>(v); }
|
||||
|
||||
// A bounded little-endian reader over a byte blob. Every read is length-checked; once a
|
||||
// read runs past the end the reader latches `ok=false` and yields zeros, so a truncated
|
||||
// blob degrades to a partial/empty parse rather than reading out of bounds.
|
||||
@@ -215,11 +228,38 @@ struct ByteReader {
|
||||
}
|
||||
// Signed ints go on the wire as u32 two's-complement (fixed 32-bit width).
|
||||
int i32() { return static_cast<int>(static_cast<std::int32_t>(u32())); }
|
||||
|
||||
std::uint64_t u64() {
|
||||
if (!ok || pos + 8 > bytes.size()) { ok = false; return 0; }
|
||||
std::uint64_t v = 0;
|
||||
for (int b = 0; b < 8; ++b)
|
||||
v |= static_cast<std::uint64_t>(bytes[pos + static_cast<std::size_t>(b)]) << (b * 8);
|
||||
pos += 8;
|
||||
return v;
|
||||
}
|
||||
// Signed 64-bit frame indices go on the wire as u64 two's-complement (fixed width).
|
||||
std::int64_t i64() { return static_cast<std::int64_t>(u64()); }
|
||||
|
||||
// Non-consuming peek of the next u32 (for the zones-payload format-marker probe). Yields
|
||||
// 0 and latches nothing when fewer than 4 bytes remain — the caller treats a short blob
|
||||
// as "no marker" and falls through to the (also-guarded) v1 count read.
|
||||
std::uint32_t peekU32() const {
|
||||
if (!ok || pos + 4 > bytes.size()) return 0;
|
||||
return static_cast<std::uint32_t>(bytes[pos]) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[pos + 3]) << 24);
|
||||
}
|
||||
};
|
||||
|
||||
// Append the zones payload (zone count + per-zone records) — the shared body of the v2
|
||||
// performance blob and the v3 component blob, so both write zones identically.
|
||||
// Append the zones payload — the shared body of the v2 performance blob and the v3 component
|
||||
// blob, so both write zones identically. Always emits PAYLOAD v2 (the S11 self-describing
|
||||
// marker + version + EXTENDED records): the marker precedes the zone count so any reader can
|
||||
// detect the record shape independently of the envelope version (see sample_map.h). The S11
|
||||
// loop/start overrides therefore round-trip through EITHER envelope with no envelope bump.
|
||||
void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map) {
|
||||
putU32le(out, kZonesFormatMarker);
|
||||
putU32le(out, kZonesPayloadVersion);
|
||||
putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
|
||||
for (const PerformanceZone& z : map.zones) {
|
||||
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size()));
|
||||
@@ -231,14 +271,30 @@ void putZonesPayload(std::vector<std::uint8_t>& out, const PerformanceMap& map)
|
||||
putU32le(out,
|
||||
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride)));
|
||||
}
|
||||
// S11 extension: loop override (hasLoop flag + start/end), then start point.
|
||||
out.push_back(z.loopOverride ? 1 : 0);
|
||||
if (z.loopOverride) {
|
||||
out.push_back(z.loopOverride->hasLoop ? 1 : 0);
|
||||
putU64le(out, asU64(z.loopOverride->start));
|
||||
putU64le(out, asU64(z.loopOverride->end));
|
||||
}
|
||||
out.push_back(z.startPoint ? 1 : 0);
|
||||
if (z.startPoint) putU64le(out, asU64(*z.startPoint));
|
||||
}
|
||||
}
|
||||
|
||||
// Read a zones payload (zone count + per-zone records) from `r` into `map`. Shared by the
|
||||
// v2 performance parse and the v3 component parse. A truncated mid-zone read keeps the zones
|
||||
// that parsed cleanly and drops the rest; the reader position is left after the last byte
|
||||
// read successfully.
|
||||
// Read a zones payload from `r` into `map`. Shared by the performance parse and the component
|
||||
// parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the
|
||||
// loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
|
||||
// clean back-compat lift, the overrides simply default absent). A truncated mid-zone read
|
||||
// keeps the zones that parsed cleanly and drops the rest.
|
||||
void readZonesPayload(ByteReader& r, PerformanceMap& map) {
|
||||
bool extended = false;
|
||||
if (r.peekU32() == kZonesFormatMarker) {
|
||||
r.u32(); // consume the marker
|
||||
const std::uint32_t pv = r.u32(); // payload version
|
||||
extended = (pv >= 2); // v2+ carries the loop/start tail
|
||||
}
|
||||
const std::uint32_t count = r.u32();
|
||||
for (std::uint32_t i = 0; i < count && r.ok; ++i) {
|
||||
PerformanceZone z;
|
||||
@@ -248,6 +304,18 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
|
||||
z.highNote = r.i32();
|
||||
const std::uint8_t hasOverride = r.u8();
|
||||
if (hasOverride) z.rootOverride = r.i32();
|
||||
if (extended) {
|
||||
const std::uint8_t hasLoop = r.u8();
|
||||
if (hasLoop) {
|
||||
SampleLoop lp;
|
||||
lp.hasLoop = (r.u8() != 0);
|
||||
lp.start = r.i64();
|
||||
lp.end = r.i64();
|
||||
z.loopOverride = lp;
|
||||
}
|
||||
const std::uint8_t hasStart = r.u8();
|
||||
if (hasStart) z.startPoint = r.i64();
|
||||
}
|
||||
if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
|
||||
map.zones.push_back(std::move(z));
|
||||
}
|
||||
|
||||
+46
-9
@@ -122,11 +122,20 @@ Keymap buildTier0Keymap(std::vector<AudioSample> monoFrames, int sampleRate,
|
||||
// One authored zone: a bank sample mapped to an inclusive [lowNote, highNote] key range,
|
||||
// with an optional root-note override. rootOverride absent -> repitch from the bank
|
||||
// sample's own S2 rootNote intrinsic (or middle C when the bank left it empty).
|
||||
//
|
||||
// S11 loop/start overrides (instrument-owned, D-B — mirror of rootOverride): the sustain
|
||||
// loop and the initial read position are FACTS about the file (S2 bank intrinsics), but the
|
||||
// instrument may override them per zone WITHOUT writing back to the bank. loopOverride wins
|
||||
// over the bank's S2 loop intrinsic when set; startPoint sets the voice's initial read frame
|
||||
// (absent -> frame 0). Both are seeded from the bank intrinsic in the editor and stored here;
|
||||
// resolvePerformance folds override-beats-intrinsic into the effective ResolvedZone.
|
||||
struct PerformanceZone {
|
||||
std::string sampleId; // bank sample id this zone plays
|
||||
int lowNote = 0; // inclusive
|
||||
int highNote = 127; // inclusive
|
||||
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic
|
||||
std::optional<SampleLoop> loopOverride; // instrument-owned sustain loop; absent -> bank intrinsic
|
||||
std::optional<std::int64_t> startPoint; // instrument-owned initial read frame; absent -> 0
|
||||
};
|
||||
|
||||
// The instrument's performance map: an ordered list of zones. Order is authoritative for
|
||||
@@ -148,7 +157,8 @@ struct ResolvedZone {
|
||||
int lowNote = 0;
|
||||
int highNote = 127;
|
||||
int rootNote = 60; // effective: override, else bank intrinsic, else 60
|
||||
SampleLoop loop; // bank intrinsic
|
||||
SampleLoop loop; // effective: loopOverride, else bank S2 intrinsic (S11)
|
||||
std::int64_t startFrame = 0; // effective initial read frame: startPoint, else 0 (S11)
|
||||
};
|
||||
|
||||
// The result of resolving a performance map against the live bank blob. `zones` are the
|
||||
@@ -191,21 +201,48 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
|
||||
// instrument is a read-only bank consumer; S4 precedent). Versioned binary, tolerant of
|
||||
// truncation/wrong-version by design (bounded reads, never throws across the host).
|
||||
//
|
||||
// Format (v2): 4-byte LE version tag (== 2), then a 4-byte LE zone count, then per zone:
|
||||
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
|
||||
// 1 byte hasOverride (0/1), 4-byte LE rootOverride (present only when hasOverride==1).
|
||||
// BACK-COMPAT: a v1 blob (the S4 single-selection format: version tag 1 + id bytes) is
|
||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance
|
||||
// saved under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob
|
||||
// deserializes to an EMPTY map.
|
||||
// Format: 4-byte LE ENVELOPE version tag (== kPerformanceStateVersion, == 2), then the
|
||||
// ZONES PAYLOAD.
|
||||
//
|
||||
// ZONES-PAYLOAD FORMAT VERSIONING (S11 — self-describing, envelope-independent). The zones
|
||||
// payload carries its OWN version so the per-zone record can grow (S11's loop/start overrides)
|
||||
// WITHOUT bumping the envelope version — the envelope (this v2 blob and the v3 ComponentState
|
||||
// below, and S7's forthcoming v4) simply wraps whatever payload version it holds. This is the
|
||||
// key composition property: the zone-record extension is versioned inside the map blob, not on
|
||||
// the envelope, so S11 (zone-record fields) and S7 (envelope v4 for channel mode) do not
|
||||
// collide on a single version number.
|
||||
// * PAYLOAD v1 (pre-S11, on-the-wire shipped): 4-byte LE zone count, then per zone:
|
||||
// 4-byte LE id length, id bytes, 4-byte LE lowNote, 4-byte LE highNote,
|
||||
// 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
|
||||
// A payload starting with a small u32 (the zone count) is v1 — there is no marker.
|
||||
// * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
|
||||
// count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
|
||||
// to each zone record after rootOverride:
|
||||
// 1 byte hasLoopOverride (0/1); iff set: 1 byte loop.hasLoop, 8-byte LE loop.start,
|
||||
// 8-byte LE loop.end (both two's-complement int64);
|
||||
// 1 byte hasStartPoint (0/1); iff set: 8-byte LE startPoint (two's-complement int64).
|
||||
// The reader detects the marker to know the record shape — a v1 payload (no marker) reads
|
||||
// the shorter record; a v2 payload reads the extended one. Both compose under ANY envelope.
|
||||
// BACK-COMPAT: a v1 ENVELOPE blob (the S4 single-selection format: version tag 1 + id bytes) is
|
||||
// lifted to a single full-keyboard zone playing that id (no override) — so an instance saved
|
||||
// under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob deserializes
|
||||
// to an EMPTY map.
|
||||
//
|
||||
// These two functions serialize the ZONES only. Since S10 the instrument's full component
|
||||
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
|
||||
// below, the v3 format the processor actually reads/writes. serializePerformance/
|
||||
// deserializePerformance are retained for the v3 zones payload + the v1/v2 back-compat lift.
|
||||
// deserializePerformance are retained for the zones payload + the v1/v2 back-compat lift.
|
||||
|
||||
inline constexpr std::uint32_t kPerformanceStateVersion = 2;
|
||||
|
||||
// The zones-payload format version and its detection marker (S11). serializePerformance and
|
||||
// serializeComponentState both emit PAYLOAD v2 (marker + version + extended records) so the
|
||||
// S11 loop/start overrides round-trip through EITHER envelope. Readers accept a v1 payload
|
||||
// (no marker) for back-compat. The marker is a high sentinel that a legitimate zone count
|
||||
// (bounded by 128 MIDI zones in practice, always tiny) can never collide with.
|
||||
inline constexpr std::uint32_t kZonesPayloadVersion = 2;
|
||||
inline constexpr std::uint32_t kZonesFormatMarker = 0xFFFFFF00u;
|
||||
|
||||
// The performance map serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
|
||||
|
||||
|
||||
@@ -151,7 +151,14 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
|
||||
if (v > 127) v = 127;
|
||||
velocityGain_ = static_cast<double>(v) / 127.0;
|
||||
ratio_ = pitchRatio(note, rootNote);
|
||||
readPos_ = 0.0;
|
||||
// Initial read position honors the sample's start-point offset (S11). Clamp into
|
||||
// [0, frames): a start at or past the end degrades to 0 (play from the top) rather
|
||||
// than starting a voice already off the end. A negative start (shouldn't occur —
|
||||
// the map clamps) is likewise pinned to 0.
|
||||
const std::int64_t frameCount = static_cast<std::int64_t>(sample.frames.size());
|
||||
std::int64_t start = sample.startFrame;
|
||||
if (start < 0 || start >= frameCount) start = 0;
|
||||
readPos_ = static_cast<double>(start);
|
||||
sample_ = &sample;
|
||||
env_.configure(adsr);
|
||||
env_.noteOn();
|
||||
|
||||
@@ -50,6 +50,12 @@ struct SampleData {
|
||||
// note-relative, so rate cancels for repitch)
|
||||
int rootNote = 60; // MIDI note recorded at (plays at unity here)
|
||||
SampleLoop loop; // sustain loop, if any
|
||||
// Initial read position (frame offset) a voice starts playback at — frame 0 by
|
||||
// default, so an unset start point is exactly the pre-S11 behavior. S11 makes this
|
||||
// an instrument-side per-zone override (the "start point" marker); S15 builds on it
|
||||
// (both play modes carry a modifiable start). Clamped into [0, frames) at note-on:
|
||||
// a start >= the sample length is a no-op (voice starts at 0), never out of bounds.
|
||||
std::int64_t startFrame = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// waveform_view.cpp — see waveform_view.h. Pure math; no host types.
|
||||
|
||||
#include "waveform_view.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib> // std::abs (int overload)
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) {
|
||||
if (f < 0) return 0;
|
||||
if (f > frameCount) return frameCount;
|
||||
return f;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) {
|
||||
const int w = std::max(0, area.width());
|
||||
if (frameCount <= 0 || w <= 0) return area.left;
|
||||
const std::int64_t f = clampFrame(frame, frameCount);
|
||||
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line
|
||||
// visually centered on its frame; the divide is exact rational (multiply first).
|
||||
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
|
||||
return area.left + static_cast<int>(num / frameCount);
|
||||
}
|
||||
|
||||
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
|
||||
const int w = std::max(0, area.width());
|
||||
if (frameCount <= 0 || w <= 0) return 0;
|
||||
if (x <= area.left) return 0;
|
||||
if (x >= area.right) return frameCount;
|
||||
const std::int64_t dx = static_cast<std::int64_t>(x - area.left);
|
||||
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw
|
||||
// agree at bin granularity.
|
||||
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
|
||||
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
|
||||
}
|
||||
|
||||
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y) {
|
||||
if (count <= 0 || frames == nullptr) return -1;
|
||||
if (!contains(area, x, y)) return -1;
|
||||
for (int i = 0; i < count; ++i) {
|
||||
const int mx = frameToX(area, frameCount, frames[i]);
|
||||
if (x >= mx - kMarkerGrabWidth && x <= mx + kMarkerGrabWidth) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
|
||||
int dxPixels) {
|
||||
const std::int64_t start = clampFrame(startFrame, frameCount);
|
||||
if (dxPixels == 0) return start;
|
||||
const int w = std::max(0, area.width());
|
||||
if (frameCount <= 0 || w <= 0) return start; // no room to move
|
||||
// Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame).
|
||||
const std::int64_t magnitude =
|
||||
(static_cast<std::int64_t>(std::abs(dxPixels)) * frameCount +
|
||||
static_cast<std::int64_t>(w) / 2) /
|
||||
static_cast<std::int64_t>(w);
|
||||
const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude;
|
||||
return clampFrame(start + shift, frameCount);
|
||||
}
|
||||
|
||||
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
||||
std::int64_t target) {
|
||||
if (pcm == nullptr || frames < 2) return clampFrame(target, frames > 0 ? frames - 1 : 0);
|
||||
// Clamp target into a valid sample index [0, frames).
|
||||
std::int64_t t = target;
|
||||
if (t < 0) t = 0;
|
||||
if (t > frames - 1) t = frames - 1;
|
||||
|
||||
// A crossing lives at frame i (1 <= i < frames) when sign(pcm[i-1]) != sign(pcm[i]) OR
|
||||
// pcm[i] == 0. isCrossing(i) tests exactly that. We fan out from t: at each distance d we
|
||||
// probe t-d before t+d, so an equidistant tie resolves to the LOWER frame (deterministic).
|
||||
auto isCrossing = [&](std::int64_t i) -> bool {
|
||||
if (i < 1 || i >= frames) return false;
|
||||
const AudioSample a = pcm[i - 1];
|
||||
const AudioSample b = pcm[i];
|
||||
if (b == 0.0f) return true; // a sample on zero is its own crossing
|
||||
return (a < 0.0f) != (b < 0.0f); // sign change between i-1 and i
|
||||
};
|
||||
|
||||
if (isCrossing(t)) return t;
|
||||
for (std::int64_t d = 1; d < frames; ++d) {
|
||||
const std::int64_t lo = t - d;
|
||||
if (lo >= 1 && isCrossing(lo)) return lo; // lower side wins the tie
|
||||
const std::int64_t hi = t + d;
|
||||
if (hi < frames && isCrossing(hi)) return hi;
|
||||
// Stop once both probes have run off both ends — no crossing anywhere.
|
||||
if (lo < 1 && hi >= frames) break;
|
||||
}
|
||||
return t; // no sign change in the whole buffer -> keep the raw (clamped) target
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
@@ -0,0 +1,83 @@
|
||||
// waveform_view.h — PURE waveform/marker geometry + zero-crossing snap for the S11
|
||||
// waveform surface. NO VST3, NO REAPER, NO SWELL/LICE types at the boundary. The mirror
|
||||
// of keyboard_strip / editor_geometry: the fiddly frame<->pixel + marker hit-test + snap
|
||||
// arithmetic lives here, unit-tested outside the DAW, while the editor shell
|
||||
// (reasampler_editor.cpp) draws the envelope + markers and marshals mouse events into it.
|
||||
//
|
||||
// The surface maps a sample's full frame span [0, frameCount] linearly across a horizontal
|
||||
// waveform rect. Draggable MARKERS mark frames of interest (S11: start point, loop start,
|
||||
// loop end). The marker set is GENERIC — N named markers with drag + snap — deliberately
|
||||
// not three hardcoded specials, so S15 (Trigger/Gate) can repurpose this same surface with a
|
||||
// different marker set (start + %-length end + fades) without reworking the machinery.
|
||||
//
|
||||
// Interaction resolves through the pure DRAG-DELTA resolver here: the shell captures a grab
|
||||
// on WM_LBUTTONDOWN (markerAtPoint identifies the grabbed marker), feeds each WM_MOUSEMOVE's
|
||||
// pixel delta back through resolveDragFrame (which clamps + optionally zero-crossing-snaps),
|
||||
// and commits on WM_LBUTTONUP. Live feedback is the shell re-drawing the in-flight frame.
|
||||
//
|
||||
// It reuses the same Rect + contains() as editor_geometry (one shared geometry idiom), so
|
||||
// this header depends on editor_geometry.h rather than redefining a rectangle type. Audio
|
||||
// is the peaks AudioSample float alias (the one house precedent — sampler_core / wav_trim do
|
||||
// the same), so the zero-crossing helper takes the same mono PCM the shell already decoded.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "editor_geometry.h" // Rect, contains — one shared geometry idiom
|
||||
#include "peaks.h" // AudioSample (float), the mono PCM the snap scans
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// The width (px) of a marker's grab region either side of its x line: a grab within this many
|
||||
// pixels of a marker's drawn x is a grab OF that marker. Mirrors keyboard_strip's edge-grab
|
||||
// idiom — wide enough to grab a 1px line comfortably, narrow enough that adjacent markers stay
|
||||
// distinguishable.
|
||||
inline constexpr int kMarkerGrabWidth = 5;
|
||||
|
||||
// The x pixel (inside `area`) of frame `frame` under the linear map: frame 0 -> area.left,
|
||||
// frame frameCount -> area.right. A frame is clamped to [0, frameCount] before mapping, so an
|
||||
// out-of-range frame pins to an edge rather than escaping the rect. frameCount <= 0 or a
|
||||
// zero-width area pins every frame to area.left (a degenerate, non-inverting result). Pure.
|
||||
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame);
|
||||
|
||||
// The frame a point x (inside `area`) maps to under the inverse linear map, clamped to
|
||||
// [0, frameCount]. A point left of area.left yields 0; right of area.right yields frameCount.
|
||||
// frameCount <= 0 or a zero-width area yields 0. Pure — the inverse of frameToX (round-trips
|
||||
// to the same frame at bin granularity).
|
||||
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x);
|
||||
|
||||
// Which marker (index into a caller-supplied parallel `frames` array, in draw order) a grab at
|
||||
// (x, y) lands on, or -1 for a point off every marker (or off the waveform area). A marker is
|
||||
// grabbed when x is within kMarkerGrabWidth of its drawn x AND y is inside `area`. First marker
|
||||
// in order wins a tie where two markers overlap within the grab band (deterministic, mirroring
|
||||
// keyboard_strip's first-match). `frames` is `count` frame indices; a null/empty array or
|
||||
// count <= 0 yields -1. Pure — a raw pointer at the boundary (no host container), like
|
||||
// keyboard_strip::zoneBarAtPoint.
|
||||
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
|
||||
int count, int x, int y);
|
||||
|
||||
// Resolve a drag to a new frame. Given the frame the grabbed marker held at grab time
|
||||
// (`startFrame`) and the horizontal pixel delta since grab (`dxPixels`), returns the frame the
|
||||
// marker should now hold: startFrame shifted by round(dxPixels * frameCount / areaWidth),
|
||||
// clamped to [0, frameCount]. A zero-width area or non-positive frameCount pins the result to
|
||||
// the clamped startFrame (no motion). This is the single arithmetic behind every marker drag;
|
||||
// the shell applies clamps BETWEEN markers (start <= loopEnd, loopStart <= loopEnd) after this
|
||||
// per-marker resolve. Pure — rounding is to the nearest frame. Returns the clamped startFrame
|
||||
// for dxPixels == 0.
|
||||
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
|
||||
int dxPixels);
|
||||
|
||||
// The nearest zero-crossing frame to `target` in the mono PCM, for the loop/start snap (the
|
||||
// S2 zero-crossing-aware requirement). A zero crossing is a frame index i (1 <= i < frames)
|
||||
// where the sign of pcm[i-1] and pcm[i] differ (a sample exactly 0 counts as its own crossing
|
||||
// — pcm[i] == 0 snaps to i). The search fans out symmetrically from the clamped target and
|
||||
// returns the closest crossing frame; ties (equidistant crossings on both sides) resolve to
|
||||
// the LOWER frame (deterministic). When the PCM has NO sign change anywhere (all one sign, or
|
||||
// fewer than 2 frames), returns the clamped target unchanged (nothing to snap to — the caller
|
||||
// keeps the raw frame). `target` is clamped to [0, frames) before searching. Pure — scans the
|
||||
// decoded PCM the shell already holds; no host types, no file I/O.
|
||||
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
|
||||
std::int64_t target);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
Reference in New Issue
Block a user