diff --git a/CMakeLists.txt b/CMakeLists.txt index 22857c9..5da172f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -621,6 +621,15 @@ target_include_directories(editor_geometry PUBLIC src/vst) add_library(bridge_marshal STATIC src/vst/bridge_marshal.cpp) target_include_directories(bridge_marshal PUBLIC src/vst) +# embed_strip (Phase S6) — PURE layout + hit-test for the embedded TCP/MCP strip: the +# 128-key span -> zone-segment rects, point -> zone selection, and the level-band fill. +# The mirror of editor_geometry (whose Rect + contains() it reuses); unit-tested outside +# the DAW, while the embed shell (src/vst/reasampler_embed.cpp) marshals REAPER's embed +# messages (paint bitmap + mouse coords) into it. Links editor_geometry for the shared Rect. +add_library(embed_strip STATIC src/vst/embed_strip.cpp) +target_include_directories(embed_strip PUBLIC src/vst) +target_link_libraries(embed_strip PUBLIC editor_geometry) + # sample_map (Phase S4) — PURE mapping logic for the Tier-0 instrument: the live bank # blob -> selected sample (via the SHARED bank_book JSON parse, NOT a second parser), # interleaved->mono downmix (the Tier-0 channel policy), the Tier-0 chromatic keymap @@ -641,6 +650,10 @@ add_executable(bridge_marshal_tests tests/test_bridge_marshal.cpp) target_link_libraries(bridge_marshal_tests PRIVATE bridge_marshal) add_test(NAME bridge_marshal_tests COMMAND bridge_marshal_tests) +add_executable(embed_strip_tests tests/test_embed_strip.cpp) +target_link_libraries(embed_strip_tests PRIVATE embed_strip) +add_test(NAME embed_strip_tests COMMAND embed_strip_tests) + # sample_map: the S4 mapping heart. Links ONLY sample_map (+ its pure deps) — NEITHER # the VST3 SDK nor the REAPER SDK — the same structural plain-data-boundary proof the # sampler_core test enforces. @@ -800,6 +813,7 @@ if(WIN32) src/vst/vst_entry.cpp src/vst/reasampler_processor.cpp src/vst/reasampler_editor.cpp + src/vst/reasampler_embed.cpp src/vst/reaper_bridge.cpp # SDK module entry — compiled into the module (not the static lib) so the # InitDll/ExitDll dll exports survive the link (see vst3_sdk note above). @@ -813,8 +827,10 @@ if(WIN32) # peaks) transitively. capture_paths: the shared M4 path resolution (resolveBankFile / # projectDirOfRpp) the bridge + processor use. Its PUBLIC include dirs (src, src/vst) # give the shell TUs their headers (ext_keys.h, bank_book.h, sampler_core.h, ...). + # embed_strip (S6): the pure inline-strip layout + hit-test the embed shell marshals + # into; it links editor_geometry transitively (shared Rect). target_link_libraries(reasampler_vst PRIVATE vst3_sdk editor_geometry bridge_marshal - sample_map capture_paths) + sample_map capture_paths embed_strip) # SDK_INC gives reaper_vst3_interfaces.h + reaper_plugin_functions.h for the bridge; # WDL_INC gives LICE for the editor. The VST3 SDK headers come from vst3_sdk PUBLIC. target_include_directories(reasampler_vst PRIVATE ${SDK_INC} ${WDL_INC}) diff --git a/src/vst/embed_strip.cpp b/src/vst/embed_strip.cpp new file mode 100644 index 0000000..e1bf0d9 --- /dev/null +++ b/src/vst/embed_strip.cpp @@ -0,0 +1,86 @@ +// embed_strip.cpp — see embed_strip.h. Pure math; no host types. + +#include "embed_strip.h" + +#include + +namespace reasampler::vst { + +namespace { + +// Clamp a MIDI note to [0, kEmbedKeyCount-1]. +int clampNote(int n) { + if (n < 0) return 0; + if (n > kEmbedKeyCount - 1) return kEmbedKeyCount - 1; + return n; +} + +// Map a key boundary in [0, kEmbedKeyCount] to an x pixel inside a band of the given +// left/width. keyEdge is a boundary (0..128), so keyEdge==128 maps to the band's right. +// Integer math, floored — a zone's left uses floor(low) and its right uses floor(high+1), +// which tiles adjacent zones without a seam. +int keyEdgeToX(int bandLeft, int bandWidth, int keyEdge) { + if (keyEdge <= 0) return bandLeft; + if (keyEdge >= kEmbedKeyCount) return bandLeft + bandWidth; + return bandLeft + (keyEdge * bandWidth) / kEmbedKeyCount; +} + +} // namespace + +EmbedLayout layoutEmbed(int w, int h) { + const int cw = std::max(0, w); + const int ch = std::max(0, h); + + EmbedLayout out; + + // The level band takes a fixed height at the bottom, but never so much that the keymap + // above it falls below its minimum (or that the band exceeds the area). On a very short + // area the band yields to the keymap entirely. + int bandH = std::min(kEmbedLevelBandHeight, ch); + if (ch - bandH < kEmbedKeymapMinHeight) { + bandH = std::max(0, ch - kEmbedKeymapMinHeight); + } + const int keymapBottom = ch - bandH; + + out.keymap = Rect{0, 0, cw, keymapBottom}; + out.levelBand = Rect{0, keymapBottom, cw, ch}; + return out; +} + +Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote) { + const Rect& band = layout.keymap; + const int bandWidth = std::max(0, band.width()); + + int lo = clampNote(lowNote); + int hi = clampNote(highNote); + if (lo > hi) lo = hi; // defensive: a malformed zone collapses rather than inverts + + const int leftX = keyEdgeToX(band.left, bandWidth, lo); + const int rightX = keyEdgeToX(band.left, bandWidth, hi + 1); + return Rect{leftX, band.top, std::max(leftX, rightX), band.bottom}; +} + +int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, + int y) { + if (zoneCount <= 0 || zones == nullptr) return -1; + if (!contains(layout.keymap, x, y)) return -1; + // First covering zone in draw order wins (first-match, mirroring the core's resolve). + for (int i = 0; i < zoneCount; ++i) { + const Rect r = zoneSegmentRect(layout, zones[i].lowNote, zones[i].highNote); + if (contains(r, x, y)) return i; + } + return -1; // on the band but on an uncovered key +} + +Rect levelFillRect(const EmbedLayout& layout, double level) { + const Rect& band = layout.levelBand; + if (band.width() <= 0 || band.height() <= 0) return Rect{}; + double l = level; + if (l < 0.0) l = 0.0; + if (l > 1.0) l = 1.0; + const int fillW = static_cast(l * band.width()); + if (fillW <= 0) return Rect{}; + return Rect{band.left, band.top, band.left + fillW, band.bottom}; +} + +} // namespace reasampler::vst diff --git a/src/vst/embed_strip.h b/src/vst/embed_strip.h new file mode 100644 index 0000000..b16a88d --- /dev/null +++ b/src/vst/embed_strip.h @@ -0,0 +1,76 @@ +// embed_strip.h — PURE layout + hit-test for the S6 embedded TCP/MCP strip. NO VST3, +// NO REAPER, NO SWELL/LICE types at the boundary. The mirror of editor_geometry / +// mode_switch: the fiddly rectangle math for the compact inline keymap/level strip lives +// here so it is unit-tested outside the DAW, while the embed shell (reasampler_embed.cpp) +// marshals REAPER's embed messages (paint bitmap + mouse coords) into these functions. +// +// The strip is a single compact band REAPER draws inline in the track/mixer control panel +// (context TCP or MCP) via the Cockos embedded-UI surface. It shows: +// * the zone layout — each performance zone as a horizontal segment across the keyboard +// span (MIDI 0..127 mapped to the strip width), so the keymap reads at a glance; and +// * a thin level band at the bottom — a 0..1 activity indicator the shell fills. +// Interaction is zone SELECTION at most (S6 constraint: no new editing semantics) — a +// click maps to the zone whose key range covers that point, or -1. +// +// It reuses the same Rect + contains() as editor_geometry (the strip and the editor share +// one geometry idiom), so this header depends on editor_geometry.h rather than redefining +// a second rectangle type. + +#pragma once + +#include "editor_geometry.h" // Rect, contains — one shared geometry idiom + +namespace reasampler::vst { + +// The full MIDI key span the strip maps across its width. 128 keys (0..127); the strip's +// horizontal axis is this range, so a zone [lowNote, highNote] becomes a sub-rectangle. +inline constexpr int kEmbedKeyCount = 128; + +// Fixed metrics for the strip, exposed so the shell and tests agree. +inline constexpr int kEmbedLevelBandHeight = 4; // the bottom activity band (px) +inline constexpr int kEmbedKeymapMinHeight = 6; // keymap area collapses no smaller + +// One zone rendered on the strip: its inclusive MIDI key range. This is the minimal +// projection of a PerformanceZone the strip needs (it does not carry sample ids or PCM — +// the shell resolves labels; the strip only lays out ranges). lowNote/highNote are +// expected in [0,127] with low <= high, but the layout clamps defensively so a malformed +// zone never yields an out-of-strip rect. +struct EmbedZone { + int lowNote = 0; + int highNote = 127; +}; + +// The strip's regions, derived from the (w x h) embed area REAPER reports. Both clamp to +// the area so a degenerate (tiny) size never yields a region spilling outside the surface. +struct EmbedLayout { + Rect keymap; // top: the zone-segment band (the compact keymap) + Rect levelBand; // bottom: the thin level/activity indicator +}; + +// Divide a (w x h) embed area into the strip's regions. Pure: same inputs -> same layout. +// The level band takes a fixed height at the bottom (clamped so it never exceeds the area +// or starves the keymap below kEmbedKeymapMinHeight); the keymap takes the rest. A zero or +// negative size yields empty rects (no inversion). +EmbedLayout layoutEmbed(int w, int h); + +// The horizontal sub-rectangle of the keymap band for a zone spanning [lowNote, highNote] +// (inclusive). The 128-key span maps linearly across keymap.width(); the returned rect +// spans the half-open pixel range [x(lowNote), x(highNote+1)) so adjacent zones (e.g. +// 0..59 and 60..127) tile without a gap or overlap. Notes are clamped to [0,127] and low +// is clamped to <= high, so a malformed zone yields an in-band (possibly zero-width) rect, +// never an inverted one. Pure. +Rect zoneSegmentRect(const EmbedLayout& layout, int lowNote, int highNote); + +// The zone a click at (x, y) lands on, given the zones in draw order, or -1 for a click +// outside the keymap band or on a key not covered by any zone. When zones overlap on a +// key, the FIRST covering zone in order wins — mirroring the sampler core's first-match +// Keymap::resolve and the editor's zone order, so selection agrees with playback. Pure. +int zoneAtPoint(const EmbedLayout& layout, const EmbedZone* zones, int zoneCount, int x, + int y); + +// The filled portion of the level band for a 0..1 level. Clamps level to [0,1]; the +// returned rect is the left sub-rectangle of levelBand whose width is level * band width +// (rounded down). level <= 0 -> empty rect; level >= 1 -> the whole band. Pure. +Rect levelFillRect(const EmbedLayout& layout, double level); + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_embed.cpp b/src/vst/reasampler_embed.cpp new file mode 100644 index 0000000..959ed8c --- /dev/null +++ b/src/vst/reasampler_embed.cpp @@ -0,0 +1,213 @@ +// reasampler_embed.cpp — see reasampler_embed.h. The IReaperUIEmbedInterface shell. +// Windows-only (D5); guarded so a non-Windows build degrades to a stub that reports +// "not supported" and draws nothing. + +#include "reasampler_embed.h" + +#include +#include + +#include "editor_geometry.h" // Rect (shared with embed_strip) +#include "embed_strip.h" // the pure strip layout + hit-test +#include "ext_keys.h" // kProjExtBanksKey +#include "reaper_bridge.h" +#include "reasampler_processor.h" + +// wdltypes.h first: it defines INT_PTR portably (and pulls on Windows), which +// reaper_plugin_fx_embed.h's REAPER_FXEMBED_IBitmap::Extended needs as its return type. +#include "wdltypes.h" + +// REAPER's embed message/bitmap contract (vendored). REAPER_FXEMBED_IBitmap is an alias of +// LICE_IBitmap, and the WM_* / DrawInfo / SizeHints definitions live here. +#include "reaper_plugin_fx_embed.h" + +#ifdef _WIN32 +// LICE — the same drawing stack the IPlugView editor and bank_panel use. REAPER hands us a +// LICE bitmap; we draw into it with the same calls, then return (REAPER blits it). +#include "lice/lice.h" +#endif + +using namespace Steinberg; + +// DECLARE_CLASS_IID in the REAPER header only DECLARES IReaperUIEmbedInterface::iid; some +// TU must DEFINE it. This is the only place that answers queryInterface for it, so the +// definition lives with its sole use (mirrors reaper_bridge.cpp doing this for +// IReaperHostApplication). +DEF_CLASS_IID(Steinberg::IReaperUIEmbedInterface) + +namespace reasampler::vst { + +namespace { +#ifdef _WIN32 +// Palette — mirrored from reasampler_editor.cpp so the inline strip reads as the same tool. +const LICE_pixel kColBackground = LICE_RGBA(28, 28, 30, 255); +const LICE_pixel kColZone = LICE_RGBA(44, 44, 48, 255); +const LICE_pixel kColZoneSel = LICE_RGBA(58, 96, 84, 255); +const LICE_pixel kColZoneBorder = LICE_RGBA(20, 20, 22, 255); +const LICE_pixel kColLevelBg = LICE_RGBA(20, 20, 22, 255); +const LICE_pixel kColLevelFill = LICE_RGBA(120, 200, 160, 255); +const LICE_pixel kColEmpty = LICE_RGBA(70, 70, 74, 255); +const COLORREF kRgbText = RGB(210, 230, 220); + +// A short display name for a bank sample id, from the snapshotted list (the editor's helper, +// duplicated small rather than shared across the shell/pure boundary). +std::string sampleLabel(const std::vector& samples, const std::string& id) { + for (const SampleChoice& c : samples) { + if (c.id == id) return c.displayName.empty() ? c.id : c.displayName; + } + return "?"; +} +#endif + +// Project the instrument's performance map into the strip's minimal zone shape (key ranges +// only). Pure projection — kept here (shell side) because it reads PerformanceMap, a shell +// type; embed_strip stays free of it. +std::vector toEmbedZones(const PerformanceMap& map) { + std::vector out; + out.reserve(map.zones.size()); + for (const PerformanceZone& z : map.zones) out.push_back(EmbedZone{z.lowNote, z.highNote}); + return out; +} + +} // namespace + +tresult PLUGIN_API ReaSamplerEmbed::queryInterface(const TUID iid, void** obj) { + QUERY_INTERFACE(iid, obj, FUnknown::iid, IReaperUIEmbedInterface) + QUERY_INTERFACE(iid, obj, IReaperUIEmbedInterface::iid, IReaperUIEmbedInterface) + *obj = nullptr; + return kNoInterface; +} + +void ReaSamplerEmbed::refresh() { + if (!processor_) { + samples_.clear(); + map_.zones.clear(); + selectedId_.clear(); + selectedZone_ = -1; + return; + } + auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey); + samples_ = banks ? listSamples(*banks) : std::vector{}; + map_ = processor_->performanceMap(); + selectedId_ = processor_->selectedSampleId(); + if (selectedZone_ >= static_cast(map_.zones.size())) selectedZone_ = -1; +} + +TPtrInt ReaSamplerEmbed::embed_message(int msg, TPtrInt parm2, TPtrInt parm3) { + switch (msg) { + case REAPER_FXEMBED_WM_IS_SUPPORTED: +#ifdef _WIN32 + return 1; // supported and available +#else + return 0; // not a build target off Windows +#endif + case REAPER_FXEMBED_WM_CREATE: + refresh(); // prime the first paint's snapshot + return 0; + case REAPER_FXEMBED_WM_DESTROY: + return 0; +#ifdef _WIN32 + case REAPER_FXEMBED_WM_PAINT: + return paint(parm2, parm3) ? 1 : 0; + case REAPER_FXEMBED_WM_LBUTTONDOWN: + // Selection at most (S6): map the click to a zone; force a redraw if it changed. + return onMouseDown(parm3) ? REAPER_FXEMBED_RETNOTIFY_INVALIDATE : 0; +#endif + default: + return 0; // unhandled messages (cursor, wheel, hittest, minmax) fall through + } +} + +#ifdef _WIN32 + +bool ReaSamplerEmbed::paint(TPtrInt bitmap, TPtrInt drawInfo) { + auto* bmp = reinterpret_cast(bitmap); + auto* di = reinterpret_cast(drawInfo); + if (!bmp || !di) return false; + const int w = di->width; + const int h = di->height; + if (w <= 0 || h <= 0) return false; + + // Re-read live state each paint (UI thread) so the strip reflects keymap edits + bank + // changes without its own timer — REAPER repaints the embed surface on its cadence. + refresh(); + + // REAPER hands us its own bitmap sized to the embed area; draw directly into it (unlike + // the editor, which owns a LICE_SysBitmap and BitBlt's). Origin is the bitmap's (0,0). + LICE_FillRect(bmp, 0, 0, w, h, kColBackground, 1.0f, 0); + + const EmbedLayout layout = layoutEmbed(w, h); + + if (map_.zones.empty()) { + // No keymap authored yet: show a single faint band spanning the keymap area so the + // strip still reads as "present but empty" (Tier-0 fallback plays chromatically). + LICE_FillRect(bmp, layout.keymap.left, layout.keymap.top, layout.keymap.width(), + layout.keymap.height(), kColEmpty, 0.5f, 0); + HDC dc = bmp->getDC(); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, kRgbText); + RECT gr{layout.keymap.left + 4, layout.keymap.top, layout.keymap.right, + layout.keymap.bottom}; + const std::string label = + samples_.empty() ? "ReaSampler (bank empty)" : "ReaSampler (no zones)"; + DrawTextA(dc, label.c_str(), -1, &gr, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX); + } else { + // Draw each zone as a segment across the keymap span, first-match order (so the + // painted order matches selection + playback). The selected zone is highlighted. + for (int i = 0; i < static_cast(map_.zones.size()); ++i) { + const PerformanceZone& z = map_.zones[i]; + const Rect r = zoneSegmentRect(layout, z.lowNote, z.highNote); + if (r.width() <= 0) continue; + const bool sel = (i == selectedZone_); + LICE_FillRect(bmp, r.left, r.top, r.width(), r.height(), + sel ? kColZoneSel : kColZone, 1.0f, 0); + LICE_DrawRect(bmp, r.left, r.top, r.width() - 1, r.height() - 1, kColZoneBorder, + 1.0f, 0); + // Label the segment with the sample name when it is wide enough to read. + if (r.width() >= 24) { + HDC dc = bmp->getDC(); + SetBkMode(dc, TRANSPARENT); + SetTextColor(dc, kRgbText); + RECT gr{r.left + 3, r.top, r.right - 2, r.bottom}; + const std::string label = sampleLabel(samples_, z.sampleId); + DrawTextA(dc, label.c_str(), -1, &gr, + DT_LEFT | DT_VCENTER | DT_SINGLELINE | DT_NOPREFIX | DT_END_ELLIPSIS); + } + } + } + + // The level band: a static background here; a live activity level is a later refinement + // (the processor would publish a peak the UI thread reads). Draw the empty band so the + // strip's geometry is complete and the DAW-verify sees the band lifecycle now. + if (layout.levelBand.height() > 0) { + LICE_FillRect(bmp, layout.levelBand.left, layout.levelBand.top, + layout.levelBand.width(), layout.levelBand.height(), kColLevelBg, 1.0f, + 0); + const double level = processor_ ? processor_->embedActivityLevel() : 0.0; + const Rect fill = levelFillRect(layout, level); + if (fill.width() > 0) { + LICE_FillRect(bmp, fill.left, fill.top, fill.width(), fill.height(), + kColLevelFill, 1.0f, 0); + } + } + + return true; +} + +bool ReaSamplerEmbed::onMouseDown(TPtrInt drawInfo) { + auto* di = reinterpret_cast(drawInfo); + if (!di || di->width <= 0 || di->height <= 0) return false; + refresh(); + const EmbedLayout layout = layoutEmbed(di->width, di->height); + const std::vector zones = toEmbedZones(map_); + const int hit = zoneAtPoint(layout, zones.data(), static_cast(zones.size()), + di->mouse_x, di->mouse_y); + if (hit == selectedZone_) return false; // no change -> no redraw + selectedZone_ = hit; + return true; +} + +#endif // _WIN32 + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_embed.h b/src/vst/reasampler_embed.h new file mode 100644 index 0000000..b4bad22 --- /dev/null +++ b/src/vst/reasampler_embed.h @@ -0,0 +1,99 @@ +// reasampler_embed.h — the S6 embedded TCP/MCP UI shell. Implements REAPER's +// IReaperUIEmbedInterface (vendor/reaper-sdk/sdk/reaper_plugin_fx_embed.h + +// reaper_vst3_interfaces.h) so the instrument draws a compact keymap/level strip INLINE in +// the track/mixer control panel — the same Cockos surface REAPER's own embedded FX use. +// +// VERIFIED CONTRACT (against reaper_plugin_fx_embed.h + reaper_vst3_interfaces.h): +// * VST3 exposes this by having the IEditController answer queryInterface for +// IReaperUIEmbedInterface (iid {0x049bf9e7,0xbc74ead0,0xc4101e86,0x7f725981}). Our +// SingleComponentEffect IS the edit controller, so the processor's queryInterface hands +// REAPER a reference to this object. +// * The single method is embed_message(int msg, TPtrInt parm2, TPtrInt parm3). msg is a +// REAPER_FXEMBED_WM_* value (aliased to Win32 WM_*): +// - WM_IS_SUPPORTED (0x0000): return 1 (supported+available), -1, or 0. +// - WM_CREATE (0x0001) / WM_DESTROY (0x0002): embed begin/end; return ignored. +// - WM_PAINT (0x000F): parm2 = REAPER_FXEMBED_IBitmap* (alias LICE_IBitmap) to draw +// into; parm3 = REAPER_FXEMBED_DrawInfo* (context TCP=1/MCP=2, width/height, mouse, +// flags). Return 1 if drawing occurred, 0 otherwise. +// - WM_GETMINMAXINFO (0x0024): parm3 = SizeHints*; return 1 if filled. +// - mouse WM_* (0x0200..0x020A): parm3 = DrawInfo*; return RETNOTIFY_INVALIDATE +// (0x1000000) to force a redraw. Capture is auto-managed by the host. +// * There is NO plugin-owned window/HWND here (unlike the IPlugView editor): REAPER hands +// a LICE bitmap per paint; we only draw into it and read mouse coords from DrawInfo. +// +// RT DISCIPLINE (S6 constraint): all embed messages arrive on REAPER's UI thread; nothing +// here runs in process(). It reads the same live state the editor reads (bank over the +// bridge + the processor's performance map) with the same off-audio-thread accessors — no +// new locks visible to process, read-only over the bank. Windows-only (D5), guarded so a +// non-Windows build stays compilable. +// +// The strip's LAYOUT + HIT-TEST is pure (embed_strip.h, unit-tested); this shell marshals +// REAPER's messages to/from it and draws with the same LICE idiom as reasampler_editor. + +#pragma once + +#include +#include + +#include "pluginterfaces/base/funknown.h" + +#include "sample_map.h" // SampleChoice, PerformanceMap (the state the strip reflects) + +// REAPER's VST3-side embed interface (vendored). Uses UNQUALIFIED Steinberg types, so it is +// pulled into the Steinberg namespace the same way reaper_bridge.cpp includes the host +// interface header. Its iid is DEFINEd (DEF_CLASS_IID) in reasampler_embed.cpp. +namespace Steinberg { +#include "reaper_vst3_interfaces.h" +} // namespace Steinberg + +namespace reasampler::vst { + +class ReaSamplerProcessor; + +// Implements IReaperUIEmbedInterface. Lifetime is OWNED by the processor (the processor +// holds the sole unique_ptr and hands out AddRef'd references from queryInterface); the +// back-pointer to the processor is therefore always valid while this lives. +class ReaSamplerEmbed : public Steinberg::IReaperUIEmbedInterface { +public: + explicit ReaSamplerEmbed(ReaSamplerProcessor* processor) : processor_(processor) {} + + // The one embed entry point. Routes each REAPER_FXEMBED_WM_* message; see the header + // note above for the per-message contract. UI thread only. + Steinberg::TPtrInt embed_message(int msg, Steinberg::TPtrInt parm2, + Steinberg::TPtrInt parm3) override; + + // FUnknown: this object's lifetime is owned by the processor, not the host refcount, so + // AddRef/release are no-ops (the processor's unique_ptr governs destruction) and + // queryInterface answers only FUnknown + IReaperUIEmbedInterface. This mirrors how the + // SDK's OBJ refcount would otherwise churn; here the owning processor guarantees the + // object outlives every borrowed reference REAPER holds during embedding. + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, + void** obj) override; + Steinberg::uint32 PLUGIN_API addRef() override { return 1000; } + Steinberg::uint32 PLUGIN_API release() override { return 1000; } + +private: +#ifdef _WIN32 + // Draw the current strip into REAPER's supplied LICE bitmap. Returns true if it drew. + bool paint(Steinberg::TPtrInt bitmap, Steinberg::TPtrInt drawInfo); + // Handle a mouse-down inside the strip: map to a zone and select it (S6: selection at + // most — no new editing semantics). Returns true if the selection changed (the caller + // then asks REAPER to invalidate). + bool onMouseDown(Steinberg::TPtrInt drawInfo); +#endif + + // Snapshot the live bank + the instrument's performance map for the next paint, exactly + // as the editor's refreshSampleList does (bridge read + processor accessors, UI thread). + void refresh(); + + ReaSamplerProcessor* processor_ = nullptr; + // Snapshotted for the current paint (refreshed each paint off the audio thread). + std::vector samples_; + PerformanceMap map_; + std::string selectedId_; + // The zone the last click selected (mirrored to the processor's editor-shared selection + // where meaningful); -1 = none. Drives the strip's highlight. + int selectedZone_ = -1; +}; + +} // namespace reasampler::vst diff --git a/src/vst/reasampler_processor.cpp b/src/vst/reasampler_processor.cpp index 9eb7a59..db7febd 100644 --- a/src/vst/reasampler_processor.cpp +++ b/src/vst/reasampler_processor.cpp @@ -17,6 +17,7 @@ #include "capture_paths.h" // resolveBankFile (shared M4 path resolution) #include "ext_keys.h" // kProjExtBanksKey (shared wire contract) #include "reasampler_editor.h" +#include "reasampler_embed.h" // S6 embed shell + IReaperUIEmbedInterface (its iid DEF'd there) #include "sample_map.h" // selectSample, resolvePerformance, buildZonedKeymap, state (de)ser #include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse) @@ -91,6 +92,23 @@ FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) { return static_cast(new ReaSamplerProcessor()); } +// Out-of-line so unique_ptr sees the complete type here. +ReaSamplerProcessor::~ReaSamplerProcessor() = default; + +tresult PLUGIN_API ReaSamplerProcessor::queryInterface(const TUID iid, void** obj) { + // S6: expose REAPER's inline-embed interface. REAPER queries the IEditController for + // IReaperUIEmbedInterface (reaper_vst3_interfaces.h); hand it our lazily-created embed + // shell. We own the shell (unique_ptr); the borrowed reference is valid because the + // processor outlives it. All other iids fall through to the SDK's queryInterface. + if (FUnknownPrivate::iidEqual(iid, IReaperUIEmbedInterface::iid)) { + if (!embed_) embed_ = std::make_unique(this); + embed_->addRef(); + *obj = static_cast(embed_.get()); + return kResultOk; + } + return SingleComponentEffect::queryInterface(iid, obj); +} + tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) { tresult result = SingleComponentEffect::initialize(context); if (result != kResultOk) return result; @@ -357,6 +375,16 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) { if (inst) { inst->engine.render(ch0, static_cast(frames)); } + // Block peak (mono, pre-replicate) for the embedded strip's level indicator. A + // single scan of ch0 + one relaxed atomic store — RT-safe (no alloc/IO/lock). The + // UI thread reads it via embedActivityLevel(); a plain store is sufficient because + // the readout is advisory (no ordering dependency on other state). + float peak = 0.f; + for (int32 i = 0; i < frames; ++i) { + const float a = ch0[i] < 0.f ? -ch0[i] : ch0[i]; + if (a > peak) peak = a; + } + embedPeak_.store(peak, std::memory_order_relaxed); // Duplicate the mono render across the remaining output channels. for (int32 ch = 1; ch < out.numChannels; ++ch) { if (float* buf = out.channelBuffers32[ch]) { diff --git a/src/vst/reasampler_processor.h b/src/vst/reasampler_processor.h index dd4efce..67dda1b 100644 --- a/src/vst/reasampler_processor.h +++ b/src/vst/reasampler_processor.h @@ -33,6 +33,8 @@ namespace reasampler::vst { +class ReaSamplerEmbed; // S6 embedded TCP/MCP UI shell (owned below; see queryInterface) + // One fully-built, ready-to-play instrument snapshot: the decoded keymap and the voice // engine that plays it. The engine holds a reference into the keymap, so the two MUST // live and die together at a STABLE address — hence this is heap-allocated and neither @@ -59,6 +61,9 @@ struct LoadedInstrument { class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect { public: ReaSamplerProcessor() = default; + // Out-of-line so the owned ReaSamplerEmbed (held by unique_ptr, forward-declared here) + // is a complete type at the destruction point (defined in the .cpp). + ~ReaSamplerProcessor() override; // The factory create function (registered in vst_entry.cpp). static Steinberg::FUnknown* createInstance(void* /*context*/); @@ -87,6 +92,19 @@ public: // Hands the host our LICE IPlugView editor. Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override; + // Override queryInterface to additionally expose REAPER's IReaperUIEmbedInterface (S6): + // REAPER queries the IEditController for it to drive the inline TCP/MCP embed surface. + // All other iids delegate to SingleComponentEffect's implementation unchanged. + Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid, + void** obj) override; + + // The embedded-strip activity level (0..1), read by the S6 embed shell on the UI thread. + // Backed by embedPeak_, the per-block mono peak the audio thread stores relaxed — a + // lock-free advisory readout, never touched with a lock the audio thread could contend. + double embedActivityLevel() const { + return static_cast(embedPeak_.load(std::memory_order_relaxed)); + } + // Called by the editor (main/UI thread) when the user picks a sample, and internally // on load. Reads the live bank over the bridge, resolves+decodes the selected WAV // OFF the audio thread, and publishes the built instrument to process() via an @@ -163,6 +181,18 @@ private: // off-thread only. double sampleRate_ = 44100.0; Steinberg::int32 maxBlockSize_ = 4096; + + // --- S6 embedded TCP/MCP UI --------------------------------------------- + // The embed shell (IReaperUIEmbedInterface), created lazily on the first queryInterface + // and owned here for the processor's lifetime. REAPER borrows AddRef'd references from + // queryInterface; the shell's refcount is a no-op because THIS unique_ptr governs its + // destruction (the processor always outlives the borrowed references). + std::unique_ptr embed_; + + // The per-block mono peak (0..1+) the audio thread stores relaxed; the embed strip's + // level indicator reads it via embedActivityLevel(). Advisory only — a plain atomic, + // no ordering coupling, never guarded by a lock the audio thread touches. + std::atomic embedPeak_{0.f}; }; } // namespace reasampler::vst diff --git a/tests/test_embed_strip.cpp b/tests/test_embed_strip.cpp new file mode 100644 index 0000000..4ce3c22 --- /dev/null +++ b/tests/test_embed_strip.cpp @@ -0,0 +1,157 @@ +// Standalone tests for reasampler::vst::embed_strip — no VST3, no REAPER, no framework. +// Same fast assert loop as the sibling pure tests (editor_geometry et al.): assert the +// embedded TCP/MCP strip's layout math + zone hit-testing + level fill directly. +// +// Covers: layoutEmbed splitting a normal area into keymap + level band, a tiny area +// (band yields to the keymap minimum, no inversion), and a zero area (all empty); +// zoneSegmentRect mapping the 128-key span linearly, tiling adjacent zones seamlessly, +// clamping out-of-range/inverted notes; zoneAtPoint hitting the covering zone, first-match +// on overlap, missing on uncovered keys and off-band, and rejecting a null/empty list; +// levelFillRect clamping 0..1 and its endpoints. + +#include "../src/vst/embed_strip.h" + +#include + +using namespace reasampler::vst; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +// --- layoutEmbed -------------------------------------------------------------- + +static void testLayoutNormalArea() { + // A comfortable inline strip: keymap band on top, thin level band pinned to the bottom. + const EmbedLayout L = layoutEmbed(300, 40); + CHECK(L.keymap.left == 0 && L.keymap.top == 0 && L.keymap.right == 300); + CHECK(L.levelBand.left == 0 && L.levelBand.right == 300); + // Level band is the fixed height at the very bottom; keymap fills the rest, contiguous. + CHECK(L.levelBand.height() == kEmbedLevelBandHeight); + CHECK(L.levelBand.bottom == 40); + CHECK(L.keymap.bottom == L.levelBand.top); + CHECK(L.keymap.height() == 40 - kEmbedLevelBandHeight); +} + +static void testLayoutTinyAreaKeepsKeymap() { + // A very short area: the level band must yield so the keymap keeps its minimum, and no + // rect inverts. + const EmbedLayout L = layoutEmbed(300, 8); + CHECK(L.keymap.height() >= 0); + CHECK(L.levelBand.height() >= 0); + CHECK(L.keymap.bottom == L.levelBand.top); + CHECK(L.levelBand.bottom == 8); + // The keymap is not starved below its floor when the area allows it. + CHECK(L.keymap.height() >= kEmbedKeymapMinHeight || 8 < kEmbedKeymapMinHeight); +} + +static void testLayoutZeroArea() { + const EmbedLayout L = layoutEmbed(0, 0); + CHECK(L.keymap.width() <= 0 && L.keymap.height() <= 0); + CHECK(L.levelBand.width() <= 0 && L.levelBand.height() <= 0); + // Negative dimensions clamp to a zero-area, non-inverted rect. + const EmbedLayout N = layoutEmbed(-50, -50); + CHECK(N.keymap.right >= N.keymap.left && N.keymap.bottom >= N.keymap.top); +} + +// --- zoneSegmentRect ---------------------------------------------------------- + +static void testZoneSegmentFullSpan() { + // A zone covering the whole keyboard spans the entire keymap band width. + const EmbedLayout L = layoutEmbed(256, 40); + const Rect r = zoneSegmentRect(L, 0, 127); + CHECK(r.left == L.keymap.left); + CHECK(r.right == L.keymap.right); + CHECK(r.top == L.keymap.top && r.bottom == L.keymap.bottom); +} + +static void testAdjacentZonesTileSeamlessly() { + // 256px band, 128 keys -> 2px/key. Zones 0..59 and 60..127 must abut with no gap or + // overlap: the low zone's right == the high zone's left. + const EmbedLayout L = layoutEmbed(256, 40); + const Rect lo = zoneSegmentRect(L, 0, 59); + const Rect hi = zoneSegmentRect(L, 60, 127); + CHECK(lo.left == L.keymap.left); + CHECK(hi.right == L.keymap.right); + CHECK(lo.right == hi.left); // seamless tile — the load-bearing assertion + CHECK(lo.right == L.keymap.left + 60 * 2); // 60 keys * 2px +} + +static void testZoneSegmentClampsBadNotes() { + const EmbedLayout L = layoutEmbed(256, 40); + // Out-of-range notes clamp into the band; an inverted zone (low > high) collapses to a + // zero-or-positive-width rect, never inverts. + const Rect over = zoneSegmentRect(L, -10, 200); + CHECK(over.left == L.keymap.left && over.right == L.keymap.right); + const Rect inv = zoneSegmentRect(L, 100, 20); + CHECK(inv.right >= inv.left); +} + +// --- zoneAtPoint -------------------------------------------------------------- + +static void testZoneAtPointHits() { + const EmbedLayout L = layoutEmbed(256, 40); + const EmbedZone zones[2] = {{0, 59}, {60, 127}}; + // A point inside the low zone's segment resolves to zone 0; inside the high zone, 1. + const Rect lo = zoneSegmentRect(L, 0, 59); + const Rect hi = zoneSegmentRect(L, 60, 127); + const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + CHECK(zoneAtPoint(L, zones, 2, lo.left + 1, yMid) == 0); + CHECK(zoneAtPoint(L, zones, 2, hi.right - 1, yMid) == 1); +} + +static void testZoneAtPointFirstMatchOnOverlap() { + const EmbedLayout L = layoutEmbed(256, 40); + // Two overlapping zones; the FIRST in order must win the contested keys. + const EmbedZone zones[2] = {{0, 127}, {40, 80}}; + const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + const Rect contested = zoneSegmentRect(L, 40, 80); + CHECK(zoneAtPoint(L, zones, 2, contested.left + 1, yMid) == 0); // zone 0 wins +} + +static void testZoneAtPointMisses() { + const EmbedLayout L = layoutEmbed(256, 40); + const EmbedZone zones[1] = {{60, 72}}; // a narrow zone; most keys uncovered + const int yMid = (L.keymap.top + L.keymap.bottom) / 2; + // A key left of the zone is uncovered -> -1. + CHECK(zoneAtPoint(L, zones, 1, L.keymap.left + 1, yMid) == -1); + // A point in the level band (below the keymap) is off the keymap -> -1. + CHECK(zoneAtPoint(L, zones, 1, L.levelBand.left + 4, L.levelBand.top) == -1); + // Empty / null list -> -1. + CHECK(zoneAtPoint(L, zones, 0, L.keymap.left + 1, yMid) == -1); + CHECK(zoneAtPoint(L, nullptr, 3, L.keymap.left + 1, yMid) == -1); +} + +// --- levelFillRect ------------------------------------------------------------ + +static void testLevelFillClamps() { + const EmbedLayout L = layoutEmbed(200, 40); + // Zero / negative -> empty. + CHECK(levelFillRect(L, 0.0).width() <= 0); + CHECK(levelFillRect(L, -1.0).width() <= 0); + // Full / over-full -> the whole band width. + CHECK(levelFillRect(L, 1.0).width() == L.levelBand.width()); + CHECK(levelFillRect(L, 5.0).width() == L.levelBand.width()); + // Half -> ~half the band, pinned to the band's left and vertical extent. + const Rect half = levelFillRect(L, 0.5); + CHECK(half.left == L.levelBand.left); + CHECK(half.top == L.levelBand.top && half.bottom == L.levelBand.bottom); + CHECK(half.width() == L.levelBand.width() / 2); +} + +int main() { + testLayoutNormalArea(); + testLayoutTinyAreaKeepsKeymap(); + testLayoutZeroArea(); + testZoneSegmentFullSpan(); + testAdjacentZonesTileSeamlessly(); + testZoneSegmentClampsBadNotes(); + testZoneAtPointHits(); + testZoneAtPointFirstMatchOnOverlap(); + testZoneAtPointMisses(); + testLevelFillClamps(); + + if (g_fail == 0) std::printf("embed_strip: all tests passed\n"); + else std::printf("embed_strip: %d FAILED\n", g_fail); + return g_fail == 0 ? 0 : 1; +}