b182146f9a
Pure planLaneMinting decides which tracks hold >1 mode's content and which managed lane each item lands on; view.cpp applies it (I_FREEMODE/I_NUMFIXEDLANES/P_LANENAME/ I_FIXEDLANE) under one undo block, driven off the auto-tag detection tick. Manual lanes and their items are never touched. Load-time reconcile rebuilds ownership from durable lane names before reapplying active-mode visibility.
1290 lines
60 KiB
C++
1290 lines
60 KiB
C++
// 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 <map>
|
|
#include <set>
|
|
#include <string>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
#include "bank_grid.h"
|
|
#include "bank_model.h"
|
|
#include "capture_paths.h"
|
|
#include "guid_diff.h" // GuidBaseline — new-content detection (D2 Wave 2)
|
|
#include "lane_keys.h" // managed/manual lane heuristic (D2 Wave 2)
|
|
#include "mode_switch.h"
|
|
#include "peaks.h"
|
|
#include "persist.h"
|
|
#include "tail_control.h" // TailSetting, cycleTailMode, tailToggleLabel (pure)
|
|
#include "track_guid.h" // guidString — canonical track GUID key (D2 Wave 2)
|
|
#include "view.h" // applyMode — the D2/D4 mode-activation entrypoint the switch fires
|
|
#include "view_mode_model.h" // autoTagNewContent / NewItem (D2 Wave 2)
|
|
|
|
// 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>
|
|
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux)
|
|
#else
|
|
#include <pthread.h>
|
|
#endif
|
|
#include "wdltypes.h"
|
|
#include "swell/swell.h"
|
|
#include "lice/lice.h"
|
|
|
|
#include "resource.h"
|
|
|
|
// reaper_plugin.h defines preview_register_t (the stock preview struct) and the
|
|
// REAPER_PLUGIN_HINSTANCE / registration types. main.cpp includes it with
|
|
// REAPERAPI_IMPLEMENT; here we only need the type declarations.
|
|
#include "reaper_plugin.h"
|
|
|
|
#define REAPERAPI_MINIMAL
|
|
#define REAPERAPI_WANT_DockWindowAddEx
|
|
#define REAPERAPI_WANT_DockWindowActivate
|
|
#define REAPERAPI_WANT_DockWindowRemove
|
|
#define REAPERAPI_WANT_EnumProjects
|
|
#define REAPERAPI_WANT_MarkProjectDirty // mark dirty when the tail toggle changes (saves with the project)
|
|
#define REAPERAPI_WANT_GetMainHwnd
|
|
#define REAPERAPI_WANT_PCM_Source_CreateFromFile
|
|
#define REAPERAPI_WANT_PCM_Source_Destroy
|
|
// New-content detection (D2 Wave 2): enumerate live tracks + items and read fixed-lane
|
|
// state to classify an item's lane as managed vs manual.
|
|
#define REAPERAPI_WANT_CountTracks
|
|
#define REAPERAPI_WANT_GetTrack
|
|
#define REAPERAPI_WANT_GetMediaTrackInfo_Value
|
|
#define REAPERAPI_WANT_GetSetMediaTrackInfo_String
|
|
#define REAPERAPI_WANT_CountTrackMediaItems
|
|
#define REAPERAPI_WANT_GetTrackMediaItem
|
|
#define REAPERAPI_WANT_GetMediaItemInfo_Value
|
|
#define REAPERAPI_WANT_GetSetMediaItemInfo_String
|
|
// Stock preview API (verified against reaper_plugin.h / reaper_plugin_functions.h):
|
|
// PlayPreview/StopPreview drive a caller-owned preview_register_t. These are the
|
|
// STOCK symbols (not SWS-only) — see the audition section below.
|
|
#define REAPERAPI_WANT_PlayPreview
|
|
#define REAPERAPI_WANT_StopPreview
|
|
#include "reaper_plugin_functions.h"
|
|
|
|
// main.cpp owns the module instance handle (needed to load the dialog resource)
|
|
// and REAPER's dispatch struct (needed to register the keyboard accelerator hook).
|
|
extern REAPER_PLUGIN_HINSTANCE g_hInst;
|
|
extern reaper_plugin_info_t* g_rec;
|
|
|
|
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);
|
|
// Selection chrome (Wave B). Selected cells get a tinted fill + brighter border;
|
|
// the focused cell (audition/nav target) gets a distinct accent border so it is
|
|
// distinguishable within a multi-selection.
|
|
const LICE_pixel kColSelBg = LICE_RGBA(38, 66, 58, 255); // selected fill tint
|
|
const LICE_pixel kColSelBorder = LICE_RGBA(120, 200, 160, 255);// selected border
|
|
const LICE_pixel kColFocusBorder = LICE_RGBA(210, 230, 220, 255);// focused-cell border
|
|
|
|
// --- Mode-switch header (D5) --------------------------------------------------
|
|
// A fixed-height segmented control at the top of the client area: one segment per
|
|
// registered Design-View mode, the active one lit. The grid is offset below it.
|
|
// Layout math (segment rects, hit-test) lives in the pure mode_switch module; only
|
|
// the draw + click routing is here.
|
|
constexpr int kHeaderHeight = 30; // px; fixed strip, grid starts below it
|
|
|
|
const LICE_pixel kColHeaderBg = LICE_RGBA(20, 20, 22, 255); // header strip fill
|
|
const LICE_pixel kColSegBg = LICE_RGBA(44, 44, 48, 255); // inactive segment
|
|
const LICE_pixel kColSegActiveBg = LICE_RGBA(58, 96, 84, 255); // active (lit) segment
|
|
const LICE_pixel kColSegBorder = LICE_RGBA(70, 70, 76, 255); // segment divider
|
|
|
|
// Segment label colors are COLORREFs (SetTextColor takes RGB, not LICE_pixel).
|
|
const COLORREF kRgbSegText = RGB(170, 170, 176); // inactive label
|
|
const COLORREF kRgbSegActiveText = RGB(220, 235, 228); // active label
|
|
|
|
// --- Tail-mode footer (T1 exposure) -------------------------------------------
|
|
// A fixed-height strip at the BOTTOM of the client area holding the tail-mode
|
|
// toggle ("Tail: Off / Auto / Manual"). Clicking anywhere in it cycles the mode
|
|
// (None -> Auto -> Manual -> None). Display/settings only: it mutates the panel's
|
|
// in-memory tail setting the plain capture actions read — NEVER the project/bank/
|
|
// arrange. The cycle/label logic is the pure tail_control module; only the draw +
|
|
// click routing is here. The grid viewport is shortened by this strip's height so
|
|
// cells never draw under it.
|
|
constexpr int kFooterHeight = 26; // px; fixed strip at the bottom
|
|
|
|
const LICE_pixel kColFooterBg = LICE_RGBA(20, 20, 22, 255); // footer strip fill
|
|
const LICE_pixel kColFooterBorder = LICE_RGBA(70, 70, 76, 255); // top divider
|
|
const COLORREF kRgbFooterText = RGB(190, 205, 198); // toggle label
|
|
|
|
// --- 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;
|
|
|
|
// --- Interaction (Wave B) -------------------------------------------------
|
|
|
|
// The current cell selection (indices into bank->all(), focus, anchor). Pure
|
|
// math lives in bank_grid; this holds the live state the pointer/keyboard
|
|
// mutate. A bank change (generation bump) resets it (indices could dangle).
|
|
Selection selection;
|
|
|
|
// The item count the selection was last validated against. On a bank change we
|
|
// clear the selection rather than risk indices pointing past the new count.
|
|
int selItemCount = 0;
|
|
|
|
// --- Tail-mode toggle -----------------------------------------------------
|
|
// The authoritative tail setting now lives in ReaSamplerSession (session->tail()),
|
|
// NOT in panel state, so it travels inside the .rpp (persist serializes it on save,
|
|
// restores it on project load). The panel reads it for drawing and mutates it via
|
|
// the footer click (cycle mode) and scroll-wheel (Manual fine-adjust), marking the
|
|
// project dirty so the choice saves. bankPanelTailSetting is the read seam for the
|
|
// capture actions. Held here only through the session pointer above.
|
|
|
|
// --- Audition preview (Wave B) --------------------------------------------
|
|
//
|
|
// The stock preview register we hand to PlayPreview/StopPreview. Its cs/mutex
|
|
// is initialized ONCE (initPreview) and destroyed ONCE (deinitPreview) across
|
|
// the panel's lifetime — NOT per playback — because REAPER's audio thread may
|
|
// touch the register's guarded fields. `previewSrc` is the PCM_source currently
|
|
// owned by `preview.src`; non-null exactly while auditioning. `previewActive`
|
|
// tracks whether PlayPreview succeeded and StopPreview is still owed.
|
|
preview_register_t preview{};
|
|
PCM_source* previewSrc = nullptr;
|
|
bool previewActive = false;
|
|
bool previewInited = false; // guards double init / deinit
|
|
|
|
// --- New-content detection (D2 Wave 2) ------------------------------------
|
|
//
|
|
// Each timer tick diffs the live track+item GUID set against the previous tick to
|
|
// auto-tag content created SINCE the last tick into the then-active mode. The
|
|
// baseline carries the first-poll-after-open guard (GuidBaseline self-arms on its
|
|
// first observe()) so pre-existing content is never mass-tagged (it stays Arrange).
|
|
//
|
|
// Project-load re-arm is driven by persist's AUTHORITATIVE load lifecycle, NOT by a
|
|
// pointer compare here. main.cpp calls bankPanelNotifyProjectLoaded() on the exact
|
|
// tick persist restores a project's membership + active mode (the same tick it
|
|
// reapplies the active mode); that sets reloadPending so the NEXT detect tick this
|
|
// same tick re-baselines against the fully-loaded set and reports nothing new. This
|
|
// replaces the former `proj != lastProject` re-arm, which used a WEAKER signal than
|
|
// persist (pointer-only vs persist's GUID-primary identity) and so missed a load onto
|
|
// a RECYCLED ReaProject* address — the just-loaded project's pre-existing tracks then
|
|
// diffed against the previous project's stale baseline and were mass-tagged into the
|
|
// active mode (the reload-mis-tag bug). Coordinating with persist's signal makes the
|
|
// two identity checks agree by construction.
|
|
//
|
|
// Lives for the extension's lifetime alongside the session, independent of panel
|
|
// open/close — detection must run whether or not the dock is visible (content is
|
|
// created in the arrange, not the panel).
|
|
GuidBaseline contentBaseline;
|
|
bool reloadPending = false; // set by bankPanelNotifyProjectLoaded; drained next detect tick
|
|
};
|
|
|
|
PanelState g_panel;
|
|
|
|
// Forward declarations for the interaction/audition helpers defined lower down but
|
|
// referenced by earlier sections (e.g. refreshFingerprint stops audition on a bank
|
|
// change). Definitions live in the "Audition preview" / "Selection + input" blocks.
|
|
void stopAudition();
|
|
|
|
// --- 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).
|
|
// `selected` tints the fill and brightens the border; `focused` overrides the
|
|
// border with the accent color so the caret cell reads within a multi-selection.
|
|
void drawThumbnail(LICE_IBitmap* bmp, const CellRect& rect, const Envelope& env,
|
|
bool selected, bool focused) {
|
|
const LICE_pixel bg = selected ? kColSelBg : kColCellBg;
|
|
LICE_pixel border = selected ? kColSelBorder : kColCellBorder;
|
|
if (focused) border = kColFocusBorder;
|
|
|
|
LICE_FillRect(bmp, rect.x, rect.y, rect.width, rect.height, bg, 1.0f, 0);
|
|
LICE_DrawRect(bmp, rect.x, rect.y, rect.width, rect.height, border, 1.0f, 0);
|
|
// The focused cell gets a second inset rectangle so it stays distinct even when
|
|
// its neighbors are also selected (double outline reads as "the active one").
|
|
if (focused)
|
|
LICE_DrawRect(bmp, rect.x + 1, rect.y + 1, rect.width - 2, rect.height - 2,
|
|
border, 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 mode-switch header rect for a client of width `w`: the full-width strip of
|
|
// fixed height at the top. Pure geometry (mode_switch owns the segment division);
|
|
// this just sizes the band. Shared by paint and click routing so both agree.
|
|
HeaderRect panelHeader(int w) {
|
|
return HeaderRect{0, 0, w, kHeaderHeight};
|
|
}
|
|
|
|
// The number of registered Design-View modes (segments to draw). 0 when no session.
|
|
int modeCount() {
|
|
if (!g_panel.session) return 0;
|
|
return static_cast<int>(g_panel.session->view().modes().size());
|
|
}
|
|
|
|
// Draws the segmented mode switch into the header strip of width `w`: one segment
|
|
// per registered mode (ordinal order), the active mode lit, each labeled with its
|
|
// display name. READ-ONLY: reads g_session->view() live; never mutates the model here
|
|
// (activation happens on click, in handleClick).
|
|
void drawModeSwitch(LICE_IBitmap* bmp, int w) {
|
|
if (!g_panel.session) return;
|
|
const ViewModeModel& view = g_panel.session->view();
|
|
const std::vector<Mode>& modes = view.modes().all();
|
|
const int n = static_cast<int>(modes.size());
|
|
|
|
// Strip background first (so an empty/absent switch still reads as a header band).
|
|
LICE_FillRect(bmp, 0, 0, w, kHeaderHeight, kColHeaderBg, 1.0f, 0);
|
|
if (n <= 0) return;
|
|
|
|
const HeaderRect header = panelHeader(w);
|
|
const std::vector<SegmentRect> segs = computeSegmentRects(header, n);
|
|
if (segs.empty()) return;
|
|
|
|
const std::string& activeId = view.activeModeId();
|
|
HDC dc = bmp->getDC();
|
|
|
|
for (int i = 0; i < n; ++i) {
|
|
const SegmentRect& s = segs[static_cast<std::size_t>(i)];
|
|
const Mode& mode = modes[static_cast<std::size_t>(i)];
|
|
const bool active = mode.id == activeId;
|
|
|
|
LICE_FillRect(bmp, s.x, s.y, s.width, s.height,
|
|
active ? kColSegActiveBg : kColSegBg, 1.0f, 0);
|
|
LICE_DrawRect(bmp, s.x, s.y, s.width, s.height, kColSegBorder, 1.0f, 0);
|
|
|
|
if (!dc) continue;
|
|
|
|
// Display name centered in the segment. A single-line centered label;
|
|
// the segment is wide enough for the seed modes' short names.
|
|
const std::string& label = mode.displayName;
|
|
RECT rc{s.x, s.y, s.x + s.width, s.y + s.height};
|
|
SetTextColor(dc, active ? kRgbSegActiveText : kRgbSegText);
|
|
SetBkMode(dc, TRANSPARENT);
|
|
DrawText(dc, label.c_str(), -1, &rc,
|
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
|
|
}
|
|
}
|
|
|
|
// The tail-toggle footer rect for a client of width `w` and height `h`: the
|
|
// full-width strip of fixed height pinned to the BOTTOM. A RECT (not HeaderRect)
|
|
// since the whole strip is one hit target — a click anywhere in it cycles the mode.
|
|
// Shared by paint and click routing so both agree on the band. Degenerate (empty)
|
|
// when the client is too short to host it above the header.
|
|
RECT panelFooter(int w, int h) {
|
|
RECT rc{};
|
|
rc.left = 0;
|
|
rc.right = w;
|
|
rc.top = h - kFooterHeight;
|
|
rc.bottom = h;
|
|
// Clamp so the footer never rides up into (or above) the header band on a very
|
|
// short panel — it collapses to empty rather than overlapping the mode switch.
|
|
if (rc.top < kHeaderHeight) rc.top = rc.bottom; // empty: top == bottom
|
|
return rc;
|
|
}
|
|
|
|
// The session's live tail setting (default None / 2 s when no session). Single read
|
|
// point so draw, wheel-adjust, and the capture read seam all agree on the source.
|
|
TailSetting currentTail() {
|
|
return g_panel.session ? g_panel.session->tail() : TailSetting{};
|
|
}
|
|
|
|
// Draws the tail-mode toggle into the footer strip: a filled band, a top divider,
|
|
// and the current mode's label ("Tail: Off / Auto / Manual Xs") from the pure
|
|
// tail_control module. READ-ONLY: reads session->tail(); the input handlers mutate it.
|
|
void drawTailFooter(LICE_IBitmap* bmp, int w, int h) {
|
|
const RECT f = panelFooter(w, h);
|
|
if (f.top >= f.bottom) return; // no room — skip (short panel)
|
|
|
|
LICE_FillRect(bmp, f.left, f.top, w, kFooterHeight, kColFooterBg, 1.0f, 0);
|
|
// Top divider so the strip reads as distinct from the grid above it.
|
|
LICE_Line(bmp, f.left, f.top, f.right, f.top, kColFooterBorder, 1.0f, 0, false);
|
|
|
|
HDC dc = bmp->getDC();
|
|
if (!dc) return;
|
|
const std::string label = tailToggleLabel(currentTail());
|
|
RECT rc = f;
|
|
rc.left += 8; // small left pad so the label is not flush against the edge
|
|
SetTextColor(dc, kRgbFooterText);
|
|
SetBkMode(dc, TRANSPARENT);
|
|
DrawText(dc, label.c_str(), -1, &rc,
|
|
DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_END_ELLIPSIS);
|
|
}
|
|
|
|
// True iff client-relative (x, y) falls inside the (non-degenerate) footer strip.
|
|
// Shared by the footer click (cycle mode) and the scroll-wheel (Manual fine-adjust)
|
|
// so both agree on the hit target.
|
|
bool pointInFooter(int x, int y) {
|
|
if (!g_panel.hwnd) return false;
|
|
RECT cr{};
|
|
GetClientRect(g_panel.hwnd, &cr);
|
|
const RECT f = panelFooter(cr.right - cr.left, cr.bottom - cr.top);
|
|
return f.top < f.bottom && x >= f.left && x < f.right && y >= f.top && y < f.bottom;
|
|
}
|
|
|
|
// Commits the current tail setting to ext state and marks the active project dirty
|
|
// so the change travels inside the .rpp on Ctrl+S. saveToActiveProject() is the only
|
|
// path that calls SetProjExtState for the tail key — calling it here closes the gap
|
|
// where toggle/scroll would dirty the project but the new value was never written.
|
|
// On an unsaved project saveToActiveProject() no-ops cleanly (documented in persist.h).
|
|
// MarkProjectDirty runs unconditionally so REAPER knows a save is owed either way.
|
|
// NON-DESTRUCTIVE: touches nothing in the bank/arrange.
|
|
void markTailDirty() {
|
|
if (g_panel.session) g_panel.session->saveToActiveProject();
|
|
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
|
if (proj) MarkProjectDirty(proj);
|
|
}
|
|
|
|
// The cell rects for the panel's CURRENT client width and bank size, translated
|
|
// DOWN by the header height so the grid sits below the mode switch. Both paint and
|
|
// mouse hit-testing call this so they share identical geometry (no drift between
|
|
// what is drawn and what a click resolves to). Returns empty when the window is
|
|
// gone or the bank is empty.
|
|
std::vector<CellRect> panelRects() {
|
|
if (!g_panel.hwnd) return {};
|
|
const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr;
|
|
if (!bank || bank->empty()) return {};
|
|
RECT cr{};
|
|
GetClientRect(g_panel.hwnd, &cr);
|
|
const int w = cr.right - cr.left;
|
|
if (w <= 0) return {};
|
|
std::vector<CellRect> rects =
|
|
computeCellRects(static_cast<int>(bank->size()), w, kGrid);
|
|
for (CellRect& r : rects) r.y += kHeaderHeight; // offset below the header
|
|
return rects;
|
|
}
|
|
|
|
// 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();
|
|
std::vector<CellRect> rects =
|
|
computeCellRects(static_cast<int>(samples.size()), w, kGrid);
|
|
for (CellRect& r : rects) r.y += kHeaderHeight; // grid sits below the header
|
|
// 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;
|
|
// Cells must not draw under the footer strip: the visible grid stops at the
|
|
// footer top (or the client bottom when the panel is too short for a footer).
|
|
const RECT footer = panelFooter(w, h);
|
|
const int gridBottom = footer.top < footer.bottom ? footer.top : h;
|
|
for (std::size_t i = 0; i < rects.size(); ++i) {
|
|
const CellRect& rect = rects[i];
|
|
// Skip cells entirely below the visible grid area (Wave A has no scroll;
|
|
// this just avoids computing thumbnails that cannot be seen).
|
|
if (rect.y >= gridBottom) continue;
|
|
const int idx = static_cast<int>(i);
|
|
const bool selected = g_panel.selection.contains(idx);
|
|
const bool focused = g_panel.selection.focus == idx;
|
|
const Envelope& env = thumbnailFor(samples[i], binWidth, projectDir);
|
|
drawThumbnail(&bmp, rect, env, selected, focused);
|
|
}
|
|
}
|
|
|
|
// The mode switch and tail footer draw LAST so their bands overlay the top/bottom
|
|
// of the grid / empty-state area regardless of which branch ran above.
|
|
drawModeSwitch(&bmp, w);
|
|
drawTailFooter(&bmp, w, h);
|
|
|
|
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();
|
|
// The selection indexes into the OLD bank order; a bank change (capture /
|
|
// project load) can invalidate those indices, so clear it and stop any
|
|
// audition of a sample that may no longer exist at the same index.
|
|
if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) {
|
|
g_panel.selection = Selection{};
|
|
stopAudition();
|
|
}
|
|
g_panel.selItemCount = static_cast<int>(g_panel.session->bank().size());
|
|
return true;
|
|
}
|
|
|
|
// --- New-content detection (D2 Wave 2) ----------------------------------------
|
|
//
|
|
// REAPER exposes no "item/track added" callback, so we diff live project state on the
|
|
// existing timer. Each tick: enumerate every track GUID and every item GUID, diff
|
|
// against the previous tick (GuidBaseline, first-poll-guarded), and auto-tag the new
|
|
// GUIDs into the active mode via the pure autoTagNewContent. An item on a MANUAL lane
|
|
// is exempt (design point #1) — its lane's durable name lacks the managed prefix. All
|
|
// enumeration is READ-ONLY on the project; the only mutation is to the in-memory
|
|
// membership index (persisted by persist on the next save, same as an action-driven tag).
|
|
|
|
// True iff `tr` has I_FREEMODE==2 (fixed lanes enabled). The SDK value is verified
|
|
// in view.cpp (kFreeModeFixedLanes=2); reproduced here as a local constant so
|
|
// bank_panel.cpp stays self-contained without pulling in view.cpp's private namespace.
|
|
constexpr int kFreeModeFixedLanes = 2;
|
|
|
|
bool isFixedLaneTrack(MediaTrack* tr) {
|
|
return static_cast<int>(GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == kFreeModeFixedLanes;
|
|
}
|
|
|
|
// Reads the durable P_LANENAME for the lane that item `it` sits on. Returns empty if
|
|
// the lane is unnamed or P_LANENAME is unavailable. Callers must already know the track
|
|
// is a fixed-lane track (I_FREEMODE==2) before calling this — the manual/non-manual
|
|
// distinction only applies there. On a non-fixed-lane track `I_FIXEDLANE` is
|
|
// meaningless; the isOnManualLane predicate handles that case via its isFixedLaneTrack
|
|
// argument, so callers should not call this at all for non-fixed-lane tracks.
|
|
std::string itemLaneName(MediaTrack* tr, MediaItem* it) {
|
|
const int laneIdx = static_cast<int>(GetMediaItemInfo_Value(it, "I_FIXEDLANE"));
|
|
char parm[32];
|
|
std::snprintf(parm, sizeof(parm), "P_LANENAME:%d", laneIdx);
|
|
char buf[512] = {0};
|
|
if (!GetSetMediaTrackInfo_String(tr, parm, buf, false)) return {};
|
|
return std::string(buf);
|
|
}
|
|
|
|
// An item's canonical GUID string via GetSetMediaItemInfo_String("GUID"). Empty on
|
|
// failure (a read failure must never be tagged — guid_diff/autoTag both skip empties).
|
|
std::string itemGuid(MediaItem* it) {
|
|
char buf[64] = {0};
|
|
if (!GetSetMediaItemInfo_String(it, "GUID", buf, false)) return {};
|
|
return std::string(buf);
|
|
}
|
|
|
|
// Enumerates the live project's track + item GUIDs. Fills `allGuids` (the full live set,
|
|
// baseline input) and, for each item, records whether it sits on a manual lane so a
|
|
// newly-detected item can be exempted from auto-tag without a second project walk.
|
|
//
|
|
// Manual-lane classification uses the single pure predicate isOnManualLane(isFixedLaneTrack,
|
|
// laneName) from lane_keys — the same predicate the apply path consults — so the exemption
|
|
// rule is defined in exactly one place and is unit-tested there.
|
|
void enumerateLiveGuids(ReaProject* proj, std::set<std::string>& allGuids,
|
|
std::map<std::string, bool>& itemOnManualLane) {
|
|
const int trackCount = CountTracks(proj);
|
|
for (int t = 0; t < trackCount; ++t) {
|
|
MediaTrack* tr = GetTrack(proj, t);
|
|
if (!tr) continue;
|
|
std::string tg = guidString(tr);
|
|
if (!tg.empty()) allGuids.insert(tg);
|
|
|
|
// Compute the fixed-lane status once per track (not per item) — I_FREEMODE is a
|
|
// track-level attribute and is the same for every item on the track.
|
|
const bool fixedLane = isFixedLaneTrack(tr);
|
|
|
|
const int itemCount = CountTrackMediaItems(tr);
|
|
for (int i = 0; i < itemCount; ++i) {
|
|
MediaItem* it = GetTrackMediaItem(tr, i);
|
|
if (!it) continue;
|
|
std::string ig = itemGuid(it);
|
|
if (ig.empty()) continue;
|
|
allGuids.insert(ig);
|
|
// Classify via the single shared predicate. For a fixed-lane track we read
|
|
// the item's lane name; for a normal track we pass "" (isOnManualLane returns
|
|
// false immediately for non-fixed-lane tracks regardless of name).
|
|
const std::string ln = fixedLane ? itemLaneName(tr, it) : std::string{};
|
|
itemOnManualLane[ig] = isOnManualLane(fixedLane, ln);
|
|
}
|
|
}
|
|
}
|
|
|
|
// One detection tick: diff live GUIDs against the baseline and auto-tag the new ones
|
|
// into the active mode. Runs every timer tick regardless of panel open/close (content
|
|
// is created in the arrange). READ-ONLY on the project; mutates only the in-memory
|
|
// membership index.
|
|
//
|
|
// INTENTIONAL: membership mutation happens OUTSIDE any Undo block. Auto-tag is a
|
|
// background metadata update (like setting a label), not a destructive project edit.
|
|
// persist.cpp writes it on the next project save alongside the bank and view state, the
|
|
// same way an action-driven tag is persisted. Wrapping this in an Undo block would flood
|
|
// the REAPER undo history with a new entry for every timer tick that sees new content.
|
|
// Returns true iff this tick tagged at least one new GUID into a mode — the signal the
|
|
// caller uses to decide whether to run the lane-minting pass (a track can only newly
|
|
// become multi-mode when auto-tag just placed content on it). No tag ⇒ nothing to mint.
|
|
bool detectNewContent() {
|
|
if (!g_panel.session) return false;
|
|
|
|
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
|
|
|
// A project (re)load re-arms the first-poll guard so we never diff across two
|
|
// projects. The signal is persist's — main.cpp calls bankPanelNotifyProjectLoaded()
|
|
// on the tick persist restores the project's membership + active mode, which sets
|
|
// reloadPending. Draining it here re-baselines against the fully-loaded set (that
|
|
// same tick's reapply-active-mode enumerated those tracks, so they are present),
|
|
// and the observe() below returns nothing new — pre-existing untagged tracks stay
|
|
// Arrange. GuidBaseline self-arms on its first observe() for the very first tick, so
|
|
// no separate first-tick handling is needed here. Using persist's GUID-primary load
|
|
// signal (not a local pointer compare) is what fixes the reload-mis-tag: the two
|
|
// identity checks can no longer diverge on a recycled ReaProject* address.
|
|
if (g_panel.reloadPending) {
|
|
g_panel.contentBaseline.reset();
|
|
g_panel.reloadPending = false;
|
|
}
|
|
|
|
std::set<std::string> live;
|
|
std::map<std::string, bool> itemOnManualLane;
|
|
enumerateLiveGuids(proj, live, itemOnManualLane);
|
|
|
|
const std::vector<std::string> added = g_panel.contentBaseline.observe(live);
|
|
if (added.empty()) return false; // first poll after open, or nothing new this tick
|
|
|
|
// Split the new GUIDs into tracks vs items so the pure decision can apply the
|
|
// manual-lane exemption to items only. A GUID present in the item-lane map is an
|
|
// item; otherwise it is a track (track GUIDs never appear in that map).
|
|
std::vector<std::string> newTracks;
|
|
std::vector<NewItem> newItems;
|
|
for (const std::string& g : added) {
|
|
auto it = itemOnManualLane.find(g);
|
|
if (it == itemOnManualLane.end()) {
|
|
newTracks.push_back(g); // a track GUID
|
|
} else {
|
|
newItems.push_back(NewItem{g, it->second}); // an item; carries its exemption
|
|
}
|
|
}
|
|
|
|
ViewModeModel& model = g_panel.session->view();
|
|
const std::vector<AutoTag> tags =
|
|
autoTagNewContent(newTracks, newItems, model.activeModeId());
|
|
for (const AutoTag& tag : tags)
|
|
model.membership().tag(tag.guid, tag.modeId);
|
|
return !tags.empty();
|
|
}
|
|
|
|
// --- Audition preview ---------------------------------------------------------
|
|
//
|
|
// READ-ONLY / NON-DESTRUCTIVE (load-bearing principle): audition is PREVIEW
|
|
// playback only. It NEVER inserts into the arrange, creates items/tracks, or
|
|
// mutates the project or bank. PlayPreview streams a caller-owned PCM_source
|
|
// through REAPER's preview bus and touches nothing in the project.
|
|
//
|
|
// FLAGGED RUNTIME ASSUMPTIONS (header does not specify these; verified only by
|
|
// signature/struct, not semantics — DAW-verify):
|
|
// 1. REAPER's audio thread reads the preview_register_t by POINTER while the
|
|
// preview is active (the struct's own comment mandates a cs/mutex we init),
|
|
// so the register must outlive playback — we hold it in g_panel (static),
|
|
// never on the stack.
|
|
// 2. StopPreview is assumed to detach the source from the audio thread BEFORE it
|
|
// returns, making it safe to PCM_Source_Destroy the source immediately after.
|
|
// This is the conventional contract (SWS' preview helpers rely on it) but is
|
|
// NOT documented in the header — flagged. If a rare race surfaced, the fix is
|
|
// a StartPreviewFade + deferred free; not done now (YAGNI, no evidence).
|
|
// 3. m_out_chan == 0 routes to the first hardware output pair (stereo). We do not
|
|
// set mono (&1024). volume 1.0, loop false, curpos 0.
|
|
|
|
// Initializes the preview register's cs/mutex ONCE for the panel's lifetime. The
|
|
// preview struct guards its fields with a platform lock the caller must set up
|
|
// (reaper_plugin.h). Idempotent.
|
|
void initPreview() {
|
|
if (g_panel.previewInited) return;
|
|
#ifdef _WIN32
|
|
InitializeCriticalSection(&g_panel.preview.cs);
|
|
#else
|
|
pthread_mutex_init(&g_panel.preview.mutex, nullptr);
|
|
#endif
|
|
g_panel.previewInited = true;
|
|
}
|
|
|
|
// Stops any active preview and frees the owned PCM_source. Safe to call when
|
|
// nothing is playing (no-op). Every stop path funnels through here so the source
|
|
// is freed exactly once and never dangles.
|
|
void stopAudition() {
|
|
if (g_panel.previewActive) {
|
|
StopPreview(&g_panel.preview);
|
|
g_panel.previewActive = false;
|
|
}
|
|
// Free the source AFTER StopPreview has detached it (assumption #2). Clear the
|
|
// register's src so a stale pointer can never be handed back to PlayPreview.
|
|
if (g_panel.previewSrc) {
|
|
PCM_Source_Destroy(g_panel.previewSrc);
|
|
g_panel.previewSrc = nullptr;
|
|
}
|
|
g_panel.preview.src = nullptr;
|
|
}
|
|
|
|
// Destroys the preview register's cs/mutex on panel teardown, after stopAudition.
|
|
void deinitPreview() {
|
|
if (!g_panel.previewInited) return;
|
|
#ifdef _WIN32
|
|
DeleteCriticalSection(&g_panel.preview.cs);
|
|
#else
|
|
pthread_mutex_destroy(&g_panel.preview.mutex);
|
|
#endif
|
|
g_panel.previewInited = false;
|
|
}
|
|
|
|
// Auditions the sample at bank index `idx`: stops any prior preview, loads the
|
|
// sample's file as a PCM_source, and starts stock preview playback. Re-audition
|
|
// (calling with a new idx while one plays) stops the previous first. On any
|
|
// failure (bad index, unsaved project, unreadable file, PlayPreview refusal) it
|
|
// leaves nothing playing and no source leaked.
|
|
void startAudition(int idx) {
|
|
// Always stop+free the previous first — re-audition semantics, and it clears
|
|
// previewSrc so the load below starts clean.
|
|
stopAudition();
|
|
|
|
const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr;
|
|
if (!bank) return;
|
|
const std::vector<Sample>& samples = bank->all();
|
|
if (idx < 0 || idx >= static_cast<int>(samples.size())) return;
|
|
|
|
const std::string projectDir = currentProjectDir();
|
|
const std::string abs = resolveBankFile(projectDir, samples[idx].relativePath);
|
|
if (abs.empty()) return; // unsaved project / unresolvable — nothing to play
|
|
|
|
PCM_source* src = PCM_Source_CreateFromFile(abs.c_str());
|
|
if (!src) return; // unreadable file — no preview, no leak
|
|
|
|
// Fill the register. cs/mutex already initialized (initPreview at panel open).
|
|
g_panel.preview.src = src;
|
|
g_panel.preview.m_out_chan = 0; // first hardware output pair (assumption #3)
|
|
g_panel.preview.curpos = 0.0;
|
|
g_panel.preview.loop = false;
|
|
g_panel.preview.volume = 1.0;
|
|
g_panel.preview.peakvol[0] = 0.0;
|
|
g_panel.preview.peakvol[1] = 0.0;
|
|
g_panel.preview.preview_track = nullptr;
|
|
|
|
if (PlayPreview(&g_panel.preview) != 0) {
|
|
g_panel.previewSrc = src; // we now own it until stopAudition frees it
|
|
g_panel.previewActive = true;
|
|
} else {
|
|
// PlayPreview refused — free the source we created rather than leak it.
|
|
PCM_Source_Destroy(src);
|
|
g_panel.preview.src = nullptr;
|
|
}
|
|
}
|
|
|
|
// --- Selection + input --------------------------------------------------------
|
|
|
|
// True while VK_CONTROL / VK_SHIFT is physically down. SWELL does NOT set MK_* bits
|
|
// in a mouse message's wParam (swell-types.h), so modifier state is read live via
|
|
// GetAsyncKeyState — the portable path (Win/mac/GDK all support these two VKs).
|
|
bool ctrlDown() { return (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0; }
|
|
bool shiftDown() { return (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0; }
|
|
|
|
// The current bank item count (0 when no session/bank).
|
|
int bankItemCount() {
|
|
const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr;
|
|
return bank ? static_cast<int>(bank->size()) : 0;
|
|
}
|
|
|
|
// Requests a repaint of the whole client area (selection/focus chrome changed).
|
|
void invalidatePanel() {
|
|
if (g_panel.hwnd) InvalidateRect(g_panel.hwnd, nullptr, FALSE);
|
|
}
|
|
|
|
// Handles a left-button click at client (x, y): hit-test to a cell, update the
|
|
// selection through the pure model with the live modifier state, repaint. A click
|
|
// on empty space (gap/margin/below grid) clears the selection AND stops audition
|
|
// (deselect stop path). READ-ONLY: never mutates the bank/project.
|
|
void handleClick(int x, int y) {
|
|
// Mode-switch header takes precedence: a click in the header band activates the
|
|
// clicked mode via the D2/D4 view shell (the same action the user can bind) and
|
|
// repaints. Load-bearing principle preserved — this fires a Design-View toggle,
|
|
// it never inserts into the arrange or mutates the bank.
|
|
if (g_panel.session) {
|
|
RECT cr{};
|
|
GetClientRect(g_panel.hwnd, &cr);
|
|
const int w = cr.right - cr.left;
|
|
const int n = modeCount();
|
|
const int seg = hitTestSegment(x, y, panelHeader(w), n);
|
|
if (seg >= 0) {
|
|
const std::vector<Mode>& modes = g_panel.session->view().modes().all();
|
|
if (seg < static_cast<int>(modes.size())) {
|
|
applyMode(g_panel.session->view(), modes[static_cast<std::size_t>(seg)].id,
|
|
nullptr);
|
|
invalidatePanel(); // active-segment highlight + parked-track redraw
|
|
}
|
|
return; // header click consumed; do NOT fall through to grid selection
|
|
}
|
|
}
|
|
|
|
// Tail-mode footer: a click anywhere in the bottom strip cycles the tail mode
|
|
// (None -> Auto -> Manual -> None) and repaints. It mutates the SESSION's tail
|
|
// setting (which the capture actions read and persist saves with the project) and
|
|
// marks the project dirty so the choice travels inside the .rpp — it touches
|
|
// NOTHING in the bank/arrange. Checked before the grid so a footer click never
|
|
// selects.
|
|
if (g_panel.session && pointInFooter(x, y)) {
|
|
TailSetting& tail = g_panel.session->tail();
|
|
tail.mode = cycleTailMode(tail.mode);
|
|
markTailDirty();
|
|
invalidatePanel();
|
|
return; // footer click consumed; do NOT fall through to grid selection
|
|
}
|
|
|
|
const std::vector<CellRect> rects = panelRects();
|
|
const int hit = hitTestCell(x, y, rects);
|
|
const int count = bankItemCount();
|
|
|
|
if (hit < 0) {
|
|
// Click on empty space clears the selection and stops any audition.
|
|
if (!g_panel.selection.empty() || g_panel.selection.focus >= 0) {
|
|
g_panel.selection = Selection{};
|
|
stopAudition();
|
|
invalidatePanel();
|
|
}
|
|
return;
|
|
}
|
|
|
|
g_panel.selection =
|
|
applyClick(g_panel.selection, hit, ctrlDown(), shiftDown(), count);
|
|
g_panel.selItemCount = count;
|
|
invalidatePanel();
|
|
}
|
|
|
|
// Handles a scroll-wheel notch over client (x, y) with signed wheel delta `delta`.
|
|
// Fine-adjusts the Manual tail length in kManualStepMs steps ONLY when the cursor is
|
|
// over the footer strip AND the mode is Manual — wheel up lengthens, down shortens,
|
|
// clamped to [0, kMaxTailMs]. In Off/Auto (or off the footer) it does nothing (returns
|
|
// false so the caller can let REAPER/the docker handle the wheel normally). On a real
|
|
// change it mutates the SESSION's tail setting, marks the project dirty (so it saves),
|
|
// and repaints the live length. Returns true iff the wheel was consumed.
|
|
bool handleWheel(int x, int y, int delta) {
|
|
if (!g_panel.session) return false;
|
|
if (!pointInFooter(x, y)) return false;
|
|
|
|
TailSetting& tail = g_panel.session->tail();
|
|
if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only
|
|
|
|
// One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad
|
|
// that sends fractional deltas still steps predictably. Sign carries direction.
|
|
const int notches = delta / 120;
|
|
if (notches == 0) return false; // sub-notch movement — nothing to apply yet
|
|
|
|
const double before = tail.manualMs;
|
|
tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs);
|
|
if (tail.manualMs == before) return true; // already at a bound — consumed, no change
|
|
|
|
markTailDirty();
|
|
invalidatePanel(); // label shows the new length live
|
|
return true;
|
|
}
|
|
|
|
// The column count for the panel's CURRENT client width (nav needs the same wrap
|
|
// the layout uses). >= 1.
|
|
int columnsNow() {
|
|
if (!g_panel.hwnd) return 1;
|
|
RECT cr{};
|
|
GetClientRect(g_panel.hwnd, &cr);
|
|
return columnsForWidth(cr.right - cr.left, kGrid);
|
|
}
|
|
|
|
// True iff `hwnd` is our panel window or a descendant of it (the accelerator hook
|
|
// only claims keys when focus is inside the panel). Walks the parent chain.
|
|
bool isOurWindow(HWND hwnd) {
|
|
for (HWND w = hwnd; w; w = GetParent(w))
|
|
if (w == g_panel.hwnd) return true;
|
|
return false;
|
|
}
|
|
|
|
// Handles a key-down (virtual key `vk`) while the panel is focused. Returns true if
|
|
// the key was consumed (arrow nav / Enter/Space audition / Esc stop), false to let
|
|
// REAPER handle it. Arrow keys mutate the selection through the pure nav model and
|
|
// repaint; Shift extends. READ-ONLY: never mutates the bank/project.
|
|
bool handleKey(int vk) {
|
|
const int count = bankItemCount();
|
|
if (count <= 0) return false;
|
|
|
|
switch (vk) {
|
|
case VK_LEFT:
|
|
case VK_RIGHT:
|
|
case VK_UP:
|
|
case VK_DOWN: {
|
|
const NavKey nk = vk == VK_LEFT ? NavKey::Left
|
|
: vk == VK_RIGHT ? NavKey::Right
|
|
: vk == VK_UP ? NavKey::Up
|
|
: NavKey::Down;
|
|
g_panel.selection =
|
|
navigate(g_panel.selection, nk, columnsNow(), count, shiftDown());
|
|
g_panel.selItemCount = count;
|
|
invalidatePanel();
|
|
return true;
|
|
}
|
|
case VK_RETURN:
|
|
case VK_SPACE:
|
|
// Audition the focused cell. Enter/Space with no focus does nothing
|
|
// (nothing to play). Re-audition stops the previous inside startAudition.
|
|
if (g_panel.selection.focus >= 0)
|
|
startAudition(g_panel.selection.focus);
|
|
return true;
|
|
case VK_ESCAPE:
|
|
// Stop audition (does not clear the selection — Esc is "stop", not
|
|
// "deselect"). No-op when nothing is playing; still consume so REAPER
|
|
// does not treat Esc as a global stop while the panel is focused.
|
|
stopAudition();
|
|
return true;
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// The keyboard accelerator hook (registered with "accelerator"). REAPER calls this
|
|
// for every keystroke; we claim arrow/Enter/Space/Esc ONLY when focus is inside the
|
|
// panel, eating them so REAPER does not steal arrows for the arrange. Returns 1 to
|
|
// eat, 0 to pass on (not our window / not our key).
|
|
int translateAccel(MSG* msg, accelerator_register_t* /*ctx*/) {
|
|
if (!msg || msg->message != WM_KEYDOWN) return 0; // key-down only
|
|
if (!g_panel.open || !g_panel.hwnd) return 0;
|
|
if (!isOurWindow(GetFocus())) return 0; // focus not in the panel
|
|
return handleKey(static_cast<int>(msg->wParam)) ? 1 : 0;
|
|
}
|
|
|
|
accelerator_register_t g_accel{translateAccel, true, nullptr};
|
|
bool g_accelRegistered = false;
|
|
|
|
// Registers the keyboard hook once (on first panel open). isLocal must be true
|
|
// (reaper_plugin.h). Safe to call repeatedly.
|
|
void registerAccel() {
|
|
if (g_accelRegistered || !g_rec) return;
|
|
g_rec->Register("accelerator", &g_accel);
|
|
g_accelRegistered = true;
|
|
}
|
|
|
|
// Mirror-unregisters the keyboard hook on teardown.
|
|
void unregisterAccel() {
|
|
if (!g_accelRegistered || !g_rec) return;
|
|
g_rec->Register("-accelerator", &g_accel);
|
|
g_accelRegistered = false;
|
|
}
|
|
|
|
// --- 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_LBUTTONDOWN: {
|
|
// Take keyboard focus so the accelerator hook routes arrows/audition
|
|
// keys to us, then resolve the click. Coordinates are client-relative
|
|
// signed shorts in lParam (SWELL sets these even though it omits the
|
|
// MK_* modifier bits in wParam — hence GetAsyncKeyState for modifiers).
|
|
SetFocus(hwnd);
|
|
const int x = GET_X_LPARAM(lParam);
|
|
const int y = GET_Y_LPARAM(lParam);
|
|
handleClick(x, y);
|
|
return 0;
|
|
}
|
|
case WM_MOUSEWHEEL: {
|
|
// Fine-adjust the Manual tail length when the wheel is over the footer.
|
|
// UNLIKE the button messages, WM_MOUSEWHEEL carries SCREEN coordinates in
|
|
// lParam (Win32 and SWELL agree — swell-generic-gdk.cpp §WM_MOUSEWHEEL), so
|
|
// convert to client space before hit-testing the footer. The signed wheel
|
|
// delta is the HIWORD of wParam (SWELL packs it as (delta<<16), delta=+/-120,
|
|
// matching GET_WHEEL_DELTA_WPARAM). Consume (return 1) only when the footer
|
|
// handler acts, so scrolling elsewhere in the dock still behaves normally.
|
|
POINT pt{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)};
|
|
ScreenToClient(hwnd, &pt);
|
|
const int delta = static_cast<short>(HIWORD(wParam));
|
|
return handleWheel(pt.x, pt.y, delta) ? 1 : 0;
|
|
}
|
|
case WM_DESTROY:
|
|
// REAPER closed the dock (user X'd it). Stop any audition (window-close
|
|
// stop path — no preview may outlive the window) and reflect closed
|
|
// state so the toggle re-opens rather than reusing a dead HWND.
|
|
stopAudition();
|
|
g_panel.selection = Selection{};
|
|
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).
|
|
// Set up the preview register's lock ONCE before the window can audition.
|
|
initPreview();
|
|
|
|
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;
|
|
|
|
// Start receiving arrow/audition keys while the panel is open.
|
|
registerAccel();
|
|
|
|
// 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() {
|
|
// Stop audition before the window goes away (window-close stop path). WM_DESTROY
|
|
// also stops, but stop here too so a DockWindowRemove that suppresses WM_DESTROY
|
|
// still tears the preview down (idempotent: stopAudition no-ops if not playing).
|
|
stopAudition();
|
|
g_panel.selection = Selection{};
|
|
unregisterAccel();
|
|
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;
|
|
}
|
|
|
|
std::vector<std::string> bankPanelSelectedSampleIds() {
|
|
std::vector<std::string> ids;
|
|
const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr;
|
|
if (!bank) return ids;
|
|
const std::vector<Sample>& samples = bank->all();
|
|
const int count = static_cast<int>(samples.size());
|
|
// selection.indices is sorted-ascending unique (bank_grid invariant), so the
|
|
// returned ids come out in bank order. Guard each index against the live count
|
|
// in case the selection outran a shrink the fingerprint pass hasn't cleared yet.
|
|
for (int idx : g_panel.selection.indices) {
|
|
if (idx >= 0 && idx < count)
|
|
ids.push_back(samples[static_cast<std::size_t>(idx)].id);
|
|
}
|
|
return ids;
|
|
}
|
|
|
|
void bankPanelNotifyProjectLoaded() {
|
|
// Persist restored a project's membership + active mode this tick (main.cpp calls
|
|
// this from the same consumeLoadSignal() branch that reapplies the active mode).
|
|
// Arm the new-content detector to re-baseline on its next tick so the just-loaded
|
|
// project's pre-existing content is treated as the baseline (nothing new) rather
|
|
// than diffed against the previous project and mass-tagged into the active mode.
|
|
// A flag (not an inline reset) because detectNewContent owns the baseline and runs
|
|
// later in the SAME OnTimer tick — it drains this and re-baselines against the live
|
|
// set in one place, keeping the reset and the observe() adjacent and ordered.
|
|
g_panel.reloadPending = true;
|
|
}
|
|
|
|
void bankPanelRefresh() {
|
|
// New-content auto-tag detection runs EVERY tick regardless of panel open/close:
|
|
// tracks/items are created in the arrange view, not the panel, so detection must
|
|
// not be gated on the dock being visible. READ-ONLY on the project; only mutates
|
|
// the in-memory membership index (persist saves it like any action-driven tag).
|
|
const bool tagged = detectNewContent();
|
|
|
|
// Lane minting (D2 Wave 3) runs ONLY when detection just tagged new content — a
|
|
// track can only newly become multi-mode when auto-tag placed content on it. Unlike
|
|
// the invisible membership tag above, minting is a visible structural mutation
|
|
// (I_FREEMODE/I_FIXEDLANE/P_LANENAME), so mintManagedLanes wraps it in its own Undo
|
|
// block and only mints for tracks that hold >1 mode's content — a single-mode track
|
|
// is left to D1 whole-track parking. Managed lanes only; manual lanes untouched.
|
|
if (tagged && g_panel.session) {
|
|
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
|
mintManagedLanes(g_panel.session->view(), proj);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
TailSetting bankPanelTailSetting() {
|
|
// The authoritative setting lives in the session (session->tail()) so it travels
|
|
// inside the .rpp: it loads per project and saves with the project. This stays the
|
|
// read seam for the capture actions. manualMs is clamped here so a caller always
|
|
// receives a within-cap length regardless of what was stored/scrolled.
|
|
TailSetting s = currentTail();
|
|
s.manualMs = clampManualMs(s.manualMs);
|
|
return s;
|
|
}
|
|
|
|
void bankPanelShutdown() {
|
|
closePanel(); // stops audition + destroys the window
|
|
deinitPreview(); // destroy the preview lock (after the last stop)
|
|
g_panel.cache.clear();
|
|
g_panel.session = nullptr;
|
|
}
|
|
|
|
} // namespace reasampler
|