diff --git a/src/core/instrument/CLAUDE.md b/src/core/instrument/CLAUDE.md index 9d54898..c9fa2ee 100644 --- a/src/core/instrument/CLAUDE.md +++ b/src/core/instrument/CLAUDE.md @@ -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 Fritsch–Carlson 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. diff --git a/src/core/instrument/engine/CMakeLists.txt b/src/core/instrument/engine/CMakeLists.txt index 864b9b9..96e4d39 100644 --- a/src/core/instrument/engine/CMakeLists.txt +++ b/src/core/instrument/engine/CMakeLists.txt @@ -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) diff --git a/src/core/instrument/engine/limiter.cpp b/src/core/instrument/engine/limiter.cpp new file mode 100644 index 0000000..ea602bb --- /dev/null +++ b/src/core/instrument/engine/limiter.cpp @@ -0,0 +1,207 @@ +// limiter.cpp — see limiter.h. + +#include "core/instrument/engine/limiter.h" + +#include +#include + +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(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(limiterCeilingLinear()); + const double rate = sampleRate > 0.0 ? sampleRate : 48000.0; + releaseCoeff_ = static_cast(1.0 - std::exp(-1.0 / (kLimiterReleaseSeconds * rate))); + mixStep_ = static_cast(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(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(sincPi(centred / kLimiterOversample) * hann); + } + } + + delayL_.assign(static_cast(latency_), 0.f); + delayR_.assign(static_cast(latency_), 0.f); + wedgeVal_.assign(static_cast(window_), 1.f); + wedgeIdx_.assign(static_cast(window_), 0); + avgRing_.assign(static_cast(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(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(wedgeHead_)] <= pushIndex_ - window_) { + wedgeHead_ = (wedgeHead_ + 1) % window_; + --wedgeCount_; + } + while (wedgeCount_ > 0) { + const int back = (wedgeHead_ + wedgeCount_ - 1) % window_; + if (wedgeVal_[static_cast(back)] < target) break; + --wedgeCount_; + } + const int slot = (wedgeHead_ + wedgeCount_) % window_; + wedgeVal_[static_cast(slot)] = target; + wedgeIdx_[static_cast(slot)] = pushIndex_; + ++wedgeCount_; + ++pushIndex_; + const float windowMin = wedgeVal_[static_cast(wedgeHead_)]; + + // Moving average of the same width over those minima. + avgSum_ += static_cast(windowMin) - static_cast(avgRing_[static_cast(avgPos_)]); + avgRing_[static_cast(avgPos_)] = windowMin; + avgPos_ = (avgPos_ + 1 == window_) ? 0 : avgPos_ + 1; + float smoothed = static_cast(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(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 diff --git a/src/core/instrument/engine/limiter.h b/src/core/instrument/engine/limiter.h new file mode 100644 index 0000000..600c666 --- /dev/null +++ b/src/core/instrument/engine/limiter.h @@ -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 +#include +#include + +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 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 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 wedgeVal_; + std::vector wedgeIdx_; + int wedgeHead_ = 0, wedgeCount_ = 0; + std::int64_t pushIndex_ = 0; + std::vector 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 diff --git a/src/core/instrument/engine/meter_ballistics.cpp b/src/core/instrument/engine/meter_ballistics.cpp new file mode 100644 index 0000000..b312669 --- /dev/null +++ b/src/core/instrument/engine/meter_ballistics.cpp @@ -0,0 +1,57 @@ +// meter_ballistics.cpp — see meter_ballistics.h. + +#include "core/instrument/engine/meter_ballistics.h" + +#include + +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 diff --git a/src/core/instrument/engine/meter_ballistics.h b/src/core/instrument/engine/meter_ballistics.h new file mode 100644 index 0000000..40ed766 --- /dev/null +++ b/src/core/instrument/engine/meter_ballistics.h @@ -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 diff --git a/src/core/instrument/map/component_state_io.h b/src/core/instrument/map/component_state_io.h index f680682..287d616 100644 --- a/src/core/instrument/map/component_state_io.h +++ b/src/core/instrument/map/component_state_io.h @@ -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 @@ -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 diff --git a/src/core/instrument/map/params_payload.cpp b/src/core/instrument/map/params_payload.cpp index 2984da3..1aa523b 100644 --- a/src/core/instrument/map/params_payload.cpp +++ b/src/core/instrument/map/params_payload.cpp @@ -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(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& out, const InstrumentParams& p) putLE(out, static_cast( static_cast(p.bakeHold.quarterExponent()))); out.push_back(static_cast(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{}; diff --git a/src/core/instrument/map/sample_map.h b/src/core/instrument/map/sample_map.h index 419b38b..0cac6b6 100644 --- a/src/core/instrument/map/sample_map.h +++ b/src/core/instrument/map/sample_map.h @@ -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) diff --git a/src/shell/instrument/CLAUDE.md b/src/shell/instrument/CLAUDE.md index f9d6ed3..bf0774a 100644 --- a/src/shell/instrument/CLAUDE.md +++ b/src/shell/instrument/CLAUDE.md @@ -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_` 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_` 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. diff --git a/src/shell/instrument/CMakeLists.txt b/src/shell/instrument/CMakeLists.txt index d01e6fe..55180b1 100644 --- a/src/shell/instrument/CMakeLists.txt +++ b/src/shell/instrument/CMakeLists.txt @@ -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) diff --git a/src/shell/instrument/processor_state.cpp b/src/shell/instrument/processor_state.cpp index 112decf..95b369b 100644 --- a/src/shell/instrument/processor_state.cpp +++ b/src/shell/instrument/processor_state.cpp @@ -12,6 +12,7 @@ #include #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 lock(paramsMutex_); - params_ = params; + { + std::lock_guard 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 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() { diff --git a/src/shell/instrument/reasampler_processor.cpp b/src/shell/instrument/reasampler_processor.cpp index bf5f14c..7c173d3 100644 --- a/src/shell/instrument/reasampler_processor.cpp +++ b/src/shell/instrument/reasampler_processor.cpp @@ -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 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( + 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(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 diff --git a/src/shell/instrument/reasampler_processor.h b/src/shell/instrument/reasampler_processor.h index 95290fc..e831b2f 100644 --- a/src/shell/instrument/reasampler_processor.h +++ b/src/shell/instrument/reasampler_processor.h @@ -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(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(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 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_") 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 embed_; - // Per-block mono peak the audio thread stores relaxed; embedActivityLevel() reads it - // for the embed strip's level indicator. Advisory only. - std::atomic 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 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 meterPeakL_{0.f}; + std::atomic meterPeakR_{0.f}; + std::atomic meterMinGain_{1.f}; + std::atomic meterClip_{false}; }; } // namespace reasampler::vst diff --git a/tests/test_component_state_io.cpp b/tests/test_component_state_io.cpp index 85daaac..276734c 100644 --- a/tests/test_component_state_io.cpp +++ b/tests/test_component_state_io.cpp @@ -453,7 +453,7 @@ static void testGoldenFullBlobFixture() { 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x00, 0x00,0x05,0x00,0x00,0x00,0x53,0x6e,0x61,0x72,0x65,0x13,0x00,0x00,0x00,0x67,0x75, 0x69,0x64,0x2d,0x31,0x32,0x33,0x34,0x2d,0x35,0x36,0x37,0x38,0x2d,0x61,0x62,0x63, - 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0e,0x00,0x00, + 0x64,0x04,0x00,0x00,0x00,0x6b,0x69,0x63,0x6b,0x00,0xff,0xff,0xff,0x0f,0x00,0x00, 0x00,0x01,0x24,0x00,0x00,0x00,0x01,0x01,0xe8,0x03,0x00,0x00,0x00,0x00,0x00,0x00, 0x88,0x13,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0xfa,0x00,0x00,0x00,0x00,0x00,0x00, 0x00,0x01,0x9a,0x99,0x99,0x99,0x99,0x99,0xa9,0x3f,0x00,0x00,0x00,0x00,0x00,0x00, @@ -549,6 +549,8 @@ static void testGoldenFullBlobFixture() { // --- payload v14 bake Hold, at its one-bar default --- 0x02,0x00,0x00,0x00, // quarterExponent 2 (== 1/1) 0x00, // Straight + // --- payload v15 limiter enable --- + 0x00, // bypassed (the default) }; // clang-format on CHECK(bytes.size() == sizeof(kGolden)); @@ -596,19 +598,22 @@ static void testEnvelopePrefixBytesFrozen() { CHECK(bytes[4] == 0); // ChannelMode::Mono } CHECK(kComponentStateVersion == 11); - CHECK(kParamsPayloadVersion == 14); + CHECK(kParamsPayloadVersion == 15); CHECK(kParamsSingleRecordVersion == 8); CHECK(kParamsFormatMarker == 0xFFFFFF00u); - // The filter, staged-curve, loop, velocity, spline and bake-Hold tails rode PAYLOAD bumps, - // not envelope ones — the two axes stay independent, so a future envelope field cannot - // collide with any of them on one number. + // The filter, staged-curve, loop, velocity, spline, bake-Hold and limiter tails rode + // PAYLOAD bumps, not envelope ones — the two axes stay independent, so a future envelope + // field cannot collide with any of them on one number. This pins the NUMBERS only; that + // each tail's bytes sit in the order its number implies is + // testAppendedTailsSitInVersionOrderOnTheWire's job. CHECK(kParamsFilterVersion > kParamsSingleRecordVersion); CHECK(kParamsCurveVersion > kParamsFilterVersion); CHECK(kParamsLoopVersion > kParamsCurveVersion); CHECK(kParamsVelocityVersion > kParamsLoopVersion); CHECK(kParamsSplineVersion > kParamsVelocityVersion); CHECK(kParamsBakeHoldVersion > kParamsSplineVersion); - CHECK(kParamsPayloadVersion == kParamsBakeHoldVersion); + CHECK(kParamsLimiterVersion > kParamsBakeHoldVersion); + CHECK(kParamsPayloadVersion == kParamsLimiterVersion); } // --- The filter tail (payload v9) -------------------------------------------- @@ -787,21 +792,28 @@ static void testNonFiniteAhdSecondsLiftToZero() { // --- The v13 hard-flag tail: corruption must never widen past its own three curves ----------- -// The two trailing blocks of a CURRENT blob, so the splice tests below can cut back to the +// The three trailing blocks of a CURRENT blob, so the splice tests below can cut back to the // hard flags and rewrite them without hand-counting the payload twice. Every velocity curve // in those fixtures is at its default 2-point shape, which is what pins the flag block sizes. static constexpr std::size_t kHardFlagTailBytes = 4 + 2 + 4 + 2 + 4 + 2; static constexpr std::size_t kBakeHoldTailBytes = 4 + 1; +static constexpr std::size_t kLimiterTailBytes = 1; -// The v14 tail, re-appended after a splice so the record still ends where the reader expects. +// The v14/v15 tails, re-appended after a splice so the record still ends where the reader +// expects. They go back in wire order: Hold first, then the limiter byte. static void putBakeHoldTail(std::vector& out, int quarterExponent, note::DivisionModifier modifier) { legacy::u32v(out, static_cast(static_cast(quarterExponent))); legacy::u8v(out, static_cast(modifier)); } -static void putDefaultBakeHoldTail(std::vector& out) { +static void putLimiterTail(std::vector& out, bool enabled) { + legacy::u8v(out, enabled ? 1 : 0); +} + +static void putDefaultTrailingTails(std::vector& out) { putBakeHoldTail(out, 2, note::DivisionModifier::Straight); // 1/1, the field's default + putLimiterTail(out, false); // bypassed, the field's default } // A hard-flag COUNT that disagrees with the curve fromPoints already built, but is still @@ -836,8 +848,8 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() { // order in params_payload.cpp) is deterministic and this test can splice it exactly. std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes); - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); + bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); legacy::u32v(bytes, 5); // amp: bogus count... for (int i = 0; i < 5; ++i) legacy::u8v(bytes, 0); // ...with 5 REAL bytes, so nothing shifts legacy::u32v(bytes, 2); // filter: correct count, unchanged @@ -846,7 +858,7 @@ static void testV13HardFlagInBoundsMismatchDropsFlagsOnly() { legacy::u32v(bytes, 2); // pitch: correct count, unchanged legacy::u8v(bytes, 0); legacy::u8v(bytes, 0); - putDefaultBakeHoldTail(bytes); + putDefaultTrailingTails(bytes); const ComponentState out = deserializeComponentState(bytes, 48000.0); // Every param preceding AND following the corrupted amp tail survives untouched. @@ -884,8 +896,8 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() { in.params.loopCrossfadeFrames = 321; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes); - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); + bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); legacy::u32v(bytes, 1000); // amp: a count its own tail cannot possibly carry // …and nothing at all after it, so the blob simply ends inside the v13 tail. @@ -903,6 +915,7 @@ static void testV13HardFlagOutOfBoundsCountSurvivesWithoutWipingTheRecord() { CHECK(out.params.velocityCurve.size() == 2); // unaffected: not misapplied, not discarded CHECK(!out.params.velocityCurve.points()[0].hard); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); + CHECK(!out.params.limiterEnabled); } // The stranding case, and the reason a bogus count DRAINS rather than skipping in place: the @@ -918,6 +931,9 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric in.params.play.adsr.releaseSeconds = 0.44; in.params.loopCrossfadeFrames = 321; in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet); + // Enabled on the in-state so the drain has something to cost on the LAST tail too: a drain + // that stopped short of it would hand back the stored `true` off bytes it cannot trust. + in.params.limiterEnabled = true; // A THREE-point amp curve, so its flag block is three bytes rather than two: the // misaligned reads below then land on bytes that decode to something other than the @@ -928,12 +944,13 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric reasampler::instrument::engine::CurveDomain::Unipolar); // A REAL blob with exactly ONE corrupt field: the amp hard-flag count, patched in place. - // Everything after it — the amp flags, both well-formed neighbour blocks, and the Hold — - // is exactly what the serializer wrote, which is the whole hazard. + // Everything after it — the amp flags, both well-formed neighbour blocks, the Hold and the + // limiter byte — is exactly what the serializer wrote, which is the whole hazard. constexpr std::size_t kThreePointFlagTail = (4 + 3) + (4 + 2) + (4 + 2); + constexpr std::size_t kTrailingTails = kBakeHoldTailBytes + kLimiterTailBytes; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kThreePointFlagTail + kBakeHoldTailBytes); - const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kBakeHoldTailBytes; + CHECK(bytes.size() >= kThreePointFlagTail + kTrailingTails); + const std::size_t ampCountAt = bytes.size() - kThreePointFlagTail - kTrailingTails; for (std::size_t i = 0; i < 4; ++i) bytes[ampCountAt + i] = i == 0 ? 0x00 : 0xFF; const ComponentState out = deserializeComponentState(bytes, 48000.0); @@ -950,6 +967,8 @@ static void testV13HardFlagCountThatStrandsAlignmentLeavesTheHoldAbsentNotFabric // clamps to the top rung. CHECK(out.params.bakeHold != note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight)); + // The drain reaches the last tail as well: the stored `true` is past the damage too. + CHECK(!out.params.limiterEnabled); } // Numeric domains are established at the DOOR, not at each consumer. A NaN pitch depth reaches @@ -1025,10 +1044,10 @@ static void testV13HardFlagTailTruncatedMidCountSurvivesWithoutWipingTheRecord() in.params.loopCrossfadeFrames = 5; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes); - // Drops the bake-Hold tail with the flags: the truncation strands everything after it, - // which is the whole point — Hold lifts to its default alongside the flags. - bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes); + CHECK(bytes.size() >= kHardFlagTailBytes + kBakeHoldTailBytes + kLimiterTailBytes); + // Drops the bake-Hold and limiter tails with the flags: the truncation strands everything + // after it, which is the whole point — both lift to their defaults alongside the flags. + bytes.resize(bytes.size() - kHardFlagTailBytes - kBakeHoldTailBytes - kLimiterTailBytes); legacy::u8v(bytes, 0x02); // half of the amp tail's 4-byte LE count, then nothing legacy::u8v(bytes, 0x00); @@ -1214,6 +1233,87 @@ static void testPriorPayloadVersionsLiftToAHardSeam() { } } +// The v15 rung. The limiter enable is a strict SUFFIX on v14, so a v14 blob is a valid prefix +// of it and lifts to BYPASSED — the migration bar for a project saved before the limiter +// existed: it reopens with the limiter off and therefore sounding identical. +// +// The v14 case is also the load-bearing ORDERING proof at the reader. A v14 blob is the +// current one with its last byte cut, so if the limiter byte were written AHEAD of the Hold +// the cut would take the Hold's modifier instead and the v14 read would resolve the Hold off +// the limiter byte — the stored division below would not survive. Transposing the two writes +// fails here, not merely in the byte fixture. +static void testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact() { + ComponentState in; + in.selectionId = "pad"; + in.params.limiterEnabled = true; + in.params.keyTrack = 0.25; // a neighbour ahead of the new byte, so a misread shows up here too + // Ξ's v14 field, off its default, so the lift below can prove it came back untouched. + in.params.bakeHold = note::makeDivision(-1, note::DivisionModifier::Dotted); + + const ComponentState out = deserializeComponentState(serializeComponentState(in), 48000.0); + CHECK(out.params.limiterEnabled); + CHECK(out.params.keyTrack == 0.25); + CHECK(out.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted)); + + // The same state stamped v14, with exactly the one appended byte cut away: byte-for-byte + // what the Ξ binary wrote. Its Hold must survive in full. + const ComponentState v14 = deserializeComponentState( + payloadDowngradedTo(in, kParamsBakeHoldVersion, kLimiterTailBytes), 48000.0); + CHECK(!v14.params.limiterEnabled); + CHECK(v14.params.bakeHold == note::makeDivision(-1, note::DivisionModifier::Dotted)); + CHECK(v14.params.keyTrack == 0.25); + + // And a v13 blob, one rung further back, lifts to BOTH defaults. + const ComponentState v13 = deserializeComponentState( + payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes), + 48000.0); + CHECK(!v13.params.limiterEnabled); + CHECK(v13.params.bakeHold == InstrumentParams{}.bakeHold); + CHECK(v13.params.keyTrack == 0.25); + + // Bypassed is the default at the struct as well as on the wire. + CHECK(!InstrumentParams{}.limiterEnabled); + const ComponentState fresh = + deserializeComponentState(serializeComponentState(ComponentState{}), 48000.0); + CHECK(!fresh.params.limiterEnabled); +} + +// The ORDERING proof at the WRITER, stated in bytes rather than in prose: the payload's whole +// discipline is that each version's fields are a strict suffix on the previous version's, so +// v14's Hold pair must be emitted BEFORE v15's limiter byte or every v14 blob already saved +// mis-parses. Asserted at absolute offsets from the end of the blob, with both fields off +// their defaults, so transposing the two writes fails on the values and not just the layout. +static void testAppendedTailsSitInVersionOrderOnTheWire() { + ComponentState in; + in.selectionId = "pad"; + in.params.bakeHold = note::makeDivision(-2, note::DivisionModifier::Triplet); + in.params.limiterEnabled = true; + + const std::vector bytes = serializeComponentState(in); + CHECK(bytes.size() > kBakeHoldTailBytes + kLimiterTailBytes); + + // The last six bytes are, in order: the v14 Hold's 4-byte LE exponent, its 1-byte + // modifier, then the v15 limiter byte. + const std::size_t holdAt = bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes; + CHECK(bytes[holdAt + 0] == 0xfe); // -2 as int32 LE two's-complement + CHECK(bytes[holdAt + 1] == 0xff); + CHECK(bytes[holdAt + 2] == 0xff); + CHECK(bytes[holdAt + 3] == 0xff); + CHECK(bytes[holdAt + 4] == static_cast(note::DivisionModifier::Triplet)); + CHECK(bytes[bytes.size() - 1] == 0x01); // the limiter enable, last + + // The same claim from the other side: flipping only the limiter changes only the LAST + // byte, so the byte the limiter owns cannot be one the Hold also writes. + ComponentState off = in; + off.params.limiterEnabled = false; + const std::vector offBytes = serializeComponentState(off); + CHECK(offBytes.size() == bytes.size()); + if (offBytes.size() == bytes.size()) { + for (std::size_t i = 0; i + 1 < bytes.size(); ++i) CHECK(offBytes[i] == bytes[i]); + CHECK(offBytes[bytes.size() - 1] == 0x00); + } +} + // The sharp edge of the bipolar change: v12 widened the filter curve's y domain, and the lift // is a pure DOMAIN RE-TAG — no rescaling, no rounding. A pre-v12 curve's y values all lie in // [0,1], which is inside [-1,+1], so every knot must come back bit-identical, the depth beside @@ -1308,10 +1408,10 @@ static void testV13BlobLiftsToTheDefaultHold() { in.params.loopCrossfadeFrames = 128; in.params.bakeHold = note::makeDivision(5, note::DivisionModifier::Dotted); - // Stamp the payload back to v13 and drop exactly the v14 tail: byte-for-byte what the - // previous binary would have written. - const std::vector v13 = - payloadDowngradedTo(in, kParamsSplineVersion, kBakeHoldTailBytes); + // Stamp the payload back to v13 and drop the v14 and v15 tails both: byte-for-byte what + // the v13 binary would have written. + const std::vector v13 = payloadDowngradedTo( + in, kParamsSplineVersion, kBakeHoldTailBytes + kLimiterTailBytes); const ComponentState out = deserializeComponentState(v13, 48000.0); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); CHECK(out.selectionId == "pad"); @@ -1327,18 +1427,22 @@ static void testBakeHoldCorruptPairClampsToTheLadder() { ComponentState in; in.selectionId = "pad"; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kBakeHoldTailBytes); - bytes.resize(bytes.size() - kBakeHoldTailBytes); + CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes); + bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes); legacy::u32v(bytes, static_cast(static_cast(9999))); legacy::u8v(bytes, 200); // an unnamed modifier byte + putLimiterTail(bytes, true); // a well-formed byte after it, so the clamp is the only fault const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.params.bakeHold == note::makeDivision(note::kMaxQuarterExponent, note::DivisionModifier::Straight)); + // The tail behind the corrupt pair still lands on its own field: the clamp consumed exactly + // the five bytes it was owed, so the limiter byte was not read out of the Hold's modifier. + CHECK(out.params.limiterEnabled); } -// A blob truncated INSIDE the v14 tail costs the Hold alone. It sits last, so without the -// revive a stray missing byte would reset every parameter ahead of it to defaults. +// A blob truncated INSIDE the v14 tail costs the Hold alone — and, with the v15 byte stranded +// behind it, the limiter's revive is what stops that truncation resetting the record anyway. static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() { ComponentState in; in.selectionId = "pad"; @@ -1346,15 +1450,17 @@ static void testBakeHoldTruncatedTailSurvivesWithoutWipingTheRecord() { in.params.play.adsr.attackSeconds = 0.017; in.params.loopCrossfadeFrames = 96; in.params.bakeHold = note::makeDivision(4, note::DivisionModifier::Triplet); + in.params.limiterEnabled = true; std::vector bytes = serializeComponentState(in); - CHECK(bytes.size() >= kBakeHoldTailBytes); - bytes.resize(bytes.size() - kBakeHoldTailBytes); + CHECK(bytes.size() >= kBakeHoldTailBytes + kLimiterTailBytes); + bytes.resize(bytes.size() - kBakeHoldTailBytes - kLimiterTailBytes); legacy::u8v(bytes, 0x02); // two of the exponent's four bytes, then nothing legacy::u8v(bytes, 0x00); const ComponentState out = deserializeComponentState(bytes, 48000.0); CHECK(out.params.bakeHold == InstrumentParams{}.bakeHold); + CHECK(!out.params.limiterEnabled); // stranded behind the Hold, and revived not wiped CHECK(out.selectionId == "pad"); CHECK(out.params.rootOverride && *out.params.rootOverride == 71); CHECK(out.params.play.adsr.attackSeconds == 0.017); @@ -1935,6 +2041,8 @@ int main() { testLoopSpanAndCrossfadeRoundTrip(); testNegativeCrossfadeOnTheWireLiftsToZero(); testPriorPayloadVersionsLiftToAHardSeam(); + testLimiterEnableRoundTripsAndV14LiftsToBypassedWithItsHoldIntact(); + testAppendedTailsSitInVersionOrderOnTheWire(); testPreV12FilterVelocityLiftsAsAPureDomainReTag(); testWriterEmitsCurrentPayloadVersion(); testSingleZoneMigrationIsLossless(); diff --git a/tests/test_limiter.cpp b/tests/test_limiter.cpp new file mode 100644 index 0000000..a35daf3 --- /dev/null +++ b/tests/test_limiter.cpp @@ -0,0 +1,285 @@ +// Standalone tests for reasampler::instrument::engine::Limiter — no VST3, no REAPER, no +// framework. The properties the master bus depends on, asserted rather than judged by ear: +// +// * bypassed and settled, process() does not touch one byte of the buffers (the byte-identical +// at-rest path) and reports no reduction; +// * engaged below the ceiling, the output is the input DELAYED and bit-exact — nothing is +// louder, quieter or altered at rest, and there is no makeup gain to find; +// * engaged on program +12 dB over, no output sample passes the ceiling; bypassed, the same +// program still passes 0 dBFS, so the toggle is doing the work; +// * the detection is TRUE-peak: a signal whose SAMPLES all clear the ceiling but whose +// inter-sample peak does not still engages; +// * the gain is stereo-linked, so a dual-mono signal stays centered across a full toggle; +// * the engage/disengage crossfade leaves no step larger than the signal's own. + +#include "../src/core/instrument/engine/limiter.h" + +#include +#include +#include +#include +#include + +using namespace reasampler::instrument::engine; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static constexpr double kRate = 48000.0; + +// A deterministic non-repeating pattern, so an untouched-buffer check cannot pass by accident. +static std::vector pattern(int n, float scale = 1.f) { + std::vector v(static_cast(n)); + std::uint32_t s = 0x1234567u; + for (int i = 0; i < n; ++i) { + s = s * 1664525u + 1013904223u; + v[static_cast(i)] = + scale * (static_cast(static_cast(s >> 8) % 20001 - 10000) / 10000.f); + } + return v; +} + +// Runs `in` through `lim` in blocks of `block`, returning the output and the smallest gain +// reported across the whole run. +static std::vector runMono(Limiter& lim, const std::vector& in, int block, + float* minGainOut = nullptr) { + std::vector out = in; + float lowest = 1.f; + for (std::size_t i = 0; i < out.size(); i += static_cast(block)) { + const int n = static_cast( + std::min(static_cast(block), out.size() - i)); + const float g = lim.process(out.data() + i, nullptr, n); + if (g < lowest) lowest = g; + } + if (minGainOut) *minGainOut = lowest; + return out; +} + +static void testBypassedLeavesEveryByteUntouched() { + Limiter lim; + lim.prepare(kRate); + CHECK(!lim.enabled()); + const std::vector in = pattern(2048, 1.8f); // well over full scale + float minGain = 0.f; + const std::vector out = runMono(lim, in, 512, &minGain); + bool identical = true; + for (std::size_t i = 0; i < in.size(); ++i) { + if (out[i] != in[i]) { identical = false; break; } + } + CHECK(identical); + CHECK(minGain == 1.f); + // And that untouched signal still passes 0 dBFS — the toggle, not the meter, is what + // stops it. + float peak = 0.f; + for (float v : out) peak = std::max(peak, std::fabs(v)); + CHECK(peak > 1.f); +} + +static void testEngagedBelowThresholdIsTheInputDelayedBitExactly() { + Limiter lim; + lim.setEnabled(true); + lim.prepare(kRate); // prepare snaps to the target: no crossfade, no priming + const int latency = limiterLookaheadSamples(kRate); + // Comfortably under the ceiling at every sample AND between samples. + const std::vector in = pattern(4096, 0.4f); + float minGain = 0.f; + const std::vector out = runMono(lim, in, 256, &minGain); + CHECK(minGain == 1.f); // exactly unity: there is no makeup gain and no residual trim + bool exact = true; + for (std::size_t i = static_cast(latency); i < in.size(); ++i) { + if (out[i] != in[i - static_cast(latency)]) { exact = false; break; } + } + CHECK(exact); +} + +static void testEngagedHoldsTheCeilingOnProgramTwelveDbOver() { + Limiter lim; + lim.setEnabled(true); + lim.prepare(kRate); + const int latency = limiterLookaheadSamples(kRate); + const float ceiling = static_cast(limiterCeilingLinear()); + // +12 dB over the ceiling, sustained, with the transient content the pattern gives. + std::vector in = pattern(24000, ceiling * 3.98f); + float minGain = 0.f; + const std::vector out = runMono(lim, in, 128, &minGain); + CHECK(minGain < 0.4f); // it really did pull the gain down + float worst = 0.f; + for (std::size_t i = static_cast(latency); i < out.size(); ++i) { + worst = std::max(worst, std::fabs(out[i])); + } + // Sample peak, so the true-peak ceiling is the bound with room to spare for float rounding. + CHECK(worst <= ceiling * 1.0001f); +} + +static void testTruePeakDetectionEngagesWhereSamplePeakWouldNot() { + // fs/4 at 45 degrees: every SAMPLE sits at A/sqrt(2) while the waveform reaches A between + // them. A sample-peak detector would pass this through untouched. + const double amp = 1.2; + const float ceiling = static_cast(limiterCeilingLinear()); + std::vector in(8000); + for (std::size_t i = 0; i < in.size(); ++i) { + in[i] = static_cast( + amp * std::cos(3.14159265358979323846 * (0.5 * static_cast(i) + 0.25))); + } + float samplePeak = 0.f; + for (float v : in) samplePeak = std::max(samplePeak, std::fabs(v)); + CHECK(samplePeak < ceiling); // the premise: no SAMPLE is over + + Limiter lim; + lim.setEnabled(true); + lim.prepare(kRate); + float minGain = 0.f; + runMono(lim, in, 256, &minGain); + CHECK(minGain < 1.f); +} + +static void testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle() { + Limiter lim; + lim.prepare(kRate); + const float ceiling = static_cast(limiterCeilingLinear()); + const std::vector src = pattern(48000, ceiling * 2.5f); + std::vector l = src, r = src; // dual mono: L and R are the same signal + const int block = 64; + bool centered = true; + for (std::size_t i = 0; i < l.size(); i += static_cast(block)) { + // Toggle on a quarter in and off three quarters in, so the run covers bypassed, + // the engage crossfade, fully engaged, the disengage crossfade, and bypassed again. + if (i >= l.size() / 4 && !lim.enabled()) lim.setEnabled(true); + if (i >= (l.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); + const int n = static_cast( + std::min(static_cast(block), l.size() - i)); + lim.process(l.data() + i, r.data() + i, n); + } + for (std::size_t i = 0; i < l.size(); ++i) { + if (l[i] != r[i]) { centered = false; break; } + } + CHECK(centered); + // And the engaged stretch really was limited, so the equality above is not equality on an + // untouched buffer. + float worstEngaged = 0.f; + for (std::size_t i = l.size() / 2; i < (l.size() * 3) / 4; ++i) { + worstEngaged = std::max(worstEngaged, std::fabs(l[i])); + } + CHECK(worstEngaged <= ceiling * 1.0001f); + CHECK(worstEngaged > 0.f); +} + +static void testToggleEmitsNoStepLargerThanTheSignalsOwn() { + // A steady sine: the crossfade blends it with a copy of itself delayed by the lookahead, + // which at 440 Hz is nearly half a cycle out — switching hard instead of fading would step + // by up to twice the amplitude, so this assertion has real teeth. + const double freq = 440.0; + const double amp = 0.5; // under the ceiling: this measures the TRANSITION, not limiting + std::vector x(48000); + for (std::size_t i = 0; i < x.size(); ++i) { + x[i] = static_cast( + amp * std::sin(2.0 * 3.14159265358979323846 * freq * static_cast(i) / kRate)); + } + const float naturalStep = + static_cast(amp * 2.0 * 3.14159265358979323846 * freq / kRate); + + Limiter lim; + lim.prepare(kRate); + const int block = 32; + for (std::size_t i = 0; i < x.size(); i += static_cast(block)) { + if (i >= x.size() / 4 && !lim.enabled()) lim.setEnabled(true); + if (i >= (x.size() * 3) / 4 && lim.enabled()) lim.setEnabled(false); + const int n = static_cast( + std::min(static_cast(block), x.size() - i)); + lim.process(x.data() + i, nullptr, n); + } + float worstStep = 0.f; + for (std::size_t i = 1; i < x.size(); ++i) { + worstStep = std::max(worstStep, std::fabs(x[i] - x[i - 1])); + } + CHECK(worstStep <= naturalStep * 1.2f); +} + +static void testCrossfadeSettlesToTheExactEngagedAndBypassedPaths() { + Limiter lim; + lim.prepare(kRate); + const int latency = limiterLookaheadSamples(kRate); + const int settle = static_cast(kLimiterCrossfadeSeconds * kRate) + latency + 64; + const std::vector src = pattern(4 * settle, 0.3f); // under the ceiling throughout + + std::vector y = src; + lim.setEnabled(true); + lim.process(y.data(), nullptr, static_cast(y.size())); + // Past the crossfade the engaged path is exactly the delayed input again. + bool exact = true; + for (std::size_t i = static_cast(settle); i < y.size(); ++i) { + if (y[i] != src[i - static_cast(latency)]) { exact = false; break; } + } + CHECK(exact); + + std::vector z = src; + lim.setEnabled(false); + lim.process(z.data(), nullptr, static_cast(z.size())); + bool passthrough = true; + for (std::size_t i = static_cast(settle); i < z.size(); ++i) { + if (z[i] != src[i]) { passthrough = false; break; } + } + CHECK(passthrough); + // And once settled bypassed, the next block is untouched again. + std::vector w = pattern(512, 1.5f); + const std::vector before = w; + CHECK(lim.process(w.data(), nullptr, static_cast(w.size())) == 1.f); + bool untouched = true; + for (std::size_t i = 0; i < w.size(); ++i) { + if (w[i] != before[i]) { untouched = false; break; } + } + CHECK(untouched); +} + +static void testGainNeverRisesAboveUnity() { + // "No makeup gain, ever, of any kind" as a property rather than an absence: across quiet, + // loud and silent material the applied gain is never above 1 and the output magnitude is + // never above the input's own. + Limiter lim; + lim.setEnabled(true); + lim.prepare(kRate); + std::vector in = pattern(16000, 2.0f); + for (std::size_t i = 4000; i < 8000; ++i) in[i] = 0.f; // a silent stretch + for (std::size_t i = 8000; i < 12000; ++i) in[i] *= 0.001f; // and a very quiet one + float minGain = 0.f; + const std::vector out = runMono(lim, in, 200, &minGain); + CHECK(minGain <= 1.f); + float inPeak = 0.f, outPeak = 0.f; + for (std::size_t i = 0; i < in.size(); ++i) { + inPeak = std::max(inPeak, std::fabs(in[i])); + outPeak = std::max(outPeak, std::fabs(out[i])); + } + CHECK(outPeak <= inPeak); +} + +static void testBakedConstants() { + CHECK(kLimiterCeilingDbTp == -0.3); + CHECK(std::fabs(limiterCeilingLinear() - std::pow(10.0, -0.3 / 20.0)) < 1e-12); + CHECK(limiterCeilingLinear() < 1.0); + // 2 ms at the common rates, and never below the detector's own group delay. + CHECK(limiterLookaheadSamples(48000.0) == 96); + CHECK(limiterLookaheadSamples(44100.0) == 88); + CHECK(limiterLookaheadSamples(96000.0) == 192); + CHECK(limiterLookaheadSamples(0.0) == 0); + CHECK(limiterLookaheadSamples(-1.0) == 0); + CHECK(limiterLookaheadSamples(100.0) > kLimiterOsDelay); +} + +int main() { + testBypassedLeavesEveryByteUntouched(); + testEngagedBelowThresholdIsTheInputDelayedBitExactly(); + testEngagedHoldsTheCeilingOnProgramTwelveDbOver(); + testTruePeakDetectionEngagesWhereSamplePeakWouldNot(); + testStereoLinkedGainKeepsDualMonoCenteredAcrossAToggle(); + testToggleEmitsNoStepLargerThanTheSignalsOwn(); + testCrossfadeSettlesToTheExactEngagedAndBypassedPaths(); + testGainNeverRisesAboveUnity(); + testBakedConstants(); + if (g_fail) { + std::printf("%d FAILURE(S)\n", g_fail); + return 1; + } + std::printf("limiter tests passed\n"); + return 0; +} diff --git a/tests/test_meter_ballistics.cpp b/tests/test_meter_ballistics.cpp new file mode 100644 index 0000000..8a889dd --- /dev/null +++ b/tests/test_meter_ballistics.cpp @@ -0,0 +1,140 @@ +// Standalone tests for reasampler::instrument::engine::meter_ballistics — no VST3, no REAPER, +// no framework. The meter's whole behaviour is asserted here without a host: instantaneous +// rise, the 20 dB/s fall, the 1.5 s peak hold and its release at the same rate, the clip +// latch and its clear, and the dB -> normalized map the bar is drawn against. + +#include "../src/core/instrument/engine/meter_ballistics.h" + +#include +#include + +using namespace reasampler::instrument::engine; + +static int g_fail = 0; +#define CHECK(cond) do { if(!(cond)) { \ + std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0) + +static bool near(double a, double b, double eps = 1e-9) { return std::fabs(a - b) <= eps; } + +static double linearFromDb(double db) { return std::pow(10.0, db / 20.0); } + +static void testDbFromLinear() { + CHECK(near(meterDbFromLinear(1.0), 0.0)); + CHECK(near(meterDbFromLinear(0.5), -6.0205999132796239, 1e-9)); + CHECK(near(meterDbFromLinear(2.0), 6.0205999132796239, 1e-9)); + // Silence and anything under the floor read the floor, not -inf: the ballistics subtract + // from this value, so it has to stay finite. + CHECK(meterDbFromLinear(0.0) == kMeterFloorDb); + CHECK(meterDbFromLinear(-1.0) == kMeterFloorDb); + CHECK(meterDbFromLinear(1e-9) == kMeterFloorDb); +} + +static void testNormFromDbIsLinearInDbAndClamped() { + CHECK(meterNormFromDb(kMeterFloorDb) == 0.0); + CHECK(meterNormFromDb(kMeterTopDb) == 1.0); + CHECK(meterNormFromDb(-1000.0) == 0.0); + CHECK(meterNormFromDb(1000.0) == 1.0); + // Linear in dB: equal dB steps are equal normalized steps anywhere in the span. + const double a = meterNormFromDb(-48.0) - meterNormFromDb(-54.0); + const double b = meterNormFromDb(-6.0) - meterNormFromDb(-12.0); + CHECK(near(a, b, 1e-12)); + CHECK(near(a, 6.0 / (kMeterTopDb - kMeterFloorDb), 1e-12)); + // The 0 dB tick, which the scale's heavier rule is drawn on. + CHECK(near(meterNormFromDb(0.0), 60.0 / 66.0, 1e-12)); +} + +static void testRiseIsInstantaneous() { + MeterState s; + s = advanceMeter(s, linearFromDb(-12.0), 1.0 / 30.0); + CHECK(near(s.levelDb, -12.0, 1e-9)); + // A louder block on the very next frame displays at once, however short the frame. + s = advanceMeter(s, linearFromDb(-3.0), 1e-6); + CHECK(near(s.levelDb, -3.0, 1e-9)); +} + +static void testFallIsTwentyDbPerSecond() { + MeterState s; + s = advanceMeter(s, 1.0, 0.0); // 0 dBFS + CHECK(near(s.levelDb, 0.0, 1e-9)); + s = advanceMeter(s, 0.0, 0.5); + CHECK(near(s.levelDb, -10.0, 1e-9)); + s = advanceMeter(s, 0.0, 0.25); + CHECK(near(s.levelDb, -15.0, 1e-9)); + // The fall never takes the bar under the peak the block itself carried. + s = advanceMeter(s, linearFromDb(-14.0), 1.0); + CHECK(near(s.levelDb, -14.0, 1e-9)); + // And it stops at the floor. + for (int i = 0; i < 20; ++i) s = advanceMeter(s, 0.0, 0.5); + CHECK(near(s.levelDb, kMeterFloorDb, 1e-9)); +} + +static void testPeakHoldLatchesForOnePointFiveSecondsThenFallsAtTheSameRate() { + MeterState s; + s = advanceMeter(s, 1.0, 0.0); + CHECK(near(s.holdDb, 0.0, 1e-9)); + CHECK(near(s.holdRemainingSeconds, kMeterPeakHoldSeconds, 1e-12)); + + // 1.4 s of silence: the bar has long fallen away, the tick has not moved. + for (int i = 0; i < 14; ++i) s = advanceMeter(s, 0.0, 0.1); + CHECK(near(s.holdDb, 0.0, 1e-9)); + CHECK(s.levelDb < -20.0); + + // Past 1.5 s it releases at the bar's own rate — 0.2 s past the latch is 4 dB down. + s = advanceMeter(s, 0.0, 0.3); + CHECK(near(s.holdDb, -4.0, 1e-9)); + s = advanceMeter(s, 0.0, 0.1); + CHECK(near(s.holdDb, -6.0, 1e-9)); + + // A new peak re-latches it and restarts the hold. + s = advanceMeter(s, linearFromDb(-2.0), 0.1); + CHECK(near(s.holdDb, -2.0, 1e-9)); + CHECK(near(s.holdRemainingSeconds, kMeterPeakHoldSeconds, 1e-12)); +} + +static void testHoldNeverFallsBelowTheBar() { + MeterState s; + s = advanceMeter(s, 1.0, 0.0); + for (int i = 0; i < 40; ++i) s = advanceMeter(s, linearFromDb(-20.0), 0.1); + CHECK(near(s.levelDb, -20.0, 1e-9)); + CHECK(near(s.holdDb, -20.0, 1e-9)); +} + +static void testClipLatchesAtFullScaleAndOnlyClearsOnRequest() { + MeterState s; + s = advanceMeter(s, linearFromDb(-0.01), 0.1); + CHECK(!s.clip); // under 0 dBFS does not latch + s = advanceMeter(s, 1.0, 0.1); + CHECK(s.clip); // exactly 0 dBFS does + for (int i = 0; i < 100; ++i) s = advanceMeter(s, 0.0, 0.1); + CHECK(s.clip); // and silence does not unlatch it + s = clearMeterClip(s); + CHECK(!s.clip); + s = advanceMeter(s, linearFromDb(-6.0), 0.1); + CHECK(!s.clip); // cleared stays cleared while nothing reaches full scale + CHECK(near(s.levelDb, -6.0, 1e-9)); // and clearing left the ballistics alone +} + +static void testNonPositiveElapsedFreezesTheBallistics() { + MeterState s; + s = advanceMeter(s, 1.0, 0.0); + const MeterState frozen = advanceMeter(s, 0.0, -1.0); + CHECK(near(frozen.levelDb, s.levelDb, 1e-12)); + CHECK(near(frozen.holdDb, s.holdDb, 1e-12)); +} + +int main() { + testDbFromLinear(); + testNormFromDbIsLinearInDbAndClamped(); + testRiseIsInstantaneous(); + testFallIsTwentyDbPerSecond(); + testPeakHoldLatchesForOnePointFiveSecondsThenFallsAtTheSameRate(); + testHoldNeverFallsBelowTheBar(); + testClipLatchesAtFullScaleAndOnlyClearsOnRequest(); + testNonPositiveElapsedFreezesTheBallistics(); + if (g_fail) { + std::printf("%d FAILURE(S)\n", g_fail); + return 1; + } + std::printf("meter_ballistics tests passed\n"); + return 0; +}