S6: embedded TCP/MCP UI via IReaperUIEmbedInterface

Inline keymap/level strip drawn into REAPER's embed bitmap, reusing the LICE
idiom. Pure embed_strip layout/hit-test (tested); processor exposes the embed
interface off queryInterface and publishes an RT-safe block peak for the level.
This commit is contained in:
2026-07-26 18:12:27 -04:00
parent b653a4f6a7
commit 03e760631c
8 changed files with 706 additions and 1 deletions
+17 -1
View File
@@ -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})
+86
View File
@@ -0,0 +1,86 @@
// embed_strip.cpp — see embed_strip.h. Pure math; no host types.
#include "embed_strip.h"
#include <algorithm>
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<int>(l * band.width());
if (fillW <= 0) return Rect{};
return Rect{band.left, band.top, band.left + fillW, band.bottom};
}
} // namespace reasampler::vst
+76
View File
@@ -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
+213
View File
@@ -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 <string>
#include <vector>
#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 <windows.h> 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<SampleChoice>& 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<EmbedZone> toEmbedZones(const PerformanceMap& map) {
std::vector<EmbedZone> 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<SampleChoice>{};
map_ = processor_->performanceMap();
selectedId_ = processor_->selectedSampleId();
if (selectedZone_ >= static_cast<int>(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<LICE_IBitmap*>(bitmap);
auto* di = reinterpret_cast<const REAPER_FXEMBED_DrawInfo*>(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<int>(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<const REAPER_FXEMBED_DrawInfo*>(drawInfo);
if (!di || di->width <= 0 || di->height <= 0) return false;
refresh();
const EmbedLayout layout = layoutEmbed(di->width, di->height);
const std::vector<EmbedZone> zones = toEmbedZones(map_);
const int hit = zoneAtPoint(layout, zones.data(), static_cast<int>(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
+99
View File
@@ -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 <string>
#include <vector>
#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<SampleChoice> 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
+28
View File
@@ -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<IAudioProcessor*>(new ReaSamplerProcessor());
}
// Out-of-line so unique_ptr<ReaSamplerEmbed> 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<ReaSamplerEmbed>(this);
embed_->addRef();
*obj = static_cast<IReaperUIEmbedInterface*>(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<std::size_t>(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]) {
+30
View File
@@ -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<double>(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<ReaSamplerEmbed> 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<float> embedPeak_{0.f};
};
} // namespace reasampler::vst
+157
View File
@@ -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 <cstdio>
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;
}