// bank_panel.cpp — REAPER-facing docked grid (M5, Wave A). See bank_panel.h. // // Compiled into the reaper_reasampler MODULE. Includes reaper_plugin_functions.h // WITHOUT REAPERAPI_IMPLEMENT — main.cpp owns the API pointers; here they are // extern (CLAUDE.md §contract). // // What this file owns (all REAPER/SWELL/LICE-bound, hence DAW-verified, not unit // tested): // * a SWELL dialog (IDD_BANK_PANEL) docked via DockWindowAddEx / undocked via // DockWindowRemove; toggled open/closed. // * WM_PAINT: draw the current bank as a grid of waveform thumbnails using LICE, // or a centered empty-state string when the bank is empty. // * per-sample PCM read via PCM_source (PCM_Source_CreateFromFile + // PCM_source::GetSamples) fed to peaks::computeEnvelope at the cell width. // * an in-memory thumbnail cache keyed by (sample id, draw width, bank // generation) so paint does not recompute envelopes every frame. // // READ-ONLY (load-bearing principle): this panel never inserts into the arrange // and never mutates the project or the bank. It only reads g_session.bank() and // reads sample files off disk. // // THUMBNAIL-CACHE DECISION (CONTEXT.md §Open questions "recompute vs store peak // bins alongside the index"): for Wave A we RECOMPUTE into an in-memory cache and // do NOT persist peak bins in the index. Rationale: the persisted index stays // lean and format-stable; envelopes are cheap to recompute on demand and must be // recomputed anyway whenever the panel width (bin count) changes, which a stored // fixed-resolution bin set could not satisfy. Storing bins is a later optimization // if profiling shows recompute cost matters (it is bounded: one read + one O(frames) // pass per sample, only on cache miss). #include "bank_panel.h" #include #include #include #include #include #include #include #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 #include // GET_X_LPARAM / GET_Y_LPARAM (SWELL supplies them on mac/linux) #else #include #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 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 buf(4096, '\0'); EnumProjects(-1, buf.data(), static_cast(buf.size())); std::string rpp(buf.data()); if (rpp.empty()) return {}; // unsaved project: no resolvable bank return normalizeSlashes(fs::path(rpp).parent_path().string()); } // --- Thumbnail computation ---------------------------------------------------- // Reads up to kMaxThumbnailFrames of interleaved PCM from `absPath` and computes a // per-channel min/max envelope at `width` bins. Returns an empty envelope on any // failure (missing file, unreadable source, zero-length) — the caller draws an // empty cell rather than propagating an error. READ-ONLY: opens the file through // a PCM_source and destroys it; never touches the project. Envelope computeThumbnail(const std::string& absPath, int width) { if (width <= 0 || absPath.empty()) return {}; PCM_source* src = PCM_Source_CreateFromFile(absPath.c_str()); if (!src) return {}; const int nch = src->GetNumChannels(); const double srate = src->GetSampleRate(); const double lengthSec = src->GetLength(); if (nch <= 0 || srate < 1.0 || lengthSec <= 0.0) { PCM_Source_Destroy(src); return {}; } // Frames to read: the whole sample, capped so a long bounce stays cheap. std::int64_t totalFrames = static_cast(lengthSec * srate); if (totalFrames <= 0) { PCM_Source_Destroy(src); return {}; } int frames = totalFrames > kMaxThumbnailFrames ? kMaxThumbnailFrames : static_cast(totalFrames); // One GetSamples call filling a caller-allocated interleaved buffer. block.length // is the requested frame count; samples_out reports what was actually rendered // (may be short at end-of-file). We ask at the source's own rate so no resample. std::vector buf(static_cast(frames) * nch, 0.0); PCM_source_transfer_t block{}; block.time_s = 0.0; block.samplerate = srate; block.nch = nch; block.length = frames; block.samples = buf.data(); block.samples_out = 0; src->GetSamples(&block); PCM_Source_Destroy(src); const int got = block.samples_out; if (got <= 0) return {}; // ReaSample is double in some builds, float in others; peaks consumes float // (peaks::Sample is a float alias, its native buffer type). Convert at this // boundary — use `float` explicitly, NOT `reasampler::Sample`, because that // name also denotes bank_model's metadata struct in this same namespace when // both headers are visible (they are here in the module). const std::size_t sampleCount = static_cast(got) * nch; std::vector pcm(sampleCount); for (std::size_t i = 0; i < sampleCount; ++i) pcm[i] = static_cast(buf[i]); return computeEnvelope(pcm, static_cast(nch), static_cast(got), static_cast(width)); } // Returns the cached envelope for `sample` at `width`, computing+inserting it on a // miss. Keyed by (id, width, current generation) so a resize or bank change misses // and recomputes. `projectDir` resolves the sample's relative path to disk. const Envelope& thumbnailFor(const Sample& sample, int width, const std::string& projectDir) { ThumbnailKey key{sample.id, width, g_panel.generation}; const std::string ks = thumbnailKeyString(key); auto it = g_panel.cache.find(ks); if (it != g_panel.cache.end()) return it->second.envelope; const std::string abs = resolveBankFile(projectDir, sample.relativePath); CachedThumbnail thumb; thumb.width = width; thumb.envelope = computeThumbnail(abs, width); auto ins = g_panel.cache.emplace(ks, std::move(thumb)); return ins.first->second.envelope; } // --- Drawing ------------------------------------------------------------------ // Draws one sample's envelope into `rect` of `bmp`: a cell background, border, a // zero midline, and the min/max waveform. Multi-channel envelopes are stacked // vertically (each channel gets an equal horizontal band) so a stereo sample shows // both channels without folding (precision invariant: no stereo fold). // `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(env.size()); const int bandH = rect.height / channels; for (int ch = 0; ch < channels; ++ch) { const ChannelEnvelope& bins = env[ch]; const int bandTop = rect.y + ch * bandH; const int midY = bandTop + bandH / 2; // half-height in pixels a full-scale (|value|==1) sample reaches, minus a // 2px inset so the waveform never touches the cell border. const double halfSpan = (bandH / 2) - 2; LICE_Line(bmp, rect.x + 2, midY, rect.x + rect.width - 2, midY, kColMidline, 1.0f, 0, false); const int nbins = static_cast(bins.size()); if (nbins <= 0) continue; // Map bin i -> a column x within the cell's inner width. The envelope was // computed at `width` bins == the cell's drawable columns, so bin i maps // to column i; guard anyway if they differ (e.g. cached at another width). const int innerW = rect.width - 4; // 2px inset each side for (int i = 0; i < nbins; ++i) { const int x = rect.x + 2 + (nbins > 1 ? (i * (innerW - 1)) / (nbins - 1) : 0); // min<=max always (peaks invariant). Draw a vertical line from the // min sample to the max sample, clamped to the band. int yMax = midY - static_cast(bins[i].max * halfSpan); // max -> up int yMin = midY - static_cast(bins[i].min * halfSpan); // min -> down if (yMax < bandTop) yMax = bandTop; if (yMin > bandTop + bandH - 1) yMin = bandTop + bandH - 1; LICE_Line(bmp, x, yMin, x, yMax, kColWaveform, 1.0f, 0, false); } } } // Draws the empty-state message centered in the client area. void drawEmptyState(HWND hwnd, LICE_IBitmap* bmp, int w, int h) { (void)hwnd; LICE_Clear(bmp, kColBackground); HDC dc = bmp->getDC(); if (!dc) return; const char* msg = "No samples in this project's bank yet. Capture one to see it here."; RECT rc{0, 0, w, h}; SetTextColor(dc, RGB(200, 200, 205)); SetBkMode(dc, TRANSPARENT); DrawText(dc, msg, -1, &rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE | DT_WORDBREAK); } // The 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(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& modes = view.modes().all(); const int n = static_cast(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 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(i)]; const Mode& mode = modes[static_cast(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 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 rects = computeCellRects(static_cast(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& samples = bank->all(); std::vector rects = computeCellRects(static_cast(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(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(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(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(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& allGuids, std::map& 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 live; std::map itemOnManualLane; enumerateLiveGuids(proj, live, itemOnManualLane); const std::vector 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 newTracks; std::vector 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 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& samples = bank->all(); if (idx < 0 || idx >= static_cast(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(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& modes = g_panel.session->view().modes().all(); if (seg < static_cast(modes.size())) { applyMode(g_panel.session->view(), modes[static_cast(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 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(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(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 bankPanelSelectedSampleIds() { std::vector ids; const BankIndex* bank = g_panel.session ? &g_panel.session->bank() : nullptr; if (!bank) return ids; const std::vector& samples = bank->all(); const int count = static_cast(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(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