Γ-W1-T2: the master bus — a true-peak limiter whose ceiling is a theorem, the meter's published half, and the plugin's first PDC report

This commit is contained in:
2026-08-01 19:05:57 -04:00
parent 4fa021edae
commit 3baf4ee50b
17 changed files with 1187 additions and 65 deletions
+3 -2
View File
@@ -11,7 +11,8 @@ The pure engine/geometry core this shell wraps (`sampler_core`, `pitch_shift`,
`sample_map`, `component_state_io`, `play_params.h`, `editor_geometry`, `sample_bands`,
`sample_chrome`, `keyboard_strip`, `waveform_view`, `capture_browser`, `browser_scroll`,
`param_slider`, `param_taper`, `trigger_seam`, `velocity_curve`, `embed_strip`, `knob_deck`,
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`, `reasampler_uid.h`) lives in `core/instrument/*` and
`deck_groups`, `deck_values`, `bake_hold`, `curve_popup`, `spline_edit`, `master_gain`,
`limiter`, `meter_ballistics`, `reasampler_uid.h`) lives in `core/instrument/*` and
`core/wire` and is documented there — this directory consumes it but does not own it.
## Invariants
@@ -104,7 +105,7 @@ declared ahead of the instrument slots at that member in `reasampler_processor.h
## Modules
- `reaper_bridge` — READ-ONLY bank consumer: receives bank snapshots from the extension and exposes them as a read-only view. **Never writes to the extension's bank** — this is a load-bearing invariant; no mutation path exists in this module. It owns TWO prefix-guarded ext-state write entry points, `writeUsageExtState` (`rsusage_`) and `writeBakeExtState` (`rsbake_`), each refusing every other key; neither weakens the read-only-*bank* invariant, because neither payload is bank state and `banks`/`view`/`tail`/`assign` stay structurally unwritable. Both PROVE the write by reading the key back (`wire::extStateWriteLanded`) — `SetProjExtState`'s own return cannot speak for one key, so testing it was a guard that could never fire, and the bake's "could not publish" refusal was consequently unreachable. It also owns the bake crossing — `extensionActionAvailable` / `invokeExtensionAction` (`NamedCommandLookup` + `Main_OnCommandEx` with `getReaperParent(3)`, the instance's OWN project tab, as `proj` — a request, not a DAW-verified guarantee; see the header) and `projectTempoBpm`.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish.
- `reasampler_processor` (`shell/instrument/`: `reasampler_processor.cpp` lifecycle + `process()`, `processor_state.cpp` component-state I/O + UI-thread parameter accessors, `processor_reload.cpp` the off-audio-thread `reloadInstrument`/publish family — Q-W2v, T4-12 split; `process()` and its per-block work stay ONE TU on purpose, no cross-TU call on the per-sample path) — VST3 `SingleComponentEffect` shell: declares event-input bus + **permanently stereo** output (GA fix: dynamic mono↔stereo bus renegotiation deleted; `ChannelMode` is now decode-only), marshals MIDI note-on/off into the VoiceEngine, renders audio; owns off-audio-thread `reloadInstrument` + atomic pointer swap so `process()` does no allocation, no file I/O, no bridge calls. The instance state is `{loaded capture id, one InstrumentParams}`, and `reloadInstrument` resolves + decodes exactly that one capture into the `SampleData` the engine plays. **Self-contained playback (pS):** `ComponentState` v10 adds a `SampleRefs` table — per referenced sample, a project-relative path + decode intrinsics (root, loop, channels, displayName); `reloadInstrument` decodes directly from `SampleRefs`, bank-free (plays with the extension absent). The bank/bridge is a browser source: loading a capture copies its reference in; the reopen-heal timer + poll-to-play apparatus are removed. `retireIdleDrain()` retires fully-idle drain snapshots on the UI-timer cadence. Voice-param edits (`setVoiceCount`/`setVoiceMode`/`setMonoTrigger`) rebuild the engine from the already-decoded `SampleData` via the drain-slot swap — no bank re-read, no WAV re-decode, no audible cut to ringing tails. **FB1:** applies the post-mixer `masterGainLinear` (from `ComponentState` v8) as a per-sample ramp over the summed output — no zipper noise. **GA v9:** `channelModeExplicit_` flag persisted; `channelModeFor()` auto-defaults the mode from the loaded capture's channel count when the flag is not set. **pS:** `ComponentState` bumped v9→v10 (`SampleRefs` table); pre-v10 blobs lift to empty refs and re-save self-contained. **pS-usage:** publishes instance usage (held `SampleRefs` paths) to `rsusage_<instanceGuid>` at the tail of `reloadInstrument` (off audio thread) via `reaper_bridge::writeUsageExtState`; `ComponentState` bumped v10→**v11** (`instanceGuid` field); pre-v11 blobs mint guid on first publish. **The master bus:** the summed output runs `voice mixer → master gain → limiter (core/instrument/engine/limiter) → output bus`, with the meter tapped at the bus output POST-limiter and published per block as relaxed atomics (per-channel peak, latched clip, the block's smallest limiter gain). The limiter's enable is persisted in the parameter set (params payload v14) and mirrored onto the audio thread by `setInstrumentParams`, the single funnel every writer already goes through. That mirror is also what `getLatencySamples()` answers from — the plugin's FIRST latency reporting: 0 bypassed, the lookahead engaged. `setLimiterEnabled` requests the host's `restartComponent(kLatencyChanged)`, UI thread only and never from `process()`; it is a LATENCY restart with the bus untouched, NOT the retired per-mode `kIoChanged` bus renegotiation the invariant above forbids.
- `reasampler_editor` — VST3 `IPlugView` LICE editor shell: hosts a LICE-drawn child window; the Sample face is home and Browse is a modal picker over it. Split on the Sample face's BAND axis, mirroring the pure `sample_bands` allocator: `editor_session` (session/bridge state, caches, commit-and-reload), `editor_controls` (the ONE `faceLayout` band resolve every paint and hit-test path shares, the node-drag bounds, the value labels, and the per-instance controls the parameter set does not carry — the parameter-set binding itself is the pure `core/instrument/ui/deck_values` module this only adapts int ids onto), `editor_models` (the orthogonal half: which stored struct each transient editor selection names — the staged-envelope pack/unpack, the drawn contour, and the three velocity curves), then matching paint and input sets — `editor_paint`/`editor_input` (dispatch + drag router + hover dispatch), `_chrome`, `_waveform`, `_deck` — plus the two band-independent surfaces (`_browse` for the modal picker, `_curve` for the velocity-curve popup) and `editor_platform` (IPlugView/Win32 window plumbing). Shared internals in `editor_internal.h`, no TU of its own. Drop-onto-editor ingest is NOT shipped (deferred).
- `reasampler_embed` — implements `IReaperUIEmbedInterface` so the instrument draws inline in the TCP/MCP without a plugin-owned HWND; delegates layout to `embed_strip`. A read-only readout: the loaded capture across the keyboard span with its root marked, plus the activity level. It takes no mouse input (there is nothing on the strip to select).
- `editor_stroke` — the editor's LICE side of the analytic stroker: builds a coverage mask with the pure `core/ui/stroke_aa` and blends it into the bitmap ONCE, writing straight to the bitmap's bits (the arithmetic matches LICE's own mode-0 combine, so a stroke composites identically to every other kit draw). Every radial and spline stroke on the editor routes through `strokeArcAA` / `strokePolylineAA` / `strokeLineAA`. Holds the draw-thread-only scratch mask and arc point list — reuse, not a hidden dependency: threading a canvas through the eight paint sites would grow those signatures to carry an allocation detail. Deliberately does NOT touch `shell/panel/draw_kit`: the waveform stroke, the docked bank panel and the browse cards are out of this seam's blast radius.
+1 -1
View File
@@ -89,7 +89,7 @@ if(WIN32 AND EXISTS "${VST3_SDK}/public.sdk/source/main/pluginfactory.cpp")
waveform_view bank_sync browser_scroll param_slider tooltip
theme component_geometry bank_grid trigger_seam envelope_overlay envelope_edit
knob_deck deck_groups deck_values curve_popup spline_edit master_gain sample_usage
bake_hold
limiter meter_ballistics bake_hold
file_bytes curve_law stroke_aa
curve_tessellate
bake_plan bake_render bake_reset bake_wire wav_codec)
+41 -2
View File
@@ -12,6 +12,7 @@
#include <vector>
#include "pluginterfaces/base/ibstream.h"
#include "pluginterfaces/vst/ivsteditcontroller.h" // RestartFlags::kLatencyChanged
#include "core/instrument/engine/master_gain.h" // masterGainMaxLinear (post-mixer gain clamp)
#include "core/instrument/map/component_state_io.h" // the ComponentState codec
@@ -148,8 +149,46 @@ InstrumentParams ReaSamplerProcessor::instrumentParams() {
}
void ReaSamplerProcessor::setInstrumentParams(const InstrumentParams& params) {
std::lock_guard<std::mutex> lock(paramsMutex_);
params_ = params;
{
std::lock_guard<std::mutex> lock(paramsMutex_);
params_ = params;
}
// Every writer of the parameter set — setState, the editor's commits, the bake's adopt —
// funnels through here, so mirroring the limiter flag at this one point is what keeps the
// audio thread's copy and the latency report from ever lagging what is persisted.
publishLimiterEnabled(params.limiterEnabled);
}
void ReaSamplerProcessor::publishLimiterEnabled(bool on) {
limiterEnabled_.store(on, std::memory_order_relaxed);
limiter_.setEnabled(on);
}
void ReaSamplerProcessor::setLimiterEnabled(bool on) {
{
std::lock_guard<std::mutex> lock(paramsMutex_);
if (params_.limiterEnabled == on) return; // no change: no restart to request
params_.limiterEnabled = on;
}
publishLimiterEnabled(on);
// The SDK requires this on the UI thread and answers getLatencySamples only after the host's
// own deactivate/reactivate — so the flag above is already committed by the time the host
// asks. This is a kLatencyChanged restart with the bus untouched, NOT the retired per-mode
// kIoChanged bus renegotiation (see initialize()); do not conflate the two.
if (componentHandler) componentHandler->restartComponent(kLatencyChanged);
}
MasterBusMeter ReaSamplerProcessor::masterBusMeter() const {
MasterBusMeter m;
m.peakL = meterPeakL_.load(std::memory_order_relaxed);
m.peakR = meterPeakR_.load(std::memory_order_relaxed);
m.minGain = meterMinGain_.load(std::memory_order_relaxed);
m.clip = meterClip_.load(std::memory_order_relaxed);
return m;
}
void ReaSamplerProcessor::clearMasterBusClip() {
meterClip_.store(false, std::memory_order_relaxed);
}
void ReaSamplerProcessor::publishLiveParams() {
+32 -8
View File
@@ -95,6 +95,11 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
// project's ext-state parses, nothing retries until the next activation or editor
// tick — open a pre-v10 instrument once after upgrading if it restores silent.
reloadInstrument();
// The host performs this deactivate/reactivate whenever it acts on a kLatencyChanged
// request, so the limiter starts each activation with an empty delay line and snapped
// to its persisted state — no crossfade, because there is nothing sounding to be
// continuous with once the block above has destroyed every voice.
limiter_.reset();
} else {
std::lock_guard<std::mutex> lock(reloadMutex_);
// Free EVERYTHING, including live_: its voices are frozen mid-flight, and if it
@@ -108,6 +113,12 @@ tresult PLUGIN_API ReaSamplerProcessor::setActive(TBool state) {
return kResultOk;
}
uint32 PLUGIN_API ReaSamplerProcessor::getLatencySamples() {
if (!limiterEnabled_.load(std::memory_order_relaxed)) return 0;
return static_cast<uint32>(
instrument::engine::limiterLookaheadSamples(sampleRate_));
}
tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
sampleRate_ = setup.sampleRate;
maxBlockSize_ = setup.maxSamplesPerBlock;
@@ -116,6 +127,8 @@ tresult PLUGIN_API ReaSamplerProcessor::setupProcessing(ProcessSetup& setup) {
if (sampleRate_ > 0.0) {
gainRampStep_ = static_cast<float>(1.0 / (kGainRampSeconds * sampleRate_));
}
// Every limiter allocation and transcendental happens here, off the audio thread.
limiter_.prepare(sampleRate_);
return SingleComponentEffect::setupProcessing(setup);
}
@@ -238,7 +251,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
}
if (data.numOutputs <= 0 || !data.outputs || data.numSamples <= 0) {
embedPeak_.store(0.f, std::memory_order_relaxed);
publishSilentMeterBlock();
return kResultOk;
}
AudioBusBuffers& out = data.outputs[0];
@@ -247,7 +260,7 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
// 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) {
embedPeak_.store(0.f, std::memory_order_relaxed);
publishSilentMeterBlock();
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;
@@ -294,21 +307,26 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
}
}
}
// The chain's last stage before the bus, after the gain above.
const float minGain = limiter_.process(ch0, ch1, frames);
meterMinGain_.store(minGain, std::memory_order_relaxed);
// Channels beyond the first two mirror ch0 (defensive — REAPER negotiates 1 or 2).
for (int32 ch = 2; ch < out.numChannels; ++ch) {
if (float* buf = out.channelBuffers32[ch]) {
for (int32 i = 0; i < frames; ++i) buf[i] = ch0[i];
}
}
// Block peak (max across L/R) for the embed strip's level indicator.
float peak = 0.f;
// Meter tap: the bus output, post-limiter. Raw per-channel block peaks only.
float peakL = 0.f, peakR = 0.f;
for (int32 i = 0; i < frames; ++i) {
const float a0 = ch0[i] < 0.f ? -ch0[i] : ch0[i];
const float a1 = ch1[i] < 0.f ? -ch1[i] : ch1[i];
if (a0 > peak) peak = a0;
if (a1 > peak) peak = a1;
if (a0 > peakL) peakL = a0;
if (a1 > peakR) peakR = a1;
}
embedPeak_.store(peak, std::memory_order_relaxed);
meterPeakL_.store(peakL, std::memory_order_relaxed);
meterPeakR_.store(peakR, std::memory_order_relaxed);
if (peakL >= 1.f || peakR >= 1.f) meterClip_.store(true, std::memory_order_relaxed);
} else if (ch0) {
// Mono: render into channel 0, replicate to any extra channels (defensive).
for (int32 i = 0; i < frames; ++i) ch0[i] = 0.f;
@@ -335,17 +353,23 @@ tresult PLUGIN_API ReaSamplerProcessor::process(ProcessData& data) {
}
}
}
const float minGain = limiter_.process(ch0, nullptr, frames);
meterMinGain_.store(minGain, std::memory_order_relaxed);
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);
meterPeakL_.store(peak, std::memory_order_relaxed);
meterPeakR_.store(peak, std::memory_order_relaxed);
if (peak >= 1.f) meterClip_.store(true, std::memory_order_relaxed);
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];
}
}
} else {
publishSilentMeterBlock();
}
// Report silence only when nothing is loaded (lets the host optimize when idle); with
+61 -6
View File
@@ -20,6 +20,7 @@
#include "shell/instrument/reaper_bridge.h"
#include "core/instrument/map/sample_map.h" // InstrumentParams (the one parameter set)
#include "core/instrument/map/component_state_io.h" // ComponentState codec
#include "core/instrument/engine/limiter.h" // the master bus's post-gain limiter
#include "core/instrument/engine/live_params.h" // LiveParams (the live-parameter block)
#include "core/instrument/engine/voice_engine.h"
@@ -33,6 +34,16 @@ using instrument::map::kPreviewVelocityDefault;
class ReaSamplerEmbed; // embedded TCP/MCP UI shell (owned below; see queryInterface)
// What the audio thread publishes about the OUTPUT BUS, post-limiter, once per block. Raw
// magnitudes only — the UI converts to dB and runs the ballistics (engine/meter_ballistics),
// because a hold timer or a log on the audio thread would be per-block work that buys nothing.
struct MasterBusMeter {
float peakL = 0.f; // max |x| this block
float peakR = 0.f;
float minGain = 1.f; // smallest limiter gain applied this block; 1 = no reduction
bool clip = false; // LATCHED at a block peak >= 0 dBFS; only clearMasterBusClip lowers it
};
// The decoded capture + the voice engine playing it. The engine holds a reference to the
// sample, so both must live/die together at a stable address — heap-allocated,
// non-copyable, non-movable. process() only ever reads this through an atomic pointer.
@@ -92,6 +103,12 @@ public:
Steinberg::tresult PLUGIN_API process(
Steinberg::Vst::ProcessData& data) override;
// The plugin's PDC report: 0 with the limiter bypassed, the limiter's lookahead with it
// engaged. Read from the PERSISTED enable, never from a transient — the SDK's contract
// (pluginterfaces/vst/ivsteditcontroller.h, kLatencyChanged) is that the host asks this
// AFTER the deactivate/reactivate it performs, and setActive(false) clears the engine.
Steinberg::uint32 PLUGIN_API getLatencySamples() override;
// Fixed stereo output bus — channel mode is a decode policy, never a bus fact; mono
// renders dual-mono through it. Do not reintroduce per-instance bus renegotiation.
// Accepts only a single stereo output proposal; otherwise rejects and keeps stereo.
@@ -108,12 +125,18 @@ public:
Steinberg::tresult PLUGIN_API queryInterface(const Steinberg::TUID iid,
void** obj) override;
// The embedded-strip activity level (0..1) for the embed shell, UI thread. Backed by
// embedPeak_, a lock-free relaxed atomic the audio thread writes each block.
// The embedded-strip activity level (0..1) for the embed shell, UI thread. The loudest of
// the two published bus peaks — one publication serves the strip and the meter.
double embedActivityLevel() const {
return static_cast<double>(embedPeak_.load(std::memory_order_relaxed));
const float l = meterPeakL_.load(std::memory_order_relaxed);
const float r = meterPeakR_.load(std::memory_order_relaxed);
return static_cast<double>(l > r ? l : r);
}
// What the audio thread published about the output bus last block. UI thread.
MasterBusMeter masterBusMeter() const;
void clearMasterBusClip();
// Resolves the selection against the instance-owned SampleRefs, decodes its WAV
// off-thread, and publishes the built instrument via atomic swap — no bank read
// required. When the bank blob is readable it's first folded into the refs table
@@ -208,6 +231,15 @@ public:
}
void setMasterGainLinear(double linear); // clamped to [0, masterGainMaxLinear()]
// The master-bus limiter's single enable (persisted in the parameter set). UI thread only:
// the setter requests the host's kLatencyChanged restart, which the SDK requires be issued
// from the UI thread and which process() must therefore never trigger. Setting the value it
// already holds is a no-op, so repeated clicks on one segment cost no restart.
bool limiterEnabled() const {
return limiterEnabled_.load(std::memory_order_relaxed);
}
void setLimiterEnabled(bool on);
// Fires a one-shot preview note-on/off through the live VoiceEngine — the same
// noteOn/noteOff host MIDI uses, so a preview is a real voice (counts against voice
// count, can steal/be stolen, respects Poly/Mono + Retrigger/Legato). Off the audio
@@ -248,6 +280,19 @@ private:
// Requires reloadMutex_ held — shared by reloadInstrument and rebuildVoiceEngine.
void publishBuiltLocked(std::unique_ptr<LoadedInstrument> built);
// Publishes a silent block to the meter. EVERY process() path that emits no audio calls
// this, or the bar freezes at the last peak it saw. The clip latch is deliberately not
// touched — it survives silence until the user clears it.
void publishSilentMeterBlock() {
meterPeakL_.store(0.f, std::memory_order_relaxed);
meterPeakR_.store(0.f, std::memory_order_relaxed);
meterMinGain_.store(1.f, std::memory_order_relaxed);
}
// Mirrors the persisted limiter enable onto the audio thread and the latency reader. Called
// from every writer of the parameter set, so the three views can never disagree.
void publishLimiterEnabled(bool on);
// Publishes this instance's held captures to its per-instance ext-state key
// ("rsusage_<instanceGuid>") so the extension's prune can never reclaim them. Called at
// the tail of every reloadInstrument, off the audio thread. Mints instanceGuid_ on
@@ -401,9 +446,19 @@ private:
// unique_ptr, so its own refcount is a no-op.
std::unique_ptr<ReaSamplerEmbed> embed_;
// Per-block mono peak the audio thread stores relaxed; embedActivityLevel() reads it
// for the embed strip's level indicator. Advisory only.
std::atomic<float> embedPeak_{0.f};
// The master-bus limiter, applied post-gain over the summed output. Its own enable target
// is the mirror of params_.limiterEnabled; limiterEnabled_ is the lock-free copy
// getLatencySamples answers from.
instrument::engine::Limiter limiter_;
std::atomic<bool> limiterEnabled_{false};
// What the audio thread publishes about the output bus each block, relaxed — peaks, the
// latched clip, and the limiter's smallest gain. No dB, no ballistics, no hold timer here;
// the UI runs those off these values and its own elapsed time.
std::atomic<float> meterPeakL_{0.f};
std::atomic<float> meterPeakR_{0.f};
std::atomic<float> meterMinGain_{1.f};
std::atomic<bool> meterClip_{false};
};
} // namespace reasampler::vst