S11: waveform view with draggable start/loop markers + zero-crossing snap

Pure waveform_view module (frame<->pixel markers, zero-crossing snap); per-zone
loop/start overrides on PerformanceZone with self-versioning zone payload; core
start-point read offset; editor waveform surface with drag machine.
This commit is contained in:
2026-07-26 21:26:23 -04:00
parent ee82b50fb7
commit 9743547dff
12 changed files with 1052 additions and 36 deletions
+99
View File
@@ -0,0 +1,99 @@
// waveform_view.cpp — see waveform_view.h. Pure math; no host types.
#include "waveform_view.h"
#include <algorithm>
#include <cstdlib> // std::abs (int overload)
namespace reasampler::vst {
namespace {
std::int64_t clampFrame(std::int64_t f, std::int64_t frameCount) {
if (f < 0) return 0;
if (f > frameCount) return frameCount;
return f;
}
} // namespace
int frameToX(const Rect& area, std::int64_t frameCount, std::int64_t frame) {
const int w = std::max(0, area.width());
if (frameCount <= 0 || w <= 0) return area.left;
const std::int64_t f = clampFrame(frame, frameCount);
// Linear map: x = left + round(f * w / frameCount). Rounding keeps the marker line
// visually centered on its frame; the divide is exact rational (multiply first).
const std::int64_t num = f * static_cast<std::int64_t>(w) + frameCount / 2;
return area.left + static_cast<int>(num / frameCount);
}
std::int64_t xToFrame(const Rect& area, std::int64_t frameCount, int x) {
const int w = std::max(0, area.width());
if (frameCount <= 0 || w <= 0) return 0;
if (x <= area.left) return 0;
if (x >= area.right) return frameCount;
const std::int64_t dx = static_cast<std::int64_t>(x - area.left);
// Inverse of frameToX: frame = round(dx * frameCount / w). Round so click and marker draw
// agree at bin granularity.
const std::int64_t num = dx * frameCount + static_cast<std::int64_t>(w) / 2;
return clampFrame(num / static_cast<std::int64_t>(w), frameCount);
}
int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t* frames,
int count, int x, int y) {
if (count <= 0 || frames == nullptr) return -1;
if (!contains(area, x, y)) return -1;
for (int i = 0; i < count; ++i) {
const int mx = frameToX(area, frameCount, frames[i]);
if (x >= mx - kMarkerGrabWidth && x <= mx + kMarkerGrabWidth) return i;
}
return -1;
}
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
int dxPixels) {
const std::int64_t start = clampFrame(startFrame, frameCount);
if (dxPixels == 0) return start;
const int w = std::max(0, area.width());
if (frameCount <= 0 || w <= 0) return start; // no room to move
// Proportional shift, rounded to the nearest frame (same linear map as frameToX/xToFrame).
const std::int64_t magnitude =
(static_cast<std::int64_t>(std::abs(dxPixels)) * frameCount +
static_cast<std::int64_t>(w) / 2) /
static_cast<std::int64_t>(w);
const std::int64_t shift = dxPixels > 0 ? magnitude : -magnitude;
return clampFrame(start + shift, frameCount);
}
std::int64_t nearestZeroCrossing(const AudioSample* pcm, std::int64_t frames,
std::int64_t target) {
if (pcm == nullptr || frames < 2) return clampFrame(target, frames > 0 ? frames - 1 : 0);
// Clamp target into a valid sample index [0, frames).
std::int64_t t = target;
if (t < 0) t = 0;
if (t > frames - 1) t = frames - 1;
// A crossing lives at frame i (1 <= i < frames) when sign(pcm[i-1]) != sign(pcm[i]) OR
// pcm[i] == 0. isCrossing(i) tests exactly that. We fan out from t: at each distance d we
// probe t-d before t+d, so an equidistant tie resolves to the LOWER frame (deterministic).
auto isCrossing = [&](std::int64_t i) -> bool {
if (i < 1 || i >= frames) return false;
const AudioSample a = pcm[i - 1];
const AudioSample b = pcm[i];
if (b == 0.0f) return true; // a sample on zero is its own crossing
return (a < 0.0f) != (b < 0.0f); // sign change between i-1 and i
};
if (isCrossing(t)) return t;
for (std::int64_t d = 1; d < frames; ++d) {
const std::int64_t lo = t - d;
if (lo >= 1 && isCrossing(lo)) return lo; // lower side wins the tie
const std::int64_t hi = t + d;
if (hi < frames && isCrossing(hi)) return hi;
// Stop once both probes have run off both ends — no crossing anywhere.
if (lo < 1 && hi >= frames) break;
}
return t; // no sign change in the whole buffer -> keep the raw (clamped) target
}
} // namespace reasampler::vst