From b3c9fad9baba2bbd96792b3919d8ba4dc7a88e26 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Mon, 27 Jul 2026 19:09:50 -0400 Subject: [PATCH] =?UTF-8?q?fix(waveform):=204x-oversampled=20min/max=20env?= =?UTF-8?q?elope,=20columnMinMax=20homed=20in=20peaks,=20panel=20routed=20?= =?UTF-8?q?through=20shared=20drawWaveform=20=E2=80=94=20one=20gap-free=20?= =?UTF-8?q?algorithm=20on=20all=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 4 +- src/bank_panel.cpp | 64 ++++++------------- src/draw_kit.cpp | 22 ++++--- src/draw_kit.h | 29 +++++++-- src/peaks.cpp | 32 ++++++++++ src/peaks.h | 15 +++++ src/vst/reasampler_editor.cpp | 27 +++++--- src/vst/waveform_view.cpp | 32 +--------- src/vst/waveform_view.h | 13 ---- tests/test_peaks.cpp | 113 +++++++++++++++++++++++++++++++++- tests/test_waveform_view.cpp | 92 +-------------------------- 11 files changed, 240 insertions(+), 203 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 73182c7..2394361 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index cf2fc52..e43ed14 100644 --- a/src/bank_panel.cpp +++ b/src/bank_panel.cpp @@ -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 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(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(nch), static_cast(got), - static_cast(width)); + static_cast(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(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(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(compressAmplitudeForDisplay(bins[i].max) * halfSpan); // max -> up - int yMin = midY - static_cast(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}; diff --git a/src/draw_kit.cpp b/src/draw_kit.cpp index beaf132..76f5d3b 100644 --- a/src/draw_kit.cpp +++ b/src/draw_kit.cpp @@ -8,8 +8,7 @@ #include -#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(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(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( compressAmplitudeForDisplay(mm.max) * halfSpan); diff --git a/src/draw_kit.h b/src/draw_kit.h index ae4eaa0..b32cb63 100644 --- a/src/draw_kit.h +++ b/src/draw_kit.h @@ -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 diff --git a/src/peaks.cpp b/src/peaks.cpp index af65994..b2a695a 100644 --- a/src/peaks.cpp +++ b/src/peaks.cpp @@ -3,6 +3,7 @@ #include #include #include +#include // peaks implementation. // @@ -64,6 +65,37 @@ Envelope computeEnvelope(const std::vector& interleaved, return envelope; } +MinMax columnMinMax(const ChannelEnvelope& bins, int columnCount, int col) { + const int nbins = static_cast(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(col) * nbins) / columnCount; + const std::int64_t end64 = + (static_cast(col) + 1) * nbins / columnCount; + // col <= columnCount-1 guarantees begin64 <= (columnCount-1)*nbins/columnCount < nbins. + const int colBinBegin = static_cast(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(end64) : colBinBegin + 1; + const int clampedEnd = (scanEnd <= nbins) ? scanEnd : nbins; + + MinMax result = bins[static_cast(colBinBegin)]; + for (int b = colBinBegin + 1; b < clampedEnd; ++b) { + const MinMax& mm = bins[static_cast(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& interleaved, std::size_t channelCount, std::size_t frameCount, diff --git a/src/peaks.h b/src/peaks.h index dceeee5..dc1365a 100644 --- a/src/peaks.h +++ b/src/peaks.h @@ -66,6 +66,21 @@ Envelope computeEnvelope(const std::vector& 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 diff --git a/src/vst/reasampler_editor.cpp b/src/vst/reasampler_editor.cpp index e068f1f..a0fb7d6 100644 --- a/src/vst/reasampler_editor.cpp +++ b/src/vst/reasampler_editor.cpp @@ -121,10 +121,12 @@ std::string sampleLabel(const std::vector& 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& mono = monoPcmFor(sampleId); Envelope env; if (!mono.empty()) { - env = computeEnvelope(mono, 1, mono.size(), - static_cast((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::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(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::max)(1, waveformColumnCount(toKitBox(waveArea)))) * + kWaveformOversample; + const std::size_t bins = + static_cast(wantBins < frames ? wantBins : frames); + const Envelope env = computeEnvelope(pcm, 1, pcm.size(), bins); drawEnvelope(bmp, waveArea, env); const SetupMarkers m = pickedMarkers(frames); diff --git a/src/vst/waveform_view.cpp b/src/vst/waveform_view.cpp index 52f71b1..2b6d70c 100644 --- a/src/vst/waveform_view.cpp +++ b/src/vst/waveform_view.cpp @@ -3,8 +3,7 @@ #include "waveform_view.h" #include -#include // std::abs (int overload) -#include // std::size_t +#include // 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(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(colBinBegin)]; - for (int b = colBinBegin + 1; b < clampedEnd; ++b) { - const MinMax& mm = bins[static_cast(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); diff --git a/src/vst/waveform_view.h b/src/vst/waveform_view.h index d266d96..4831cbf 100644 --- a/src/vst/waveform_view.h +++ b/src/vst/waveform_view.h @@ -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 diff --git a/tests/test_peaks.cpp b/tests/test_peaks.cpp index d3b10ab..713f70b 100644 --- a/tests/test_peaks.cpp +++ b/tests/test_peaks.cpp @@ -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& mins, + const std::vector& 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(c.nbins)); // all {0,0} + env[static_cast(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; diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp index 74e3042..2969cd9 100644 --- a/tests/test_waveform_view.cpp +++ b/tests/test_waveform_view.cpp @@ -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& mins, - const std::vector& 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;