fix(waveform): 4x-oversampled min/max envelope, columnMinMax homed in peaks, panel routed through shared drawWaveform — one gap-free algorithm on all surfaces

This commit is contained in:
2026-07-27 19:09:50 -04:00
parent 20308c842e
commit b3c9fad9ba
11 changed files with 240 additions and 203 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 waveform_view)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC} src/vst)
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})
# 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
+20 -44
View File
@@ -13,7 +13,8 @@
// LICE-drawn named-banks tab-page region below (one tab per named bank, an
// overflow/scroll strip), and two full-height toggles that collapse the split.
// Each region reuses the M5 grid render loop (waveform thumbnails / empty state).
// * per-sample PCM read via PCM_source fed to peaks::computeEnvelope at cell width.
// * per-sample PCM read via PCM_source fed to peaks::computeEnvelope, oversampled
// (kWaveformOversample bins per drawn pixel column) for the FA3 gap-free draw.
// * an in-memory thumbnail cache keyed by (sample id, draw width, bank generation).
// * id-keyed bank management (create / rename / delete / evacuate / activate) and
// sample move/copy — driven from a tab context menu and a drag — against the B1
@@ -416,7 +417,7 @@ std::vector<const Bank*> namedBanks() {
return out;
}
// --- Thumbnail computation (unchanged from M5) --------------------------------
// --- Thumbnail computation (M5; `width` is a BIN count since FA3 oversampling) --
Envelope computeThumbnail(const std::string& absPath, int width) {
if (width <= 0 || absPath.empty()) return {};
@@ -458,9 +459,13 @@ Envelope computeThumbnail(const std::string& absPath, int width) {
for (std::size_t i = 0; i < sampleCount; ++i)
pcm[i] = static_cast<float>(buf[i]);
// Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with
// trailing empty {0,0} bins, which would render a very short sample as a comb of
// spikes over flat gaps.
const int binCount = width < got ? width : got;
return computeEnvelope(pcm, static_cast<std::size_t>(nch),
static_cast<std::size_t>(got),
static_cast<std::size_t>(width));
static_cast<std::size_t>(binCount));
}
const Envelope& thumbnailFor(const Sample& sample, int width,
@@ -479,7 +484,7 @@ const Envelope& thumbnailFor(const Sample& sample, int width,
return ins.first->second.envelope;
}
// --- Drawing: thumbnails (unchanged from M5) ----------------------------------
// --- Drawing: thumbnails (via the kit's shared drawWaveform since FA3) ---------
// Draws the L7 decorative metadata overlay on a card: bars.beats.subdivisions bottom-LEFT
// (musical, from the capture-time tempo + meter stamp) and seconds.milliseconds bottom-RIGHT
@@ -529,45 +534,11 @@ void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2, ring, 1.0f, 0);
}
// Waveform plot (peaks invariant: min<=max). The wave keeps its NORMAL accent color in
// every state (L7 dropped the inverted bg/base wave on the selected cell — the cell fill
// is no longer inverted, so no contrast swap is needed).
const LICE_pixel midCol = toLice(roleColor(Role::LineHairline));
const LICE_pixel waveCol = toLice(roleColor(Role::AccentPrimary));
if (env.empty()) {
const int midY = rect.y + rect.height / 2;
LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, midCol, 1.0f, 0, false);
if (sample) drawCardMeta(bmp, rect, *sample); // L7 overlay even on an empty envelope
return;
}
const int channels = static_cast<int>(env.size());
const int bandH = rect.height / channels;
for (int ch = 0; ch < channels; ++ch) {
const ChannelEnvelope& bins = env[ch];
const int bandTop = rect.y + ch * bandH;
const int midY = bandTop + bandH / 2;
const double halfSpan = (bandH / 2) - 2;
LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, midCol, 1.0f, 0, false);
const int nbins = static_cast<int>(bins.size());
if (nbins <= 0) continue;
const int innerW = rect.width - 4;
for (int i = 0; i < nbins; ++i) {
const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0);
// min<=max always (peaks invariant). Draw a vertical line from the
// min sample to the max sample, clamped to the band.
int yMax = midY - static_cast<int>(compressAmplitudeForDisplay(bins[i].max) * halfSpan); // max -> up
int yMin = midY - static_cast<int>(compressAmplitudeForDisplay(bins[i].min) * halfSpan); // min -> down
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);
}
}
// Waveform plot through the kit's shared primitive (FA3): the SAME per-pixel-column
// min/max envelope draw the VST editor hero + browser cards use — one algorithm, one
// look, everywhere. The oversampled env (see drawRegionGrid's binWidth) collapses per
// column via peaks::columnMinMax inside the kit; an empty env draws just the midline.
drawWaveform(bmp, cell, env);
// L7 decorative metadata overlay, drawn last so it sits over the waveform.
if (sample) drawCardMeta(bmp, rect, *sample);
@@ -1379,7 +1350,12 @@ void drawRegionGrid(LICE_IBitmap* bmp, const RECT& region, bool isBanks,
// BankIndex insertion order. Selection/focus are keyed by the occupied-ordinal (selection
// space); a slot maps back to its ordinal via selectionForSlot.
const RegionDisplay disp = regionDisplay(region, isBanks, reg);
const int binWidth = kGrid.cellWidth - 4;
// FA3 anti-alias: thumbnails are computed OVERSAMPLED — kWaveformOversample bins per
// drawn pixel column — and drawWaveform collapses them per column (peaks::columnMinMax)
// so steep transients render as true full-height spans. computeThumbnail clamps the
// request to the frame count.
const int binWidth = kWaveformOversample *
waveformColumnCount(KitBox{0, 0, kGrid.cellWidth, kGrid.cellHeight});
for (const SlotCellRect& r : disp.slotRects) {
if (r.y >= grid.bottom) continue; // below the viewport: skip (no scroll)
const CellRect rect{r.x, r.y, r.width, r.height};
+14 -8
View File
@@ -8,8 +8,7 @@
#include <cstddef>
#include "bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (pure)
#include "vst/waveform_view.h" // columnMinMax — per-pixel-column envelope merge (pure)
#include "bank_grid.h" // compressAmplitudeForDisplay — the shared dB display curve (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.
@@ -278,6 +277,11 @@ void drawListRow(LICE_IBitmap* bmp, const ListRowBox& row, const char* label,
}
}
int waveformColumnCount(const KitBox& box) {
const int w = box.width - 4; // fixed 2px inset each side (matches drawWaveform below)
return w > 0 ? w : 0;
}
void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) {
if (!bmp || box.empty()) return;
@@ -293,7 +297,7 @@ 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
const int innerW = waveformColumnCount(box); // 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)];
@@ -306,12 +310,14 @@ void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env) {
if (bins.empty() || innerW <= 0) continue;
// 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).
// Render one filled vertical span per pixel column. peaks::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 — callers oversample (kWaveformOversample bins per
// column) so each span shows true extremes. Same dB display compression
// everywhere (bank_grid, pure).
for (int col = 0; col < innerW; ++col) {
const MinMax mm = vst::columnMinMax(bins, innerW, col);
const MinMax mm = columnMinMax(bins, innerW, col);
const int x = box.x + 2 + col;
int yMax = midY - static_cast<int>(
compressAmplitudeForDisplay(mm.max) * halfSpan);
+23 -6
View File
@@ -115,12 +115,29 @@ void drawSlider(LICE_IBitmap* bmp, const SliderGeometry& geom, InteractionState
void drawListRow(LICE_IBitmap* bmp, const ListRowBox& row, const char* label,
int thumbWidth, InteractionState state);
// A waveform envelope drawn as a min/max column plot over the bg/panel surface: a midline
// per channel and an accent vertical line per bin (the same shape the panel thumbnail
// draws, lifted into the kit so the panel and the L3 editor waveform share it). `box` is
// the draw region; `env` is the per-channel min/max envelope from peaks::computeEnvelope.
// An empty env draws just the midline. The caller fills the surface first (or passes a box
// already filled); this draws only the wave + midline.
// The number of pixel columns drawWaveform renders inside `box` (its fixed 2px side
// insets), never negative. Callers size the envelope they compute from this: request
// `kWaveformOversample * waveformColumnCount(box)` bins (clamped to the frame count) and
// drawWaveform collapses them per column — the anti-aliasing lever. Requesting fewer bins
// than columns still renders gap-free (the enclosing bin fills each column) but at the
// envelope's coarser resolution.
int waveformColumnCount(const KitBox& box);
// The house oversampling factor for waveform envelopes: bins requested per drawn pixel
// column. Each display column then shows the true min/max of ~4 bins (via
// peaks::columnMinMax), so steep transients render as accurate full-height spans instead
// of aliased single-bin dots.
inline constexpr int kWaveformOversample = 4;
// A waveform envelope drawn as a min/max plot over the bg/panel surface: a midline per
// channel and one accent vertical span PER PIXEL COLUMN, each column covering the true
// extremes of every bin that projects to it (peaks::columnMinMax — gap-free at any
// bins-to-pixels ratio). The ONE waveform shape in the system: the dock-panel thumbnail,
// the browser cards, and the editor hero all render through this. `box` is the draw
// region; `env` is the per-channel min/max envelope from peaks::computeEnvelope, ideally
// oversampled (see waveformColumnCount / kWaveformOversample above). An empty env draws
// just the midline. The caller fills the surface first (or passes a box already filled);
// this draws only the wave + midline.
void drawWaveform(LICE_IBitmap* bmp, const KitBox& box, const Envelope& env);
} // namespace reasampler
+32
View File
@@ -3,6 +3,7 @@
#include <algorithm>
#include <climits>
#include <cmath>
#include <cstdint>
// peaks implementation.
//
@@ -64,6 +65,37 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
return envelope;
}
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col) {
const int nbins = static_cast<int>(bins.size());
if (columnCount <= 0 || nbins == 0) return MinMax{};
// Clamp col to [0, columnCount-1].
if (col < 0) col = 0;
if (col >= columnCount) col = columnCount - 1;
// Half-open bin range for this column, mirroring computeEnvelope's exact partition.
// 64-bit products: col*nbins can exceed int range for a large oversampled envelope
// (same overflow discipline as computeEnvelope's frame-span arithmetic above).
const std::int64_t begin64 = (static_cast<std::int64_t>(col) * nbins) / columnCount;
const std::int64_t end64 =
(static_cast<std::int64_t>(col) + 1) * nbins / columnCount;
// col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins.
const int colBinBegin = static_cast<int>(begin64);
// When the column spans no full bin (more columns than bins), use the enclosing bin
// so no column is left empty.
const int scanEnd = (end64 > begin64) ? static_cast<int>(end64) : 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::size_t lastFrameAboveThreshold(const std::vector<AudioSample>& interleaved,
std::size_t channelCount,
std::size_t frameCount,
+15
View File
@@ -66,6 +66,21 @@ Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
std::size_t frameCount,
std::size_t binCount);
// The merged min/max for display column `col` (0-based, of `columnCount` total columns)
// of a pre-computed per-bin ChannelEnvelope: the true extremes of every bin that projects
// to that column. This is the display-side collapse of an envelope computed at HIGHER
// resolution than the drawn width (oversampled bins -> per-pixel-column min/max), so a
// steep transient whose adjacent bins hold disjoint spans (e.g. {0.9,1.0} then
// {-1.0,-0.9}) renders as one gap-free vertical span instead of two separated dots.
//
// Bin->column mapping mirrors computeEnvelope's half-open partition:
// column col owns bins [col*nbins/columnCount, (col+1)*nbins/columnCount).
// When that range is empty (more columns than bins), the enclosing bin
// (col*nbins/columnCount) fills the column — so no column is left empty and no bin is
// ever dropped. columnCount <= 0 or bins.empty() returns {0, 0}; `col` is clamped to
// [0, columnCount-1]. Pure.
MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col);
// Sentinel returned by lastFrameAboveThreshold when NO frame in the scanned range
// peaks above the threshold (pure silence at that level). SIZE_MAX is unambiguous:
// no valid frame index can equal it (a real index is < frameCount <= SIZE_MAX for
+20 -7
View File
@@ -121,10 +121,12 @@ std::string sampleLabel(const std::vector<SampleChoice>& samples, const std::str
return "?";
}
// The bin count a card's thumbnail is computed at: the card thumbnail width, so one bin
// per horizontal pixel.
// The bin count a card's thumbnail is computed at: kWaveformOversample bins per drawn
// thumbnail pixel column (FA3 anti-alias) — drawWaveform collapses them per column via
// peaks::columnMinMax. thumbnailFor clamps the request to the decoded frame count.
int thumbBins(const BrowserLayout& layout) {
return (std::max)(1, cardThumbnailRect(layout, 0).width());
return (std::max)(1, kWaveformOversample *
waveformColumnCount(toKitBox(cardThumbnailRect(layout, 0))));
}
#endif
} // namespace
@@ -570,8 +572,12 @@ const Envelope& ReaSamplerEditor::thumbnailFor(const std::string& sampleId, int
const std::vector<AudioSample>& mono = monoPcmFor(sampleId);
Envelope env;
if (!mono.empty()) {
env = computeEnvelope(mono, 1, mono.size(),
static_cast<std::size_t>((std::max)(1, binCount)));
// Clamp bins to the frame count: computeEnvelope pads binCount > frameCount with
// trailing empty {0,0} bins, which would render a very short sample as a comb of
// spikes over flat gaps.
const std::size_t bins =
(std::min)(static_cast<std::size_t>((std::max)(1, binCount)), mono.size());
env = computeEnvelope(mono, 1, mono.size(), bins);
}
auto ins = thumbCache_.emplace(key, std::move(env));
return ins.first->second;
@@ -1005,8 +1011,15 @@ void ReaSamplerEditor::paintSample(LICE_IBitmap* bmp, int w, int h) {
const Rect waveArea = bands.hero;
fillSurface(bmp, toKitBox(waveArea), Role::BgBase, InteractionState::Rest);
if (frames > 0 && waveArea.width() > 0) {
const int bins = (std::max)(1, waveArea.width());
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), static_cast<std::size_t>(bins));
// FA3 anti-alias: request kWaveformOversample bins per drawn pixel column (clamped
// to the frame count) — drawWaveform collapses them per column via
// peaks::columnMinMax into a gap-free true min/max envelope.
const std::int64_t wantBins =
static_cast<std::int64_t>((std::max)(1, waveformColumnCount(toKitBox(waveArea)))) *
kWaveformOversample;
const std::size_t bins =
static_cast<std::size_t>(wantBins < frames ? wantBins : frames);
const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins);
drawEnvelope(bmp, waveArea, env);
const SetupMarkers m = pickedMarkers(frames);
+1 -31
View File
@@ -3,8 +3,7 @@
#include "waveform_view.h"
#include <algorithm>
#include <cstdlib> // std::abs (int overload)
#include <cstddef> // std::size_t
#include <cstdlib> // std::abs (int overload)
namespace reasampler::vst {
@@ -66,35 +65,6 @@ 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,19 +68,6 @@ 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
+112 -1
View File
@@ -6,7 +6,10 @@
// ramp envelope monotonic across bins; DC/silence -> min==max; multi-channel
// independence (no fold); short buffer (fewer frames than bins) and non-divisible
// length (remainder bin); binCount==1 whole-buffer envelope; zero frames / zero
// channels / binCount==0 degenerate inputs.
// channels / binCount==0 degenerate inputs. Plus columnMinMax (the display-side
// per-pixel-column collapse, FA3): 1:1 passthrough, upsample fallback, downsample
// merge, steep disjoint-span merge, no-bin-dropped spike sweep, no-column-empty
// coverage, col clamp, degenerate inputs.
#include "../src/peaks.h"
@@ -348,6 +351,106 @@ static void testLastFrameDegenerate() {
CHECK(lastFrameAboveThreshold(buf, 2, 100, 0.1f) == 1);
}
// --- columnMinMax (display-side per-pixel-column collapse, FA3) ----------------
// 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 testColumnOneToOne() {
// 4 bins, 4 columns: each column is a pure passthrough of its one bin.
ChannelEnvelope env = makeEnvelope({-1.0f, -0.5f, 0.0f, 0.5f},
{ 0.5f, 0.0f, 0.5f, 1.0f});
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 testColumnUpsampleFallback() {
// 2 bins, 4 columns: columns 0,1 fall back to enclosing bin 0; 2,3 to bin 1 —
// no column left empty when there are more columns than bins.
ChannelEnvelope env = makeEnvelope({-1.0f, 0.5f}, {0.0f, 1.0f});
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 testColumnDownsampleMerge() {
// 4 bins, 2 columns: each column is the true min/max union of its 2 bins.
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 testColumnSteepDisjointSpans() {
// THE anti-alias case: two adjacent bins holding DISJOINT spans (a steep edge —
// all-positive then all-negative). Merged into one column the result must bridge
// both extremes as one full span {-1.0, 1.0}. The old per-bin overdraw could never
// produce a wrong value here — only the merge path exercises this.
ChannelEnvelope env = makeEnvelope({0.9f, -1.0f}, {1.0f, -0.9f});
const MinMax mm = columnMinMax(env, 1, 0);
CHECK(mm.min == -1.0f && mm.max == 1.0f);
}
static void testColumnNoBinDropped() {
// Downsample coverage: EVERY bin must land in some column (union of columns ==
// union of bins). For several non-integer ratios, plant a lone {-1,+1} spike in
// bin j (all other bins {0,0}) and assert some column reports it — a partition
// that skipped bin j would lose the spike entirely.
const struct { int nbins; int cols; } cases[] = {{7, 3}, {10, 4}, {9, 2}, {6, 10}};
for (const auto& c : cases) {
for (int j = 0; j < c.nbins; ++j) {
ChannelEnvelope env(static_cast<std::size_t>(c.nbins)); // all {0,0}
env[static_cast<std::size_t>(j)] = MinMax{-1.0f, 1.0f};
bool found = false;
for (int col = 0; col < c.cols; ++col) {
const MinMax mm = columnMinMax(env, c.cols, col);
if (mm.min == -1.0f && mm.max == 1.0f) { found = true; break; }
}
CHECK(found);
}
}
}
static void testColumnNoColumnEmpty() {
// Gap-free coverage: 6 bins over 10 columns (non-integer ratio) — every column
// must carry a real bin's values (all bins strictly positive, so a default {0,0}
// would expose a skipped column).
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) {
const MinMax mm = columnMinMax(env, 10, col);
CHECK(mm.min >= 0.1f && mm.max <= 0.7f);
CHECK(mm.min <= mm.max);
}
}
static void testColumnColClamp() {
// col outside [0, columnCount-1] clamps: negative to the first, large to the last.
ChannelEnvelope env = makeEnvelope({-0.5f, 0.5f}, {-0.1f, 0.9f});
CHECK(columnMinMax(env, 2, -5).min == -0.5f);
CHECK(columnMinMax(env, 2, 999).max == 0.9f);
}
static void testColumnDegenerate() {
// Empty envelope, columnCount <= 0 -> {0, 0}.
ChannelEnvelope empty;
const MinMax z = columnMinMax(empty, 4, 0);
CHECK(z.min == 0.0f && z.max == 0.0f);
ChannelEnvelope env = makeEnvelope({0.3f}, {0.7f});
const MinMax z2 = columnMinMax(env, 0, 0);
CHECK(z2.min == 0.0f && z2.max == 0.0f);
const MinMax z3 = columnMinMax(env, -1, 0);
CHECK(z3.min == 0.0f && z3.max == 0.0f);
}
int main() {
testSineEnvelope();
testRampMonotonic();
@@ -364,6 +467,14 @@ int main() {
testLastFrameAllAbove();
testLastFramePerChannelMaxAbs();
testLastFrameDegenerate();
testColumnOneToOne();
testColumnUpsampleFallback();
testColumnDownsampleMerge();
testColumnSteepDisjointSpans();
testColumnNoBinDropped();
testColumnNoColumnEmpty();
testColumnColClamp();
testColumnDegenerate();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
+1 -91
View File
@@ -7,8 +7,7 @@
// 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);
// columnMinMax (per-pixel-column bin merge: 1:1, upsample, downsample, degenerate).
// no-crossing keeps target, target clamp, degenerate buffers).
#include "../src/vst/waveform_view.h"
@@ -17,8 +16,6 @@
using namespace reasampler::vst;
using reasampler::AudioSample;
using reasampler::MinMax;
using reasampler::ChannelEnvelope;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
@@ -195,86 +192,6 @@ 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();
@@ -300,13 +217,6 @@ 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;