Merge dev (M5 bank_panel) into view-shell branch
# Conflicts: # CMakeLists.txt
This commit is contained in:
+39
-6
@@ -37,7 +37,16 @@ add_library(capture_paths STATIC src/capture_paths.cpp)
|
|||||||
target_include_directories(capture_paths PUBLIC src)
|
target_include_directories(capture_paths PUBLIC src)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 2c) Pure view_mode_model library — NO REAPER, NO SWELL. The Design View heart
|
# 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)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2d) Pure view_mode_model library — NO REAPER, NO SWELL. The Design View heart
|
||||||
# (Phase D1): mode registry + GUID-keyed membership index + folder-tree-aware
|
# (Phase D1): mode registry + GUID-keyed membership index + folder-tree-aware
|
||||||
# visibility derivation + parking/restore planner + JSON round-trip. Mirror of
|
# visibility derivation + parking/restore planner + JSON round-trip. Mirror of
|
||||||
# bank_model; the folder tree is an INPUT supplied by the D2 shell.
|
# bank_model; the folder tree is an INPUT supplied by the D2 shell.
|
||||||
@@ -72,6 +81,10 @@ add_executable(capture_paths_tests tests/test_capture_paths.cpp)
|
|||||||
target_link_libraries(capture_paths_tests PRIVATE capture_paths)
|
target_link_libraries(capture_paths_tests PRIVATE capture_paths)
|
||||||
add_test(NAME capture_paths_tests COMMAND capture_paths_tests)
|
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)
|
||||||
|
|
||||||
add_executable(view_mode_model_tests tests/test_view_mode_model.cpp)
|
add_executable(view_mode_model_tests tests/test_view_mode_model.cpp)
|
||||||
target_link_libraries(view_mode_model_tests PRIVATE view_mode_model)
|
target_link_libraries(view_mode_model_tests PRIVATE view_mode_model)
|
||||||
add_test(NAME view_mode_model_tests COMMAND view_mode_model_tests)
|
add_test(NAME view_mode_model_tests COMMAND view_mode_model_tests)
|
||||||
@@ -83,21 +96,35 @@ add_test(NAME view_tree_tests COMMAND view_tree_tests)
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 4) The REAPER extension — a loadable module (dlopen'd by REAPER, not linked).
|
# 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
|
add_library(reaper_reasampler MODULE
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/capture.cpp
|
src/capture.cpp
|
||||||
src/persist.cpp
|
src/persist.cpp
|
||||||
|
src/bank_panel.cpp
|
||||||
|
${LICE_SRC}
|
||||||
src/view_mode_model.cpp
|
src/view_mode_model.cpp
|
||||||
src/view_tree.cpp
|
src/view_tree.cpp
|
||||||
src/view.cpp
|
src/view.cpp
|
||||||
)
|
)
|
||||||
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths)
|
target_link_libraries(reaper_reasampler PRIVATE bank_model capture_paths peaks bank_grid view_mode_model)
|
||||||
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
|
||||||
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
set_target_properties(reaper_reasampler PROPERTIES PREFIX "" OUTPUT_NAME "reaper_reasampler")
|
||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
# Native Win32. REAPER provides nothing extra to link.
|
# Native Win32. REAPER provides nothing extra to link. The bank_panel dialog
|
||||||
# Dialog resources return with the docked bank_panel (Milestone 5).
|
# template (M5) is compiled from src/resource.rc by the platform RC compiler.
|
||||||
|
target_sources(reaper_reasampler PRIVATE src/resource.rc)
|
||||||
|
|
||||||
elseif(APPLE)
|
elseif(APPLE)
|
||||||
# macOS: use REAPER's OWN SWELL at runtime via the modstub.
|
# macOS: use REAPER's OWN SWELL at runtime via the modstub.
|
||||||
@@ -106,7 +133,10 @@ elseif(APPLE)
|
|||||||
target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP)
|
target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP)
|
||||||
target_link_libraries(reaper_reasampler PRIVATE "-framework AppKit")
|
target_link_libraries(reaper_reasampler PRIVATE "-framework AppKit")
|
||||||
set_target_properties(reaper_reasampler PROPERTIES SUFFIX ".dylib")
|
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()
|
else()
|
||||||
# Linux: REAPER's libSwell.so is used at runtime via the generic modstub.
|
# Linux: REAPER's libSwell.so is used at runtime via the generic modstub.
|
||||||
@@ -114,5 +144,8 @@ else()
|
|||||||
target_sources(reaper_reasampler PRIVATE ${SWELL}/swell-modstub-generic.cpp)
|
target_sources(reaper_reasampler PRIVATE ${SWELL}/swell-modstub-generic.cpp)
|
||||||
target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP)
|
target_compile_definitions(reaper_reasampler PRIVATE SWELL_PROVIDED_BY_APP)
|
||||||
set_target_properties(reaper_reasampler PROPERTIES SUFFIX ".so")
|
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()
|
endif()
|
||||||
|
|||||||
@@ -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<CellRect> computeCellRects(int itemCount,
|
||||||
|
int panelWidth,
|
||||||
|
const GridSpec& spec) {
|
||||||
|
std::vector<CellRect> rects;
|
||||||
|
if (itemCount <= 0) return rects;
|
||||||
|
|
||||||
|
const int cols = columnsForWidth(panelWidth, spec);
|
||||||
|
rects.reserve(static_cast<std::size_t>(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
|
||||||
@@ -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 <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<CellRect> 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
|
||||||
@@ -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 <cstdint>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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 <windows.h>
|
||||||
|
#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<std::string, CachedThumbnail> 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<char> buf(4096, '\0');
|
||||||
|
EnumProjects(-1, buf.data(), static_cast<int>(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<std::int64_t>(lengthSec * srate);
|
||||||
|
if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; }
|
||||||
|
int frames = totalFrames > kMaxThumbnailFrames
|
||||||
|
? kMaxThumbnailFrames
|
||||||
|
: static_cast<int>(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<ReaSample> buf(static_cast<std::size_t>(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<std::size_t>(got) * nch;
|
||||||
|
std::vector<float> pcm(sampleCount);
|
||||||
|
for (std::size_t i = 0; i < sampleCount; ++i)
|
||||||
|
pcm[i] = static_cast<float>(buf[i]);
|
||||||
|
|
||||||
|
return computeEnvelope(pcm, static_cast<std::size_t>(nch),
|
||||||
|
static_cast<std::size_t>(got),
|
||||||
|
static_cast<std::size_t>(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<int>(env.size());
|
||||||
|
const int bandH = rect.height / channels;
|
||||||
|
|
||||||
|
for (int ch = 0; ch < channels; ++ch) {
|
||||||
|
const ChannelEnvelope& bins = env[ch];
|
||||||
|
const int bandTop = rect.y + ch * bandH;
|
||||||
|
const int midY = bandTop + bandH / 2;
|
||||||
|
// 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<int>(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<int>(bins[i].max * halfSpan); // max -> up
|
||||||
|
int yMin = midY - static_cast<int>(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<Sample>& samples = bank->all();
|
||||||
|
const std::vector<CellRect> rects =
|
||||||
|
computeCellRects(static_cast<int>(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
|
||||||
@@ -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
|
||||||
+50
-1
@@ -21,6 +21,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "bank_model.h"
|
#include "bank_model.h"
|
||||||
|
#include "bank_panel.h"
|
||||||
#include "capture.h"
|
#include "capture.h"
|
||||||
#include "persist.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.
|
// (user keybindings key off it) — see the prefix note above.
|
||||||
static int g_cmdCaptureMasterSpike = 0;
|
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
|
// 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
|
// 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
|
// 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()
|
static void OnTimer()
|
||||||
{
|
{
|
||||||
g_session.poll();
|
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.
|
// 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 == 0) return false;
|
||||||
if (command == g_cmdCaptureMasterSpike) { RunCaptureMasterSpike(); return true; }
|
if (command == g_cmdCaptureMasterSpike) { RunCaptureMasterSpike(); return true; }
|
||||||
|
if (command == g_cmdToggleBankPanel) { reasampler::bankPanelToggle(); return true; }
|
||||||
return false;
|
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.
|
// gaccel storage must outlive registration — REAPER holds the pointer.
|
||||||
static gaccel_register_t g_accelCaptureMaster{};
|
static gaccel_register_t g_accelCaptureMaster{};
|
||||||
|
static gaccel_register_t g_accelToggleBankPanel{};
|
||||||
|
|
||||||
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
||||||
REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t* rec)
|
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)
|
if (g_rec)
|
||||||
{
|
{
|
||||||
g_rec->Register("-timer", (void*)&OnTimer);
|
g_rec->Register("-timer", (void*)&OnTimer);
|
||||||
|
g_rec->Register("-toggleaction", (void*)&OnToggleAction);
|
||||||
g_rec->Register("-hookcommand", (void*)&OnHookCommand);
|
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("-gaccel", (void*)&g_accelCaptureMaster);
|
||||||
g_rec->Register("-command_id",
|
g_rec->Register("-command_id",
|
||||||
(void*)(REASAMPLER_ACTION_PREFIX "CAPTURE_MASTER_SPIKE"));
|
(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;
|
g_rec = nullptr;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -153,9 +181,30 @@ extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(
|
|||||||
g_accelCaptureMaster.accel.cmd = g_cmdCaptureMasterSpike;
|
g_accelCaptureMaster.accel.cmd = g_cmdCaptureMasterSpike;
|
||||||
g_accelCaptureMaster.desc = "ReaSampler: capture master mix (spike)";
|
g_accelCaptureMaster.desc = "ReaSampler: capture master mix (spike)";
|
||||||
rec->Register("gaccel", (void*)&g_accelCaptureMaster);
|
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
|
// 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
|
// 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.
|
// state, on a Save-As it relocates the bank folder under the new .rpp.
|
||||||
|
|||||||
+5
-5
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
Envelope computeEnvelope(const std::vector<Sample>& interleaved,
|
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
||||||
std::size_t channelCount,
|
std::size_t channelCount,
|
||||||
std::size_t frameCount,
|
std::size_t frameCount,
|
||||||
std::size_t binCount) {
|
std::size_t binCount) {
|
||||||
@@ -48,11 +48,11 @@ Envelope computeEnvelope(const std::vector<Sample>& interleaved,
|
|||||||
continue; // empty span (binCount > frames) -> keep {0,0}
|
continue; // empty span (binCount > frames) -> keep {0,0}
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sample first = interleaved[begin * channelCount + ch];
|
const AudioSample first = interleaved[begin * channelCount + ch];
|
||||||
Sample lo = first;
|
AudioSample lo = first;
|
||||||
Sample hi = first;
|
AudioSample hi = first;
|
||||||
for (std::size_t f = begin + 1; f < end; ++f) {
|
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);
|
lo = std::min(lo, s);
|
||||||
hi = std::max(hi, s);
|
hi = std::max(hi, s);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-9
@@ -13,18 +13,23 @@
|
|||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
// Canonical in-memory sample type. `float` is REAPER's native audio buffer format
|
// Canonical in-memory audio-sample type. `float` is REAPER's native audio buffer
|
||||||
// (its render/PCM_source callbacks hand back interleaved 32-bit float), so peaks
|
// format (its render/PCM_source callbacks hand back interleaved 32-bit float), so
|
||||||
// consumes that directly with no lossy conversion. If a capture ever lands as a
|
// peaks consumes that directly with no lossy conversion. If a capture ever lands
|
||||||
// different depth, the caller converts to float at the boundary — the thumbnail
|
// as a different depth, the caller converts to float at the boundary — the
|
||||||
// core stays single-typed.
|
// thumbnail core stays single-typed.
|
||||||
using Sample = float;
|
//
|
||||||
|
// 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.
|
// 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.
|
// min <= max always. For an empty bin (more bins than frames), both are 0.
|
||||||
struct MinMax {
|
struct MinMax {
|
||||||
Sample min = 0.0f;
|
AudioSample min = 0.0f;
|
||||||
Sample max = 0.0f;
|
AudioSample max = 0.0f;
|
||||||
|
|
||||||
bool operator==(const MinMax& o) const { return min == o.min && max == o.max; }
|
bool operator==(const MinMax& o) const { return min == o.min && max == o.max; }
|
||||||
};
|
};
|
||||||
@@ -56,7 +61,7 @@ using Envelope = std::vector<ChannelEnvelope>;
|
|||||||
// binCount == 0 -> per channel: an empty bin vector.
|
// binCount == 0 -> per channel: an empty bin vector.
|
||||||
// channelCount == 0 -> an empty envelope (no channels).
|
// channelCount == 0 -> an empty envelope (no channels).
|
||||||
// frameCount == 0 -> per channel: binCount bins, all {0, 0}.
|
// frameCount == 0 -> per channel: binCount bins, all {0, 0}.
|
||||||
Envelope computeEnvelope(const std::vector<Sample>& interleaved,
|
Envelope computeEnvelope(const std::vector<AudioSample>& interleaved,
|
||||||
std::size_t channelCount,
|
std::size_t channelCount,
|
||||||
std::size_t frameCount,
|
std::size_t frameCount,
|
||||||
std::size_t binCount);
|
std::size_t binCount);
|
||||||
|
|||||||
@@ -163,10 +163,46 @@ void ReaSamplerSession::saveToActiveProject() {
|
|||||||
const std::string json = bank_.serialize();
|
const std::string json = bank_.serialize();
|
||||||
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||||
kProjExtIndexKey, json.c_str());
|
kProjExtIndexKey, json.c_str());
|
||||||
|
|
||||||
|
// Additive: the Design-View model rides alongside the bank in its own key.
|
||||||
|
// Independent write — does not disturb the bank_index above.
|
||||||
|
const std::string viewJson = view_.serialize();
|
||||||
|
SetProjExtState(static_cast<ReaProject*>(proj), kProjExtNamespace,
|
||||||
|
kProjExtViewKey, viewJson.c_str());
|
||||||
|
|
||||||
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
MarkProjectDirty(static_cast<ReaProject*>(proj));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// Load the Design-View model from a project's view_state key, or return a fresh
|
||||||
|
// default. An absent/empty key (older project with no view state) yields a
|
||||||
|
// default-constructed model (Arrange + Design seeded, active = Arrange) — graceful,
|
||||||
|
// never a crash. Malformed JSON is warned and also falls back to default, mirroring
|
||||||
|
// the bank's malformed-index handling. The whole model round-trips: modes,
|
||||||
|
// membership, show-both, snapshots, and active mode all ride inside the one blob.
|
||||||
|
ViewModeModel loadViewModel(ReaProject* proj) {
|
||||||
|
if (!proj) return ViewModeModel{};
|
||||||
|
const std::string viewJson =
|
||||||
|
getProjExtStateString(proj, kProjExtNamespace, kProjExtViewKey);
|
||||||
|
if (viewJson.empty()) return ViewModeModel{}; // no stored view state -> default
|
||||||
|
std::optional<ViewModeModel> loaded = ViewModeModel::deserialize(viewJson);
|
||||||
|
if (!loaded) {
|
||||||
|
ShowConsoleMsg("ReaSampler: stored view state is malformed — ignoring.\n");
|
||||||
|
return ViewModeModel{};
|
||||||
|
}
|
||||||
|
return std::move(*loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
|
void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDir) {
|
||||||
|
// The view model is restored on EVERY load path (peer-symmetry with the bank
|
||||||
|
// reset below): switching to a project with no view state must clear stale
|
||||||
|
// in-memory state, not inherit the previous project's. D3 restores MODEL STATE
|
||||||
|
// only — no visibility/processing is applied here (that is D4).
|
||||||
|
view_ = loadViewModel(static_cast<ReaProject*>(proj));
|
||||||
|
|
||||||
if (!proj) {
|
if (!proj) {
|
||||||
bank_ = BankIndex{};
|
bank_ = BankIndex{};
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
#include "bank_model.h"
|
#include "bank_model.h"
|
||||||
|
#include "view_mode_model.h"
|
||||||
|
|
||||||
namespace reasampler {
|
namespace reasampler {
|
||||||
|
|
||||||
@@ -31,6 +32,12 @@ inline constexpr const char* kProjExtNamespace = "reasampler";
|
|||||||
// serialized BankIndex). FOREVER-STABLE for the same reason.
|
// serialized BankIndex). FOREVER-STABLE for the same reason.
|
||||||
inline constexpr const char* kProjExtIndexKey = "bank_index";
|
inline constexpr const char* kProjExtIndexKey = "bank_index";
|
||||||
|
|
||||||
|
// The ext-state key the Design-View ViewModeModel JSON is stored under (one key
|
||||||
|
// holds the whole serialized model: modes + membership + show-both + snapshots +
|
||||||
|
// active mode). Distinct from kProjExtIndexKey — one namespace, two keys.
|
||||||
|
// FOREVER-STABLE: changing it orphans every already-saved project's view state.
|
||||||
|
inline constexpr const char* kProjExtViewKey = "view_state";
|
||||||
|
|
||||||
// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED
|
// The ext-state key holding a GUID we mint per project to establish CONTENT-BASED
|
||||||
// project identity (REAPER exposes no stable per-project GUID). poll() uses it to
|
// project identity (REAPER exposes no stable per-project GUID). poll() uses it to
|
||||||
// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch
|
// tell a genuine Save-As (same GUID, new .rpp path) apart from a project switch
|
||||||
@@ -62,6 +69,13 @@ public:
|
|||||||
BankIndex& bank() { return bank_; }
|
BankIndex& bank() { return bank_; }
|
||||||
const BankIndex& bank() const { return bank_; }
|
const BankIndex& bank() const { return bank_; }
|
||||||
|
|
||||||
|
// The in-memory Design-View model. The view/action layer mutates it (tag,
|
||||||
|
// toggle, snapshot); persist serializes it on save and replaces it on project
|
||||||
|
// load — exactly as it treats the bank. D3 persists MODEL STATE only; applying
|
||||||
|
// visibility/processing (reapply-on-open) is D4's job, not this member's.
|
||||||
|
ViewModeModel& view() { return view_; }
|
||||||
|
const ViewModeModel& view() const { return view_; }
|
||||||
|
|
||||||
// Serialize the current bank to the active project's ext state (namespace
|
// Serialize the current bank to the active project's ext state (namespace
|
||||||
// "reasampler"). Non-destructive beyond writing our own ext-state key. Safe
|
// "reasampler"). Non-destructive beyond writing our own ext-state key. Safe
|
||||||
// to call when there is no active/saved project (it no-ops).
|
// to call when there is no active/saved project (it no-ops).
|
||||||
@@ -75,6 +89,11 @@ public:
|
|||||||
private:
|
private:
|
||||||
BankIndex bank_;
|
BankIndex bank_;
|
||||||
|
|
||||||
|
// The Design-View model. Default-constructed = Arrange + Design seeded, active
|
||||||
|
// = Arrange; loadFromProject leaves this default when a project has no stored
|
||||||
|
// view_state (older project), so an absent key is graceful, not a crash.
|
||||||
|
ViewModeModel view_;
|
||||||
|
|
||||||
// The project identity last observed by poll(), used to detect load/Save-As.
|
// The project identity last observed by poll(), used to detect load/Save-As.
|
||||||
// Identity is the GUID we mint per project (kProjExtGuidKey), NOT the raw
|
// Identity is the GUID we mint per project (kProjExtGuidKey), NOT the raw
|
||||||
// ReaProject* pointer — see the class comment for why. The .rpp path is
|
// ReaProject* pointer — see the class comment for why. The .rpp path is
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 <windows.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
IDD_BANK_PANEL DIALOG DISCARDABLE 0, 0, 400, 300
|
||||||
|
STYLE WS_CHILD
|
||||||
|
FONT 8, "MS Shell Dlg"
|
||||||
|
BEGIN
|
||||||
|
END
|
||||||
@@ -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 <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
+13
-13
@@ -27,8 +27,8 @@ constexpr double kPi = 3.14159265358979323846;
|
|||||||
|
|
||||||
// A full-scale sine over `frames` frames, mono, `cycles` complete periods so every
|
// A full-scale sine over `frames` frames, mono, `cycles` complete periods so every
|
||||||
// bin sees both a near-peak and a near-trough.
|
// bin sees both a near-peak and a near-trough.
|
||||||
static std::vector<Sample> monoSine(std::size_t frames, double cycles, float amp) {
|
static std::vector<AudioSample> monoSine(std::size_t frames, double cycles, float amp) {
|
||||||
std::vector<Sample> buf(frames);
|
std::vector<AudioSample> buf(frames);
|
||||||
for (std::size_t i = 0; i < frames; ++i) {
|
for (std::size_t i = 0; i < frames; ++i) {
|
||||||
const double phase = 2.0 * kPi * cycles * (double)i / (double)frames;
|
const double phase = 2.0 * kPi * cycles * (double)i / (double)frames;
|
||||||
buf[i] = amp * (float)std::sin(phase);
|
buf[i] = amp * (float)std::sin(phase);
|
||||||
@@ -60,7 +60,7 @@ static void testSineEnvelope() {
|
|||||||
static void testRampMonotonic() {
|
static void testRampMonotonic() {
|
||||||
const std::size_t frames = 10000;
|
const std::size_t frames = 10000;
|
||||||
const std::size_t bins = 50;
|
const std::size_t bins = 50;
|
||||||
std::vector<Sample> buf(frames);
|
std::vector<AudioSample> buf(frames);
|
||||||
for (std::size_t i = 0; i < frames; ++i) {
|
for (std::size_t i = 0; i < frames; ++i) {
|
||||||
buf[i] = (float)i / (float)(frames - 1); // 0.0 .. 1.0
|
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 frames = 1000;
|
||||||
const std::size_t bins = 16;
|
const std::size_t bins = 16;
|
||||||
|
|
||||||
std::vector<Sample> silence(frames, 0.0f);
|
std::vector<AudioSample> silence(frames, 0.0f);
|
||||||
Envelope se = computeEnvelope(silence, 1, frames, bins);
|
Envelope se = computeEnvelope(silence, 1, frames, bins);
|
||||||
for (const MinMax& mm : se[0]) {
|
for (const MinMax& mm : se[0]) {
|
||||||
CHECK(mm.min == 0.0f);
|
CHECK(mm.min == 0.0f);
|
||||||
CHECK(mm.max == 0.0f);
|
CHECK(mm.max == 0.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<Sample> dc(frames, 0.5f);
|
std::vector<AudioSample> dc(frames, 0.5f);
|
||||||
Envelope de = computeEnvelope(dc, 1, frames, bins);
|
Envelope de = computeEnvelope(dc, 1, frames, bins);
|
||||||
for (const MinMax& mm : de[0]) {
|
for (const MinMax& mm : de[0]) {
|
||||||
CHECK(mm.min == 0.5f);
|
CHECK(mm.min == 0.5f);
|
||||||
@@ -121,7 +121,7 @@ static void testMultiChannelNoFold() {
|
|||||||
|
|
||||||
// Interleave: [ch0, ch1] per frame; ch0 = sine, ch1 = 0.
|
// Interleave: [ch0, ch1] per frame; ch0 = sine, ch1 = 0.
|
||||||
auto sine = monoSine(frames, /*cycles=*/64.0, amp);
|
auto sine = monoSine(frames, /*cycles=*/64.0, amp);
|
||||||
std::vector<Sample> buf(frames * 2);
|
std::vector<AudioSample> buf(frames * 2);
|
||||||
for (std::size_t i = 0; i < frames; ++i) {
|
for (std::size_t i = 0; i < frames; ++i) {
|
||||||
buf[i * 2 + 0] = sine[i];
|
buf[i * 2 + 0] = sine[i];
|
||||||
buf[i * 2 + 1] = 0.0f;
|
buf[i * 2 + 1] = 0.0f;
|
||||||
@@ -147,7 +147,7 @@ static void testMultiChannelNoFold() {
|
|||||||
static void testChannelsNotAveraged() {
|
static void testChannelsNotAveraged() {
|
||||||
const std::size_t frames = 100;
|
const std::size_t frames = 100;
|
||||||
const std::size_t bins = 4;
|
const std::size_t bins = 4;
|
||||||
std::vector<Sample> buf(frames * 2);
|
std::vector<AudioSample> buf(frames * 2);
|
||||||
for (std::size_t i = 0; i < frames; ++i) {
|
for (std::size_t i = 0; i < frames; ++i) {
|
||||||
buf[i * 2 + 0] = 1.0f;
|
buf[i * 2 + 0] = 1.0f;
|
||||||
buf[i * 2 + 1] = -1.0f;
|
buf[i * 2 + 1] = -1.0f;
|
||||||
@@ -164,7 +164,7 @@ static void testChannelsNotAveraged() {
|
|||||||
static void testShortBuffer() {
|
static void testShortBuffer() {
|
||||||
const std::size_t frames = 3;
|
const std::size_t frames = 3;
|
||||||
const std::size_t bins = 8;
|
const std::size_t bins = 8;
|
||||||
std::vector<Sample> buf = {0.25f, -0.5f, 0.75f};
|
std::vector<AudioSample> buf = {0.25f, -0.5f, 0.75f};
|
||||||
|
|
||||||
Envelope env = computeEnvelope(buf, 1, frames, bins);
|
Envelope env = computeEnvelope(buf, 1, frames, bins);
|
||||||
CHECK(env[0].size() == bins);
|
CHECK(env[0].size() == bins);
|
||||||
@@ -190,7 +190,7 @@ static void testShortBuffer() {
|
|||||||
static void testNonDivisibleRemainderBin() {
|
static void testNonDivisibleRemainderBin() {
|
||||||
const std::size_t frames = 10;
|
const std::size_t frames = 10;
|
||||||
const std::size_t bins = 3;
|
const std::size_t bins = 3;
|
||||||
std::vector<Sample> buf(frames);
|
std::vector<AudioSample> buf(frames);
|
||||||
for (std::size_t i = 0; i < frames; ++i) buf[i] = (float)i; // 0..9
|
for (std::size_t i = 0; i < frames; ++i) buf[i] = (float)i; // 0..9
|
||||||
|
|
||||||
Envelope env = computeEnvelope(buf, 1, frames, bins);
|
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.
|
// binCount == 1: the whole buffer collapses to a single min/max.
|
||||||
static void testSingleBinWholeBuffer() {
|
static void testSingleBinWholeBuffer() {
|
||||||
std::vector<Sample> buf = {-0.3f, 0.8f, -0.9f, 0.1f, 0.4f};
|
std::vector<AudioSample> buf = {-0.3f, 0.8f, -0.9f, 0.1f, 0.4f};
|
||||||
Envelope env = computeEnvelope(buf, 1, buf.size(), 1);
|
Envelope env = computeEnvelope(buf, 1, buf.size(), 1);
|
||||||
CHECK(env[0].size() == 1);
|
CHECK(env[0].size() == 1);
|
||||||
CHECK(env[0][0].min == -0.9f);
|
CHECK(env[0][0].min == -0.9f);
|
||||||
@@ -214,7 +214,7 @@ static void testSingleBinWholeBuffer() {
|
|||||||
|
|
||||||
// Degenerate inputs: defined behavior, no UB, no throw.
|
// Degenerate inputs: defined behavior, no UB, no throw.
|
||||||
static void testDegenerateInputs() {
|
static void testDegenerateInputs() {
|
||||||
std::vector<Sample> buf = {0.1f, 0.2f, 0.3f, 0.4f};
|
std::vector<AudioSample> buf = {0.1f, 0.2f, 0.3f, 0.4f};
|
||||||
|
|
||||||
// Zero frames -> binCount bins, all {0,0}.
|
// Zero frames -> binCount bins, all {0,0}.
|
||||||
Envelope zf = computeEnvelope(buf, 1, 0, 4);
|
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);
|
CHECK(over[0][1].min == 0.3f && over[0][1].max == 0.4f);
|
||||||
|
|
||||||
// Empty buffer, non-zero request -> all-zero bins, no crash.
|
// Empty buffer, non-zero request -> all-zero bins, no crash.
|
||||||
std::vector<Sample> empty;
|
std::vector<AudioSample> empty;
|
||||||
Envelope eb = computeEnvelope(empty, 2, 10, 3);
|
Envelope eb = computeEnvelope(empty, 2, 10, 3);
|
||||||
CHECK(eb.size() == 2);
|
CHECK(eb.size() == 2);
|
||||||
for (const auto& chenv : eb) {
|
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.
|
// same loop iteration path that would UB for pathological binCount near SIZE_MAX.
|
||||||
const std::size_t frames = 4;
|
const std::size_t frames = 4;
|
||||||
const std::size_t binCount = 9;
|
const std::size_t binCount = 9;
|
||||||
std::vector<Sample> buf = {0.1f, 0.2f, 0.3f, 0.4f};
|
std::vector<AudioSample> buf = {0.1f, 0.2f, 0.3f, 0.4f};
|
||||||
|
|
||||||
Envelope env = computeEnvelope(buf, 1, frames, binCount);
|
Envelope env = computeEnvelope(buf, 1, frames, binCount);
|
||||||
CHECK(env.size() == 1);
|
CHECK(env.size() == 1);
|
||||||
|
|||||||
Reference in New Issue
Block a user