diff --git a/CMakeLists.txt b/CMakeLists.txt index 2394361..73182c7 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) -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 diff --git a/src/draw_kit.cpp b/src/draw_kit.cpp index 99ebe94..beaf132 100644 --- a/src/draw_kit.cpp +++ b/src/draw_kit.cpp @@ -8,7 +8,8 @@ #include -#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(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(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(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( - 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/vst/waveform_view.cpp b/src/vst/waveform_view.cpp index 2b6d70c..52f71b1 100644 --- a/src/vst/waveform_view.cpp +++ b/src/vst/waveform_view.cpp @@ -3,7 +3,8 @@ #include "waveform_view.h" #include -#include // std::abs (int overload) +#include // std::abs (int overload) +#include // 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(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 4831cbf..d266d96 100644 --- a/src/vst/waveform_view.h +++ b/src/vst/waveform_view.h @@ -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 diff --git a/tests/test_waveform_view.cpp b/tests/test_waveform_view.cpp index 2969cd9..74e3042 100644 --- a/tests/test_waveform_view.cpp +++ b/tests/test_waveform_view.cpp @@ -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& 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(); @@ -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;