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.
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user