Merge ps-w8-t2-waveform: S11 waveform view + draggable loop points
This commit is contained in:
+128
-2
@@ -479,6 +479,40 @@ static void testResolveLoopThreaded() {
|
||||
CHECK(r.zones.size() == 1);
|
||||
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);
|
||||
// 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 ----------------------------------------
|
||||
@@ -506,6 +540,24 @@ static void testBuildZonedKeymapMultiZone() {
|
||||
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() {
|
||||
// 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).
|
||||
@@ -563,11 +615,79 @@ static void testPerformanceStateRoundTrip() {
|
||||
|
||||
static void testPerformanceStateEmpty() {
|
||||
const std::vector<std::uint8_t> bytes = serializePerformance(PerformanceMap{});
|
||||
// Just the version + zero-count header.
|
||||
CHECK(bytes.size() == 8);
|
||||
// Envelope version (4) + zones-payload marker (4) + payload version (4) + zero count (4).
|
||||
CHECK(bytes.size() == 16);
|
||||
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() {
|
||||
// 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");
|
||||
@@ -854,16 +974,22 @@ int main() {
|
||||
testResolveStaleIdDropsZone();
|
||||
testResolveRootPrecedence();
|
||||
testResolveLoopThreaded();
|
||||
testResolveLoopOverrideWins();
|
||||
testResolveLoopOverrideDisablesLoop();
|
||||
testBuildZonedKeymapMultiZone();
|
||||
testBuildZonedKeymapThreadsLoopAndStart();
|
||||
testBuildZonedKeymapDropsEmptyPcm();
|
||||
testBuildZonedKeymapOverlapFirstWins();
|
||||
testBuildZonedKeymapEmpty();
|
||||
testPerformanceStateRoundTrip();
|
||||
testPerformanceStateEmpty();
|
||||
testPerformanceStateLoopStartRoundTrip();
|
||||
testPerformanceStateV1PayloadBackCompat();
|
||||
testPerformanceStateV1BackCompat();
|
||||
testPerformanceStateGarbage();
|
||||
testPerformanceStateNegativeNotesRoundTrip();
|
||||
testComponentStateRoundTrip();
|
||||
testComponentStateLoopStartRoundTrip();
|
||||
testComponentStateSelectionOnlyNoZones();
|
||||
testComponentStateEmptyIsEmpty();
|
||||
testComponentStateV1BackCompat();
|
||||
|
||||
@@ -520,6 +520,115 @@ static void testAbsentLoopGoesSilent() {
|
||||
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));
|
||||
}
|
||||
|
||||
static void testStartAfterLoopEndWrapsIntoLoop() {
|
||||
// Regression (S11 reviewer finding): if startFrame > loop.end (but still < frameCount),
|
||||
// the voice's initial read head is past the loop end. The wrap-while in renderFrame must
|
||||
// pull it back into [loopStart, loopEnd) on the very first frame, so the note sounds from
|
||||
// somewhere inside the loop rather than running off the sample end silently.
|
||||
//
|
||||
// Setup: 100-frame sample; loop is [20, 40); startFrame = 60 (past loop.end = 40).
|
||||
// loop body is a constant 0.5 so every frame inside it reads 0.5.
|
||||
// After wrap: readPos starts inside [20, 40), first output frame == 0.5.
|
||||
// Voice must stay active (loop sustains it) and emit the loop value, NOT go silent.
|
||||
SampleData s;
|
||||
s.frames.resize(100, 0.0f);
|
||||
for (int i = 20; i < 40; ++i) s.frames[i] = 0.5f; // loop body
|
||||
s.rootNote = 60;
|
||||
s.startFrame = 60; // > loop.end (40), < frameCount (100)
|
||||
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); // unity ratio, full velocity
|
||||
|
||||
std::vector<AudioSample> out;
|
||||
eng.render(out, 50);
|
||||
|
||||
// Voice must still be active — the usable loop keeps it alive indefinitely.
|
||||
CHECK(eng.activeVoiceCount() == 1);
|
||||
// Every frame after the first wrap must read 0.5 (the loop body). We skip the very
|
||||
// first frame because the fractional-position wrap lands somewhere in [20,40) and the
|
||||
// exact offset depends on how many loop lengths fit into 60; what matters is that the
|
||||
// voice is alive and emitting the loop value, not 0.0 (pre-loop region).
|
||||
for (std::size_t i = 5; i < out.size(); ++i) {
|
||||
CHECK(approx(out[i], 0.5, 1e-4));
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// velocity -> volume.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -692,6 +801,11 @@ int main() {
|
||||
testZeroLengthLoopGoesSilent();
|
||||
testSingleFrameLoop();
|
||||
testAbsentLoopGoesSilent();
|
||||
testStartFrameOffsetsInitialRead();
|
||||
testStartFrameZeroIsUnchanged();
|
||||
testStartFrameOutOfRangeClampsToZero();
|
||||
testStartFrameWithLoop();
|
||||
testStartAfterLoopEndWrapsIntoLoop();
|
||||
testVelocityToVolume();
|
||||
testPolyphonyMixesAdditively();
|
||||
testChannelCount();
|
||||
|
||||
@@ -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