Γ-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 -1
View File
@@ -292,12 +292,14 @@ anything for a trigger shape.
- `time_stretch` — the TIME half beside `pitch_shift`'s PITCH half, header-only: `StretchCursor`, the per-output-frame source-feed schedule (a fractional cursor carrying its rate debt, loop-wrapped), plus the rate bounds and their clamp. Rate 1.0 is exactly one source frame per output frame with no residue, which is what makes the unity Preserve read bit-identical to the pre-stretch engine. The bounds are **measured**, not arbitrary — see the header.
- `velocity_curve` — THE monotone spline, shared by every consumer: the three velocity transfer curves and the three spline EGs. `VelocityCurve` is evaluated as ONE OR MORE FritschCarlson monotone cubic Hermite splines joined at its HARD points — a hard knot is a sub-curve boundary for tangent purposes (exactly what the point array's own ends already are), so the two adjacent segments meet at their natural angle instead of a shared derivative and the no-overshoot guarantee holds PER SEGMENT rather than globally. Points are smooth by default; the ceiling is `kMaxCurvePoints` = 128, a MUSICAL bound (long rhythmic phrases, ~two points per articulation event) and not a performance one — **do not lower it**. `eval(velocity)` is the COLD reader, called once per note-on or once per drawn pixel column; `SplineCursor` is the RT one, an indexed segment search plus one Hermite evaluation with the segment and its tangents cached across samples. Both share the same `segmentTangents`/`hermiteAt` free functions, so there is one spline and not two. It carries its own y `CurveDomain`: UNIPOLAR [0,1] is the amp's GAIN, defaulting to `flat()` (y=1, every velocity→unity — a deliberate non-back-compat replacement of the old fixed `velocity/127` path, Daniel-approved); BIPOLAR [1,1] is the signed modulation shape for pitch and filter, defaulting to `zero()` so velocity modulates neither until a curve is drawn. A bipolar curve does not imply the absence of a depth beside it: the filter keeps its `velAmount` knob and the two compose multiplicatively (`velAmount × curve.eval(v)`, `play_params.h`), while the pitch curve's throw is the fixed `kVelocityPitchRangeSemitones`.
- `master_gain` — pure dB↔linear taper math (FB1): normalized [0,1] ↔ dB ↔ linear for the post-mixer master gain control (−∞…+24 dB, norm 0 = true silence, unity ≈ 0.714). Shared by the editor knob and the processor multiply so the needle, persisted value, and audio multiply cannot drift.
- `limiter` — the master bus's lookahead brickwall limiter, the stage after `master_gain`'s multiply: a 4x-oversampled TRUE-PEAK detector in the SIDECHAIN ONLY (the signal path is never oversampled), one stereo-linked gain, a baked 0.3 dBTP ceiling and **no makeup gain of any kind**. The gain law is a sliding MINIMUM of the per-sample target over the lookahead window followed by a MOVING AVERAGE of the same width: every term of that average is a minimum whose own window contains the sample being gained, so the ceiling is held **structurally** rather than by a tuned attack, and the one-pole release only ever slows the RISE so that bound survives it. Bypassed and settled, `process()` returns without reading or writing a sample — the byte-identical at-rest path, on the same discipline as `live == nullptr` and the filter's exact skip at `modAmount == 0`. `prepare()` owns every allocation and every transcendental; the engage/disengage crossfade is the codebase's standing ramp-every-gain-path-change rule applied to a limiter switching in.
- `meter_ballistics` — the output meter's UI-side ballistics and dB scale: instantaneous rise, 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip latch, and the dB → normalized map over 60…+6 dBFS. The audio thread publishes raw block peaks and converts nothing; this module is what turns them into what the bar draws.
### `map/`
- `sample_map` — the bank blob → selected capture resolve, the channel policy (downmix / dual-mono / L-R split), `InstrumentParams` (the ONE parameter set: root/loop/start overrides, keyTrack, velocity curve, `PlaySeconds`), the single override-beats-intrinsic fold (`resolveCapture`, shared by the bank and refs paths so they cannot drift), and the `SampleData` build. **Wall-clock times stored as rate-free SECONDS, resolved against the live project rate — NO hardcoded sample rates in `src/`** (Daniel's standing ruling, load-bearing). Deliberately does NOT link the voice engine: the build's product is plain `SampleData`.
- `play_seconds` — the stored, wall-clock-SECONDS value layer (`PlaySeconds` + `AdsrSeconds` / `AhdSeconds` / `PitchEnvSeconds` / `FilterSeconds`), header-only and split from `sample_map` so a consumer that only edits those values reaches them without the bank model and the WAV codec. `resolvePlay`, which turns them into the engine's frame domain, stays with the rest of the mapping.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v14), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `component_state_io` (`core/instrument/map`) — the `ComponentState` envelope + params-payload binary codec (envelope v1…v11, params payload v1…v15), split out of `sample_map` (Q-W2v, T4-13 ≡ T2-07) so BOTH artifacts can link the codec without the extension pulling in the whole voice engine to serialize one preset blob — the extension's `instrument_drop` and the instrument's processor read/write the identical bytes, so the cross-artifact contract cannot drift. Payload v1…v7 are the RETIRED per-zone lists: still read, lifting by adopting zone one's capture + parameters (that first zone is what the old first-match resolve actually played, so it is also what supersedes the envelope's stored selection id). Payload v9 appends the per-voice filter tail; a v8 blob is a strict prefix of it and lifts to the off/neutral filter default. Every tail since is a strict suffix on the same discipline — v10 the staged curves, v11 the loop crossfade, v12 the velocity→pitch curve, v13 the dual Staged/Spline state (the three contours, plus hard-flag tails for the three velocity curves — their v7/v9/v12 blocks are frozen at 16 bytes/point and had no room for a per-point flag), v14 the resample bake's Hold division, v15 the master-bus limiter enable. v12 also RE-TAGS the y DOMAIN of one frozen slot inside the v9 filter tail — its velocity curve reads bipolar from v12 on, unipolar before — which needs no version branch, because a pre-v12 curve's y values are already valid bipolar ones; every other filter slot, `velAmount` included, keeps its meaning.
- `params_payload` — the PARAMS-PAYLOAD half of that codec, split from the envelope half on the axis the format already has: the payload carries its own version and grows independently, so the two version ladders are two responsibilities. An INTERNAL seam — the public entry points stay `serialize`/`deserializeComponentState`. The prose ladder and every version constant stay in `component_state_io.h`, their one home.
- `bank_sync` — generation change-detection + assignment-request consume: owns the yes/no decision logic so the rules are provable without a host. The processor shell owns cadence and side effects.
- `bridge_marshal` — pure marshalling helper for the REAPER VST-host bridge read: interprets the `GetProjExtState` int return against its filled buffer.
@@ -66,3 +66,11 @@ add_library(time_stretch INTERFACE)
target_include_directories(time_stretch INTERFACE ${REASAMPLER_SRC_DIR})
target_link_libraries(time_stretch INTERFACE loop_span)
reasampler_test(time_stretch LINK time_stretch)
# The master bus's two pure halves. Neither links the engine: the limiter runs on the summed
# output, and the ballistics run on what the audio thread published about it.
reasampler_pure_library(limiter SOURCES limiter.cpp)
reasampler_test(limiter LINK limiter)
reasampler_pure_library(meter_ballistics SOURCES meter_ballistics.cpp)
reasampler_test(meter_ballistics LINK meter_ballistics)
+207
View File
@@ -0,0 +1,207 @@
// limiter.cpp — see limiter.h.
#include "core/instrument/engine/limiter.h"
#include <algorithm>
#include <cmath>
namespace reasampler::instrument::engine {
namespace {
constexpr int kProtoLen = kLimiterOversample * kLimiterOsTaps + 1; // 33: odd, so phase 0 is exact
double sincPi(double x) {
if (x == 0.0) return 1.0;
const double a = 3.14159265358979323846 * x;
return std::sin(a) / a;
}
} // namespace
double limiterCeilingLinear() { return std::pow(10.0, kLimiterCeilingDbTp / 20.0); }
int limiterLookaheadSamples(double sampleRate) {
if (!(sampleRate > 0.0)) return 0;
const int n = static_cast<int>(kLimiterLookaheadSeconds * sampleRate + 0.5);
// One sample above the detector's group delay is the floor: the smoothing window must have
// at least one entry of its own for the no-overshoot bound to say anything.
return n > kLimiterOsDelay ? n : kLimiterOsDelay + 1;
}
void Limiter::prepare(double sampleRate) {
latency_ = limiterLookaheadSamples(sampleRate);
if (latency_ <= 0) latency_ = kLimiterOsDelay + 1;
window_ = latency_ - kLimiterOsDelay + 1;
ceiling_ = static_cast<float>(limiterCeilingLinear());
const double rate = sampleRate > 0.0 ? sampleRate : 48000.0;
releaseCoeff_ = static_cast<float>(1.0 - std::exp(-1.0 / (kLimiterReleaseSeconds * rate)));
mixStep_ = static_cast<float>(1.0 / (kLimiterCrossfadeSeconds * rate));
// Windowed-sinc polyphase interpolator, built here because it costs transcendentals.
// Phase 0's taps all land on sinc zeros except the centre, so it is an exact delay and is
// read straight out of the history instead of being convolved.
for (int p = 0; p < kLimiterOversample; ++p) {
for (int k = 0; k < kLimiterOsTaps; ++k) {
const int i = kLimiterOversample * k + p;
const double centred = static_cast<double>(i) - (kProtoLen - 1) / 2.0;
const double hann =
0.5 - 0.5 * std::cos(2.0 * 3.14159265358979323846 * i / (kProtoLen - 1));
osTaps_[p][k] = static_cast<float>(sincPi(centred / kLimiterOversample) * hann);
}
}
delayL_.assign(static_cast<std::size_t>(latency_), 0.f);
delayR_.assign(static_cast<std::size_t>(latency_), 0.f);
wedgeVal_.assign(static_cast<std::size_t>(window_), 1.f);
wedgeIdx_.assign(static_cast<std::size_t>(window_), 0);
avgRing_.assign(static_cast<std::size_t>(window_), 1.f);
reset();
}
void Limiter::clearState() {
std::fill(delayL_.begin(), delayL_.end(), 0.f);
std::fill(delayR_.begin(), delayR_.end(), 0.f);
delayPos_ = 0;
for (int i = 0; i < kLimiterOsTaps; ++i) { histL_[i] = 0.f; histR_[i] = 0.f; }
histPos_ = 0;
wedgeHead_ = 0;
wedgeCount_ = 0;
pushIndex_ = 0;
std::fill(avgRing_.begin(), avgRing_.end(), 1.f);
avgSum_ = static_cast<double>(window_);
avgPos_ = 0;
releaseGain_ = 1.f;
}
void Limiter::reset() {
clearState();
active_ = target_.load(std::memory_order_relaxed);
mix_ = active_ ? 1.f : 0.f;
primeRemaining_ = 0;
}
void Limiter::setEnabled(bool on) { target_.store(on, std::memory_order_relaxed); }
float Limiter::detectTruePeak(float xl, float xr, bool stereo) {
histPos_ = (histPos_ + 1) & (kLimiterOsTaps - 1);
histL_[histPos_] = xl;
if (stereo) histR_[histPos_] = xr;
// Phase 0 is the exact delay, so the sample under test is read, not convolved.
const int base = (histPos_ - kLimiterOsDelay + kLimiterOsTaps) & (kLimiterOsTaps - 1);
float peak = std::fabs(histL_[base]);
if (stereo) {
const float r0 = std::fabs(histR_[base]);
if (r0 > peak) peak = r0;
}
for (int p = 1; p < kLimiterOversample; ++p) {
float accL = 0.f, accR = 0.f;
for (int k = 0; k < kLimiterOsTaps; ++k) {
const int idx = (histPos_ - k + kLimiterOsTaps) & (kLimiterOsTaps - 1);
accL += osTaps_[p][k] * histL_[idx];
if (stereo) accR += osTaps_[p][k] * histR_[idx];
}
const float al = std::fabs(accL);
if (al > peak) peak = al;
if (stereo) {
const float ar = std::fabs(accR);
if (ar > peak) peak = ar;
}
}
return peak;
}
float Limiter::smoothGain(float target) {
// Sliding minimum over `window_` via a monotonic wedge. Expiring the front BEFORE the push
// is what bounds the wedge to `window_` entries — pushing first can lap the ring.
while (wedgeCount_ > 0 &&
wedgeIdx_[static_cast<std::size_t>(wedgeHead_)] <= pushIndex_ - window_) {
wedgeHead_ = (wedgeHead_ + 1) % window_;
--wedgeCount_;
}
while (wedgeCount_ > 0) {
const int back = (wedgeHead_ + wedgeCount_ - 1) % window_;
if (wedgeVal_[static_cast<std::size_t>(back)] < target) break;
--wedgeCount_;
}
const int slot = (wedgeHead_ + wedgeCount_) % window_;
wedgeVal_[static_cast<std::size_t>(slot)] = target;
wedgeIdx_[static_cast<std::size_t>(slot)] = pushIndex_;
++wedgeCount_;
++pushIndex_;
const float windowMin = wedgeVal_[static_cast<std::size_t>(wedgeHead_)];
// Moving average of the same width over those minima.
avgSum_ += static_cast<double>(windowMin) - static_cast<double>(avgRing_[static_cast<std::size_t>(avgPos_)]);
avgRing_[static_cast<std::size_t>(avgPos_)] = windowMin;
avgPos_ = (avgPos_ + 1 == window_) ? 0 : avgPos_ + 1;
float smoothed = static_cast<float>(avgSum_ / window_);
// Never above unity — the structural form of "no makeup gain, ever", and what makes the
// at-rest gain land on EXACTLY 1.0f after the running sum has been added to and subtracted
// from for hours.
if (!(smoothed < 1.f)) smoothed = 1.f;
// Release: falls with the smoother, rises no faster than the one-pole. Staying at or below
// `smoothed` is what preserves the no-overshoot bound.
if (smoothed < releaseGain_) releaseGain_ = smoothed;
else releaseGain_ += (smoothed - releaseGain_) * releaseCoeff_;
return releaseGain_;
}
float Limiter::process(float* left, float* right, int frames) {
if (!left || frames <= 0 || latency_ <= 0) return 1.f;
const bool want = target_.load(std::memory_order_relaxed);
if (!want && !active_) return 1.f; // settled bypass: not one sample read or written
if (want && !active_) {
// A live engage. Start dry, fill the delay line, then crossfade — so the wet path is
// never silence weighted above zero.
clearState();
active_ = true;
mix_ = 0.f;
primeRemaining_ = latency_;
}
const bool stereo = (right != nullptr);
float blockMin = 1.f;
for (int i = 0; i < frames; ++i) {
const float dryL = left[i];
const float dryR = stereo ? right[i] : 0.f;
const float peak = detectTruePeak(dryL, dryR, stereo);
const float targetGain = peak > ceiling_ ? ceiling_ / peak : 1.f;
const float gain = smoothGain(targetGain);
if (gain < blockMin) blockMin = gain;
const std::size_t slot = static_cast<std::size_t>(delayPos_);
const float wetL = delayL_[slot] * gain;
const float wetR = stereo ? delayR_[slot] * gain : 0.f;
delayL_[slot] = dryL;
if (stereo) delayR_[slot] = dryR;
delayPos_ = (delayPos_ + 1 == latency_) ? 0 : delayPos_ + 1;
// The endpoints are branches rather than blend arithmetic so a settled state is exact:
// dry + (wet - dry) * 1.0f is not wet in floating point. At m <= 0 the buffer is left
// untouched, which is the dry sample already in it.
const float m = mix_;
if (m >= 1.f) {
left[i] = wetL;
if (stereo) right[i] = wetR;
} else if (m > 0.f) {
left[i] = dryL + (wetL - dryL) * m;
if (stereo) right[i] = dryR + (wetR - dryR) * m;
}
if (primeRemaining_ > 0) {
--primeRemaining_;
} else if (want) {
mix_ = (mix_ + mixStep_ >= 1.f) ? 1.f : mix_ + mixStep_;
} else {
mix_ = (mix_ - mixStep_ <= 0.f) ? 0.f : mix_ - mixStep_;
}
}
if (!want && mix_ <= 0.f && primeRemaining_ == 0) active_ = false;
return blockMin;
}
} // namespace reasampler::instrument::engine
+113
View File
@@ -0,0 +1,113 @@
// limiter.h — the master bus's lookahead brickwall limiter: true-peak sidechain detection,
// stereo-linked gain, and NO makeup gain of any kind. RT: process() allocates nothing, takes
// no lock and evaluates no transcendental; prepare() owns every allocation and every exp/pow.
// Bypassed and settled, process() returns without touching a sample — that untouched buffer
// is what makes the master bus byte-identical to the bare ramped multiply with the limiter off.
#pragma once
#include <atomic>
#include <cstdint>
#include <vector>
namespace reasampler::instrument::engine {
// The BAKED ceiling. A safety device with no configurable controls, so this is not a
// parameter. dBTP is a TRUE-peak target, which is why the detector oversamples and the
// signal path never does.
inline constexpr double kLimiterCeilingDbTp = -0.3;
// The total delay the limiter imposes while engaged, and therefore the plugin's whole reported
// PDC latency. The detector's own group delay is inside this budget, not on top of it.
inline constexpr double kLimiterLookaheadSeconds = 0.002;
// Gain recovery. The min-then-average smoother releases in one lookahead window on its own,
// which distorts low frequencies; this one-pole only ever slows the RISE, so the smoother's
// no-overshoot bound survives it unchanged.
inline constexpr double kLimiterReleaseSeconds = 0.100;
// The engage/disengage crossfade. A limiter engaging is a gain-path change and this codebase
// ramps every gain-path change; it also covers the window before the host acts on the latency
// change, which is the plugin's to keep clean because the host schedules that, not us.
inline constexpr double kLimiterCrossfadeSeconds = 0.010;
// 4x true-peak oversampling (ITU-R BS.1770's floor at 48 kHz) over an 8-tap-per-phase
// polyphase interpolator. The 33-tap prototype's centre tap makes phase 0 an exact 4-sample
// delay, and that delay is the detector's group delay.
inline constexpr int kLimiterOversample = 4;
inline constexpr int kLimiterOsTaps = 8;
inline constexpr int kLimiterOsDelay = 4;
// kLimiterCeilingDbTp as a linear magnitude.
double limiterCeilingLinear();
// The delay the limiter imposes while engaged, in samples at `sampleRate` — what the plugin
// reports to the host's PDC. 0 at a non-positive rate; never below the detector's own delay.
int limiterLookaheadSamples(double sampleRate);
// The master-bus limiter. One instance per plugin instance; prepare() before the first block.
//
// The gain law is a sliding MINIMUM of the per-sample target gain over the lookahead window,
// then a MOVING AVERAGE of the same width. Every term of that average is a minimum whose own
// window contains the sample being gained, so the smoothed gain is <= the target gain at every
// sample by construction — the ceiling is held structurally rather than by a tuned attack.
class Limiter {
public:
// Sizes the delay line, the detector and the smoothers, and snaps to the current enable
// state. Allocates and evaluates transcendentals: main/UI thread only, never in process().
void prepare(double sampleRate);
// Clears the delay line and the detector and snaps to the current enable state, skipping
// the engage crossfade — an activation has nothing sounding to be continuous with.
// Main/UI thread only (the host guarantees process() is stopped at both call sites).
void reset();
// The enable target. Set on the UI thread, observed by process() at block start.
void setEnabled(bool on);
bool enabled() const { return target_.load(std::memory_order_relaxed); }
// Applies the limiter in place over `frames` of `left` (and `right`, which may be null for
// a mono buffer). Returns the SMALLEST gain applied in this block — 1.0 for none, and the
// value a settled bypass returns.
float process(float* left, float* right, int frames);
private:
void clearState();
// The detector's true-peak estimate for the sample kLimiterOsDelay back, given the newest
// input frame. Advances the FIR history.
float detectTruePeak(float xl, float xr, bool stereo);
// Pushes one target gain through the sliding minimum and the moving average.
float smoothGain(float target);
std::atomic<bool> target_{false};
// --- prepared geometry ---
int latency_ = 0; // total delay; also the delay ring's length
int window_ = 0; // the minimum/average width, latency_ - kLimiterOsDelay + 1
float ceiling_ = 1.f;
float releaseCoeff_ = 1.f;
float mixStep_ = 1.f;
float osTaps_[kLimiterOversample][kLimiterOsTaps] = {}; // phase 0 is unused (exact delay)
// --- audio-thread state ---
std::vector<float> delayL_, delayR_;
int delayPos_ = 0;
float histL_[kLimiterOsTaps] = {};
float histR_[kLimiterOsTaps] = {};
int histPos_ = 0;
// Monotonic wedge over the target gain: values ascending from the front, so the front is
// the window minimum. Amortized O(1) per sample, bounded by 2 ops per push over a block.
std::vector<float> wedgeVal_;
std::vector<std::int64_t> wedgeIdx_;
int wedgeHead_ = 0, wedgeCount_ = 0;
std::int64_t pushIndex_ = 0;
std::vector<float> avgRing_;
double avgSum_ = 0.0; // double: the running sum is added to and subtracted from forever
int avgPos_ = 0;
float releaseGain_ = 1.f;
bool active_ = false; // the limiter path is running (engaged, or mid-crossfade)
float mix_ = 0.f; // 0 = dry, 1 = limited
int primeRemaining_ = 0; // samples the crossfade waits on while the delay line fills
};
} // namespace reasampler::instrument::engine
@@ -0,0 +1,57 @@
// meter_ballistics.cpp — see meter_ballistics.h.
#include "core/instrument/engine/meter_ballistics.h"
#include <cmath>
namespace reasampler::instrument::engine {
double meterDbFromLinear(double linear) {
if (!(linear > 0.0)) return kMeterFloorDb; // also catches NaN
const double db = 20.0 * std::log10(linear);
return db < kMeterFloorDb ? kMeterFloorDb : db;
}
double meterNormFromDb(double db) {
if (!(db > kMeterFloorDb)) return 0.0; // also catches NaN
if (db >= kMeterTopDb) return 1.0;
return (db - kMeterFloorDb) / (kMeterTopDb - kMeterFloorDb);
}
MeterState advanceMeter(MeterState prev, double blockPeakLinear, double elapsedSeconds) {
const double dt = (elapsedSeconds > 0.0) ? elapsedSeconds : 0.0;
const double fall = kMeterFallDbPerSecond * dt;
const double peakDb = meterDbFromLinear(blockPeakLinear);
MeterState next = prev;
// Instantaneous rise, timed fall — one expression, because a fall can never take the bar
// below the peak this very block carried.
const double fallen = prev.levelDb - fall;
next.levelDb = fallen > peakDb ? fallen : peakDb;
if (next.levelDb >= next.holdDb) {
next.holdDb = next.levelDb;
next.holdRemainingSeconds = kMeterPeakHoldSeconds;
} else {
next.holdRemainingSeconds = prev.holdRemainingSeconds - dt;
if (next.holdRemainingSeconds < 0.0) {
// Spend the overshoot as fall time so the tick's release does not quantize to the
// UI frame it happened to expire on.
const double held = kMeterFallDbPerSecond * -next.holdRemainingSeconds;
const double dropped = next.holdDb - held;
next.holdDb = dropped > next.levelDb ? dropped : next.levelDb;
next.holdRemainingSeconds = 0.0;
}
}
if (blockPeakLinear >= 1.0) next.clip = true;
return next;
}
MeterState clearMeterClip(MeterState prev) {
MeterState next = prev;
next.clip = false;
return next;
}
} // namespace reasampler::instrument::engine
@@ -0,0 +1,40 @@
// meter_ballistics.h — the output meter's ballistics and its dB scale: peak fall, peak hold,
// clip latch, and the dB -> normalized map the bar draws against. UI-thread math ONLY: the
// audio thread publishes raw block peaks per block and converts, holds and decays nothing.
#pragma once
namespace reasampler::instrument::engine {
// The scale is LINEAR IN dB across this span. Above 0 dBFS is shown because that is exactly
// what the limiter-off case has to make visible.
inline constexpr double kMeterFloorDb = -60.0;
inline constexpr double kMeterTopDb = 6.0;
// A peak meter must not smooth its attack or it under-reports, so the rise is instantaneous
// and only the fall is timed. 20 dB/s is close to the IEC 60268-18 PPM fallback.
inline constexpr double kMeterFallDbPerSecond = 20.0;
inline constexpr double kMeterPeakHoldSeconds = 1.5;
// Linear magnitude -> dBFS, floored at kMeterFloorDb — a silent block reads the floor rather
// than -inf, so the state stays a finite number the ballistics can subtract from.
double meterDbFromLinear(double linear);
// dBFS -> [0,1] up the meter, clamped at both ends.
double meterNormFromDb(double db);
struct MeterState {
double levelDb = kMeterFloorDb;
double holdDb = kMeterFloorDb;
double holdRemainingSeconds = 0.0;
bool clip = false; // latched; only clearMeterClip lowers it
};
// One UI frame of ballistics against the block peak the audio thread published and the time
// since the previous frame. Clip latches at a block peak >= 0 dBFS and is never cleared here.
MeterState advanceMeter(MeterState prev, double blockPeakLinear, double elapsedSeconds);
// The click-to-clear on the meter's clip cap.
MeterState clearMeterClip(MeterState prev);
} // namespace reasampler::instrument::engine
+15 -4
View File
@@ -8,7 +8,7 @@
// own links are velocity_curve + master_gain (wire value validation), never the engine.
//
// EVERY wire format below is FROZEN; the full version ladders (envelope v1..v11, params
// payload v1..v14) must be preserved exactly. This header is the ONE home for both ladders
// payload v1..v15) must be preserved exactly. This header is the ONE home for both ladders
// and every version constant; the payload half is IMPLEMENTED in params_payload.
#include <cstdint>
@@ -104,7 +104,7 @@ namespace reasampler::instrument::map {
// which transposes nothing. A DOWNGRADE to a pre-v12 binary re-narrows the domain, so a curve
// drawn into the negative half comes back with that half clamped to 0.
//
// v13 (CURRENT WRITE FORMAT) is v12 PLUS the DUAL Staged/Spline envelope state, appended after
// v13 is v12 PLUS the DUAL Staged/Spline envelope state, appended after
// the velocity->pitch curve. Its two halves, in order:
// (a) the three spline EGs — amp, pitch, filter, in that order. Each: 1 byte mode (0 Staged /
// 1 Spline), then a SPLINE CURVE block: 4-byte LE point count N, then per point 8-byte LE
@@ -120,7 +120,7 @@ namespace reasampler::instrument::map {
// A v12-or-older blob is a strict prefix and lifts to {Staged, the y = 1 - x default contour}
// on all three EGs with no hard point anywhere, so it plays exactly as it did.
//
// v14 (CURRENT WRITE FORMAT) is v13 PLUS the resample bake's Hold division, appended after the
// v14 is v13 PLUS the resample bake's Hold division, appended after the
// hard-flag tails: 4-byte LE quarterExponent (two's-complement int32) + 1 byte modifier (0
// Straight / 1 Dotted / 2 Triplet). Decoded through makeDivision, which clamps both fields —
// never memcpy'd into the type (core/instrument/note/CLAUDE.md owns why). A v13-or-older blob
@@ -129,6 +129,13 @@ namespace reasampler::instrument::map {
// A blob truncated INSIDE this tail costs the Hold alone rather than resetting the record —
// the same revive discipline the v13 hard-flag tails follow, and for the same reason.
//
// v15 (CURRENT WRITE FORMAT) is v14 PLUS ONE byte: the master-bus limiter's enable, appended
// after the Hold division. A v14-or-older blob is a strict prefix and lifts to 0 — bypassed,
// which is also the field's product default, so a project saved before the limiter existed
// reopens with the limiter off and sounding identical. It carries the Hold's revive
// discipline too: now that it, not the Hold, is the last tail, a truncation inside this byte
// would otherwise reset the record the Hold's own revive just preserved.
//
// The two int64 slots the v5 play tail spends on the RETIRED Trigger fade pair are frozen in
// shape and still read: a pre-v10 blob's fade-in/fade-out become the Trigger AHD that replaced
// them (attack <- fade-in, decay <- fade-out, hold <- the whole remainder), converted to
@@ -160,7 +167,7 @@ inline constexpr std::uint32_t kPerformanceStateVersion = 2;
// The params-payload format version and its detection marker. The marker is a high sentinel
// no legitimate v1 zone count (bounded by 128 MIDI zones, always tiny) could ever equal, so
// a reader detects record shape independent of the envelope version.
inline constexpr std::uint32_t kParamsPayloadVersion = 14; // v13 + the bake Hold division
inline constexpr std::uint32_t kParamsPayloadVersion = 15; // v14 + the limiter enable
inline constexpr std::uint32_t kParamsFormatMarker = 0xFFFFFF00u;
// The first SINGLE-RECORD payload version. Everything below it is a retired zone list and
@@ -192,6 +199,10 @@ inline constexpr std::uint32_t kParamsSplineVersion = 13;
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsBakeHoldVersion = 14;
// v14 + the master-bus limiter enable; the appended byte branches on THIS, never on
// kParamsPayloadVersion.
inline constexpr std::uint32_t kParamsLimiterVersion = 15;
// (No nominal-rate constant.) The legacy v3 payload's wall-clock frame counts convert to
// seconds at the v3 read boundary using the PROJECT sample rate threaded in as a parameter
// (frames / projectRate = seconds) — the same rate the build already receives, so the
+36 -9
View File
@@ -221,24 +221,48 @@ void readHardFlags(ByteReader& r, VelocityCurve& curve) {
for (std::size_t i = 0; i < flags.size(); ++i) curve.setHard(i, flags[i] != 0);
}
// Read the v14 bake Hold. Same revive discipline as readHardFlags directly above, and for the
// same reason: this tail reaches no audio path, so a blob truncated inside it must cost the
// Hold alone and not reset the whole record that parsed cleanly ahead of it. It sits LAST, so
// a truncation stranding the hard flags strands this too — reviving in only one of the two
// would still wipe the record.
// THE shared ending for every appended tail past the hard flags: revive, then DRAIN. Both
// halves are load-bearing and neither is optional.
//
// Revive, because these tails reach no audio path — a blob truncated inside one must cost
// that field alone and not reset the whole record that parsed cleanly ahead of it. An r.ok
// already false on entry (an earlier, unrelated field genuinely truncated) is left alone;
// that failure is not this tail's to forgive.
//
// Drain, because a FAILED read does not advance the cursor. The bytes it rejected are still
// sitting there for the NEXT tail to consume as its own — a truncated Hold whose two
// surviving exponent bytes arrive at the limiter byte reads back as ENABLED. Reviving without
// draining does not degrade to absent; it fabricates. Every tail added after this one must
// end here too.
//
// Returns true when the caller must abandon its field.
bool reviveTruncatedTail(ByteReader& r, bool enteredOk) {
if (r.ok) return false;
if (enteredOk) r.ok = true;
drainUnaligned(r);
return true;
}
// Read the v14 bake Hold.
void readBakeHold(ByteReader& r, InstrumentParams& p) {
const bool enteredOk = r.ok;
const std::int32_t exponent = r.i32();
const std::uint8_t modifier = r.u8();
if (!r.ok) {
if (enteredOk) r.ok = true;
return;
}
if (reviveTruncatedTail(r, enteredOk)) return;
// makeDivision clamps BOTH fields, so a corrupt pair becomes the nearest legal rung
// rather than an unrepresentable one — never a memcpy into the type.
p.bakeHold = note::makeDivision(exponent, static_cast<note::DivisionModifier>(modifier));
}
// Read the v15 limiter enable. Bypassed is what a truncation means and what the field already
// holds, so a missing byte costs nothing beyond the enable itself.
void readLimiterEnable(ByteReader& r, InstrumentParams& p) {
const bool enteredOk = r.ok;
const std::uint8_t flag = r.u8();
if (reviveTruncatedTail(r, enteredOk)) return;
p.limiterEnabled = (flag != 0);
}
// Read the v9 filter tail into `p`. A blob that stops short leaves the off/neutral default,
// which is what makes a v8 blob play bit-identically under the new codec. The curve reads as
// bipolar at EVERY version — a pre-v12 blob's y values are already valid bipolar ones, so its
@@ -475,6 +499,8 @@ void putParamsPayload(std::vector<std::uint8_t>& out, const InstrumentParams& p)
putLE(out, static_cast<std::uint32_t>(
static_cast<std::int32_t>(p.bakeHold.quarterExponent())));
out.push_back(static_cast<std::uint8_t>(p.bakeHold.modifier()));
// v15: the master-bus limiter enable.
out.push_back(p.limiterEnabled ? 1 : 0);
}
// Read whichever payload shape follows: the single-record shape (v8 onward, growing by
@@ -529,6 +555,7 @@ PayloadRead readParamsPayload(ByteReader& r, double projectRate) {
readHardFlags(r, p.play.pitchVelocityCurve);
}
if (pv >= kParamsBakeHoldVersion) readBakeHold(r, p);
if (pv >= kParamsLimiterVersion) readLimiterEnable(r, p);
// A truncated record leaves whatever parsed plus construction defaults for the rest —
// the same degrade-don't-throw contract the zone ladder always had.
if (!r.ok) return PayloadRead{};
+5
View File
@@ -195,6 +195,11 @@ struct InstrumentParams {
// (bake_plan.h's bakeWindowNeedsHold is the predicate). Default one bar; a blob predating
// the field lifts to it, and no other bake changes.
note::Division bakeHold = note::makeDivision(2, note::DivisionModifier::Straight);
// The master-bus limiter's single enable. It sits OUTSIDE PlaySeconds deliberately: it is
// a post-voice-mixer concern the shell applies to the summed output, never a voice
// parameter, so it must not ride into the live block or the SampleData build. Default off
// — a blob predating the field lifts to bypassed and sounds identical.
bool limiterEnabled = false;
};
// The loaded capture resolved for decode + build: project-relative WAV path (file seam)
+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