fix(waveform): per-column min/max envelope fill eliminates gaps in steep segments

This commit is contained in:
2026-07-27 18:25:05 -04:00
parent e2bd4f4351
commit 20308c842e
5 changed files with 151 additions and 15 deletions
+2 -2
View File
@@ -928,8 +928,8 @@ add_library(reaper_reasampler MODULE
src/card_meta.cpp
src/card_drag.cpp
)
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model insert_plan render_settings batch_capture tail_control realtime_record bank_book wav_trim owned_manifest prune_reconcile prune_button app_version provenance action_buttons drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync waveform_view)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC} src/vst)
# OUTPUT_NAME is channel-derived (Phase V, V4): "reaper_reasampler" (stable, default) or
# "reaper_reasampler_beta" (beta). REAPER dlopen's any reaper_* module, so both channels'
# artifacts load side-by-side. The CMake TARGET name stays "reaper_reasampler" for both
+14 -11
View File
@@ -8,7 +8,8 @@
#include <cstddef>
#include "bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure)
#include "bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure)
#include "vst/waveform_view.h" // columnMinMax — per-pixel-column envelope merge (pure)
// SWELL / LICE. On Windows use native Win32 (windows.h first); on mac/linux SWELL is
// provided by the host. Mirrors bank_panel.cpp's include discipline.
@@ -292,6 +293,8 @@ void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) {
const int channels = static_cast<int>(env.size());
const int bandH = box.height / channels;
const int innerW = box.width - 4; // drawable pixel columns: box.x+2 .. box.x+2+innerW-1
for (int ch = 0; ch < channels; ++ch) {
const ChannelEnvelope& bins = env[static_cast<std::size_t>(ch)];
const int bandTop = box.y + ch * bandH;
@@ -301,19 +304,19 @@ void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) {
LICE_Line(bmp, box.x + 2, midY, box.x + box.width - 2, midY,
midCol, 1.0f, 0, false);
const int nbins = static_cast<int>(bins.size());
if (nbins <= 0) continue;
if (bins.empty() || innerW <= 0) continue;
const int innerW = box.width - 4;
for (int i = 0; i < nbins; ++i) {
const int x = box.x + 2 +
(nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0);
// Same dB display compression as the panel thumbnail (bank_grid, pure) so a
// waveform reads identically wherever the kit draws it.
// Render one filled vertical span per pixel column. columnMinMax merges all
// bins that project to column `col` under the same partition as computeEnvelope,
// so every pixel column is covered with no gaps regardless of the bins-to-pixels
// ratio. Same dB display compression as the panel thumbnail (bank_grid, pure).
for (int col = 0; col < innerW; ++col) {
const MinMax mm = vst::columnMinMax(bins, innerW, col);
const int x = box.x + 2 + col;
int yMax = midY - static_cast<int>(
compressAmplitudeForDisplay(bins[static_cast<std::size_t>(i)].max) * halfSpan);
compressAmplitudeForDisplay(mm.max) * halfSpan);
int yMin = midY - static_cast<int>(
compressAmplitudeForDisplay(bins[static_cast<std::size_t>(i)].min) * halfSpan);
compressAmplitudeForDisplay(mm.min) * halfSpan);
if (yMax < bandTop) yMax = bandTop;
if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1;
LICE_Line(bmp, x, yMin, x, yMax, waveCol, 1.0f, 0, false);
+31 -1
View File
@@ -3,7 +3,8 @@
#include "waveform_view.h"
#include <algorithm>
#include <cstdlib> // std::abs (int overload)
#include <cstdlib> // std::abs (int overload)
#include <cstddef> // std::size_t
namespace reasampler::vst {
@@ -65,6 +66,35 @@ std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::in
return clampFrame(start + shift, frameCount);
}
MinMax columnMinMax(const ChannelEnvelope& bins, int innerW, int col) {
const int nbins = static_cast<int>(bins.size());
if (innerW <= 0 || nbins == 0) return MinMax{};
// Clamp col to [0, innerW-1].
if (col < 0) col = 0;
if (col >= innerW) col = innerW - 1;
// Half-open bin range for this column: [colBinBegin, colBinEnd).
// Mirrors computeEnvelope's exact partition (col * nbins / innerW).
const int colBinBegin = (col * nbins) / innerW;
const int colBinEnd = ((col + 1) * nbins) / innerW;
if (colBinBegin >= nbins) return MinMax{};
// When the column spans no full bins (colBinEnd == colBinBegin), use the
// enclosing bin so every pixel column has a non-empty source.
const int scanEnd = (colBinEnd > colBinBegin) ? colBinEnd : colBinBegin + 1;
const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins;
MinMax result = bins[static_cast<std::size_t>(colBinBegin)];
for (int b = colBinBegin + 1; b < clampedEnd; ++b) {
const MinMax& mm = bins[static_cast<std::size_t>(b)];
if (mm.min < result.min) result.min = mm.min;
if (mm.max > result.max) result.max = mm.max;
}
return result;
}
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);
+13
View File
@@ -68,6 +68,19 @@ int markerAtPoint(const Rect& area, std::int64_t frameCount, const std::int64_t*
std::int64_t resolveDragFrame(const Rect& area, std::int64_t frameCount, std::int64_t startFrame,
int dxPixels);
// The merged min/max envelope for pixel column `col` (0-based, within `innerW` total columns)
// given a pre-computed per-bin ChannelEnvelope. For each pixel column the function accumulates
// all bins whose frames project to that column, returning their true min and max — so no bin is
// silently skipped when `nbins > innerW` (multiple bins per column) and no column is left empty
// when `nbins < innerW` (a column may span a fractional bin; the enclosing bin is used).
//
// The mapping mirrors computeEnvelope's exact half-open partition:
// column col owns bins [col*nbins/innerW, (col+1)*nbins/innerW).
// When that range is empty (a column maps to a bin boundary), the enclosing bin
// (col*nbins/innerW) fills the column — ensuring no pixel column is left gap-free.
// `innerW <= 0` or `bins.empty()` returns {0, 0}. `col` is clamped to [0, innerW-1]. Pure.
MinMax columnMinMax(const ChannelEnvelope& bins, int innerW, int col);
// 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
+91 -1
View File
@@ -7,7 +7,8 @@
// 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).
// no-crossing keeps target, target clamp, degenerate buffers);
// columnMinMax (per-pixel-column bin merge: 1:1, upsample, downsample, degenerate).
#include "../src/vst/waveform_view.h"
@@ -16,6 +17,8 @@
using namespace reasampler::vst;
using reasampler::AudioSample;
using reasampler::MinMax;
using reasampler::ChannelEnvelope;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
@@ -192,6 +195,86 @@ static void testZeroCrossingDegenerate() {
CHECK(nearestZeroCrossing(one.data(), 1, 0) == 0); // <2 frames -> clamped target
}
// --- columnMinMax -------------------------------------------------------------
// Helpers: build a ChannelEnvelope from parallel min/max arrays.
static ChannelEnvelope makeEnvelope(const std::vector<float>& mins,
const std::vector<float>& maxs) {
ChannelEnvelope env(mins.size());
for (std::size_t i = 0; i < mins.size(); ++i) {
env[i] = MinMax{mins[i], maxs[i]};
}
return env;
}
static void testColumnMinMaxOneToOne() {
// 4 bins, 4 pixel columns: each column maps exactly one bin.
ChannelEnvelope env = makeEnvelope({-1.0f, -0.5f, 0.0f, 0.5f},
{ 0.5f, 0.0f, 0.5f, 1.0f});
// col 0 → bin 0, col 1 → bin 1, etc.
CHECK(columnMinMax(env, 4, 0).min == -1.0f && columnMinMax(env, 4, 0).max == 0.5f);
CHECK(columnMinMax(env, 4, 1).min == -0.5f && columnMinMax(env, 4, 1).max == 0.0f);
CHECK(columnMinMax(env, 4, 2).min == 0.0f && columnMinMax(env, 4, 2).max == 0.5f);
CHECK(columnMinMax(env, 4, 3).min == 0.5f && columnMinMax(env, 4, 3).max == 1.0f);
}
static void testColumnMinMaxUpsample() {
// 2 bins, 4 pixel columns: columns 0,1 map to bin 0; columns 2,3 map to bin 1.
// Verifies that upsampling (more columns than bins) returns the enclosing bin
// and does not leave any column empty.
ChannelEnvelope env = makeEnvelope({-1.0f, 0.5f}, {0.0f, 1.0f});
// col 0: (0*2)/4=0, (1*2)/4=0 -> empty range -> fallback bin 0.
CHECK(columnMinMax(env, 4, 0).min == -1.0f && columnMinMax(env, 4, 0).max == 0.0f);
CHECK(columnMinMax(env, 4, 1).min == -1.0f && columnMinMax(env, 4, 1).max == 0.0f);
CHECK(columnMinMax(env, 4, 2).min == 0.5f && columnMinMax(env, 4, 2).max == 1.0f);
CHECK(columnMinMax(env, 4, 3).min == 0.5f && columnMinMax(env, 4, 3).max == 1.0f);
}
static void testColumnMinMaxDownsample() {
// 4 bins, 2 pixel columns: each column merges 2 bins.
// col 0: bins [0,2) → min(-1,-0.5)=-1, max(0.5,0.0)=0.5.
// col 1: bins [2,4) → min(0.0,0.5)=0.0, max(0.5,1.0)=1.0.
ChannelEnvelope env = makeEnvelope({-1.0f, -0.5f, 0.0f, 0.5f},
{ 0.5f, 0.0f, 0.5f, 1.0f});
CHECK(columnMinMax(env, 2, 0).min == -1.0f && columnMinMax(env, 2, 0).max == 0.5f);
CHECK(columnMinMax(env, 2, 1).min == 0.0f && columnMinMax(env, 2, 1).max == 1.0f);
}
static void testColumnMinMaxColClamp() {
// col out of [0, innerW-1] is clamped: negative clamps to 0, >= innerW clamps to last.
ChannelEnvelope env = makeEnvelope({-0.5f, 0.5f}, {-0.1f, 0.9f});
CHECK(columnMinMax(env, 2, -5).min == -0.5f); // clamps to col 0
CHECK(columnMinMax(env, 2, 999).max == 0.9f); // clamps to col 1
}
static void testColumnMinMaxDegenerate() {
ChannelEnvelope empty;
// Empty envelope → {0, 0}.
MinMax z = columnMinMax(empty, 4, 0);
CHECK(z.min == 0.0f && z.max == 0.0f);
// innerW <= 0 → {0, 0}.
ChannelEnvelope env = makeEnvelope({0.3f}, {0.7f});
MinMax z2 = columnMinMax(env, 0, 0);
CHECK(z2.min == 0.0f && z2.max == 0.0f);
MinMax z3 = columnMinMax(env, -1, 0);
CHECK(z3.min == 0.0f && z3.max == 0.0f);
}
static void testColumnMinMaxFullCoverageNoBlanks() {
// The critical gap-free property: for any bins/innerW ratio, every pixel column
// in [0, innerW) returns a non-zero-width or valid result (no column is skipped).
// Use 6 bins over 10 pixel columns (non-integer ratio). Every column must return
// the min/max of at least one bin (not the default {0,0} that would indicate a gap).
ChannelEnvelope env = makeEnvelope({0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f},
{0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f});
for (int col = 0; col < 10; ++col) {
MinMax mm = columnMinMax(env, 10, col);
// Every column must have a real bin value, not zero (all bins have positive values).
CHECK(mm.min >= 0.1f && mm.max <= 0.7f);
CHECK(mm.min <= mm.max);
}
}
int main() {
testFrameToXEndpoints();
testFrameToXClampsOutOfRange();
@@ -217,6 +300,13 @@ int main() {
testZeroCrossingClampsTarget();
testZeroCrossingDegenerate();
testColumnMinMaxOneToOne();
testColumnMinMaxUpsample();
testColumnMinMaxDownsample();
testColumnMinMaxColClamp();
testColumnMinMaxDegenerate();
testColumnMinMaxFullCoverageNoBlanks();
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;