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:
2026-07-26 21:26:23 -04:00
parent ee82b50fb7
commit 9743547dff
12 changed files with 1052 additions and 36 deletions
+20 -1
View File
@@ -659,6 +659,15 @@ add_library(keyboard_strip STATIC src/vst/keyboard_strip.cpp)
target_include_directories(keyboard_strip PUBLIC src/vst) target_include_directories(keyboard_strip PUBLIC src/vst)
target_link_libraries(keyboard_strip PUBLIC editor_geometry) target_link_libraries(keyboard_strip PUBLIC editor_geometry)
# waveform_view (Phase S11) — PURE frame<->pixel mapping, marker grab regions, drag-delta
# frame resolver, and the zero-crossing snap for the capture-first editor's waveform surface
# (draggable start + loop markers over the picked capture's decoded PCM). The mirror of
# keyboard_strip; links editor_geometry for the shared Rect and peaks for the AudioSample
# alias the snap scans. NEITHER SDK.
add_library(waveform_view STATIC src/vst/waveform_view.cpp)
target_include_directories(waveform_view PUBLIC src/vst src)
target_link_libraries(waveform_view PUBLIC editor_geometry peaks)
add_executable(editor_geometry_tests tests/test_editor_geometry.cpp) add_executable(editor_geometry_tests tests/test_editor_geometry.cpp)
target_link_libraries(editor_geometry_tests PRIVATE editor_geometry) target_link_libraries(editor_geometry_tests PRIVATE editor_geometry)
add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests) add_test(NAME editor_geometry_tests COMMAND editor_geometry_tests)
@@ -686,6 +695,12 @@ add_executable(keyboard_strip_tests tests/test_keyboard_strip.cpp)
target_link_libraries(keyboard_strip_tests PRIVATE keyboard_strip) target_link_libraries(keyboard_strip_tests PRIVATE keyboard_strip)
add_test(NAME keyboard_strip_tests COMMAND keyboard_strip_tests) add_test(NAME keyboard_strip_tests COMMAND keyboard_strip_tests)
# waveform_view (S11): the pure marker geometry + zero-crossing snap. Links ONLY waveform_view
# (+ its pure editor_geometry/peaks deps) — NEITHER SDK — the same plain-data-boundary proof.
add_executable(waveform_view_tests tests/test_waveform_view.cpp)
target_link_libraries(waveform_view_tests PRIVATE waveform_view)
add_test(NAME waveform_view_tests COMMAND waveform_view_tests)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -865,8 +880,12 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
# capture_browser + keyboard_strip (S10): the pure card-grid/tab + keyboard-strip # capture_browser + keyboard_strip (S10): the pure card-grid/tab + keyboard-strip
# geometry the capture-first editor draws + hit-tests against; both link editor_geometry # geometry the capture-first editor draws + hit-tests against; both link editor_geometry
# transitively (shared Rect). # transitively (shared Rect).
# waveform_view (S11): the pure frame<->pixel marker geometry + zero-crossing snap the
# editor's waveform surface draws + hit-tests against; links editor_geometry + peaks
# transitively (shared Rect + AudioSample).
target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal
sample_map capture_paths embed_strip app_version capture_browser keyboard_strip) sample_map capture_paths embed_strip app_version capture_browser keyboard_strip
waveform_view)
# SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # 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. # 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}) target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC})
+243 -14
View File
@@ -21,6 +21,7 @@
#include "reasampler_vst.h" // kPluginName (the editor title band) #include "reasampler_vst.h" // kPluginName (the editor title band)
#include "sample_map.h" #include "sample_map.h"
#include "wav_trim.h" // parseWavLayout, extractFloatFrames #include "wav_trim.h" // parseWavLayout, extractFloatFrames
#include "waveform_view.h" // frame<->pixel markers + zero-crossing snap (S11)
#ifdef _WIN32 #ifdef _WIN32
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM #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. // capture face) hosts the keyboard strip + level readout under the browser.
constexpr int kTitleHeight = 24; constexpr int kTitleHeight = 24;
constexpr int kToggleHeight = 22; 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 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 // 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.) // 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 kColStripBg = LICE_RGBA(36, 36, 40, 255);
const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255); const LICE_pixel kColStripKey = LICE_RGBA(52, 52, 58, 255);
const LICE_pixel kColRootMarker = LICE_RGBA(120, 200, 160, 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 kColZoneBar = LICE_RGBA(58, 96, 84, 255);
const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255); const LICE_pixel kColZoneBarSel = LICE_RGBA(120, 200, 160, 255);
const COLORREF kRgbText = RGB(210, 230, 220); const COLORREF kRgbText = RGB(210, 230, 220);
@@ -132,6 +139,7 @@ ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
void ReaSamplerEditor::refreshFromBank() { void ReaSamplerEditor::refreshFromBank() {
// Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER). // 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 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_) { if (!processor_) {
samples_.clear(); samples_.clear();
banks_.clear(); banks_.clear();
@@ -176,17 +184,85 @@ void ReaSamplerEditor::commitAndReload() {
#endif #endif
} }
const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int binCount) { ReaSamplerEditor::SetupMarkers ReaSamplerEditor::pickedMarkers(std::int64_t frames) const {
const std::string key = sampleId + "|" + std::to_string(binCount); SetupMarkers m;
auto it = thumbCache_.find(key); // Seed from the bank's S2 intrinsic loop (fact about the file), then let a per-zone override
if (it != thumbCache_.end()) return it->second; // 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 // 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 // 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 // parse — the mirror of the processor's decodeRelative. Every failure path caches an EMPTY
// empty envelope so a broken/missing file is not re-decoded on every paint. // vector so a broken/missing file is not re-decoded on every paint. Keyed by id (width-
// independent) — the thumbnail bins this at whatever width, the snap scans it directly.
std::string relativePath; std::string relativePath;
Envelope env; std::vector<AudioSample> mono;
if (processor_) { if (processor_) {
auto banksJson = auto banksJson =
processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
@@ -210,13 +286,27 @@ const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int
if (layout.valid) { if (layout.valid) {
std::vector<AudioSample> interleaved = std::vector<AudioSample> interleaved =
extractFloatFrames(bytes, layout, 0, layout.frameCount()); extractFloatFrames(bytes, layout, 0, layout.frameCount());
std::vector<AudioSample> mono = mono = downmixToMono(interleaved, layout.channelCount);
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(), env = computeEnvelope(mono, 1, mono.size(),
static_cast<std::size_t>((std::max)(1, binCount))); static_cast<std::size_t>((std::max)(1, binCount)));
} }
}
}
auto ins = thumbCache_.emplace(key, std::move(env)); auto ins = thumbCache_.emplace(key, std::move(env));
return ins.first->second; 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}; 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 // 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) // 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, // 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); drawText(bmp, headerR, header.c_str(), kRgbText);
Rect hintR{area.left + pad, headerR.bottom, area.right - pad, headerR.bottom + 16}; 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. // Keyboard strip with the root marker.
const Rect stripArea = setupStripArea(area); 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 commitAndReload(); // publishes the pick + reloads; process() plays it repitched
return; return;
} }
// The setup strip: grab the root marker (drag to set the picked capture's root).
if (havePick) { if (havePick) {
const Rect area{bands.content.left, setupTop, bands.content.right, bands.content.bottom}; 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 Rect stripArea = setupStripArea(area);
const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height()); const StripLayout sl = layoutStrip(stripArea.width(), stripArea.height());
const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top); const int note = keyAtPoint(sl, x - stripArea.left, y - stripArea.top);
@@ -731,6 +894,72 @@ void ReaSamplerEditor::onMouseMove(int x, int y) {
return; 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. // Zone edits: recompute the grabbed field(s) against the pure resolver, live.
if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return; if (selectedZone_ < 0 || selectedZone_ >= static_cast<int>(map_.zones.size())) return;
const Rect stripArea = zonesStripArea(bands); const Rect stripArea = zonesStripArea(bands);
+45 -2
View File
@@ -68,8 +68,14 @@ private:
// What a mouse drag is currently editing (the drag-state machine). kNone = no drag in // 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 // flight. The zone-edit grabs mirror keyboard_strip::ZoneGrab; kRootMarker is the
// single-capture root drag on the setup strip. // single-capture root drag on the setup strip; kWaveMarker is a draggable start/loop
enum class DragKind { kNone, kRootMarker, kZoneLow, kZoneHigh, kZoneBody }; // 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 #ifdef _WIN32
void paint(HDC hdc); 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). // 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); 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; ReaSamplerProcessor* processor_ = nullptr;
// --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) --- // --- Snapshot of the live bank (drawn each paint; refreshed off the audio thread) ---
@@ -126,10 +156,23 @@ private:
int dragStartHigh_ = 0; int dragStartHigh_ = 0;
int dragStartRoot_ = 60; 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) ------ // --- 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 // 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. // a bank edit (a re-captured or deleted sample) does not show a stale thumbnail.
std::unordered_map<std::string, Envelope> thumbCache_; 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 } // namespace reasampler::vst
+75 -7
View File
@@ -143,7 +143,11 @@ ResolvedPerformance resolvePerformance(const std::string& banksJson,
// Effective root: override beats bank intrinsic beats middle-C default. // Effective root: override beats bank intrinsic beats middle-C default.
rz.rootNote = z.rootOverride ? *z.rootOverride rz.rootNote = z.rootOverride ? *z.rootOverride
: (found->rootNote ? *found->rootNote : 60); : (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)); out.zones.push_back(std::move(rz));
} }
return out; return out;
@@ -161,6 +165,7 @@ Keymap buildZonedKeymap(const std::vector<ResolvedZone>& zones,
data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100; data.sampleRate = decoded[i].sampleRate > 0 ? decoded[i].sampleRate : 44100;
data.rootNote = zones[i].rootNote; data.rootNote = zones[i].rootNote;
data.loop = zones[i].loop; data.loop = zones[i].loop;
data.startFrame = zones[i].startFrame; // S11 effective start (override, else 0)
const std::size_t sampleIndex = km.samples.size(); const std::size_t sampleIndex = km.samples.size();
km.samples.push_back(std::move(data)); km.samples.push_back(std::move(data));
KeyZone zone; 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)); 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 // 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 // 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. // 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). // 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())); } 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 // Append the zones payload — the shared body of the v2 performance blob and the v3 component
// performance blob and the v3 component blob, so both write zones identically. // 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) { 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())); putU32le(out, static_cast<std::uint32_t>(map.zones.size()));
for (const PerformanceZone& z : map.zones) { for (const PerformanceZone& z : map.zones) {
putU32le(out, static_cast<std::uint32_t>(z.sampleId.size())); 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, putU32le(out,
static_cast<std::uint32_t>(static_cast<std::int32_t>(*z.rootOverride))); 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 // Read a zones payload from `r` into `map`. Shared by the performance parse and the component
// v2 performance parse and the v3 component parse. A truncated mid-zone read keeps the zones // parse. Detects the S11 format marker: present -> PAYLOAD v2 (extended records with the
// that parsed cleanly and drops the rest; the reader position is left after the last byte // loop/start tail); absent (a plain small zone count) -> PAYLOAD v1 (pre-S11 records, no tail —
// read successfully. // 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) { 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(); const std::uint32_t count = r.u32();
for (std::uint32_t i = 0; i < count && r.ok; ++i) { for (std::uint32_t i = 0; i < count && r.ok; ++i) {
PerformanceZone z; PerformanceZone z;
@@ -248,6 +304,18 @@ void readZonesPayload(ByteReader& r, PerformanceMap& map) {
z.highNote = r.i32(); z.highNote = r.i32();
const std::uint8_t hasOverride = r.u8(); const std::uint8_t hasOverride = r.u8();
if (hasOverride) z.rootOverride = r.i32(); 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 if (!r.ok) break; // truncated mid-zone -> keep what parsed cleanly, drop the rest
map.zones.push_back(std::move(z)); map.zones.push_back(std::move(z));
} }
+45 -8
View File
@@ -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, // 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 // 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). // 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 { struct PerformanceZone {
std::string sampleId; // bank sample id this zone plays std::string sampleId; // bank sample id this zone plays
int lowNote = 0; // inclusive int lowNote = 0; // inclusive
int highNote = 127; // inclusive int highNote = 127; // inclusive
std::optional<int> rootOverride; // instrument-owned override; absent -> bank intrinsic 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 // The instrument's performance map: an ordered list of zones. Order is authoritative for
@@ -148,7 +157,8 @@ struct ResolvedZone {
int lowNote = 0; int lowNote = 0;
int highNote = 127; int highNote = 127;
int rootNote = 60; // effective: override, else bank intrinsic, else 60 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 // 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 // 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). // 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: // 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, // 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). // 1 byte hasRootOverride (0/1), 4-byte LE rootOverride (present iff hasRootOverride).
// BACK-COMPAT: a v1 blob (the S4 single-selection format: version tag 1 + id bytes) is // A payload starting with a small u32 (the zone count) is v1 — there is no marker.
// lifted to a single full-keyboard zone playing that id (no override) — so an instance // * PAYLOAD v2 (S11): a 4-byte LE MARKER (kZonesFormatMarker, a high sentinel no real zone
// saved under Tier 0 restores as a one-zone Tier-1 map. A truncated/unknown/empty blob // count can equal) + a 4-byte LE payload version (== 2), THEN the v1 body PLUS, appended
// deserializes to an EMPTY map. // 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 // These two functions serialize the ZONES only. Since S10 the instrument's full component
// state is {single-capture selection id, zones} — see ComponentState / serializeComponentState // state is {single-capture selection id, zones} — see ComponentState / serializeComponentState
// below, the v3 format the processor actually reads/writes. serializePerformance/ // 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; 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). // The performance map serialized to bytes for IBStream (getState).
std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map); std::vector<std::uint8_t> serializePerformance(const PerformanceMap& map);
+8 -1
View File
@@ -151,7 +151,14 @@ void Voice::start(int note, int velocity, const SampleData& sample, int rootNote
if (v > 127) v = 127; if (v > 127) v = 127;
velocityGain_ = static_cast<double>(v) / 127.0; velocityGain_ = static_cast<double>(v) / 127.0;
ratio_ = pitchRatio(note, rootNote); 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; sample_ = &sample;
env_.configure(adsr); env_.configure(adsr);
env_.noteOn(); env_.noteOn();
+6
View File
@@ -50,6 +50,12 @@ struct SampleData {
// note-relative, so rate cancels for repitch) // note-relative, so rate cancels for repitch)
int rootNote = 60; // MIDI note recorded at (plays at unity here) int rootNote = 60; // MIDI note recorded at (plays at unity here)
SampleLoop loop; // sustain loop, if any 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;
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+99
View File
@@ -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
+83
View File
@@ -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
+128 -2
View File
@@ -479,6 +479,40 @@ static void testResolveLoopThreaded() {
CHECK(r.zones.size() == 1); CHECK(r.zones.size() == 1);
CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop); CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop);
CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800); CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 200 && r.zones[0].loop.end == 800);
// No loop override + no startPoint -> effective start is 0 (S11 default).
CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 0);
}
static void testResolveLoopOverrideWins() {
// S11: the instrument's per-zone loopOverride beats the bank's S2 loop intrinsic, and the
// startPoint feeds the effective startFrame — without mutating the bank.
Sample s = makeSample("a", "Pad", "b/a.wav", 60);
s.loop = LoopPoints{200, 800}; // bank intrinsic
const std::string json = bookJson({s}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 0, 127);
SampleLoop over; over.hasLoop = true; over.start = 1000; over.end = 4000;
z.loopOverride = over; // instrument override
z.startPoint = 512; // start offset
m.zones.push_back(z);
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1 && r.zones[0].loop.hasLoop);
CHECK(r.zones.size() == 1 && r.zones[0].loop.start == 1000 && r.zones[0].loop.end == 4000);
CHECK(r.zones.size() == 1 && r.zones[0].startFrame == 512);
}
static void testResolveLoopOverrideDisablesLoop() {
// A loopOverride with hasLoop=false explicitly REMOVES the bank's loop for this instance
// (override present-but-empty wins over the intrinsic — a deliberate "no loop here").
Sample s = makeSample("a", "Pad", "b/a.wav", 60);
s.loop = LoopPoints{200, 800};
const std::string json = bookJson({s}, {});
PerformanceMap m;
PerformanceZone z = zone("a", 0, 127);
z.loopOverride = SampleLoop{}; // hasLoop=false, start=end=0
m.zones.push_back(z);
const ResolvedPerformance r = resolvePerformance(json, m);
CHECK(r.zones.size() == 1 && !r.zones[0].loop.hasLoop);
} }
// --- performance map: buildZonedKeymap ---------------------------------------- // --- performance map: buildZonedKeymap ----------------------------------------
@@ -506,6 +540,24 @@ static void testBuildZonedKeymapMultiZone() {
CHECK(!km.resolve(24, 100).matched); CHECK(!km.resolve(24, 100).matched);
} }
static void testBuildZonedKeymapThreadsLoopAndStart() {
// S11: the effective loop + start on a ResolvedZone reach the core's SampleData so the
// voice honors them at note-on.
std::vector<ResolvedZone> zones;
ResolvedZone z0;
z0.lowNote = 0; z0.highNote = 127; z0.rootNote = 60;
z0.loop.hasLoop = true; z0.loop.start = 3; z0.loop.end = 7;
z0.startFrame = 2;
zones.push_back(z0);
std::vector<DecodedZonePcm> decoded;
decoded.push_back(DecodedZonePcm{{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, 44100});
const Keymap km = buildZonedKeymap(zones, decoded);
CHECK(km.samples.size() == 1);
CHECK(km.samples.size() == 1 && km.samples[0].loop.hasLoop &&
km.samples[0].loop.start == 3 && km.samples[0].loop.end == 7);
CHECK(km.samples.size() == 1 && km.samples[0].startFrame == 2);
}
static void testBuildZonedKeymapDropsEmptyPcm() { static void testBuildZonedKeymapDropsEmptyPcm() {
// A zone whose decoded WAV is empty is dropped; the other zone survives, and the // A zone whose decoded WAV is empty is dropped; the other zone survives, and the
// survivor's sampleIndex points at ITS sample (not the dropped one's slot). // survivor's sampleIndex points at ITS sample (not the dropped one's slot).
@@ -563,11 +615,79 @@ static void testPerformanceStateRoundTrip() {
static void testPerformanceStateEmpty() { static void testPerformanceStateEmpty() {
const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{}); const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{});
// Just the version + zero-count header. // Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4).
CHECK(bytes.size() == 8); CHECK(bytes.size() == 16);
CHECK(deserializePerformance(bytes).zones.empty()); CHECK(deserializePerformance(bytes).zones.empty());
} }
static void testPerformanceStateLoopStartRoundTrip() {
// S11: the per-zone loopOverride + startPoint survive the payload-v2 round trip.
PerformanceMap m;
PerformanceZone z = zone("pad", 24, 96, /*override=*/64);
SampleLoop lp; lp.hasLoop = true; lp.start = 12345; lp.end = 67890;
z.loopOverride = lp;
z.startPoint = 4096;
m.zones.push_back(z);
// A second zone with NO overrides proves the optional tail is per-record.
m.zones.push_back(zone("kick", 0, 23));
const PerformanceMap back = deserializePerformance(serializePerformance(m));
CHECK(back.zones.size() == 2);
CHECK(back.zones.size() == 2 && back.zones[0].rootOverride.has_value() &&
*back.zones[0].rootOverride == 64);
CHECK(back.zones.size() == 2 && back.zones[0].loopOverride.has_value() &&
back.zones[0].loopOverride->hasLoop &&
back.zones[0].loopOverride->start == 12345 &&
back.zones[0].loopOverride->end == 67890);
CHECK(back.zones.size() == 2 && back.zones[0].startPoint.has_value() &&
*back.zones[0].startPoint == 4096);
// Zone 1: no overrides -> all optionals absent after round trip.
CHECK(back.zones.size() == 2 && !back.zones[1].loopOverride.has_value());
CHECK(back.zones.size() == 2 && !back.zones[1].startPoint.has_value());
}
static void testPerformanceStateV1PayloadBackCompat() {
// A pre-S11 PAYLOAD v1 blob (no format marker: envelope v2 + bare count + short records)
// parses cleanly with the loop/start overrides defaulting absent. Hand-build the exact
// shipped shape to prove the reader still accepts the marker-less payload.
std::vector<std::uint8_t> b;
auto u32 = [&](std::uint32_t v) {
b.push_back(v & 0xFF); b.push_back((v >> 8) & 0xFF);
b.push_back((v >> 16) & 0xFF); b.push_back((v >> 24) & 0xFF);
};
u32(2); // envelope version 2
u32(1); // zone count 1 (NOT the marker -> payload v1)
const std::string id = "legacy";
u32(static_cast<std::uint32_t>(id.size()));
b.insert(b.end(), id.begin(), id.end());
u32(10); // lowNote
u32(40); // highNote
b.push_back(0); // hasRootOverride = 0 (record ends here in v1)
const PerformanceMap back = deserializePerformance(b);
CHECK(back.zones.size() == 1 && back.zones[0].sampleId == "legacy");
CHECK(back.zones.size() == 1 && back.zones[0].lowNote == 10 && back.zones[0].highNote == 40);
CHECK(back.zones.size() == 1 && !back.zones[0].loopOverride.has_value());
CHECK(back.zones.size() == 1 && !back.zones[0].startPoint.has_value());
}
static void testComponentStateLoopStartRoundTrip() {
// The overrides also round-trip through the v3 ComponentState envelope (zones nest inside
// it), so the processor's live getState/setState preserves them — the composition property.
ComponentState s;
s.selectionId = "pick";
PerformanceZone z = zone("pick", 0, 127);
SampleLoop lp; lp.hasLoop = true; lp.start = 500; lp.end = 9000;
z.loopOverride = lp;
z.startPoint = 128;
s.map.zones.push_back(z);
const ComponentState back = deserializeComponentState(serializeComponentState(s));
CHECK(back.selectionId == "pick");
CHECK(back.map.zones.size() == 1 && back.map.zones[0].loopOverride.has_value() &&
back.map.zones[0].loopOverride->start == 500 &&
back.map.zones[0].loopOverride->end == 9000);
CHECK(back.map.zones.size() == 1 && back.map.zones[0].startPoint.has_value() &&
*back.map.zones[0].startPoint == 128);
}
static void testPerformanceStateV1BackCompat() { static void testPerformanceStateV1BackCompat() {
// A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone. // A v1 blob (the S4 single-selection format) lifts to a single full-keyboard zone.
const std::vector<std::uint8_t> v1 = serializeSelection("legacy-sample-id"); const std::vector<std::uint8_t> v1 = serializeSelection("legacy-sample-id");
@@ -711,16 +831,22 @@ int main() {
testResolveStaleIdDropsZone(); testResolveStaleIdDropsZone();
testResolveRootPrecedence(); testResolveRootPrecedence();
testResolveLoopThreaded(); testResolveLoopThreaded();
testResolveLoopOverrideWins();
testResolveLoopOverrideDisablesLoop();
testBuildZonedKeymapMultiZone(); testBuildZonedKeymapMultiZone();
testBuildZonedKeymapThreadsLoopAndStart();
testBuildZonedKeymapDropsEmptyPcm(); testBuildZonedKeymapDropsEmptyPcm();
testBuildZonedKeymapOverlapFirstWins(); testBuildZonedKeymapOverlapFirstWins();
testBuildZonedKeymapEmpty(); testBuildZonedKeymapEmpty();
testPerformanceStateRoundTrip(); testPerformanceStateRoundTrip();
testPerformanceStateEmpty(); testPerformanceStateEmpty();
testPerformanceStateLoopStartRoundTrip();
testPerformanceStateV1PayloadBackCompat();
testPerformanceStateV1BackCompat(); testPerformanceStateV1BackCompat();
testPerformanceStateGarbage(); testPerformanceStateGarbage();
testPerformanceStateNegativeNotesRoundTrip(); testPerformanceStateNegativeNotesRoundTrip();
testComponentStateRoundTrip(); testComponentStateRoundTrip();
testComponentStateLoopStartRoundTrip();
testComponentStateSelectionOnlyNoZones(); testComponentStateSelectionOnlyNoZones();
testComponentStateEmptyIsEmpty(); testComponentStateEmptyIsEmpty();
testComponentStateV1BackCompat(); testComponentStateV1BackCompat();
+76
View File
@@ -520,6 +520,78 @@ static void testAbsentLoopGoesSilent() {
for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6)); for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.0, 1e-6));
} }
// ---------------------------------------------------------------------------
// start point (S11): the voice's initial read position is SampleData::startFrame.
// ---------------------------------------------------------------------------
static void testStartFrameOffsetsInitialRead() {
// A per-frame ramp (frame i holds i*0.01) so the first rendered value pinpoints the
// read position. startFrame = 30 -> the first output frame reads frame 30 (0.30).
SampleData s;
s.frames.resize(100);
for (int i = 0; i < 100; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
s.rootNote = 60;
s.startFrame = 30;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127); // unity ratio, full velocity, flat gain
std::vector<AudioSample> out;
eng.render(out, 3);
CHECK(approx(out[0], 0.30, 1e-4)); // starts at frame 30, not 0
CHECK(approx(out[1], 0.31, 1e-4)); // advances by unity ratio
CHECK(approx(out[2], 0.32, 1e-4));
}
static void testStartFrameZeroIsUnchanged() {
// startFrame default 0 is exactly the pre-S11 behavior: read begins at frame 0.
SampleData s;
s.frames.resize(20);
for (int i = 0; i < 20; ++i) s.frames[i] = static_cast<float>(i) * 0.05f;
s.rootNote = 60; // startFrame stays 0
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 1);
CHECK(approx(out[0], 0.0, 1e-6)); // frame 0
}
static void testStartFrameOutOfRangeClampsToZero() {
// A start point at/past the sample end degrades to frame 0 (play from the top), never an
// out-of-bounds read that would start the voice already exhausted.
SampleData s = dcSample(10, 60); // 10 frames of 1.0
s.startFrame = 10; // == frameCount: out of range
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 5);
// Reads from frame 0: the DC sample plays its 1.0 body rather than an immediate idle.
CHECK(eng.activeVoiceCount() == 1);
CHECK(approx(out[0], 1.0, 1e-4));
}
static void testStartFrameWithLoop() {
// Start point and loop compose: begin reading mid-sample, then sustain the loop region.
SampleData s;
s.frames.resize(40);
for (int i = 0; i < 40; ++i) s.frames[i] = static_cast<float>(i) * 0.01f;
for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body
s.rootNote = 60;
s.startFrame = 10; // begin at frame 10
s.loop.hasLoop = true;
s.loop.start = 20;
s.loop.end = 40;
Keymap km = Keymap::singleSampleChromatic(std::move(s));
VoiceEngine eng(1, km, flatAdsr());
eng.noteOn(60, 127);
std::vector<AudioSample> out;
eng.render(out, 200);
CHECK(approx(out[0], 0.10, 1e-4)); // started at frame 10
CHECK(eng.activeVoiceCount() == 1); // loop sustains it
for (std::size_t i = 60; i < out.size(); ++i) CHECK(approx(out[i], 0.5, 1e-4));
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// velocity -> volume. // velocity -> volume.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -580,6 +652,10 @@ int main() {
testZeroLengthLoopGoesSilent(); testZeroLengthLoopGoesSilent();
testSingleFrameLoop(); testSingleFrameLoop();
testAbsentLoopGoesSilent(); testAbsentLoopGoesSilent();
testStartFrameOffsetsInitialRead();
testStartFrameZeroIsUnchanged();
testStartFrameOutOfRangeClampsToZero();
testStartFrameWithLoop();
testVelocityToVolume(); testVelocityToVolume();
testPolyphonyMixesAdditively(); testPolyphonyMixesAdditively();
+223
View File
@@ -0,0 +1,223 @@
// Standalone tests for reasampler::vst::waveform_view — no VST3, no REAPER, no framework.
// Same fast assert loop as the sibling pure tests. Assert the S11 waveform surface's
// frame<->pixel mapping, marker grab regions, drag-delta frame resolver (with clamps), and
// the zero-crossing snap — the geometry + snap that back the draggable start/loop markers.
//
// Covers: frameToX / xToFrame (linear map + inverse, edge clamps, degenerate frameCount/width);
// markerAtPoint (grab band, first-match on overlap, off-area + null-array rejection);
// resolveDragFrame (round-to-nearest-frame, clamp to [0,frameCount], zero-delta/zero-width
// no-ops); nearestZeroCrossing (nearest sign-change, sample-on-zero, equidistant-tie-to-lower,
// no-crossing keeps target, target clamp, degenerate buffers).
#include "../src/vst/waveform_view.h"
#include <cstdio>
#include <vector>
using namespace reasampler::vst;
using reasampler::AudioSample;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// A comfortable waveform area: 1000px wide, offset so left != 0 (catches origin bugs).
static Rect wideArea() { return Rect{20, 10, 1020, 90}; } // width 1000
// --- frameToX / xToFrame ------------------------------------------------------
static void testFrameToXEndpoints() {
const Rect a = wideArea();
CHECK(frameToX(a, 1000, 0) == a.left); // frame 0 -> left edge
CHECK(frameToX(a, 1000, 1000) == a.right); // frameCount -> right edge
CHECK(frameToX(a, 1000, 500) == a.left + 500); // midpoint (1:1 here)
}
static void testFrameToXClampsOutOfRange() {
const Rect a = wideArea();
CHECK(frameToX(a, 1000, -50) == a.left); // below 0 pins left
CHECK(frameToX(a, 1000, 5000) == a.right); // above count pins right
}
static void testFrameToXDegenerate() {
const Rect a = wideArea();
CHECK(frameToX(a, 0, 100) == a.left); // no frames -> left
const Rect z = Rect{5, 5, 5, 45}; // zero width
CHECK(frameToX(z, 1000, 500) == z.left);
}
static void testXToFrameInverse() {
const Rect a = wideArea();
CHECK(xToFrame(a, 1000, a.left) == 0);
CHECK(xToFrame(a, 1000, a.right) == 1000);
CHECK(xToFrame(a, 1000, a.left + 250) == 250); // 1:1 map here
}
static void testXToFrameClampsOutside() {
const Rect a = wideArea();
CHECK(xToFrame(a, 1000, a.left - 100) == 0); // left of area -> 0
CHECK(xToFrame(a, 1000, a.right + 100) == 1000); // right of area -> frameCount
CHECK(xToFrame(a, 0, a.left + 10) == 0); // no frames -> 0
}
static void testFrameToXRoundTrip() {
// Round-trip at a non-1:1 scale: 800px area over 2000 frames (2.5 frames/px). frameToX then
// xToFrame should land within a couple frames (rounding both directions).
const Rect a = Rect{0, 0, 800, 60};
for (std::int64_t f = 0; f <= 2000; f += 137) {
const int x = frameToX(a, 2000, f);
const std::int64_t back = xToFrame(a, 2000, x);
CHECK(back >= f - 3 && back <= f + 3);
}
}
// --- markerAtPoint ------------------------------------------------------------
static void testMarkerAtPointGrabsWithinBand() {
const Rect a = wideArea();
// Markers at frames 100, 500, 900 -> x = left+100, left+500, left+900.
const std::int64_t frames[3] = {100, 500, 900};
const int midY = a.top + a.height() / 2;
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 100, midY) == 0);
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, midY) == 1);
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 900, midY) == 2);
// Within the grab band on either side of the line.
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 + kMarkerGrabWidth, midY) == 1);
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500 - kMarkerGrabWidth, midY) == 1);
}
static void testMarkerAtPointMissesBetween() {
const Rect a = wideArea();
const std::int64_t frames[3] = {100, 500, 900};
const int midY = a.top + a.height() / 2;
// Well away from any marker line.
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 300, midY) == -1);
// Off the area vertically.
CHECK(markerAtPoint(a, 1000, frames, 3, a.left + 500, a.top - 5) == -1);
}
static void testMarkerAtPointFirstMatchOnOverlap() {
const Rect a = wideArea();
// Two markers at the same frame -> first in order wins.
const std::int64_t frames[2] = {400, 400};
const int midY = a.top + a.height() / 2;
CHECK(markerAtPoint(a, 1000, frames, 2, a.left + 400, midY) == 0);
}
static void testMarkerAtPointRejectsNullEmpty() {
const Rect a = wideArea();
const int midY = a.top + a.height() / 2;
CHECK(markerAtPoint(a, 1000, nullptr, 3, a.left + 100, midY) == -1);
const std::int64_t frames[1] = {100};
CHECK(markerAtPoint(a, 1000, frames, 0, a.left + 100, midY) == -1);
}
// --- resolveDragFrame ---------------------------------------------------------
static void testResolveDragFrameShift() {
const Rect a = wideArea(); // 1:1 (1000px / 1000 frames)
CHECK(resolveDragFrame(a, 1000, 300, 0) == 300); // zero delta -> unchanged
CHECK(resolveDragFrame(a, 1000, 300, 100) == 400); // +100px -> +100 frames
CHECK(resolveDragFrame(a, 1000, 300, -50) == 250); // -50px -> -50 frames
}
static void testResolveDragFrameClamps() {
const Rect a = wideArea();
CHECK(resolveDragFrame(a, 1000, 50, -500) == 0); // clamp low
CHECK(resolveDragFrame(a, 1000, 950, 500) == 1000); // clamp high (== frameCount)
}
static void testResolveDragFrameRounds() {
// 500px area over 1000 frames -> 2 frames/px. A +3px drag -> round(6.0)=6; the rounding is
// at the frame centre. Use a scale where a fractional result appears.
const Rect a = Rect{0, 0, 300, 60}; // 1000 frames / 300px = 3.33 frames/px
// +3px -> 3*1000/300 = 10.0 -> 10 frames.
CHECK(resolveDragFrame(a, 1000, 100, 3) == 110);
// +1px -> 1000/300 = 3.33 -> rounds to 3.
CHECK(resolveDragFrame(a, 1000, 100, 1) == 103);
}
static void testResolveDragFrameDegenerate() {
const Rect z = Rect{0, 0, 0, 60}; // zero width
CHECK(resolveDragFrame(z, 1000, 300, 100) == 300); // pinned to start
const Rect a = wideArea();
CHECK(resolveDragFrame(a, 0, 300, 100) == 0); // no frames -> clamp(start)=0
// startFrame out of range is clamped first.
CHECK(resolveDragFrame(a, 1000, 5000, 0) == 1000);
}
// --- nearestZeroCrossing ------------------------------------------------------
static void testZeroCrossingNearest() {
// Crossings (sign change from i-1 to i): i=4 (1->-1), i=5 (-1->1), i=10 (1->-1).
std::vector<AudioSample> pcm = {1, 1, 1, 1, -1, 1, 1, 1, 1, 1, -1, -1};
// Target 4 is itself a crossing -> 4.
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 4);
// Nearest to 6: crossing 5 (dist 1) beats 4 (dist 2) and 10 (dist 4) -> 5.
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 6) == 5);
// Nearest to 9: crossing 10 (dist 1) beats 5 (dist 4) -> 10.
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 9) == 10);
}
static void testZeroCrossingSampleOnZero() {
// A sample exactly 0 is its own crossing (frame index of the zero sample).
std::vector<AudioSample> pcm = {1, 1, 0, 1, 1};
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2);
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 3) == 2);
}
static void testZeroCrossingEquidistantTieToLower() {
// Crossings at i=2 (1->-1) and i=6 (-1->1). Target 4 is equidistant (dist 2) -> lower (2).
std::vector<AudioSample> pcm = {1, 1, -1, -1, -1, -1, 1, 1};
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 4) == 2);
}
static void testZeroCrossingNoneKeepsTarget() {
// All one sign -> no crossing -> the (clamped) target comes back unchanged.
std::vector<AudioSample> pcm = {0.5f, 0.6f, 0.7f, 0.8f};
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 2) == 2);
}
static void testZeroCrossingClampsTarget() {
std::vector<AudioSample> pcm = {1, -1, 1, -1}; // crossings at 1,2,3
// Target beyond the end clamps to frames-1 (3) then finds crossing at 3.
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), 999) == 3);
// Negative target clamps to 0; nearest crossing is 1.
CHECK(nearestZeroCrossing(pcm.data(), (std::int64_t)pcm.size(), -999) == 1);
}
static void testZeroCrossingDegenerate() {
CHECK(nearestZeroCrossing(nullptr, 0, 5) == 0);
std::vector<AudioSample> one = {1};
CHECK(nearestZeroCrossing(one.data(), 1, 0) == 0); // <2 frames -> clamped target
}
int main() {
testFrameToXEndpoints();
testFrameToXClampsOutOfRange();
testFrameToXDegenerate();
testXToFrameInverse();
testXToFrameClampsOutside();
testFrameToXRoundTrip();
testMarkerAtPointGrabsWithinBand();
testMarkerAtPointMissesBetween();
testMarkerAtPointFirstMatchOnOverlap();
testMarkerAtPointRejectsNullEmpty();
testResolveDragFrameShift();
testResolveDragFrameClamps();
testResolveDragFrameRounds();
testResolveDragFrameDegenerate();
testZeroCrossingNearest();
testZeroCrossingSampleOnZero();
testZeroCrossingEquidistantTieToLower();
testZeroCrossingNoneKeepsTarget();
testZeroCrossingClampsTarget();
testZeroCrossingDegenerate();
if (g_fail == 0) std::printf("waveform_view: all tests passed\n");
else std::printf("waveform_view: %d FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
}