S4 Tier 0: the bank plays — VST3 marshals MIDI to the S3 core, reads the live bank + resolves WAV the M4 way, mono downmix, lock-free load handoff, LICE sample-pick
This commit is contained in:
@@ -13,66 +13,4 @@ std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||
return buffer;
|
||||
}
|
||||
|
||||
std::optional<std::string> extractJsonStringField(const std::string& json,
|
||||
const std::string& key) {
|
||||
// Find the member token: "key" followed (after optional whitespace) by ':' then a
|
||||
// quoted string. Scan for each candidate occurrence of the quoted key so a value
|
||||
// that happens to contain the key text can't produce a false match.
|
||||
const std::string needle = "\"" + key + "\"";
|
||||
size_t searchFrom = 0;
|
||||
|
||||
while (true) {
|
||||
const size_t keyPos = json.find(needle, searchFrom);
|
||||
if (keyPos == std::string::npos) return std::nullopt;
|
||||
|
||||
size_t i = keyPos + needle.size();
|
||||
searchFrom = i; // next candidate starts after this key token
|
||||
|
||||
// Skip whitespace to the ':'.
|
||||
while (i < json.size() &&
|
||||
(json[i] == ' ' || json[i] == '\t' || json[i] == '\n' ||
|
||||
json[i] == '\r')) {
|
||||
++i;
|
||||
}
|
||||
if (i >= json.size() || json[i] != ':') continue; // not a member — keep looking
|
||||
++i;
|
||||
|
||||
// Skip whitespace to the value.
|
||||
while (i < json.size() &&
|
||||
(json[i] == ' ' || json[i] == '\t' || json[i] == '\n' ||
|
||||
json[i] == '\r')) {
|
||||
++i;
|
||||
}
|
||||
if (i >= json.size() || json[i] != '"') return std::nullopt; // value not a string
|
||||
++i;
|
||||
|
||||
// Read the string body, honoring the common JSON escapes.
|
||||
std::string out;
|
||||
while (i < json.size()) {
|
||||
const char c = json[i];
|
||||
if (c == '\\') {
|
||||
if (i + 1 >= json.size()) return std::nullopt; // dangling escape
|
||||
const char e = json[i + 1];
|
||||
switch (e) {
|
||||
case '"': out.push_back('"'); break;
|
||||
case '\\': out.push_back('\\'); break;
|
||||
case '/': out.push_back('/'); break;
|
||||
case 'n': out.push_back('\n'); break;
|
||||
case 't': out.push_back('\t'); break;
|
||||
case 'r': out.push_back('\r'); break;
|
||||
case 'b': out.push_back('\b'); break;
|
||||
case 'f': out.push_back('\f'); break;
|
||||
default: out.push_back(e); break; // pass through unknown escapes
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c == '"') return out; // closing quote — done
|
||||
out.push_back(c);
|
||||
++i;
|
||||
}
|
||||
return std::nullopt; // unterminated string
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
+12
-22
@@ -1,18 +1,21 @@
|
||||
// bridge_marshal.h — PURE marshalling helpers for the REAPER VST-host bridge read
|
||||
// (Phase S1). NO VST3, NO REAPER types at the boundary.
|
||||
// bridge_marshal.h — PURE marshalling helper for the REAPER VST-host bridge read.
|
||||
// NO VST3, NO REAPER types at the boundary.
|
||||
//
|
||||
// The bridge shell (reaper_bridge.cpp) resolves REAPER API functions by name over the
|
||||
// host callback and invokes them; the fiddly-and-easy-to-get-wrong parts around those
|
||||
// calls — interpreting GetProjExtState's int return, walking EnumProjExtState's
|
||||
// index-until-false contract into a key set, and extracting a single value out of the
|
||||
// "reasampler" bank JSON — are pure and unit-tested here. Mirror of capture_paths /
|
||||
// wav_trim splitting the arithmetic out of a REAPER-facing shell.
|
||||
// host callback and invokes them; the one fiddly-and-easy-to-get-wrong part around
|
||||
// GetProjExtState — interpreting its int return against the buffer it filled — is pure
|
||||
// and unit-tested here. Mirror of capture_paths / wav_trim splitting the arithmetic out
|
||||
// of a REAPER-facing shell.
|
||||
//
|
||||
// The S1 spike ALSO carried a string-scan JSON reader (extractJsonStringField) as a
|
||||
// stand-in until the instrument could parse the bank properly. S4 retired it: the
|
||||
// instrument now parses the "reasampler" bank blob through the SHARED bank_book /
|
||||
// bank_model JSON path (sample_map.cpp), so there is no second JSON parser. This module
|
||||
// is back to its one honest job — the API-return decode.
|
||||
//
|
||||
// Verified against vendor/reaper-sdk/sdk/reaper_plugin_functions.h:
|
||||
// int GetProjExtState (ReaProject*, extname, key, valOutNeedBig, valOutNeedBig_sz);
|
||||
// -- returns the length written (0 when the key is absent).
|
||||
// bool EnumProjExtState(ReaProject*, extname, idx, keyOut, keyOut_sz, valOut, valOut_sz);
|
||||
// -- returns false when idx is past the last entry.
|
||||
|
||||
#pragma once
|
||||
|
||||
@@ -31,17 +34,4 @@ namespace reasampler::vst {
|
||||
std::optional<std::string> decodeGetProjExtState(int apiReturn,
|
||||
const std::string& buffer);
|
||||
|
||||
// Extract the string value for `key` out of a flat one-level JSON object — the shape
|
||||
// persist.cpp writes under the "reasampler" ext-state (e.g. the bank blob's top-level
|
||||
// fields). This is a deliberately small, dependency-free reader for the SPIKE's
|
||||
// "read a known value" proof, NOT a general JSON parser: it finds "key" as an object
|
||||
// member and returns its string value, handling the common escapes (\" \\ \n \t \/).
|
||||
// Returns nullopt if the key is absent or its value is not a string.
|
||||
//
|
||||
// The real instrument (S4) will read the bank index through the shared bank_model JSON
|
||||
// path, not this helper; this exists only to give the S1 bridge read a testable,
|
||||
// REAPER-free decode step.
|
||||
std::optional<std::string> extractJsonStringField(const std::string& json,
|
||||
const std::string& key);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -53,4 +53,23 @@ HitTarget hitTest(const EditorLayout& layout, int x, int y) {
|
||||
return HitTarget::kNone;
|
||||
}
|
||||
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index) {
|
||||
if (index < 0) return Rect{};
|
||||
const int top = layout.canvas.top + index * kSampleRowHeight;
|
||||
return Rect{layout.canvas.left, top, layout.canvas.right, top + kSampleRowHeight};
|
||||
}
|
||||
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y) {
|
||||
if (rowCount <= 0) return -1;
|
||||
// Must be within the canvas horizontally and at/below its top.
|
||||
if (x < layout.canvas.left || x >= layout.canvas.right) return -1;
|
||||
if (y < layout.canvas.top) return -1;
|
||||
const int index = (y - layout.canvas.top) / kSampleRowHeight;
|
||||
if (index < 0 || index >= rowCount) return -1;
|
||||
// Guard the bottom edge: a click below the last row's canvas bottom is outside.
|
||||
const Rect r = sampleRowRect(layout, index);
|
||||
if (y >= r.bottom) return -1;
|
||||
return index;
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -56,4 +56,23 @@ enum class HitTarget {
|
||||
// is kNone in the spike.
|
||||
HitTarget hitTest(const EditorLayout& layout, int x, int y);
|
||||
|
||||
// --- Sample-selection list (S4 Tier-0 UI) -----------------------------------
|
||||
//
|
||||
// The Tier-0 editor lists the bank's samples as a vertical stack of fixed-height rows
|
||||
// below the title bar; clicking a row selects that sample. This is the pure geometry:
|
||||
// the row rectangles and the point->row hit-test, unit-tested outside the DAW while the
|
||||
// shell draws the names and routes the click into the processor's reloadFromBank.
|
||||
|
||||
// The fixed row height (px) for one sample entry. Exposed so the shell and tests agree.
|
||||
inline constexpr int kSampleRowHeight = 22;
|
||||
|
||||
// The rectangle for row `index` (0-based) of the sample list, laid out top-down inside
|
||||
// the layout's canvas. Rows beyond what the canvas can show are still computed (the
|
||||
// shell clips at paint time); a negative index yields an empty rect. Pure.
|
||||
Rect sampleRowRect(const EditorLayout& layout, int index);
|
||||
|
||||
// The row index a click at (x, y) lands on, given `rowCount` rows, or -1 for a click
|
||||
// outside the list (above the first row, past the last, or on the title bar). Pure.
|
||||
int sampleRowHitTest(const EditorLayout& layout, int rowCount, int x, int y);
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
+39
-17
@@ -5,6 +5,8 @@
|
||||
#include <vector>
|
||||
|
||||
#include "bridge_marshal.h"
|
||||
#include "capture_paths.h" // projectDirOfRpp (shared M4 project-dir derivation)
|
||||
#include "ext_keys.h" // kProjExtNamespace (shared wire contract)
|
||||
|
||||
// The VST3 base types must be included before REAPER's VST3 interface header, which
|
||||
// uses FUnknown / CStringA / uint32 / DECLARE_CLASS_IID / PLUGIN_API from
|
||||
@@ -27,21 +29,17 @@ namespace Steinberg {
|
||||
// interface (FUnknownPtr uses the iid), so the definition lives with its sole use.
|
||||
DEF_CLASS_IID(Steinberg::IReaperHostApplication)
|
||||
|
||||
// The "reasampler" ext-state namespace + bank key. Kept in sync with persist.h by
|
||||
// value (the extension writes them); we only READ here, so we duplicate the two string
|
||||
// constants rather than pull the whole REAPER-facing persist.h into the VST artifact.
|
||||
// If persist.h's kProjExtNamespace / kProjExtBanksKey ever change, these must follow —
|
||||
// they are the shared wire contract between the extension (writer) and instrument
|
||||
// (reader). VERIFY against persist.h.
|
||||
namespace {
|
||||
constexpr const char* kReasamplerNamespace = "reasampler";
|
||||
}
|
||||
// The "reasampler" ext-state namespace is the SHARED wire contract between the
|
||||
// extension (writer) and this instrument (reader); it lives in ext_keys.h (pure,
|
||||
// REAPER-free) — reasampler::kProjExtNamespace — so the two artifacts read one symbol
|
||||
// and cannot drift. The S1 spike duplicated it locally; that duplication is retired.
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
||||
getProjExtState_ = nullptr;
|
||||
enumProjExtState_ = nullptr;
|
||||
enumProjects_ = nullptr;
|
||||
hostApp_ = nullptr;
|
||||
if (!context) return false;
|
||||
|
||||
@@ -58,6 +56,10 @@ bool ReaperBridge::connect(Steinberg::FUnknown* context) {
|
||||
reaper->getReaperApi("GetProjExtState"));
|
||||
enumProjExtState_ = reinterpret_cast<EnumProjExtStateFn>(
|
||||
reaper->getReaperApi("EnumProjExtState"));
|
||||
// EnumProjects(-1, ...) yields the active project AND its .rpp path — the same call
|
||||
// persist.cpp uses, so the instrument derives the project directory identically.
|
||||
enumProjects_ = reinterpret_cast<EnumProjectsFn>(
|
||||
reaper->getReaperApi("EnumProjects"));
|
||||
|
||||
return getProjExtState_ != nullptr;
|
||||
}
|
||||
@@ -74,15 +76,35 @@ std::optional<std::string> ReaperBridge::readReasamplerExtState(const std::strin
|
||||
// REAPER treats null as the current project for these calls, so we pass it through
|
||||
// rather than bailing — but if the read yields nothing the caller sees nullopt.
|
||||
|
||||
// GetProjExtState writes into a caller buffer; size it generously for a JSON blob
|
||||
// and let the pure decoder interpret the result. The buffer is NUL-terminated by
|
||||
// REAPER on success.
|
||||
std::vector<char> buf(64 * 1024, '\0');
|
||||
const int rv = getProjExtState_(proj, kReasamplerNamespace, key.c_str(), buf.data(),
|
||||
static_cast<int>(buf.size()));
|
||||
// GetProjExtState writes into a caller buffer; the bank blob can be large (many
|
||||
// samples), so grow the buffer until the value fits rather than risk a silent
|
||||
// truncation — mirrors persist.cpp's getProjExtStateString growing strategy. The
|
||||
// return value is the value length; if it fits strictly inside the buffer it is
|
||||
// complete, else grow and retry up to a 16 MB ceiling.
|
||||
for (int cap = 1 << 16; cap <= (1 << 24); cap <<= 2) {
|
||||
std::vector<char> buf(static_cast<std::size_t>(cap), '\0');
|
||||
const int rv = getProjExtState_(proj, kProjExtNamespace, key.c_str(),
|
||||
buf.data(), cap);
|
||||
if (rv <= 0) return std::nullopt; // absent / empty key
|
||||
std::string s(buf.data());
|
||||
if (static_cast<int>(s.size()) + 1 < cap) {
|
||||
return decodeGetProjExtState(rv, s);
|
||||
}
|
||||
// else: possibly truncated -> grow and retry.
|
||||
}
|
||||
return std::nullopt; // pathologically large (>16 MB) — give up rather than loop
|
||||
}
|
||||
|
||||
// Marshal the raw result through the pure decoder (handles the absent-key case).
|
||||
return decodeGetProjExtState(rv, std::string(buf.data()));
|
||||
std::string ReaperBridge::activeProjectDir() {
|
||||
if (!enumProjects_) return {};
|
||||
// idx=-1 is the current project tab; the out-buffer receives the full .rpp path,
|
||||
// EMPTY for a never-saved project. Same call + convention as persist.cpp; the pure
|
||||
// projectDirOfRpp turns the .rpp path into the project directory (parent, forward-
|
||||
// slashed) and keeps an unsaved project's empty path empty (no default-location
|
||||
// fallback — the tool's invariant).
|
||||
std::vector<char> buf(4096, '\0');
|
||||
enumProjects_(-1, buf.data(), static_cast<int>(buf.size()));
|
||||
return projectDirOfRpp(std::string(buf.data()));
|
||||
}
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -44,8 +44,19 @@ public:
|
||||
// Read a "reasampler" ext-state value by key from the host's active project.
|
||||
// Returns nullopt when unconnected, when the project can't be resolved, or when the
|
||||
// key is absent. This is the S1 read-spike entry point.
|
||||
//
|
||||
// NOT REAL-TIME SAFE (it allocates a read buffer and calls into REAPER): callers on
|
||||
// the audio thread MUST NOT invoke it. The S4 instrument reads on the main/UI thread
|
||||
// and hands a snapshot to the process path (see reasampler_processor.cpp).
|
||||
std::optional<std::string> readReasamplerExtState(const std::string& key);
|
||||
|
||||
// The active project's directory (the folder holding its .rpp), forward-slashed,
|
||||
// no trailing slash — the M4 convention persist uses to place the bank alongside
|
||||
// the .rpp. Empty for an unsaved project or when unconnected. The instrument
|
||||
// resolves relative sample paths against this the SAME way persist does
|
||||
// (capture_paths::projectDirOfRpp over EnumProjects(-1)'s .rpp path). Not RT-safe.
|
||||
std::string activeProjectDir();
|
||||
|
||||
private:
|
||||
// Resolved REAPER API function pointers (by name via getReaperApi). Signatures
|
||||
// verified against reaper_plugin_functions.h.
|
||||
@@ -54,10 +65,15 @@ private:
|
||||
using EnumProjExtStateFn = bool (*)(void* proj, const char* extname, int idx,
|
||||
char* keyOut, int keyOut_sz, char* valOut,
|
||||
int valOut_sz);
|
||||
// EnumProjects(-1, projfnOut, sz) -> active project + its .rpp path (SDK line
|
||||
// ~1264). The instrument uses idx=-1 (current tab) so it follows the active project,
|
||||
// and reads the .rpp path from the out-buffer exactly as persist.cpp does.
|
||||
using EnumProjectsFn = void* (*)(int idx, char* projfnOut, int projfnOut_sz);
|
||||
|
||||
void* hostApp_ = nullptr; // IReaperHostApplication* (opaque here; used in .cpp)
|
||||
GetProjExtStateFn getProjExtState_ = nullptr;
|
||||
EnumProjExtStateFn enumProjExtState_ = nullptr;
|
||||
EnumProjectsFn enumProjects_ = nullptr;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
#include <string>
|
||||
|
||||
#include "editor_geometry.h"
|
||||
#include "ext_keys.h"
|
||||
#include "reaper_bridge.h"
|
||||
#include "reasampler_processor.h"
|
||||
#include "sample_map.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windowsx.h> // GET_X_LPARAM / GET_Y_LPARAM
|
||||
@@ -47,13 +50,25 @@ void drawText(LICE_IBitmap* bmp, const Rect& r, const char* s, COLORREF col) {
|
||||
#endif
|
||||
} // namespace
|
||||
|
||||
ReaSamplerEditor::ReaSamplerEditor(ReaperBridge* bridge)
|
||||
: CPluginView(nullptr), bridge_(bridge) {
|
||||
ReaSamplerEditor::ReaSamplerEditor(ReaSamplerProcessor* processor)
|
||||
: CPluginView(nullptr), processor_(processor) {
|
||||
// Default view size; the host may resize (canResize() == true).
|
||||
ViewRect r(0, 0, 420, 260);
|
||||
setRect(r);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::refreshSampleList() {
|
||||
// Main/UI thread only — reads the live bank over the bridge (allocates, calls REAPER).
|
||||
if (!processor_) {
|
||||
samples_.clear();
|
||||
selectedId_.clear();
|
||||
return;
|
||||
}
|
||||
auto banks = processor_->bridge().readReasamplerExtState(reasampler::kProjExtBanksKey);
|
||||
samples_ = banks ? listSamples(*banks) : std::vector<SampleChoice>{};
|
||||
selectedId_ = processor_->selectedSampleId();
|
||||
}
|
||||
|
||||
ReaSamplerEditor::~ReaSamplerEditor() {
|
||||
#ifdef _WIN32
|
||||
// Defensive teardown: the host normally calls removed() (which destroys the child)
|
||||
@@ -102,6 +117,9 @@ void ReaSamplerEditor::attachedToParent() {
|
||||
classRegistered = true;
|
||||
}
|
||||
|
||||
// Snapshot the live bank so the first paint shows the sample list.
|
||||
refreshSampleList();
|
||||
|
||||
const ViewRect& r = getRect();
|
||||
childHwnd_ = CreateWindowExW(
|
||||
0, kChildClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, r.getWidth(),
|
||||
@@ -144,17 +162,13 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
||||
|
||||
const EditorLayout layout = layoutEditor(w, h);
|
||||
|
||||
// Title band.
|
||||
// Title band: the plugin name + whether a live bank is linked.
|
||||
LICE_FillRect(&bmp, layout.titleBar.left, layout.titleBar.top,
|
||||
layout.titleBar.width(), layout.titleBar.height(), kColTitleBg, 1.0f,
|
||||
0);
|
||||
// Read a known live-state value over the bridge to prove the read spike. Show the
|
||||
// raw ext-state presence (never the full blob) so the title reflects live project
|
||||
// state without dumping JSON into the UI.
|
||||
std::string title = "ReaSampler Instrument";
|
||||
if (bridge_ && bridge_->isConnected()) {
|
||||
auto banks = bridge_->readReasamplerExtState("banks");
|
||||
title += banks ? " [bank: linked]" : " [bank: none]";
|
||||
if (processor_ && processor_->bridge().isConnected()) {
|
||||
title += samples_.empty() ? " [bank: empty]" : " [pick a sample]";
|
||||
} else {
|
||||
title += " [host: no bridge]";
|
||||
}
|
||||
@@ -162,27 +176,42 @@ void ReaSamplerEditor::paint(HDC hdc) {
|
||||
layout.titleBar.right - 8, layout.titleBar.bottom};
|
||||
drawText(&bmp, titleText, title.c_str(), kRgbText);
|
||||
|
||||
// The clickable button — fill reflects the last hit-test (the routing proof).
|
||||
LICE_FillRect(&bmp, layout.button.left, layout.button.top, layout.button.width(),
|
||||
layout.button.height(), buttonHit_ ? kColBtnHitBg : kColBtnBg, 1.0f,
|
||||
0);
|
||||
LICE_DrawRect(&bmp, layout.button.left, layout.button.top, layout.button.width(),
|
||||
layout.button.height(), kColBtnBorder, 1.0f, 0);
|
||||
Rect btnText{layout.button.left + 8, layout.button.top, layout.button.right,
|
||||
layout.button.bottom};
|
||||
drawText(&bmp, btnText, buttonHit_ ? "clicked" : "click me", kRgbText);
|
||||
// Sample list: one row per bank sample, the selected one highlighted. Clip drawing
|
||||
// to rows that fall within the canvas (sampleRowRect computes all; we skip off-screen
|
||||
// ones so a huge bank doesn't waste paint).
|
||||
for (int i = 0; i < static_cast<int>(samples_.size()); ++i) {
|
||||
const Rect row = sampleRowRect(layout, i);
|
||||
if (row.top >= layout.canvas.bottom) break; // past the visible area
|
||||
const bool sel = !selectedId_.empty() && samples_[i].id == selectedId_;
|
||||
LICE_FillRect(&bmp, row.left, row.top, row.width(), row.height(),
|
||||
sel ? kColBtnHitBg : kColBtnBg, 1.0f, 0);
|
||||
if (sel) {
|
||||
LICE_DrawRect(&bmp, row.left, row.top, row.width() - 1, row.height() - 1,
|
||||
kColBtnBorder, 1.0f, 0);
|
||||
}
|
||||
Rect textR{row.left + 8, row.top, row.right - 8, row.bottom};
|
||||
const std::string& name = samples_[i].displayName;
|
||||
drawText(&bmp, textR, name.empty() ? samples_[i].id.c_str() : name.c_str(),
|
||||
kRgbText);
|
||||
}
|
||||
|
||||
BitBlt(hdc, 0, 0, w, h, bmp.getDC(), 0, 0, SRCCOPY);
|
||||
}
|
||||
|
||||
void ReaSamplerEditor::onClick(int x, int y) {
|
||||
if (!processor_) return;
|
||||
RECT cr{};
|
||||
GetClientRect(childHwnd_, &cr);
|
||||
const EditorLayout layout = layoutEditor(cr.right - cr.left, cr.bottom - cr.top);
|
||||
if (hitTest(layout, x, y) == HitTarget::kButton) {
|
||||
buttonHit_ = !buttonHit_;
|
||||
InvalidateRect(childHwnd_, nullptr, FALSE);
|
||||
}
|
||||
const int row = sampleRowHitTest(layout, static_cast<int>(samples_.size()), x, y);
|
||||
if (row < 0) return;
|
||||
|
||||
// Select this sample and rebuild the instrument OFF the audio thread (this WM_
|
||||
// handler runs on the UI thread). reloadFromBank reads the id we just set.
|
||||
processor_->setSelectedSampleId(samples_[row].id);
|
||||
processor_->reloadFromBank();
|
||||
selectedId_ = samples_[row].id;
|
||||
InvalidateRect(childHwnd_, nullptr, FALSE);
|
||||
}
|
||||
|
||||
LRESULT CALLBACK ReaSamplerEditor::wndProc(HWND hwnd, UINT msg, WPARAM wParam,
|
||||
|
||||
@@ -13,21 +13,27 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "public.sdk/source/common/pluginview.h"
|
||||
|
||||
#include "sample_map.h" // SampleChoice (the list the editor draws)
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
class ReaperBridge;
|
||||
class ReaSamplerProcessor;
|
||||
|
||||
class ReaSamplerEditor : public Steinberg::CPluginView {
|
||||
public:
|
||||
// `bridge` is owned by the processor and outlives the editor; the editor reads
|
||||
// (never mutates) it to show a live-state readout. May be null (non-REAPER host).
|
||||
explicit ReaSamplerEditor(ReaperBridge* bridge);
|
||||
// `processor` owns this editor's lifetime domain and outlives it; the editor reads
|
||||
// the live bank through it (the sample list) and drives selection + reload when the
|
||||
// user clicks a row. May be null (defensive — a real host always supplies one).
|
||||
explicit ReaSamplerEditor(ReaSamplerProcessor* processor);
|
||||
~ReaSamplerEditor() override;
|
||||
|
||||
// Accept only the Windows HWND platform type (D5: Windows-only).
|
||||
@@ -48,7 +54,7 @@ private:
|
||||
#ifdef _WIN32
|
||||
// Draw the current surface into the child window's DC via a LICE bitmap.
|
||||
void paint(HDC hdc);
|
||||
// Route a client-space click through editor_geometry::hitTest.
|
||||
// Route a client-space click: select the sample row under (x, y), if any.
|
||||
void onClick(int x, int y);
|
||||
|
||||
static LRESULT CALLBACK wndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam);
|
||||
@@ -56,10 +62,16 @@ private:
|
||||
HWND childHwnd_ = nullptr;
|
||||
#endif
|
||||
|
||||
ReaperBridge* bridge_ = nullptr;
|
||||
// Latched on click so the paint reflects the last hit-test result — the spike's
|
||||
// proof that host->click->draw routing round-trips.
|
||||
bool buttonHit_ = false;
|
||||
// Re-read the bank's sample list from the live bridge into `samples_`. Main/UI
|
||||
// thread only (reads ext-state); called on attach and after a selection reload.
|
||||
void refreshSampleList();
|
||||
|
||||
ReaSamplerProcessor* processor_ = nullptr;
|
||||
// The bank's samples, snapshotted for the current paint. Refreshed off the audio
|
||||
// thread; the paint just draws it.
|
||||
std::vector<SampleChoice> samples_;
|
||||
// The currently-selected sample id, mirrored for the paint's highlight.
|
||||
std::string selectedId_;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -2,16 +2,64 @@
|
||||
|
||||
#include "reasampler_processor.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
#include "pluginterfaces/base/ibstream.h"
|
||||
#include "pluginterfaces/vst/ivstaudioprocessor.h"
|
||||
#include "pluginterfaces/vst/ivstevents.h"
|
||||
#include "pluginterfaces/vst/vstspeaker.h"
|
||||
|
||||
#include "capture_paths.h" // resolveBankFile (shared M4 path resolution)
|
||||
#include "ext_keys.h" // kProjExtBanksKey (shared wire contract)
|
||||
#include "reasampler_editor.h"
|
||||
#include "sample_map.h" // selectSample, downmixToMono, buildTier0Keymap, state (de)ser
|
||||
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
|
||||
using namespace Steinberg;
|
||||
using namespace Steinberg::Vst;
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
namespace {
|
||||
|
||||
// Tier-0 fixed instrument shape (Tier 2 makes these editable). A gentle amp envelope so
|
||||
// notes neither click on nor cut off abruptly; sustain at unity (velocity does the
|
||||
// dynamics), a short release for a natural tail. Times are in seconds, converted to
|
||||
// frames against the live sample rate at build time.
|
||||
constexpr double kAttackSeconds = 0.003;
|
||||
constexpr double kDecaySeconds = 0.0;
|
||||
constexpr double kSustainLevel = 1.0;
|
||||
constexpr double kReleaseSeconds = 0.060;
|
||||
constexpr std::size_t kMaxVoices = 16;
|
||||
|
||||
AdsrParams tier0Adsr(double sampleRate) {
|
||||
const double sr = sampleRate > 0.0 ? sampleRate : 44100.0;
|
||||
AdsrParams p;
|
||||
p.attackFrames = static_cast<std::int64_t>(kAttackSeconds * sr);
|
||||
p.decayFrames = static_cast<std::int64_t>(kDecaySeconds * sr);
|
||||
p.sustainLevel = kSustainLevel;
|
||||
p.releaseFrames = static_cast<std::int64_t>(kReleaseSeconds * sr);
|
||||
return p;
|
||||
}
|
||||
|
||||
// Read a whole file into a byte buffer. Off-thread only (blocking file I/O). Empty on
|
||||
// any failure — the caller treats an unreadable WAV as "nothing to play".
|
||||
std::vector<std::uint8_t> readFileBytes(const std::string& path) {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return bytes;
|
||||
const std::streamoff size = f.tellg();
|
||||
if (size <= 0) return bytes;
|
||||
f.seekg(0, std::ios::beg);
|
||||
bytes.resize(static_cast<std::size_t>(size));
|
||||
if (!f.read(reinterpret_cast<char*>(bytes.data()), size)) bytes.clear();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
FUnknown* ReaSamplerProcessor::createInstance(void* /*context*/) {
|
||||
// The host owns the returned reference. Cast up to the combined interface the SDK
|
||||
// exposes (IAudioProcessor) so the FUnknown refcount is correctly rooted.
|
||||
@@ -23,7 +71,7 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
||||
if (result != kResultOk) return result;
|
||||
|
||||
// Connect the REAPER bridge. Non-fatal if it fails (non-REAPER host): the
|
||||
// instrument still loads, the editor just shows "no bridge".
|
||||
// instrument still loads, it just has no live bank to play.
|
||||
bridge_.connect(context);
|
||||
|
||||
// Instrument bus topology: one event input (MIDI in, 16 channels), one stereo audio
|
||||
@@ -35,46 +83,204 @@ tresult PLUGIN_API ReaSamplerProcessor::initialize(FUnknown* context) {
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::terminate() {
|
||||
// process() is not running at terminate. Free the live instrument and drain the
|
||||
// graveyard. Take the pointer out of the atomic first so nothing else races it.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
delete live_.exchange(nullptr);
|
||||
graveyard_.clear();
|
||||
return SingleComponentEffect::terminate();
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool /*state*/) {
|
||||
// Nothing to allocate/free in the silent skeleton; S4 will size voice buffers here
|
||||
// against the setupProcessing block size.
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
|
||||
// Activating: build the instrument from the currently-selected sample so the first
|
||||
// block after activation can play. Deactivating: process is now GUARANTEED stopped by
|
||||
// the host, so this is the safe point to reclaim the graveyard (the displaced engines
|
||||
// no reload could free while active). The build/drain are off the audio thread —
|
||||
// setActive is a main/UI-thread call.
|
||||
if (state) {
|
||||
reloadFromBank();
|
||||
} else {
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
graveyard_.clear();
|
||||
}
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
|
||||
sampleRate_ = setup.sampleRate;
|
||||
maxBlockSize_ = setup.maxSamplesPerBlock;
|
||||
return SingleComponentEffect::setupProcessing(setup);
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
// Silent skeleton: emit silence on the output bus so the instrument runs cleanly in
|
||||
// REAPER's render/record path without a null buffer. S4 marshals MIDI->core->audio.
|
||||
if (data.numOutputs > 0 && data.outputs && data.numSamples > 0) {
|
||||
AudioBusBuffers& out = data.outputs[0];
|
||||
for (int32 ch = 0; ch < out.numChannels; ++ch) {
|
||||
if (data.symbolicSampleSize == kSample32) {
|
||||
if (float* buf = out.channelBuffers32[ch]) {
|
||||
for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.f;
|
||||
}
|
||||
} else if (data.symbolicSampleSize == kSample64) {
|
||||
if (double* buf = out.channelBuffers64[ch]) {
|
||||
for (int32 i = 0; i < data.numSamples; ++i) buf[i] = 0.0;
|
||||
tresult PLUGIN_API ReaSamplerProcessor::setState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
// Read the whole component-state blob (the selected sample id, versioned). The blob
|
||||
// is small; read in one shot into a growable buffer.
|
||||
std::vector<std::uint8_t> bytes;
|
||||
std::uint8_t chunk[256];
|
||||
int32 got = 0;
|
||||
while (state->read(chunk, sizeof(chunk), &got) == kResultOk && got > 0) {
|
||||
bytes.insert(bytes.end(), chunk, chunk + got);
|
||||
if (got < static_cast<int32>(sizeof(chunk))) break;
|
||||
}
|
||||
setSelectedSampleId(deserializeSelection(bytes));
|
||||
// Rebuild from the restored selection (off-thread — setState is a load-time call).
|
||||
reloadFromBank();
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::getState(IBStream* state) {
|
||||
if (!state) return kResultFalse;
|
||||
const std::vector<std::uint8_t> bytes = serializeSelection(selectedSampleId());
|
||||
if (!bytes.empty()) {
|
||||
state->write(const_cast<std::uint8_t*>(bytes.data()),
|
||||
static_cast<int32>(bytes.size()), nullptr);
|
||||
}
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
std::string ReaSamplerProcessor::selectedSampleId() {
|
||||
std::lock_guard<std::mutex> lock(selectionMutex_);
|
||||
return selectedSampleId_;
|
||||
}
|
||||
|
||||
void ReaSamplerProcessor::setSelectedSampleId(const std::string& id) {
|
||||
std::lock_guard<std::mutex> lock(selectionMutex_);
|
||||
selectedSampleId_ = id;
|
||||
}
|
||||
|
||||
std::string ReaSamplerProcessor::reloadFromBank() {
|
||||
// OFF THE AUDIO THREAD. Serialize concurrent reloads (editor click + setState) so
|
||||
// the retired-slot free is single-writer. This mutex is NEVER taken on the audio
|
||||
// thread — process() only touches the atomic.
|
||||
std::lock_guard<std::mutex> lock(reloadMutex_);
|
||||
|
||||
// 1. Read the live bank + resolve the project dir over the bridge (allocates,
|
||||
// calls REAPER — fine here, off-thread).
|
||||
std::optional<std::string> banksJson =
|
||||
bridge_.readReasamplerExtState(kProjExtBanksKey);
|
||||
const std::string projectDir = bridge_.activeProjectDir();
|
||||
|
||||
std::string resolvedId;
|
||||
std::unique_ptr<LoadedInstrument> built;
|
||||
|
||||
if (banksJson) {
|
||||
// 2. Pick the sample (shared bank_book JSON parse — NOT a second parser).
|
||||
std::optional<SelectedSample> sel =
|
||||
selectSample(*banksJson, selectedSampleId());
|
||||
if (sel) {
|
||||
// 3. Resolve the project-relative WAV path the M4 way persist does, read +
|
||||
// decode it (file I/O off-thread), downmix to the core's mono contract.
|
||||
const std::string abs = resolveBankFile(projectDir, sel->relativePath);
|
||||
if (!abs.empty()) {
|
||||
const std::vector<std::uint8_t> bytes = readFileBytes(abs);
|
||||
const WavLayout layout = parseWavLayout(bytes);
|
||||
if (layout.valid) {
|
||||
const std::size_t frames = layout.frameCount();
|
||||
std::vector<AudioSample> interleaved =
|
||||
extractFloatFrames(bytes, layout, 0, frames);
|
||||
std::vector<AudioSample> mono =
|
||||
downmixToMono(interleaved, layout.channelCount);
|
||||
if (!mono.empty()) {
|
||||
Keymap km = buildTier0Keymap(
|
||||
std::move(mono),
|
||||
static_cast<int>(layout.sampleRate), sel->rootNote,
|
||||
sel->loop);
|
||||
built = std::make_unique<LoadedInstrument>(
|
||||
std::move(km), kMaxVoices, tier0Adsr(sampleRate_));
|
||||
// Record which id actually resolved so a first-sample fallback
|
||||
// (empty stored id) becomes the concrete selection.
|
||||
resolvedId = selectedSampleId();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flag output silence so the host can optimize (nothing plays yet).
|
||||
}
|
||||
|
||||
// 4. Publish. Atomically install the new instrument; the DISPLACED one goes to the
|
||||
// graveyard (process may still be reading it this block — it is reclaimed only when
|
||||
// process is stopped, in setActive(false)/terminate). A null `built` (no bank /
|
||||
// unreadable WAV) installs silence. `built` is heap-owned; release() hands
|
||||
// ownership to the atomic, and the exchanged pointer is re-owned by the graveyard.
|
||||
LoadedInstrument* prev = live_.exchange(built.release());
|
||||
if (prev) graveyard_.emplace_back(prev);
|
||||
return resolvedId;
|
||||
}
|
||||
|
||||
tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
|
||||
// REAL-TIME: no allocation, no IO, no locks. Load the live instrument once for the
|
||||
// whole block (a single atomic acquire).
|
||||
LoadedInstrument* inst = live_.load(std::memory_order_acquire);
|
||||
|
||||
// Marshal MIDI note-on/off from the event input into the voice engine. Tier 0 maps
|
||||
// events at block granularity (no per-event sample-offset split) — audible timing is
|
||||
// within one block, adequate for Tier 0; sample-accurate scheduling is a later tier.
|
||||
if (inst && data.inputEvents) {
|
||||
const int32 count = data.inputEvents->getEventCount();
|
||||
for (int32 i = 0; i < count; ++i) {
|
||||
Event e;
|
||||
if (data.inputEvents->getEvent(i, e) != kResultOk) continue;
|
||||
if (e.type == Event::kNoteOnEvent) {
|
||||
// A note-on with velocity 0 is a note-off by MIDI convention.
|
||||
const int vel = static_cast<int>(e.noteOn.velocity * 127.0f + 0.5f);
|
||||
if (vel <= 0) {
|
||||
inst->engine.noteOff(e.noteOn.pitch);
|
||||
} else {
|
||||
inst->engine.noteOn(e.noteOn.pitch, vel);
|
||||
}
|
||||
} else if (e.type == Event::kNoteOffEvent) {
|
||||
inst->engine.noteOff(e.noteOff.pitch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) {
|
||||
return kResultOk;
|
||||
}
|
||||
AudioBusBuffers& out = data.outputs[0];
|
||||
const int32 frames = data.numSamples;
|
||||
|
||||
// 64-bit host processing is not supported by the mono float core; emit silence
|
||||
// rather than mis-render. REAPER runs 32-bit float by default.
|
||||
if (data.symbolicSampleSize != kSample32) {
|
||||
for (int32 ch = 0; ch < out.numChannels; ++ch) {
|
||||
if (double* buf = out.channelBuffers64[ch]) {
|
||||
for (int32 i = 0; i < frames; ++i) buf[i] = 0.0;
|
||||
}
|
||||
}
|
||||
out.silenceFlags = (out.numChannels >= 64)
|
||||
? ~0ULL
|
||||
: ((1ULL << out.numChannels) - 1);
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
// Render mono into channel 0's buffer, then replicate to the other channels (the
|
||||
// core is mono-per-sample). Clear channel 0 first (render ADDS), then mix.
|
||||
float* ch0 = out.numChannels > 0 ? out.channelBuffers32[0] : nullptr;
|
||||
if (ch0) {
|
||||
for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f;
|
||||
if (inst) {
|
||||
inst->engine.render(ch0, static_cast<std::size_t>(frames));
|
||||
}
|
||||
// Duplicate the mono render across the remaining output channels.
|
||||
for (int32 ch = 1; ch < out.numChannels; ++ch) {
|
||||
if (float* buf = out.channelBuffers32[ch]) {
|
||||
for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Report silence only when nothing is loaded (lets the host optimize when idle).
|
||||
// With an instrument loaded we clear the flag so a ringing voice is not skipped.
|
||||
out.silenceFlags = inst ? 0 : ((out.numChannels >= 64)
|
||||
? ~0ULL
|
||||
: ((1ULL << out.numChannels) - 1));
|
||||
return kResultOk;
|
||||
}
|
||||
|
||||
IPlugView* PLUGIN_API ReaSamplerProcessor::createView(FIDString name) {
|
||||
if (name && FIDStringsEqual(name, ViewType::kEditor)) {
|
||||
return new ReaSamplerEditor(&bridge_);
|
||||
return new ReaSamplerEditor(this);
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
// reasampler_processor.h — the VST3 SingleComponentEffect skeleton (Phase S1). THIN
|
||||
// shell: an instrument that declares an event-input bus + a stereo audio-output bus,
|
||||
// sets up processing, and runs an empty (silent) process. Nothing plays yet — S4 wires
|
||||
// the pure sampler core into process(); S1 only proves REAPER hosts it.
|
||||
// reasampler_processor.h — the VST3 SingleComponentEffect (Phase S4, Tier 0). Wires the
|
||||
// pure S3 sampler core into a real VSTi: it declares an event-input bus + a stereo audio
|
||||
// output bus, marshals host MIDI note-on/off into the VoiceEngine, and renders the
|
||||
// engine's audio into the output bus — so a chosen bank sample plays chromatically from
|
||||
// its root note in REAPER's routing/record/render path.
|
||||
//
|
||||
// SingleComponentEffect is the SDK's combined processor+controller base — sanctioned
|
||||
// for a non-distributable, REAPER-only plugin under D5/D6 (verified: SDK class
|
||||
// reference). It gives us addAudioOutput/addEventInput and the IEditController seat, so
|
||||
// createView() can hand the host our IPlugView LICE editor.
|
||||
// for a non-distributable, REAPER-only plugin under D5/D6. It gives us
|
||||
// addAudioOutput/addEventInput, IComponent setState/getState for the instance's own
|
||||
// state (the selected sample), and the IEditController seat so createView() can hand the
|
||||
// host our IPlugView LICE editor.
|
||||
//
|
||||
// REAL-TIME DISCIPLINE (S4 hard constraint). The audio thread (process) does NO
|
||||
// allocation, NO file I/O, NO bridge calls, NO locks. Sample loading — bridge ext-state
|
||||
// read, WAV decode, path resolve, keymap build, VoiceEngine construction — all happens
|
||||
// OFF the audio thread (reloadFromBank, driven from the main/UI thread) and is handed to
|
||||
// process via a single atomic pointer swap. See the LoadedInstrument handoff below.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "public.sdk/source/vst/vstsinglecomponenteffect.h"
|
||||
|
||||
#include "reaper_bridge.h"
|
||||
#include "sampler_core.h"
|
||||
|
||||
namespace reasampler::vst {
|
||||
|
||||
// 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
|
||||
// copyable nor movable. The audio thread only ever reads it through an atomic pointer;
|
||||
// it is built and destroyed off the audio thread.
|
||||
struct LoadedInstrument {
|
||||
Keymap keymap;
|
||||
VoiceEngine engine;
|
||||
|
||||
LoadedInstrument(Keymap km, std::size_t maxVoices, const AdsrParams& adsr)
|
||||
: keymap(std::move(km)), engine(maxVoices, keymap, adsr) {}
|
||||
|
||||
LoadedInstrument(const LoadedInstrument&) = delete;
|
||||
LoadedInstrument& operator=(const LoadedInstrument&) = delete;
|
||||
};
|
||||
|
||||
class ReaSamplerProcessor : public Steinberg::Vst::SingleComponentEffect {
|
||||
public:
|
||||
ReaSamplerProcessor() = default;
|
||||
@@ -30,10 +62,16 @@ public:
|
||||
Steinberg::tresult PLUGIN_API terminate() override;
|
||||
Steinberg::tresult PLUGIN_API setActive(Steinberg::TBool state) override;
|
||||
|
||||
// Instance state = the selected bank sample id (D-B: a performance choice the
|
||||
// instrument owns; NEVER written back to the bank). Component-state, so a saved
|
||||
// REAPER project restores which sample each instance plays.
|
||||
Steinberg::tresult PLUGIN_API setState(Steinberg::IBStream* state) override;
|
||||
Steinberg::tresult PLUGIN_API getState(Steinberg::IBStream* state) override;
|
||||
|
||||
//--- from IAudioProcessor ----------------------------------------------
|
||||
Steinberg::tresult PLUGIN_API setupProcessing(
|
||||
Steinberg::Vst::ProcessSetup& setup) override;
|
||||
// Empty in the spike: emits silence (S4 fills it).
|
||||
// Marshals MIDI -> VoiceEngine -> audio output. Real-time safe (no alloc/IO/lock).
|
||||
Steinberg::tresult PLUGIN_API process(
|
||||
Steinberg::Vst::ProcessData& data) override;
|
||||
|
||||
@@ -41,8 +79,50 @@ public:
|
||||
// Hands the host our LICE IPlugView editor.
|
||||
Steinberg::IPlugView* PLUGIN_API createView(Steinberg::FIDString name) override;
|
||||
|
||||
// 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
|
||||
// atomic swap. Safe to call with no bridge / no bank (leaves silence). Returns the
|
||||
// resolved selection id ("" if nothing was loaded) for the editor to reflect.
|
||||
std::string reloadFromBank();
|
||||
|
||||
// The bridge, for the editor's live-state readout + sample list. Owned here; the
|
||||
// editor borrows it (outlives the editor).
|
||||
ReaperBridge& bridge() { return bridge_; }
|
||||
// The current selection id (main/UI thread reads for the editor). Guarded by
|
||||
// selectionMutex_ — never touched on the audio thread.
|
||||
std::string selectedSampleId();
|
||||
void setSelectedSampleId(const std::string& id);
|
||||
|
||||
private:
|
||||
ReaperBridge bridge_;
|
||||
|
||||
// --- The audio-thread handoff (S4 real-time discipline) -----------------
|
||||
// process() atomically loads `live_` at block start and marshals/renders against it —
|
||||
// a single atomic acquire, no lock, no free on the audio thread.
|
||||
//
|
||||
// reloadFromBank() (off-thread, serialized by reloadMutex_) builds a new
|
||||
// LoadedInstrument and atomically swaps it into `live_`. The DISPLACED instrument is
|
||||
// NOT freed on the reload path: process() may still be mid-block reading it, and two
|
||||
// rapid reloads could otherwise free a pointer process is using. Instead it is parked
|
||||
// in `graveyard_` and reclaimed only when process is GUARANTEED stopped — at
|
||||
// setActive(false) / terminate(), which the host never runs concurrently with
|
||||
// process. The graveyard grows by one engine per reload during a session (bounded by
|
||||
// user sample switches — a few objects), a deliberate leak-until-deactivate trade for
|
||||
// a lock-free, race-free audio thread. Tier 2 can add epoch-based reclaim if needed.
|
||||
std::atomic<LoadedInstrument*> live_{nullptr};
|
||||
std::vector<std::unique_ptr<LoadedInstrument>> graveyard_; // freed only when stopped
|
||||
std::mutex reloadMutex_; // serializes off-thread reloads + graveyard access
|
||||
|
||||
// The selected sample id (instance state). Off-thread only; a small mutex guards the
|
||||
// string against a getState/editor race. NOT read on the audio thread.
|
||||
std::mutex selectionMutex_;
|
||||
std::string selectedSampleId_;
|
||||
|
||||
// Latched from setupProcessing so setActive/reload can size against it. Read
|
||||
// off-thread only.
|
||||
double sampleRate_ = 44100.0;
|
||||
Steinberg::int32 maxBlockSize_ = 4096;
|
||||
};
|
||||
|
||||
} // namespace reasampler::vst
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// sample_map — pure implementation. See sample_map.h. NO VST3 / REAPER / SWELL /
|
||||
// vendor includes; standard library + the pure bank_book / wav_trim / sampler_core.
|
||||
|
||||
#include "sample_map.h"
|
||||
|
||||
#include <cstring> // std::memcpy
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
namespace {
|
||||
|
||||
// Translate a bank_model Sample's S2 intrinsics into the core's SampleLoop. The bank
|
||||
// stores loop points as an optional LoopPoints (both-or-neither); the core wants a
|
||||
// SampleLoop with an explicit hasLoop. Absent -> no loop.
|
||||
SampleLoop loopFromSample(const Sample& s) {
|
||||
SampleLoop out;
|
||||
if (s.loop) {
|
||||
out.hasLoop = true;
|
||||
out.start = s.loop->start;
|
||||
out.end = s.loop->end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A distilled SelectedSample from a bank_model Sample. rootNote defaults to middle C
|
||||
// (60) when the bank left the intrinsic empty — Tier 0 still plays, just centered on
|
||||
// C rather than a captured pitch (surfaced: an un-rooted sample plays unity at C4).
|
||||
SelectedSample distill(const Sample& s) {
|
||||
SelectedSample out;
|
||||
out.relativePath = s.relativePath;
|
||||
out.rootNote = s.rootNote ? *s.rootNote : 60;
|
||||
out.loop = loopFromSample(s);
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
const std::string& sampleId) {
|
||||
if (banksJson.empty()) return std::nullopt;
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return std::nullopt; // malformed -> nothing to play (never throw)
|
||||
|
||||
// Search every bank (pool first, then named — banks() is ordinal order) for the
|
||||
// stored id. A sample lives in exactly one bank, so first hit wins.
|
||||
if (!sampleId.empty()) {
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (const Sample* s = b.index.query(sampleId)) {
|
||||
return distill(*s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No stored id, or the id no longer resolves (the sample was deleted/moved out):
|
||||
// fall back to the FIRST sample in ordinal order so a fresh instance plays.
|
||||
for (const Bank& b : book->banks()) {
|
||||
if (!b.index.all().empty()) {
|
||||
return distill(b.index.all().front());
|
||||
}
|
||||
}
|
||||
return std::nullopt; // bank has zero samples anywhere
|
||||
}
|
||||
|
||||
std::vector<SampleChoice> listSamples(const std::string& banksJson) {
|
||||
std::vector<SampleChoice> out;
|
||||
if (banksJson.empty()) return out;
|
||||
std::optional<BankBook> book = BankBook::deserialize(banksJson);
|
||||
if (!book) return out;
|
||||
for (const Bank& b : book->banks()) {
|
||||
for (const Sample& s : b.index.all()) {
|
||||
out.push_back(SampleChoice{s.id, s.displayName});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount) {
|
||||
std::vector<AudioSample> out;
|
||||
if (channelCount <= 0 || interleaved.empty()) return out;
|
||||
const std::size_t stride = static_cast<std::size_t>(channelCount);
|
||||
const std::size_t frames = interleaved.size() / stride;
|
||||
out.resize(frames);
|
||||
const double inv = 1.0 / static_cast<double>(channelCount);
|
||||
for (std::size_t f = 0; f < frames; ++f) {
|
||||
double acc = 0.0;
|
||||
const std::size_t base = f * stride;
|
||||
for (std::size_t c = 0; c < stride; ++c) {
|
||||
acc += static_cast<double>(interleaved[base + c]);
|
||||
}
|
||||
out[f] = static_cast<AudioSample>(acc * inv);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> monoFrames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop) {
|
||||
SampleData data;
|
||||
data.frames = std::move(monoFrames);
|
||||
data.sampleRate = sampleRate > 0 ? sampleRate : 44100;
|
||||
data.rootNote = rootNote;
|
||||
data.loop = loop;
|
||||
return Keymap::singleSampleChromatic(std::move(data));
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId) {
|
||||
std::vector<std::uint8_t> out;
|
||||
out.resize(4 + sampleId.size());
|
||||
const std::uint32_t v = kSelectionStateVersion;
|
||||
out[0] = static_cast<std::uint8_t>(v & 0xFF);
|
||||
out[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
|
||||
out[2] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
|
||||
out[3] = static_cast<std::uint8_t>((v >> 24) & 0xFF);
|
||||
std::memcpy(out.data() + 4, sampleId.data(), sampleId.size());
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes) {
|
||||
if (bytes.size() < 4) return {}; // no version tag -> no selection
|
||||
const std::uint32_t v = static_cast<std::uint32_t>(bytes[0]) |
|
||||
(static_cast<std::uint32_t>(bytes[1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[3]) << 24);
|
||||
if (v != kSelectionStateVersion) return {}; // unknown version -> ignore
|
||||
return std::string(reinterpret_cast<const char*>(bytes.data() + 4),
|
||||
bytes.size() - 4);
|
||||
}
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -0,0 +1,110 @@
|
||||
#pragma once
|
||||
// sample_map — PURE mapping logic for the S4 Tier-0 instrument: turn the live
|
||||
// "reasampler" bank ext-state + a decoded WAV into the plain data the sampler core
|
||||
// plays, and (de)serialize the instance's selected-sample choice for VST3 component
|
||||
// state. NO VST3, NO REAPER, NO SWELL, NO vendor/ includes at the boundary — the
|
||||
// mirror of capture_paths / wav_trim / bridge_marshal splitting the fiddly, testable
|
||||
// arithmetic out of a host-facing shell.
|
||||
//
|
||||
// WHY IT EXISTS (S4 seams). The instrument reads the bank over the live-state seam
|
||||
// (the "banks" ext-state blob) and the audio over the file seam (the on-disk WAV).
|
||||
// Both of those raw inputs cross the bridge/file boundary in the shell; everything
|
||||
// after — parse the bank with the SHARED bank_model/bank_book JSON path (NOT a second
|
||||
// parser; the S1 spike's string-scan reader is retired), pick the selected sample,
|
||||
// downmix its decoded PCM to the core's mono contract, and build the Tier-0 chromatic
|
||||
// Keymap — is pure and unit-tested here.
|
||||
//
|
||||
// It links bank_book (the shared BankBook::deserialize) and wav_trim (the shared
|
||||
// 32-bit-float WAV parse — no third WAV reader) and sampler_core (the Keymap /
|
||||
// SampleData it produces). All three are pure; this stays pure.
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "bank_book.h" // BankBook::deserialize (shared bank JSON parse)
|
||||
#include "sampler_core.h" // Keymap, SampleData, SampleLoop
|
||||
#include "wav_trim.h" // parseWavLayout, extractFloatFrames (shared WAV parse)
|
||||
|
||||
namespace reasampler {
|
||||
|
||||
// The bank sample this instance is bound to, distilled from the live "banks" blob:
|
||||
// the project-relative WAV path the file seam must resolve+decode, plus the S2 bank
|
||||
// intrinsics the core repitches / loops by. A pure value — no host, no PCM yet.
|
||||
struct SelectedSample {
|
||||
std::string relativePath; // project-relative; the shell resolves it (M4 convention)
|
||||
int rootNote = 60; // S2 intrinsic; defaults to middle C when the bank left it empty
|
||||
SampleLoop loop; // S2 intrinsic; hasLoop=false when the bank left it empty
|
||||
};
|
||||
|
||||
// Resolve the bound sample from the live bank blob. `banksJson` is the raw "banks"
|
||||
// ext-state value the bridge read (may be empty / malformed — an unsaved or pre-bank
|
||||
// project). `sampleId` is this instance's stored selection.
|
||||
//
|
||||
// Precedence, all pure:
|
||||
// * empty / malformed banksJson -> nullopt (nothing to play)
|
||||
// * sampleId names a sample in ANY bank -> that sample (searched pool + named)
|
||||
// * sampleId empty or not found, bank has -> the FIRST sample in ordinal order
|
||||
// >= 1 sample (a sensible default so a fresh
|
||||
// instance plays SOMETHING; the UI can
|
||||
// then pick a specific one)
|
||||
// * bank has zero samples -> nullopt
|
||||
//
|
||||
// The "first sample" fallback is deliberate: Tier 0 is "the bank plays", and a brand-
|
||||
// new instance with no stored selection should map the bank's first sample rather than
|
||||
// stay silent until the user opens the editor.
|
||||
std::optional<SelectedSample> selectSample(const std::string& banksJson,
|
||||
const std::string& sampleId);
|
||||
|
||||
// All (id, displayName) pairs across every bank in ordinal order (pool first), for the
|
||||
// selection UI to list. Empty for an empty / malformed blob. Pure projection over the
|
||||
// shared parse — the UI never parses JSON itself.
|
||||
struct SampleChoice {
|
||||
std::string id;
|
||||
std::string displayName;
|
||||
};
|
||||
std::vector<SampleChoice> listSamples(const std::string& banksJson);
|
||||
|
||||
// Downmix interleaved float frames (the shape wav_trim::extractFloatFrames yields:
|
||||
// [f0c0,f0c1,...,f1c0,...]) to the core's MONO contract by AVERAGING channels per
|
||||
// frame. `channelCount` is the interleave stride (>= 1). CHANNEL POLICY (Tier 0,
|
||||
// documented + surfaced): the S3 core is mono-per-sample by design; bank WAVs preserve
|
||||
// their source channel count, so a stereo (or N-channel) capture is folded to a single
|
||||
// mono stream here by an equal-weight average. Averaging (not "take L", not summing) is
|
||||
// the least-surprising, no-clip default — a centered mono source stays unity, and a
|
||||
// hard-panned source is attenuated rather than silenced or doubled. Empty / zero-stride
|
||||
// in -> empty out. Pure.
|
||||
std::vector<AudioSample> downmixToMono(const std::vector<AudioSample>& interleaved,
|
||||
int channelCount);
|
||||
|
||||
// Build the Tier-0 chromatic keymap for one decoded, mono sample: one zone spanning
|
||||
// the whole keyboard, repitched from `rootNote`, looped per `loop`. This is the
|
||||
// single-sample degenerate case (Keymap::singleSampleChromatic) with the S2 intrinsics
|
||||
// threaded in. `monoFrames` is the downmixed PCM; `sampleRate` is the WAV's rate.
|
||||
Keymap buildTier0Keymap(std::vector<AudioSample> monoFrames, int sampleRate,
|
||||
int rootNote, const SampleLoop& loop);
|
||||
|
||||
// --- Instance state (VST3 setState/getState) --------------------------------
|
||||
//
|
||||
// The instrument's OWN state is which bank sample it plays (D-B: the selection is a
|
||||
// performance choice, held by the instrument, never written back to the bank). It is a
|
||||
// single string id. serialize/deserialize keep the on-the-wire form explicit and
|
||||
// versioned so a future Tier can extend it without breaking already-saved instances.
|
||||
//
|
||||
// Format (v1): a 4-byte little-endian version tag (== 1) followed by the id bytes. No
|
||||
// length prefix is needed — the id runs to the end of the stream (the host tells us the
|
||||
// byte count). deserializeSelection tolerates a truncated / wrong-version / empty blob
|
||||
// by returning "" (no selection — the instrument falls back to the bank's first sample),
|
||||
// never throwing across the host boundary.
|
||||
|
||||
inline constexpr std::uint32_t kSelectionStateVersion = 1;
|
||||
|
||||
// The selected-sample id serialized to bytes for IBStream (getState).
|
||||
std::vector<std::uint8_t> serializeSelection(const std::string& sampleId);
|
||||
|
||||
// The selected-sample id parsed back from IBStream bytes (setState). Unknown version,
|
||||
// too-short, or empty -> "" (graceful no-selection).
|
||||
std::string deserializeSelection(const std::vector<std::uint8_t>& bytes);
|
||||
|
||||
} // namespace reasampler
|
||||
@@ -289,18 +289,29 @@ void VoiceEngine::noteOff(int note) {
|
||||
if (target != kNoVoice) voices_[target].release();
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f); // S4: caller must pre-reserve — no allocation allowed under the VST3 process callback.
|
||||
void VoiceEngine::render(AudioSample* out, std::size_t frameCount) {
|
||||
// Real-time safe: no allocation, no resize — mix straight into the caller's buffer.
|
||||
// The VST3 process callback hands us the host's output channel buffer here, so the
|
||||
// audio thread never touches the heap (S4 real-time discipline).
|
||||
if (out == nullptr || frameCount == 0) return;
|
||||
for (Voice& voice : voices_) {
|
||||
if (!voice.active()) continue;
|
||||
for (std::size_t f = 0; f < frameCount; ++f) {
|
||||
if (!voice.active()) break;
|
||||
out[base + f] += voice.renderFrame();
|
||||
out[f] += voice.renderFrame();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void VoiceEngine::render(std::vector<AudioSample>& out, std::size_t frameCount) {
|
||||
// Off-thread / test path: grow the buffer (this allocates — never call under
|
||||
// process), zero-fill the appended span, then delegate to the RT mix loop so both
|
||||
// overloads share exactly one summation path.
|
||||
const std::size_t base = out.size();
|
||||
out.resize(base + frameCount, 0.0f);
|
||||
render(out.data() + base, frameCount);
|
||||
}
|
||||
|
||||
std::size_t VoiceEngine::activeVoiceCount() const {
|
||||
std::size_t n = 0;
|
||||
for (const Voice& v : voices_) {
|
||||
|
||||
+14
-3
@@ -236,9 +236,20 @@ public:
|
||||
// the older tail to ring — matches hardware behavior). No-op if none match.
|
||||
void noteOff(int note);
|
||||
|
||||
// Renders `frameCount` mono output frames, summing all active voices, appending to
|
||||
// `out` (does not clear it — the caller owns mixing/clearing). Voices that finish
|
||||
// mid-block go idle and stop contributing.
|
||||
// REAL-TIME render (S4): sums all active voices into the caller-provided buffer
|
||||
// `out[0..frameCount)`, ADDING to whatever is there (the caller clears or mixes —
|
||||
// this never touches memory it does not own and NEVER allocates). This is the
|
||||
// audio-thread entry point: the VST3 process callback passes the host's own output
|
||||
// channel buffer, so no allocation, resize, or heap traffic happens under process.
|
||||
// Voices that finish mid-block go idle and stop contributing. `out` must point at
|
||||
// at least `frameCount` writable samples; a null `out` or zero count is a no-op.
|
||||
void render(AudioSample* out, std::size_t frameCount);
|
||||
|
||||
// TEST / off-thread convenience: appends `frameCount` summed frames to `out`
|
||||
// (grows it — DO NOT call on the audio thread; it allocates). Delegates to the
|
||||
// real-time overload after sizing the buffer, so both paths share one mix loop.
|
||||
// Does not clear existing contents — appends, matching the pre-S4 contract the
|
||||
// unit tests rely on.
|
||||
void render(std::vector<AudioSample>& out, std::size_t frameCount);
|
||||
|
||||
// Count of currently active voices (for tests / diagnostics).
|
||||
|
||||
Reference in New Issue
Block a user