From 71a3b26a47c63c4b937c2f31b374b37b10b8af1c Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Wed, 22 Jul 2026 21:02:22 -0400 Subject: [PATCH] feat(bank_panel): docked bank grid with LICE waveform thumbnails (M5 Wave A) Docked SWELL window toggled by a new action, drawing the current bank as per-sample min/max thumbnails (PCM_source + peaks) with an in-memory cache. Pure grid-layout/cache-key math in bank_grid (tested). Renames peaks::Sample -> AudioSample to avoid colliding with bank_model::Sample. --- CMakeLists.txt | 43 +++- src/bank_grid.cpp | 65 ++++++ src/bank_grid.h | 88 ++++++++ src/bank_panel.cpp | 446 +++++++++++++++++++++++++++++++++++++++ src/bank_panel.h | 41 ++++ src/main.cpp | 51 ++++- src/peaks.cpp | 10 +- src/peaks.h | 23 +- src/resource.h | 10 + src/resource.rc | 22 ++ tests/test_bank_grid.cpp | 144 +++++++++++++ tests/test_peaks.cpp | 26 +-- 12 files changed, 936 insertions(+), 33 deletions(-) create mode 100644 src/bank_grid.cpp create mode 100644 src/bank_grid.h create mode 100644 src/bank_panel.cpp create mode 100644 src/bank_panel.h create mode 100644 src/resource.h create mode 100644 src/resource.rc create mode 100644 tests/test_bank_grid.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 5ab632a..828ed09 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -36,6 +36,15 @@ target_include_directories(peaks PUBLIC src) add_library(capture_paths STATIC src/capture_paths.cpp) target_include_directories(capture_paths PUBLIC src) +# --------------------------------------------------------------------------- +# 2c) Pure bank-grid layout — NO REAPER, NO SWELL. Grid tiling math (panel WxH + +# cell size + N -> cell rects, wrapping, partial last row) and the thumbnail +# cache key for the docked bank_panel (M5). Split out so the layout logic is +# unit-tested outside the DAW; the panel shell (SWELL/LICE/PCM) is DAW-verified. +# --------------------------------------------------------------------------- +add_library(bank_grid STATIC src/bank_grid.cpp) +target_include_directories(bank_grid PUBLIC src) + # --------------------------------------------------------------------------- # 3) Standalone tests for the pure modules (run without launching REAPER). # --------------------------------------------------------------------------- @@ -52,21 +61,39 @@ add_executable(capture_paths_tests tests/test_capture_paths.cpp) target_link_libraries(capture_paths_tests PRIVATE capture_paths) add_test(NAME capture_paths_tests COMMAND capture_paths_tests) +add_executable(bank_grid_tests tests/test_bank_grid.cpp) +target_link_libraries(bank_grid_tests PRIVATE bank_grid) +add_test(NAME bank_grid_tests COMMAND bank_grid_tests) + # --------------------------------------------------------------------------- # 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked). # --------------------------------------------------------------------------- +# LICE sources the bank_panel draws with: lice.cpp (LICE_SysBitmap, FillRect, +# Clear, Blit) + lice_line.cpp (Line, DrawRect). lice_line.cpp's bezier helpers +# call LICE_FillCircle from lice_arc.cpp, so that TU is required to link even +# though the panel draws no arcs. LICE routes GDI through native Win32 or, on +# mac/linux, the host SWELL (SWELL_PROVIDED_BY_APP). +set(LICE_SRC + ${WDL_INC}/lice/lice.cpp + ${WDL_INC}/lice/lice_line.cpp + ${WDL_INC}/lice/lice_arc.cpp +) + add_library(reaper_reasampler MODULE src/main.cpp src/capture.cpp src/persist.cpp + src/bank_panel.cpp + ${LICE_SRC} ) -target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths) +target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid) target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC}) set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler") if(WIN32) - # Native Win32. REAPER provides nothing extra to link. - # Dialog resources return with the docked bank_panel (Milestone 5). + # Native Win32. REAPER provides nothing extra to link. The bank_panel dialog + # template (M5) is compiled from src/resource.rc by the platform RC compiler. + target_sources(reaper_reasampler PRIVATE src/resource.rc) elseif(APPLE) # macOS: use REAPER's OWN SWELL at runtime via the modstub. @@ -75,7 +102,10 @@ elseif(APPLE) target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP) target_link_libraries(reaper_reasampler PRIVATE "-framework AppKit") set_target_properties(reaper_reasampler PROPERTIES SUFFIX ".dylib") - # Dialog: run SWELL resgen once (see README) and add the generated source. + # bank_panel dialog (M5): SWELL can't read a Win32 .rc directly. Run resgen + # once to turn src/resource.rc into a C++ source, then add it here: + # php ${WDL_INC}/swell/mac_resgen.php src/resource.rc + # target_sources(reaper_reasampler PRIVATE src/resource.rc_mac_dlg.h) # generated else() # Linux: REAPER's libSwell.so is used at runtime via the generic modstub. @@ -83,5 +113,8 @@ else() target_sources(reaper_reasampler PRIVATE ${SWELL}/swell-modstub-generic.cpp) target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP) set_target_properties(reaper_reasampler PROPERTIES SUFFIX ".so") - # Dialog: run SWELL resgen once (see README) and add the generated source. + # bank_panel dialog (M5): reuse the macOS resgen output (see README / + # CLAUDE.md §SWELL dialog resources), then add the generated source: + # php ${WDL_INC}/swell/mac_resgen.php src/resource.rc + # target_sources(reaper_reasampler PRIVATE src/resource.rc_mac_dlg.h) # generated endif() diff --git a/src/bank_grid.cpp b/src/bank_grid.cpp new file mode 100644 index 0000000..783128b --- /dev/null +++ b/src/bank_grid.cpp @@ -0,0 +1,65 @@ +// bank_grid — pure implementation. See bank_grid.h. NO REAPER / SWELL / vendor. + +#include "bank_grid.h" + +namespace reasampler { + +int columnsForWidth(int panelWidth, const GridSpec& spec) { + // Layout: [gap][cell][gap][cell]...[cell][gap]. n cells occupy + // gap + n*(cellWidth + gap). Solve for the largest n that fits panelWidth, + // clamped to at least 1 so a too-narrow panel still shows a (clipped) column. + const int cell = spec.cellWidth + spec.gap; + if (cell <= 0) return 1; // degenerate spec — one column, avoid divide-by-zero + const int usable = panelWidth - spec.gap; + if (usable < spec.cellWidth) return 1; + const int cols = usable / cell; + return cols < 1 ? 1 : cols; +} + +std::vector computeCellRects(int itemCount, + int panelWidth, + const GridSpec& spec) { + std::vector rects; + if (itemCount <= 0) return rects; + + const int cols = columnsForWidth(panelWidth, spec); + rects.reserve(static_cast(itemCount)); + + for (int i = 0; i < itemCount; ++i) { + const int col = i % cols; + const int row = i / cols; + CellRect r; + r.x = spec.gap + col * (spec.cellWidth + spec.gap); + r.y = spec.gap + row * (spec.cellHeight + spec.gap); + r.width = spec.cellWidth; + r.height = spec.cellHeight; + rects.push_back(r); + } + return rects; +} + +int contentHeight(int itemCount, int panelWidth, const GridSpec& spec) { + if (itemCount <= 0) return 0; + const int cols = columnsForWidth(panelWidth, spec); + // Ceil-divide item count by columns to get the row count (partial last row + // still occupies a full row of height). + const int rows = (itemCount + cols - 1) / cols; + return spec.gap + rows * (spec.cellHeight + spec.gap); +} + +std::string thumbnailKeyString(const ThumbnailKey& key) { + // Length-prefix the sampleId so a delimiter byte inside an id cannot forge a + // collision with a different (id, width, generation) triple. + std::string s; + s.reserve(key.sampleId.size() + 32); + s += std::to_string(key.sampleId.size()); + s += ':'; + s += key.sampleId; + s += '|'; + s += std::to_string(key.width); + s += '|'; + s += std::to_string(key.generation); + return s; +} + +} // namespace reasampler diff --git a/src/bank_grid.h b/src/bank_grid.h new file mode 100644 index 0000000..ddb7612 --- /dev/null +++ b/src/bank_grid.h @@ -0,0 +1,88 @@ +#pragma once +// bank_grid — the REAPER-free layout math and cache-key logic behind the docked +// bank_panel (M5, Wave A). The panel shell (bank_panel.cpp) owns the SWELL window, +// LICE drawing, and PCM reads; ALL of that is REAPER-bound and DAW-verified. What +// is NOT DAW-bound — how N sample cells tile a panel of a given pixel size, and +// the key that identifies a cached thumbnail — lives here so it is unit-tested +// outside the DAW (CLAUDE.md §load-bearing split). +// +// PURE MODULE: NO REAPER types, NO SWELL, NO vendor/ includes. Standard library +// only. Builds and unit-tests without REAPER. + +#include +#include +#include +#include + +namespace reasampler { + +// A single cell's pixel rectangle within the panel, top-left origin (SWELL/LICE +// convention). (x, y) is the top-left corner; width/height are the cell extents. +// These are the draw bounds for one sample's thumbnail; the panel draws its +// waveform envelope inside this rect (minus any internal padding it applies). +struct CellRect { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + + bool operator==(const CellRect& o) const { + return x == o.x && y == o.y && width == o.width && height == o.height; + } +}; + +// Fixed inputs that shape the grid. All in pixels. cellWidth/cellHeight are the +// TARGET cell size; the layout fits as many whole columns as the panel width +// allows (>= 1) and wraps to as many rows as N requires. gap is the pixel spacing +// between adjacent cells (and the outer margin), so cells never touch. +struct GridSpec { + int cellWidth = 120; + int cellHeight = 72; + int gap = 8; +}; + +// Computes the number of columns that fit in a panel of the given pixel width for +// the spec. Always >= 1 (a panel narrower than one cell still shows one column, +// clipped by the window). Pure arithmetic — the panel passes its live client +// width here and to computeCellRects. +int columnsForWidth(int panelWidth, const GridSpec& spec); + +// Tiles `itemCount` cells left-to-right, top-to-bottom into a panel of the given +// pixel width, honoring the spec's cell size and gap. Returns exactly itemCount +// rects in item order (rect i is sample i). A partial last row is left-aligned +// and simply shorter — no centering, no stretching. itemCount == 0 -> empty. +// panelWidth is used only to derive the column count; the returned rects may +// extend below any fixed viewport height (the panel scrolls/clips in Wave B). +std::vector computeCellRects(int itemCount, + int panelWidth, + const GridSpec& spec); + +// The total pixel height the grid occupies for itemCount cells at the given panel +// width and spec (top margin + rows*cellHeight + inter-row gaps + bottom margin). +// 0 when itemCount == 0. The panel uses this to know its full content height +// (scroll extent in Wave B; for Wave A it sizes the empty-vs-populated decision). +int contentHeight(int itemCount, int panelWidth, const GridSpec& spec); + +// Identifies one cached thumbnail. A cached envelope is valid only while the +// sample's identity, the draw width it was computed at, and the bank generation +// it was computed under all match. Width is part of the key because the envelope +// has exactly `width` bins per channel (peaks::computeEnvelope is width-driven); +// a resized panel needs a fresh envelope. Generation lets the panel invalidate +// every entry when the bank changes (capture / project load) without diffing. +struct ThumbnailKey { + std::string sampleId; + int width = 0; + std::uint64_t generation = 0; + + bool operator==(const ThumbnailKey& o) const { + return sampleId == o.sampleId && width == o.width && + generation == o.generation; + } +}; + +// A stable string form of the key, suitable as a map key. Deterministic: the same +// key always yields the same string, distinct keys always differ (the sampleId is +// length-prefixed so an id containing the delimiter cannot collide with another). +std::string thumbnailKeyString(const ThumbnailKey& key); + +} // namespace reasampler diff --git a/src/bank_panel.cpp b/src/bank_panel.cpp new file mode 100644 index 0000000..47846c7 --- /dev/null +++ b/src/bank_panel.cpp @@ -0,0 +1,446 @@ +// bank_panel.cpp — REAPER-facing docked grid (M5, Wave A). See bank_panel.h. +// +// Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h +// WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are +// extern (CLAUDE.md §contract). +// +// What this file owns (all REAPER/SWELL/LICE-bound, hence DAW-verified, not unit +// tested): +// * a SWELL dialog (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via +// DockWindowRemove; toggled open/closed. +// * WM_PAINT: draw the current bank as a grid of waveform thumbnails using LICE, +// or a centered empty-state string when the bank is empty. +// * per-sample PCM read via PCM_source (PCM_Source_CreateFromFile + +// PCM_source::GetSamples) fed to peaks::computeEnvelope at the cell width. +// * an in-memory thumbnail cache keyed by (sample id, draw width, bank +// generation) so paint does not recompute envelopes every frame. +// +// READ-ONLY (load-bearing principle): this panel never inserts into the arrange +// and never mutates the project or the bank. It only reads g_session.bank() and +// reads sample files off disk. +// +// THUMBNAIL-CACHE DECISION (CONTEXT.md §Open questions "recompute vs store peak +// bins alongside the index"): for Wave A we RECOMPUTE into an in-memory cache and +// do NOT persist peak bins in the index. Rationale: the persisted index stays +// lean and format-stable; envelopes are cheap to recompute on demand and must be +// recomputed anyway whenever the panel width (bin count) changes, which a stored +// fixed-resolution bin set could not satisfy. Storing bins is a later optimization +// if profiling shows recompute cost matters (it is bounded: one read + one O(frames) +// pass per sample, only on cache miss). + +#include "bank_panel.h" + +#include +#include +#include +#include +#include + +#include "bank_grid.h" +#include "bank_model.h" +#include "capture_paths.h" +#include "peaks.h" +#include "persist.h" + +// SWELL / LICE. On macOS/Linux SWELL is provided by the host (SWELL_PROVIDED_BY_APP); +// on Windows we use native Win32 (windows.h first, then swell.h no-ops on _WIN32). +// LICE routes its GDI through whichever backend is active. wdltypes.h gives +// WDL_DLGRET (the platform dialog-proc return type). +#ifdef _WIN32 +#include +#endif +#include "wdltypes.h" +#include "swell/swell.h" +#include "lice/lice.h" + +#include "resource.h" + +#define REAPERAPI_MINIMAL +#define REAPERAPI_WANT_DockWindowAddEx +#define REAPERAPI_WANT_DockWindowActivate +#define REAPERAPI_WANT_DockWindowRemove +#define REAPERAPI_WANT_EnumProjects +#define REAPERAPI_WANT_GetMainHwnd +#define REAPERAPI_WANT_PCM_Source_CreateFromFile +#define REAPERAPI_WANT_PCM_Source_Destroy +#include "reaper_plugin_functions.h" + +// main.cpp owns the module instance handle (needed to load the dialog resource). +extern REAPER_PLUGIN_HINSTANCE g_hInst; + +namespace reasampler { + +namespace { + +namespace fs = std::filesystem; + +// --- Layout / palette constants (Wave A: fixed, no user config — YAGNI) ------- + +// Cell size + spacing for the grid. Tuned for a legible thumbnail at a glance; +// revisit when audition/selection UI lands (Wave B) and cells gain chrome. +const GridSpec kGrid{/*cellWidth=*/140, /*cellHeight=*/84, /*gap=*/10}; + +// How many PCM frames to pull per sample for the thumbnail. The envelope is drawn +// at cell width (~140 bins), so a few thousand frames per bin is ample; capping +// the read keeps a long sample's thumbnail cheap without a streaming loop. A +// captured one-shot/loop is short; a full-mix bounce is downsampled visually +// anyway. If a sample is longer than this, the thumbnail shows its head — an +// acceptable Wave-A approximation, flagged for Wave B (whole-file overview). +constexpr int kMaxThumbnailFrames = 1 << 20; // ~1M frames (~22s @ 48k) + +const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255); +const LICE_pixel kColCellBg = LICE_RGBA(44, 44, 48, 255); +const LICE_pixel kColCellBorder = LICE_RGBA(70, 70, 76, 255); +const LICE_pixel kColWaveform = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColMidline = LICE_RGBA(60, 60, 66, 255); +const LICE_pixel kColText = LICE_RGBA(200, 200, 205, 255); + +// --- Panel state -------------------------------------------------------------- + +// A computed thumbnail: the per-channel envelope at a known width. Held in the +// cache so paint reuses it until the sample, width, or bank generation changes. +struct CachedThumbnail { + Envelope envelope; // one ChannelEnvelope per channel, `width` bins each + int width = 0; // bins per channel this envelope was computed at +}; + +struct PanelState { + ReaSamplerSession* session = nullptr; + + HWND hwnd = nullptr; // the docked dialog, null when closed + bool open = false; + + // Bank-change detection: a cheap fingerprint of the bank (count + ids + + // relative paths). When it changes we bump `generation`, which invalidates + // every cache entry (keyed by generation) and forces a repaint. Simpler than + // adding a mutation counter to BankIndex, and correct across same-count + // project-load swaps (the fingerprint includes ids/paths, not just size). + std::string bankFingerprint; + std::uint64_t generation = 0; + + // Thumbnail cache: key string (bank_grid::thumbnailKeyString) -> envelope. + // Entries for stale generations are lazily overwritten on next miss; a bank + // change also clears it wholesale (see refreshFingerprint) to bound memory. + std::unordered_map cache; +}; + +PanelState g_panel; + +// --- Current-project directory (mirrors persist.cpp's derivation) ------------- +// +// The index stores relative paths; resolving a bank file needs the current .rpp +// directory. persist.cpp derives this the same way for load; the panel is its own +// shell so it reads it directly rather than threading state through the session. +// FOLLOW-UP: capture.cpp, persist.cpp, and now bank_panel.cpp each carry this +// two-line derivation — a shared REAPER helper ("current project dir") is a clean +// small refactor once a third consumer exists (now it does). Out of scope this wave. +std::string currentProjectDir() { + std::vector buf(4096, '\0'); + EnumProjects(-1, buf.data(), static_cast(buf.size())); + std::string rpp(buf.data()); + if (rpp.empty()) return {}; // unsaved project: no resolvable bank + return normalizeSlashes(fs::path(rpp).parent_path().string()); +} + +// --- Thumbnail computation ---------------------------------------------------- + +// Reads up to kMaxThumbnailFrames of interleaved PCM from `absPath` and computes a +// per-channel min/max envelope at `width` bins. Returns an empty envelope on any +// failure (missing file, unreadable source, zero-length) — the caller draws an +// empty cell rather than propagating an error. READ-ONLY: opens the file through +// a PCM_source and destroys it; never touches the project. +Envelope computeThumbnail(const std::string& absPath, int width) { + if (width <= 0 || absPath.empty()) return {}; + + PCM_source* src = PCM_Source_CreateFromFile(absPath.c_str()); + if (!src) return {}; + + const int nch = src->GetNumChannels(); + const double srate = src->GetSampleRate(); + const double lengthSec = src->GetLength(); + if (nch <= 0 || srate < 1.0 || lengthSec <= 0.0) { + PCM_Source_Destroy(src); + return {}; + } + + // Frames to read: the whole sample, capped so a long bounce stays cheap. + std::int64_t totalFrames = static_cast(lengthSec * srate); + if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; } + int frames = totalFrames > kMaxThumbnailFrames + ? kMaxThumbnailFrames + : static_cast(totalFrames); + + // One GetSamples call filling a caller-allocated interleaved buffer. block.length + // is the requested frame count; samples_out reports what was actually rendered + // (may be short at end-of-file). We ask at the source's own rate so no resample. + std::vector buf(static_cast(frames) * nch, 0.0); + PCM_source_transfer_t block{}; + block.time_s = 0.0; + block.samplerate = srate; + block.nch = nch; + block.length = frames; + block.samples = buf.data(); + block.samples_out = 0; + src->GetSamples(&block); + + PCM_Source_Destroy(src); + + const int got = block.samples_out; + if (got <= 0) return {}; + + // ReaSample is double in some builds, float in others; peaks consumes float + // (peaks::Sample is a float alias, its native buffer type). Convert at this + // boundary — use `float` explicitly, NOT `reasampler::Sample`, because that + // name also denotes bank_model's metadata struct in this same namespace when + // both headers are visible (they are here in the module). + const std::size_t sampleCount = static_cast(got) * nch; + std::vector pcm(sampleCount); + for (std::size_t i = 0; i < sampleCount; ++i) + pcm[i] = static_cast(buf[i]); + + return computeEnvelope(pcm, static_cast(nch), + static_cast(got), + static_cast(width)); +} + +// Returns the cached envelope for `sample` at `width`, computing+inserting it on a +// miss. Keyed by (id, width, current generation) so a resize or bank change misses +// and recomputes. `projectDir` resolves the sample's relative path to disk. +const Envelope& thumbnailFor(const Sample& sample, int width, + const std::string& projectDir) { + ThumbnailKey key{sample.id, width, g_panel.generation}; + const std::string ks = thumbnailKeyString(key); + + auto it = g_panel.cache.find(ks); + if (it != g_panel.cache.end()) return it->second.envelope; + + const std::string abs = resolveBankFile(projectDir, sample.relativePath); + CachedThumbnail thumb; + thumb.width = width; + thumb.envelope = computeThumbnail(abs, width); + auto ins = g_panel.cache.emplace(ks, std::move(thumb)); + return ins.first->second.envelope; +} + +// --- Drawing ------------------------------------------------------------------ + +// Draws one sample's envelope into `rect` of `bmp`: a cell background, border, a +// zero midline, and the min/max waveform. Multi-channel envelopes are stacked +// vertically (each channel gets an equal horizontal band) so a stereo sample shows +// both channels without folding (precision invariant: no stereo fold). +void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env) { + LICE_FillRect(bmp, rect.x, rect.y, rect.width, rect.height, kColCellBg, 1.0f, 0); + LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, kColCellBorder, 1.0f, 0); + + if (env.empty()) { + // Unreadable / empty sample: cell drawn, no waveform. A single midline + // signals "cell present, no data" without an error dialog. + const int midY = rect.y + rect.height / 2; + LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, + kColMidline, 1.0f, 0, false); + 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; + // half-height in pixels a full-scale (|value|==1) sample reaches, minus a + // 2px inset so the waveform never touches the cell border. + const double halfSpan = (bandH / 2) - 2; + + LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, + kColMidline, 1.0f, 0, false); + + const int nbins = static_cast(bins.size()); + if (nbins <= 0) continue; + + // Map bin i -> a column x within the cell's inner width. The envelope was + // computed at `width` bins == the cell's drawable columns, so bin i maps + // to column i; guard anyway if they differ (e.g. cached at another width). + const int innerW = rect.width - 4; // 2px inset each side + 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(bins[i].max * halfSpan); // max -> up + int yMin = midY - static_cast(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, kColWaveform, 1.0f, 0, false); + } + } +} + +// Draws the empty-state message centered in the client area. +void drawEmptyState(HWND hwnd, LICE_IBitmap* bmp, int w, int h) { + (void)hwnd; + LICE_Clear(bmp, kColBackground); + HDC dc = bmp->getDC(); + if (!dc) return; + const char* msg = "No samples in this project's bank yet. Capture one to see it here."; + RECT rc{0, 0, w, h}; + SetTextColor(dc, RGB(200, 200, 205)); + SetBkMode(dc, TRANSPARENT); + DrawText(dc, msg, -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_WORDBREAK); +} + +// The full paint: build/refresh the LICE backing bitmap at client size, draw the +// grid (or empty state), then blit to the window HDC. +void paintPanel(HWND hwnd, HDC hdc) { + RECT cr{}; + GetClientRect(hwnd, &cr); + const int w = cr.right - cr.left; + const int h = cr.bottom - cr.top; + if (w <= 0 || h <= 0) return; + + // A per-paint sysbitmap. Cheap to construct; sized to the client. (Wave A + // keeps it local; if repaint cost ever matters, cache it across paints.) + LICE_SysBitmap bmp(w, h); + + const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr; + if (!bank || bank->empty()) { + drawEmptyState(hwnd, &bmp, w, h); + } else { + LICE_Clear(&bmp, kColBackground); + const std::string projectDir = currentProjectDir(); + const std::vector& samples = bank->all(); + const std::vector rects = + computeCellRects(static_cast(samples.size()), w, kGrid); + // Draw each cell's thumbnail. Inner drawable width == cell width - inset; + // compute the envelope at the cell's inner column count so bins map 1:1. + const int binWidth = kGrid.cellWidth - 4; + for (std::size_t i = 0; i < rects.size(); ++i) { + const CellRect& rect = rects[i]; + // Skip cells entirely below the viewport (Wave A has no scroll; this + // just avoids computing thumbnails that cannot be seen). + if (rect.y >= h) continue; + const Envelope& env = thumbnailFor(samples[i], binWidth, projectDir); + drawThumbnail(&bmp, rect, env); + } + } + + BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY); +} + +// --- Bank-change detection ---------------------------------------------------- + +// A cheap fingerprint of the bank: count + each sample's id and relative path. +// Ids are unique and stable; including relative paths catches an in-place file +// swap. Cheaper than hashing PCM, sufficient to know "the grid must redraw". +std::string bankFingerprint(const BankIndex& bank) { + std::string fp = std::to_string(bank.size()); + for (const Sample& s : bank.all()) { + fp += '\x1f'; + fp += s.id; + fp += '\x1f'; + fp += s.relativePath; + } + return fp; +} + +// Recomputes the fingerprint; on change, bumps the generation and clears the +// cache (bounding memory and invalidating every stale-generation entry). Returns +// true if the bank changed since last check. +bool refreshFingerprint() { + if (!g_panel.session) return false; + std::string fp = bankFingerprint(g_panel.session->bank()); + if (fp == g_panel.bankFingerprint) return false; + g_panel.bankFingerprint = std::move(fp); + ++g_panel.generation; + g_panel.cache.clear(); + return true; +} + +// --- Dialog proc + docking ---------------------------------------------------- + +WDL_DLGRET dlgProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { + switch (msg) { + case WM_PAINT: { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hwnd, &ps); + paintPanel(hwnd, hdc); + EndPaint(hwnd, &ps); + return 0; + } + case WM_DESTROY: + // REAPER closed the dock (user X'd it). Reflect closed state so the + // toggle re-opens rather than trying to reuse a dead HWND. + g_panel.hwnd = nullptr; + g_panel.open = false; + return 0; + default: + break; + } + return 0; +} + +void openPanel() { + if (g_panel.open && g_panel.hwnd) { + DockWindowActivate(g_panel.hwnd); + return; + } + // Create the dialog as a child (WS_CHILD in the template); REAPER's docker + // reparents it. lParam is unused (state lives in g_panel). + g_panel.hwnd = CreateDialogParam(g_hInst, MAKEINTRESOURCE(IDD_BANK_PANEL), + GetMainHwnd(), dlgProc, 0); + if (!g_panel.hwnd) return; + + // Dock it. identstr is a stable per-window key REAPER uses to remember the + // dock position/state across sessions; FOREVER-STABLE like the action ids. + // allowShow=true asks REAPER to show the dock if hidden. + DockWindowAddEx(g_panel.hwnd, "ReaSampler Bank", "reasampler_bank_panel", true); + DockWindowActivate(g_panel.hwnd); + g_panel.open = true; + + // Prime the fingerprint so the first timer tick doesn't count the initial + // bank as a "change" (it's already drawn on open). + refreshFingerprint(); +} + +void closePanel() { + if (g_panel.hwnd) { + DockWindowRemove(g_panel.hwnd); + DestroyWindow(g_panel.hwnd); + g_panel.hwnd = nullptr; + } + g_panel.open = false; +} + +} // namespace + +// --- Public API --------------------------------------------------------------- + +void bankPanelInit(ReaSamplerSession* session) { + g_panel.session = session; +} + +void bankPanelToggle() { + if (g_panel.open) + closePanel(); + else + openPanel(); +} + +bool bankPanelIsOpen() { + return g_panel.open; +} + +void bankPanelRefresh() { + if (!g_panel.open || !g_panel.hwnd) return; + // Repaint only when the bank actually changed (generation bump). Cheap tick + // otherwise — just a fingerprint string compare. + if (refreshFingerprint()) + InvalidateRect(g_panel.hwnd, nullptr, FALSE); +} + +void bankPanelShutdown() { + closePanel(); + g_panel.cache.clear(); + g_panel.session = nullptr; +} + +} // namespace reasampler diff --git a/src/bank_panel.h b/src/bank_panel.h new file mode 100644 index 0000000..ed69ce9 --- /dev/null +++ b/src/bank_panel.h @@ -0,0 +1,41 @@ +#pragma once +// bank_panel — the docked grid window (M5, Wave A). REAPER-facing shell: it owns +// a SWELL dialog docked via DockWindowAddEx, and paints the current project's +// bank as a grid of LICE-drawn waveform thumbnails. Read-only this wave: it NEVER +// inserts into the arrange or mutates the project/bank (CONTEXT.md §load-bearing +// principle). Audition / multi-select / keyboard nav are Wave B. +// +// The header is REAPER-free as practical: main.cpp drives the panel through these +// free functions, passing the live session so the panel reads the current bank. +// All SWELL / LICE / PCM_source use is confined to bank_panel.cpp. The pure +// layout math and cache keys live in bank_grid (unit-tested outside the DAW). + +namespace reasampler { + +class ReaSamplerSession; + +// Wires the panel into main.cpp's lifecycle. Called once after the API pointers +// are loaded, BEFORE the toggle action is registered. `session` must outlive the +// panel (it is the extension-lifetime g_session). Stores the session pointer the +// panel reads on every repaint; does not create the window yet. +void bankPanelInit(ReaSamplerSession* session); + +// Toggles the docked window: creates+docks it if hidden, hides+undocks it if +// shown. Bound to the "toggle bank panel" action. Safe to call before the first +// timer tick. +void bankPanelToggle(); + +// Whether the panel window is currently open/visible. Feeds the action's +// checked-state (toggleaction) so REAPER shows a tick next to the menu entry. +bool bankPanelIsOpen(); + +// Requests a repaint if the bank changed since the last paint (generation bump). +// Cheap when nothing changed. Driven by the timer so a capture / project load is +// reflected without the panel diffing the bank itself. +void bankPanelRefresh(); + +// Tears the panel down on extension unload: destroys the window and releases any +// cached thumbnails / PCM handles. Mirror of bankPanelInit; safe if never opened. +void bankPanelShutdown(); + +} // namespace reasampler diff --git a/src/main.cpp b/src/main.cpp index 848e77f..dbf2dcf 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -21,6 +21,7 @@ #include #include "bank_model.h" +#include "bank_panel.h" #include "capture.h" #include "persist.h" @@ -44,6 +45,11 @@ reaper_plugin_info_t* g_rec = nullptr; // REAPER's dispatch struct // (user keybindings key off it) — see the prefix note above. static int g_cmdCaptureMasterSpike = 0; +// Command id for "ReaSampler: toggle bank panel" (M5). FOREVER-STABLE string. +// The docked grid window is display-only this wave (Wave A) — the action just +// shows/hides it; it never captures, inserts, or mutates the bank. +static int g_cmdToggleBankPanel = 0; + // The persistence session (M4): owns the in-memory BankIndex and bridges it to // project ext state. A timer tick drives g_session.poll() to detect project // load / Save-As; capture adds Samples to g_session.bank(); after a capture we @@ -57,6 +63,10 @@ static reasampler::ReaSamplerSession g_session; static void OnTimer() { g_session.poll(); + // Reflect a live bank change (capture / project load) in the docked grid. + // Cheap when the bank is unchanged (a fingerprint compare); repaints only on + // an actual change. No-op when the panel is closed. + reasampler::bankPanelRefresh(); } // Runs the M3 spike: render the time-selection master mix, add the Sample, log. @@ -107,11 +117,22 @@ static bool OnHookCommand(int command, int /*flag*/) { if (command == 0) return false; if (command == g_cmdCaptureMasterSpike) { RunCaptureMasterSpike(); return true; } + if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; } return false; } +// REAPER polls this to render each of OUR actions' checked state in menus/toolbars. +// Return 1 (on) / 0 (off) for ids we own, -1 for everything else (per the contract). +static int OnToggleAction(int command) +{ + if (command == g_cmdToggleBankPanel) + return reasampler::bankPanelIsOpen() ? 1 : 0; + return -1; // not ours / non-toggling +} + // gaccel storage must outlive registration — REAPER holds the pointer. static gaccel_register_t g_accelCaptureMaster{}; +static gaccel_register_t g_accelToggleBankPanel{}; extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec) @@ -123,11 +144,18 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( if (g_rec) { g_rec->Register("-timer", (void*)&OnTimer); + g_rec->Register("-toggleaction", (void*)&OnToggleAction); g_rec->Register("-hookcommand", (void*)&OnHookCommand); + g_rec->Register("-gaccel", (void*)&g_accelToggleBankPanel); + g_rec->Register("-command_id", + (void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL")); g_rec->Register("-gaccel", (void*)&g_accelCaptureMaster); g_rec->Register("-command_id", (void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_SPIKE")); } + // Destroy the docked window and release cached thumbnails before we drop + // the API pointers (DockWindowRemove/DestroyWindow need them live). + reasampler::bankPanelShutdown(); g_rec = nullptr; return 0; } @@ -153,9 +181,30 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT( g_accelCaptureMaster.accel.cmd = g_cmdCaptureMasterSpike; g_accelCaptureMaster.desc = "ReaSampler: capture master mix (spike)"; rec->Register("gaccel", (void*)&g_accelCaptureMaster); - rec->Register("hookcommand", (void*)&OnHookCommand); } + // Point the bank panel at the live session BEFORE registering its action, so + // a toggle firing immediately has a session to read (M5). Does not open the + // window — only stores the session pointer. + reasampler::bankPanelInit(&g_session); + + // Register the M5 "toggle bank panel" action (command_id -> gaccel -> + // hookcommand + toggleaction for the checked state). + g_cmdToggleBankPanel = rec->Register( + "command_id", + (void*)(REASAMPLER_ACTION_PREFIX "TOGGLE_BANK_PANEL")); + if (g_cmdToggleBankPanel) + { + g_accelToggleBankPanel.accel.cmd = g_cmdToggleBankPanel; + g_accelToggleBankPanel.desc = "ReaSampler: toggle bank panel"; + rec->Register("gaccel", (void*)&g_accelToggleBankPanel); + rec->Register("toggleaction", (void*)&OnToggleAction); + } + + // One hookcommand routes every ReaSampler action (spike + toggle). Registered + // once, after both command ids are minted. + rec->Register("hookcommand", (void*)&OnHookCommand); + // Drive project-load / Save-As detection (M4 persist). The timer polls the // active project each tick; on a project load it reloads the bank from ext // state, on a Save-As it relocates the bank folder under the new .rpp. diff --git a/src/peaks.cpp b/src/peaks.cpp index a7e31c2..d2136fd 100644 --- a/src/peaks.cpp +++ b/src/peaks.cpp @@ -14,7 +14,7 @@ namespace reasampler { -Envelope computeEnvelope(const std::vector& interleaved, +Envelope computeEnvelope(const std::vector& interleaved, std::size_t channelCount, std::size_t frameCount, std::size_t binCount) { @@ -48,11 +48,11 @@ Envelope computeEnvelope(const std::vector& interleaved, continue; // empty span (binCount > frames) -> keep {0,0} } - const Sample first = interleaved[begin * channelCount + ch]; - Sample lo = first; - Sample hi = first; + const AudioSample first = interleaved[begin * channelCount + ch]; + AudioSample lo = first; + AudioSample hi = first; for (std::size_t f = begin + 1; f < end; ++f) { - const Sample s = interleaved[f * channelCount + ch]; + const AudioSample s = interleaved[f * channelCount + ch]; lo = std::min(lo, s); hi = std::max(hi, s); } diff --git a/src/peaks.h b/src/peaks.h index 6519c60..f967c7d 100644 --- a/src/peaks.h +++ b/src/peaks.h @@ -13,18 +13,23 @@ namespace reasampler { -// Canonical in-memory sample type. `float` is REAPER's native audio buffer format -// (its render/PCM_source callbacks hand back interleaved 32-bit float), so peaks -// consumes that directly with no lossy conversion. If a capture ever lands as a -// different depth, the caller converts to float at the boundary — the thumbnail -// core stays single-typed. -using Sample = float; +// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer +// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so +// peaks consumes that directly with no lossy conversion. If a capture ever lands +// as a different depth, the caller converts to float at the boundary — the +// thumbnail core stays single-typed. +// +// NAMED AudioSample, not `Sample`: `reasampler::Sample` is already bank_model's +// metadata struct. A `using Sample = float` here would collide at namespace scope +// wherever both headers are visible (the bank_panel module includes both). The +// audio-domain name also reads more precisely — this is one PCM sample value. +using AudioSample = float; // One bin of a channel's envelope: the extremes of every sample that fell in it. // min <= max always. For an empty bin (more bins than frames), both are 0. struct MinMax { - Sample min = 0.0f; - Sample max = 0.0f; + AudioSample min = 0.0f; + AudioSample max = 0.0f; bool operator==(const MinMax& o) const { return min == o.min && max == o.max; } }; @@ -56,7 +61,7 @@ using Envelope = std::vector; // binCount == 0 -> per channel: an empty bin vector. // channelCount == 0 -> an empty envelope (no channels). // frameCount == 0 -> per channel: binCount bins, all {0, 0}. -Envelope computeEnvelope(const std::vector& interleaved, +Envelope computeEnvelope(const std::vector& interleaved, std::size_t channelCount, std::size_t frameCount, std::size_t binCount); diff --git a/src/resource.h b/src/resource.h new file mode 100644 index 0000000..0cad811 --- /dev/null +++ b/src/resource.h @@ -0,0 +1,10 @@ +#pragma once +// resource.h — dialog/control ids for ReaSampler's SWELL dialogs. +// +// Shared by resource.rc (Windows resource compiler) and, on macOS/Linux, by the +// SWELL resgen-generated source (see CLAUDE.md §SWELL dialog resources). Keep the +// numeric ids stable and unique across the extension. + +// The docked bank panel (M5). A bare owner-drawn child dialog: it carries no +// controls — bank_panel.cpp paints the whole client area with LICE. +#define IDD_BANK_PANEL 1000 diff --git a/src/resource.rc b/src/resource.rc new file mode 100644 index 0000000..6749b22 --- /dev/null +++ b/src/resource.rc @@ -0,0 +1,22 @@ +// resource.rc — SWELL/Win32 dialog templates for ReaSampler. +// +// Windows: compiled by the platform resource compiler (MSVC rc / windres) and +// linked into reaper_reasampler. +// macOS/Linux: pre-processed once by SWELL's resgen into a C++ source that is +// added to the module target (see CLAUDE.md §SWELL dialog resources / README). +// +// IDD_BANK_PANEL is a bare child dialog with no controls: the bank_panel shell +// owns every pixel and draws the sample grid with LICE in WM_PAINT. It is created +// as a child (WS_CHILD) so REAPER's docker can reparent it into a dock. + +#include "resource.h" + +#ifdef _WIN32 +#include +#endif + +IDD_BANK_PANEL DIALOG DISCARDABLE 0, 0, 400, 300 +STYLE WS_CHILD +FONT 8, "MS Shell Dlg" +BEGIN +END diff --git a/tests/test_bank_grid.cpp b/tests/test_bank_grid.cpp new file mode 100644 index 0000000..1790a08 --- /dev/null +++ b/tests/test_bank_grid.cpp @@ -0,0 +1,144 @@ +// Standalone tests for reasampler::bank_grid — no REAPER, no test framework. +// Same fast loop as the sibling pure tests: assert the grid-layout math and the +// thumbnail cache-key stringification directly. +// +// Covers (M5 Wave A brief §Test cases): column count for a given panel width; +// cell rects for a full grid (row/column wrapping); the partial-last-row case; +// itemCount == 0; a single item; a panel too narrow for even one cell (clamp to +// one column); content-height for exact and partial rows; cache-key stability, +// width/generation/id sensitivity, and length-prefix collision resistance. + +#include "../src/bank_grid.h" + +#include +#include +#include + +using namespace reasampler; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// A spec with round numbers so expected rects are trivial to hand-compute: +// cell 100x50, gap 10. Column pitch = 110, row pitch = 60, margin = 10. +static GridSpec spec() { + GridSpec s; + s.cellWidth = 100; + s.cellHeight = 50; + s.gap = 10; + return s; +} + +// gap(10) + n*(100+10): 1 col needs 120, 2 need 230, 3 need 340. +static void testColumnsForWidth() { + const GridSpec s = spec(); + CHECK(columnsForWidth(120, s) == 1); // exactly one cell + margins + CHECK(columnsForWidth(229, s) == 1); // one pixel short of two columns + CHECK(columnsForWidth(230, s) == 2); // exactly two + CHECK(columnsForWidth(345, s) == 3); // three plus slack + CHECK(columnsForWidth(1000, s) == 9); // many +} + +// A panel narrower than a single cell still yields one column (clipped by the +// window, never zero — that would drop every sample). +static void testTooNarrowClampsToOneColumn() { + const GridSpec s = spec(); + CHECK(columnsForWidth(50, s) == 1); + CHECK(columnsForWidth(0, s) == 1); + CHECK(columnsForWidth(-20, s) == 1); +} + +// Zero items -> no rects (empty state is the panel's concern, not the layout's). +static void testZeroItems() { + const GridSpec s = spec(); + auto rects = computeCellRects(0, 500, s); + CHECK(rects.empty()); + CHECK(contentHeight(0, 500, s) == 0); +} + +// One item sits at the top-left margin. +static void testSingleItem() { + const GridSpec s = spec(); + auto rects = computeCellRects(1, 500, s); + CHECK(rects.size() == 1); + CHECK(rects[0] == (CellRect{10, 10, 100, 50})); +} + +// A full 2x2: width forces exactly two columns, four items fill two rows. +static void testFullGridWrapping() { + const GridSpec s = spec(); + // Width 230 -> exactly 2 columns. + auto rects = computeCellRects(4, 230, s); + CHECK(rects.size() == 4); + // Row 0: x = 10, 120 ; y = 10. + CHECK(rects[0] == (CellRect{10, 10, 100, 50})); + CHECK(rects[1] == (CellRect{120, 10, 100, 50})); + // Row 1: y = 70 (10 + 60). + CHECK(rects[2] == (CellRect{10, 70, 100, 50})); + CHECK(rects[3] == (CellRect{120, 70, 100, 50})); +} + +// Partial last row: 5 items in a 2-column grid -> rows of 2,2,1. The lone last +// cell is left-aligned in its row (no centering), same x as column 0. +static void testPartialLastRow() { + const GridSpec s = spec(); + auto rects = computeCellRects(5, 230, s); // 2 columns + CHECK(rects.size() == 5); + CHECK(rects[4] == (CellRect{10, 130, 100, 50})); // row 2, col 0: y = 10 + 2*60 + // content height spans 3 rows: 10 + 3*(50+10) = 190. + CHECK(contentHeight(5, 230, s) == 190); +} + +// Content height for an exact-fill grid: 4 items / 2 cols = 2 rows. +static void testContentHeightExactRows() { + const GridSpec s = spec(); + CHECK(contentHeight(4, 230, s) == 130); // 10 + 2*60 + CHECK(contentHeight(2, 230, s) == 70); // 10 + 1*60 +} + +// The cache key is stable and sensitive to every field. +static void testCacheKeyStabilityAndSensitivity() { + ThumbnailKey a{"sample-1", 120, 7}; + ThumbnailKey aSame{"sample-1", 120, 7}; + CHECK(thumbnailKeyString(a) == thumbnailKeyString(aSame)); // deterministic + + ThumbnailKey diffWidth{"sample-1", 121, 7}; + ThumbnailKey diffGen{"sample-1", 120, 8}; + ThumbnailKey diffId{"sample-2", 120, 7}; + CHECK(thumbnailKeyString(a) != thumbnailKeyString(diffWidth)); + CHECK(thumbnailKeyString(a) != thumbnailKeyString(diffGen)); + CHECK(thumbnailKeyString(a) != thumbnailKeyString(diffId)); +} + +// A sampleId containing the delimiter byte must not forge a collision with a +// different key. Without the length prefix, id "x|9" with width 0 and id "x" with +// width 9 would both tail-concatenate through the '|' delimiter and could match; +// the length prefix on the id disambiguates them. +static void testCacheKeyDelimiterCollisionResistance() { + // The canonical would-be collision: moving a "|9" fragment from the id into + // the width field. Length-prefixing the id makes the two forms distinct. + ThumbnailKey a{"x|9", 0, 0}; + ThumbnailKey b{"x", 9, 0}; + CHECK(thumbnailKeyString(a) != thumbnailKeyString(b)); + + // A second pair in the same spirit, delimiters in both id and adjacent fields. + ThumbnailKey k1{"1|2", 3, 0}; + ThumbnailKey k2{"1", 23, 0}; + CHECK(thumbnailKeyString(k1) != thumbnailKeyString(k2)); +} + +int main() { + testColumnsForWidth(); + testTooNarrowClampsToOneColumn(); + testZeroItems(); + testSingleItem(); + testFullGridWrapping(); + testPartialLastRow(); + testContentHeightExactRows(); + testCacheKeyStabilityAndSensitivity(); + testCacheKeyDelimiterCollisionResistance(); + + if (g_fail == 0) std::printf("All tests passed.\n"); + return g_fail ? 1 : 0; +} diff --git a/tests/test_peaks.cpp b/tests/test_peaks.cpp index ac81d2d..6567eed 100644 --- a/tests/test_peaks.cpp +++ b/tests/test_peaks.cpp @@ -27,8 +27,8 @@ constexpr double kPi = 3.14159265358979323846; // A full-scale sine over `frames` frames, mono, `cycles` complete periods so every // bin sees both a near-peak and a near-trough. -static std::vector monoSine(std::size_t frames, double cycles, float amp) { - std::vector buf(frames); +static std::vector monoSine(std::size_t frames, double cycles, float amp) { + std::vector buf(frames); for (std::size_t i = 0; i < frames; ++i) { const double phase = 2.0 * kPi * cycles * (double)i / (double)frames; buf[i] = amp * (float)std::sin(phase); @@ -60,7 +60,7 @@ static void testSineEnvelope() { static void testRampMonotonic() { const std::size_t frames = 10000; const std::size_t bins = 50; - std::vector buf(frames); + std::vector buf(frames); for (std::size_t i = 0; i < frames; ++i) { buf[i] = (float)i / (float)(frames - 1); // 0.0 .. 1.0 } @@ -97,14 +97,14 @@ static void testDcAndSilence() { const std::size_t frames = 1000; const std::size_t bins = 16; - std::vector silence(frames, 0.0f); + std::vector silence(frames, 0.0f); Envelope se = computeEnvelope(silence, 1, frames, bins); for (const MinMax& mm : se[0]) { CHECK(mm.min == 0.0f); CHECK(mm.max == 0.0f); } - std::vector dc(frames, 0.5f); + std::vector dc(frames, 0.5f); Envelope de = computeEnvelope(dc, 1, frames, bins); for (const MinMax& mm : de[0]) { CHECK(mm.min == 0.5f); @@ -121,7 +121,7 @@ static void testMultiChannelNoFold() { // Interleave: [ch0, ch1] per frame; ch0 = sine, ch1 = 0. auto sine = monoSine(frames, /*cycles=*/64.0, amp); - std::vector buf(frames * 2); + std::vector buf(frames * 2); for (std::size_t i = 0; i < frames; ++i) { buf[i * 2 + 0] = sine[i]; buf[i * 2 + 1] = 0.0f; @@ -147,7 +147,7 @@ static void testMultiChannelNoFold() { static void testChannelsNotAveraged() { const std::size_t frames = 100; const std::size_t bins = 4; - std::vector buf(frames * 2); + std::vector buf(frames * 2); for (std::size_t i = 0; i < frames; ++i) { buf[i * 2 + 0] = 1.0f; buf[i * 2 + 1] = -1.0f; @@ -164,7 +164,7 @@ static void testChannelsNotAveraged() { static void testShortBuffer() { const std::size_t frames = 3; const std::size_t bins = 8; - std::vector buf = {0.25f, -0.5f, 0.75f}; + std::vector buf = {0.25f, -0.5f, 0.75f}; Envelope env = computeEnvelope(buf, 1, frames, bins); CHECK(env[0].size() == bins); @@ -190,7 +190,7 @@ static void testShortBuffer() { static void testNonDivisibleRemainderBin() { const std::size_t frames = 10; const std::size_t bins = 3; - std::vector buf(frames); + std::vector buf(frames); for (std::size_t i = 0; i < frames; ++i) buf[i] = (float)i; // 0..9 Envelope env = computeEnvelope(buf, 1, frames, bins); @@ -205,7 +205,7 @@ static void testNonDivisibleRemainderBin() { // binCount == 1: the whole buffer collapses to a single min/max. static void testSingleBinWholeBuffer() { - std::vector buf = {-0.3f, 0.8f, -0.9f, 0.1f, 0.4f}; + std::vector buf = {-0.3f, 0.8f, -0.9f, 0.1f, 0.4f}; Envelope env = computeEnvelope(buf, 1, buf.size(), 1); CHECK(env[0].size() == 1); CHECK(env[0][0].min == -0.9f); @@ -214,7 +214,7 @@ static void testSingleBinWholeBuffer() { // Degenerate inputs: defined behavior, no UB, no throw. static void testDegenerateInputs() { - std::vector buf = {0.1f, 0.2f, 0.3f, 0.4f}; + std::vector buf = {0.1f, 0.2f, 0.3f, 0.4f}; // Zero frames -> binCount bins, all {0,0}. Envelope zf = computeEnvelope(buf, 1, 0, 4); @@ -238,7 +238,7 @@ static void testDegenerateInputs() { CHECK(over[0][1].min == 0.3f && over[0][1].max == 0.4f); // Empty buffer, non-zero request -> all-zero bins, no crash. - std::vector empty; + std::vector empty; Envelope eb = computeEnvelope(empty, 2, 10, 3); CHECK(eb.size() == 2); for (const auto& chenv : eb) { @@ -257,7 +257,7 @@ static void testLargeBinCountOverflowGuard() { // same loop iteration path that would UB for pathological binCount near SIZE_MAX. const std::size_t frames = 4; const std::size_t binCount = 9; - std::vector buf = {0.1f, 0.2f, 0.3f, 0.4f}; + std::vector buf = {0.1f, 0.2f, 0.3f, 0.4f}; Envelope env = computeEnvelope(buf, 1, frames, binCount); CHECK(env.size() == 1);