diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp index cf2fc52..ef5227b 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, one bin per +// drawn pixel column; drawWaveform's gap-free render comes from peaks::columnMinMax. // * 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 gap-free: request one bin per drawn pixel column; drawWaveform's + // peaks::columnMinMax exact partition makes every column gap-free — overbinning + // produces byte-identical pixels at higher memory/CPU cost. 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/component_geometry.cpp b/src/component_geometry.cpp index 1951b91..65c1d0e 100644 --- a/src/component_geometry.cpp +++ b/src/component_geometry.cpp @@ -100,4 +100,9 @@ int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCou return row; } +int waveformColumnCount(const KitBox& box) { + const int w = box.width - 4; // fixed 2px inset each side (matches drawWaveform) + return w > 0 ? w : 0; +} + } // namespace reasampler diff --git a/src/component_geometry.h b/src/component_geometry.h index 875d5c6..d536932 100644 --- a/src/component_geometry.h +++ b/src/component_geometry.h @@ -126,4 +126,14 @@ ListRowBox computeListRow(const KitBox& list, int index, int rowHeight); // a phantom row. Half-open bounds match computeListRow so the hit maps to the drawn row. int hitTestListRow(int px, int py, const KitBox& list, int rowHeight, int rowCount); +// --- Waveform column count --------------------------------------------------- +// +// The number of pixel columns drawWaveform renders inside `box` (its fixed 2px side +// insets), never negative. Callers pass this count directly as the `binCount` argument to +// peaks::computeEnvelope — one bin per column is the correct resolution, and +// peaks::columnMinMax's exact partition makes the render gap-free at any bins-to-pixels +// ratio. Overbinning does NOT improve render quality (columnMinMax's frame union is +// identical whether bins == columns or bins == k*columns) and wastes memory and CPU. +int waveformColumnCount(const KitBox& box); + } // namespace reasampler diff --git a/src/draw_kit.cpp b/src/draw_kit.cpp index 99ebe94..4c935fa 100644 --- a/src/draw_kit.cpp +++ b/src/draw_kit.cpp @@ -292,6 +292,8 @@ 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 = 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)]; const int bandTop = box.y + ch * bandH; @@ -301,19 +303,22 @@ 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(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. peaks::columnMinMax merges + // all bins that project to column `col` under the exact same partition as + // computeEnvelope used to build the envelope, so every pixel column is covered + // with no gaps regardless of the bins-to-pixels ratio. With one bin per column + // (kWaveformOversample == 1) each span covers the true min/max of exactly the + // frames that fall in that column. Same dB display compression everywhere + // (bank_grid, pure). + for (int col = 0; col < innerW; ++col) { + const MinMax mm = columnMinMax(bins, innerW, col); + const int x = box.x + 2 + col; int yMax = midY - static_cast( - compressAmplitudeForDisplay(bins[static_cast(i)].max) * halfSpan); + compressAmplitudeForDisplay(mm.max) * halfSpan); int yMin = midY - static_cast( - compressAmplitudeForDisplay(bins[static_cast(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); diff --git a/src/draw_kit.h b/src/draw_kit.h index ae4eaa0..ba69dae 100644 --- a/src/draw_kit.h +++ b/src/draw_kit.h @@ -115,12 +115,28 @@ 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. +// waveformColumnCount — declared in component_geometry.h (already included above). Returns +// the drawable column count inside `box` (box.width minus the fixed 2px insets each side). +// Callers pass this value directly as the `binCount` argument to peaks::computeEnvelope; +// overbinning (more bins than columns) costs memory and CPU without changing a rendered +// pixel — peaks::columnMinMax's exact partition already makes the draw gap-free. + +// Multiplier kept at 1 (no oversampling). kWaveformOversample is present only so existing +// call sites `kWaveformOversample * waveformColumnCount(box)` compile unchanged; a value of +// 1 means they request exactly one bin per column, which is correct. The gap-free render +// comes from peaks::columnMinMax's exact partition, NOT from extra bins. +inline constexpr int kWaveformOversample = 1; + +// 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 because columnMinMax partitions bins exactly as computeEnvelope +// does, so every pixel column is always covered). 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, sized to waveformColumnCount(box) bins (clamped to frame count). +// 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 a4b3b8a..231015b 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: one bin per drawn pixel column — the +// gap-free render comes from peaks::columnMinMax's exact partition, not from extra bins. +// 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 @@ -582,8 +584,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; @@ -1017,8 +1023,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 gap-free: one bin per drawn pixel column (kWaveformOversample == 1, so this + // multiplies by 1). The gap-free draw comes from peaks::columnMinMax's exact + // partition — extra bins produce no visible change. Clamped to frame count below. + 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/tests/test_component_geometry.cpp b/tests/test_component_geometry.cpp index f39f319..3d97095 100644 --- a/tests/test_component_geometry.cpp +++ b/tests/test_component_geometry.cpp @@ -173,6 +173,22 @@ static void testListRowLayoutHitAgreement() { } } +// --- waveformColumnCount ----------------------------------------------------- + +static void testWaveformColumnCount() { + // Normal box: width minus the two 2px side insets. + CHECK(waveformColumnCount(KitBox{0, 0, 100, 40}) == 96); + CHECK(waveformColumnCount(KitBox{10, 5, 50, 20}) == 46); + // Minimum: a 5-wide box yields exactly 1 drawable column. + CHECK(waveformColumnCount(KitBox{0, 0, 5, 10}) == 1); + // Too narrow to have any drawable columns: 4 or fewer columns returns 0. + CHECK(waveformColumnCount(KitBox{0, 0, 4, 10}) == 0); + CHECK(waveformColumnCount(KitBox{0, 0, 1, 10}) == 0); + // Degenerate (empty) box returns 0 — graceful suppression. + CHECK(waveformColumnCount(KitBox{}) == 0); + CHECK(waveformColumnCount(KitBox{0, 0, 0, 40}) == 0); +} + int main() { testHitTestBoxHalfOpen(); testButtonBoxInset(); @@ -190,6 +206,7 @@ int main() { testListRowHitTest(); testListRowHitTestBoundedByCount(); testListRowLayoutHitAgreement(); + testWaveformColumnCount(); if (g_fail == 0) std::printf("component_geometry: all tests passed\n"); else std::printf("component_geometry: %d CHECK(s) FAILED\n", g_fail); 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;