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:
+20
-1
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
+128
-2
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user