Files
reasampler/tests/test_master_meter.cpp
T

318 lines
15 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Standalone tests for reasampler::instrument::ui::master_meter — no VST3, no REAPER, no
// framework. Assert:
//
// * column interior — the 22/4/36 decomposition, the exported column width the deck reserves,
// the mono bar taking the whole field, the two stereo bars, all inside the column.
// * bar count — the SAME LaneSplit resolveLaneSplit folds, over channel mode x source
// channel count, so it can never become a second rule.
// * the dB axis — top/floor on the field's edges, an interior value, and the clamps.
// * ballistics — instantaneous rise, 20 dB/s fall, the 1.5 s hold and its release AT RATE;
// the audio thread's clip latch surviving a UI frame; the per-field single-lane fold.
// * the GR lamp — lit only while the limiter reduces, held, and surviving the 500 ms tick the
// editor actually runs it at.
#include "../src/core/instrument/ui/master_meter.h"
#include "../src/core/instrument/ui/waveform_view.h"
#include <cmath>
#include <cstdio>
using namespace reasampler;
using namespace reasampler::instrument::ui;
using reasampler::instrument::engine::kMeterFallDbPerSecond;
using reasampler::instrument::engine::kMeterFloorDb;
using reasampler::instrument::engine::kMeterPeakHoldSeconds;
using reasampler::instrument::engine::kMeterTopDb;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// The shipped column: 62 px wide, 186 tall (knob_deck's spanning geometry).
static const Rect kColumn = Rect::ltrb(1114, 40, 1176, 226);
static void testColumnDividesIntoGutterAndBarField() {
const MeterRects m = meterRects(kColumn, LaneSplit::Single);
CHECK(m.labels.x == kColumn.x);
CHECK(m.labels.width == kMeterLabelW);
CHECK(m.field.x == m.labels.right() + kMeterLabelGap);
CHECK(m.field.width == kMeterFieldW);
// The three parts account for the column exactly — a residue would leave dead pixels the
// scale's numerals would then be centred against. Asserted against the EXPORTED width the
// deck reserves, not against this fixture's literal rect: the deck reading the same
// constant is what keeps the reserve and the interior from drifting apart.
CHECK(kMeterLabelW + kMeterLabelGap + kMeterFieldW == kMeterColumnW);
CHECK(kMeterColumnW == kColumn.width);
CHECK(m.field.right() == kColumn.right());
// Full height in both rects: the column spans both row baselines as ONE readout.
CHECK(m.labels.y == kColumn.y && m.labels.bottom() == kColumn.bottom());
CHECK(m.field.y == kColumn.y && m.field.bottom() == kColumn.bottom());
}
static void testMonoDrawsOneWideBarAndStereoDrawsTwo() {
const MeterRects mono = meterRects(kColumn, LaneSplit::Single);
CHECK(mono.barA == mono.field); // the one bar IS the field
CHECK(mono.barB.empty());
const MeterRects st = meterRects(kColumn, LaneSplit::Stereo);
CHECK(!st.barB.empty());
CHECK(st.barA.width == st.barB.width);
CHECK(st.barA.width == (kMeterFieldW - kMeterBarGap) / 2);
CHECK(st.barA.width == 17);
CHECK(st.barB.x - st.barA.right() == kMeterBarGap);
// Both bars inside the field, and the pair fills it to the pixel.
CHECK(st.barA.x == st.field.x);
CHECK(st.barB.right() == st.field.right());
CHECK(st.barA.y == st.field.y && st.barB.bottom() == st.field.bottom());
}
// The bar count is NOT a second rule: it is resolveLaneSplit's answer for the same (mode,
// source) pair the waveform asks about. A mono source under stereo mode is dual-mono — one
// source, two views.
static void testBarCountFollowsTheWaveformsOwnLaneSplit() {
const Rect band = Rect::ltrb(8, 100, 1182, 458);
for (bool stereoMode : {false, true}) {
for (int sourceChannels : {1, 2}) {
const LaneSplit split = resolveLaneSplit(stereoMode, sourceChannels);
const MeterRects m = meterRects(kColumn, split);
const int bars = m.barB.empty() ? 1 : 2;
// The waveform's own surface folds the SAME call, so on a band tall enough to
// divide the two answers agree by construction rather than by coincidence.
CHECK(bars == waveformSurface(band, stereoMode, sourceChannels).laneCount);
// Spelled out per combination so a regression names which one broke.
const bool expectTwo = stereoMode && sourceChannels >= 2;
CHECK(bars == (expectTwo ? 2 : 1));
}
}
}
static void testDbAxisSpansTheFieldAndClamps() {
const MeterRects m = meterRects(kColumn, LaneSplit::Single);
CHECK(meterDbToY(m.field, kMeterTopDb) == m.field.y);
CHECK(meterDbToY(m.field, kMeterFloorDb) == m.field.bottom());
// Monotone downward as the level falls.
int prev = m.field.y;
for (double db = kMeterTopDb; db >= kMeterFloorDb; db -= 6.0) {
const int y = meterDbToY(m.field, db);
CHECK(y >= prev);
prev = y;
}
// Clamped outside the scale rather than drawn off the field.
CHECK(meterDbToY(m.field, kMeterTopDb + 40.0) == m.field.y);
CHECK(meterDbToY(m.field, kMeterFloorDb - 40.0) == m.field.bottom());
// One INTERIOR point, because endpoints plus monotonicity are satisfied by any log or
// piecewise map through them, and the scale is specified LINEAR in dB. 27 is the
// midpoint of 60…+6, so it must land on the field's own midpoint: 186 x 0.5 = 93.
CHECK(meterDbToY(m.field, -27.0) == m.field.bottom() - 93);
// And a quarter of the way up, which fixes the slope rather than just the centre.
CHECK(meterDbToY(m.field, -43.5) == m.field.bottom() - 47); // round(0.25 x 186) = 47
}
// The numeral SET is spec-pinned (0, 12, 24, 36, 48, 60) as a property of the scale, so it
// is asserted here rather than left as a modulo inside the painter.
static void testEveryOtherTickCarriesANumeral() {
const int expected[] = {6, -6, -18, -30, -42, -54};
for (int db : expected) CHECK(!meterTickNumeralled(db));
const int numeralled[] = {0, -12, -24, -36, -48, -60};
for (int db : numeralled) CHECK(meterTickNumeralled(db));
}
// The floor tick sits ON the field's bottom edge, so an unclamped y±5 numeral box hangs below
// the column and into the deck's bottom padding.
static void testTheFloorNumeralStaysInsideTheGutter() {
const MeterRects m = meterRects(kColumn, LaneSplit::Single);
const Rect floorLabel = meterNumeralRect(m.labels, meterDbToY(m.field, kMeterFloorDb));
CHECK(floorLabel.bottom() <= m.labels.bottom());
CHECK(floorLabel.y >= m.labels.y);
CHECK(floorLabel.height == 10); // clamped, not squashed — the numeral still has its band
const Rect topLabel = meterNumeralRect(m.labels, meterDbToY(m.field, kMeterTopDb));
CHECK(topLabel.y >= m.labels.y);
CHECK(topLabel.height == 10);
// An interior tick is centred on its rule, which is the case the clamp must not disturb.
const int midY = meterDbToY(m.field, -24.0);
CHECK(meterNumeralRect(m.labels, midY).y == midY - 5);
}
// A column narrower than the interior needs yields NOTHING rather than a field overrunning it.
// Reachable only if the deck's reserve and this module's interior ever disagree — which is
// exactly what kMeterColumnW exists to prevent.
static void testAColumnTooNarrowForTheInteriorDrawsNothing() {
const Rect narrow = Rect::ltrb(0, 0, kMeterColumnW - 1, 186);
const MeterRects m = meterRects(narrow, LaneSplit::Stereo);
CHECK(m.field.empty() && m.barA.empty() && m.barB.empty());
// Exactly the needed width still lays out.
CHECK(!meterRects(Rect::ltrb(0, 0, kMeterColumnW, 186), LaneSplit::Stereo).field.empty());
}
static void testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond() {
MasterMeterUi s;
// Unity on the left, silence on the right: the two channels are independent.
s = advanceMasterMeter(s, {1.0, 0.0, 1.0, false}, 0.1);
CHECK(std::fabs(s.left.levelDb - 0.0) < 1e-9); // rise is instantaneous, this very frame
CHECK(s.right.levelDb == kMeterFloorDb);
// One second of silence: exactly kMeterFallDbPerSecond of fall, not a smoothed decay.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 1.0);
CHECK(std::fabs(s.left.levelDb - -kMeterFallDbPerSecond) < 1e-9);
}
static void testPeakHoldSitsForItsFullWindowThenReleases() {
MasterMeterUi s;
s = advanceMasterMeter(s, {1.0, 1.0, 1.0, false}, 0.1);
const double held = s.left.holdDb;
CHECK(std::fabs(held - 0.0) < 1e-9);
// Just under the hold window: the bar has fallen a long way, the tick has not moved.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, kMeterPeakHoldSeconds - 0.01);
CHECK(s.left.levelDb < held - 20.0);
CHECK(std::fabs(s.left.holdDb - held) < 1e-9);
// Past it, the tick releases at the SAME 20 dB/s the bar uses — pinned by value, not as an
// inequality: a slower release would satisfy "it fell" and still be the wrong meter. The
// frame spends the 0.01 s of hold it had left and releases for the remaining 0.49 s, which
// is also what proves the release does not quantize to whole UI frames.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 0.5);
CHECK(std::fabs(s.left.holdDb - (held - kMeterFallDbPerSecond * 0.49)) < 1e-9);
CHECK(s.left.holdDb >= s.left.levelDb);
}
// The published latch is the authoritative one: a clip between two UI frames never appears in
// the block peak this frame samples, so dropping it would silently lose the report.
static void testClipLatchesFromThePublishedFlagAndClearsOnDemand() {
MasterMeterUi s;
CHECK(!meterClipped(s));
s = advanceMasterMeter(s, {0.25, 0.25, 1.0, /*clip=*/true}, 0.1);
CHECK(meterClipped(s));
// Latched: quiet frames do not lower it.
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 5.0);
CHECK(meterClipped(s));
s = clearMasterMeterClip(s);
CHECK(!meterClipped(s));
// And the UI's own sample latches it too, when the loud block IS the one sampled.
s = advanceMasterMeter(s, {1.0, 0.0, 1.0, false}, 0.1);
CHECK(meterClipped(s));
}
static void testGrLampLitOnlyWhileTheLimiterReduces() {
MasterMeterUi s;
CHECK(!grLampLit(s));
// A gain of 1 is no reduction, however long it is held.
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 0.1);
CHECK(s.reductionDb == 0.0);
CHECK(!grLampLit(s));
// ~6 dB of reduction lights it, and arms the hold.
s = advanceMasterMeter(s, {0.5, 0.5, 0.5, false}, 0.1);
CHECK(std::fabs(s.reductionDb - 6.0206) < 1e-3);
CHECK(grLampLit(s));
CHECK(s.reductionHoldSeconds == kMeterPeakHoldSeconds);
// Held flat, not decaying, for its whole window — the peak tick's own contract.
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, kMeterPeakHoldSeconds - 0.01);
CHECK(std::fabs(s.reductionDb - 6.0206) < 1e-3);
CHECK(grLampLit(s));
// Past the window it releases at the meter's 20 dB/s, and 6 dB of catch is gone inside a
// third of a second of release.
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 1.0);
CHECK(!grLampLit(s));
CHECK(s.reductionDb == 0.0);
}
// The cadence the lamp ACTUALLY runs at is editor_platform's 500 ms sync tick, and the whole
// point of the hold is that the lamp survives it. Without one, a 6 dB catch decays 20 x 0.5 =
// 10 dB on the very next frame and clamps to 0 — lit for exactly one repaint. Pinned in frames,
// because "how many times does this draw lit" is arithmetic, not a look.
static void testGrLampSurvivesTheFiveHundredMillisecondTick() {
constexpr double kTick = 0.5; // editor_platform.cpp's kSyncTimerIntervalMs
MasterMeterUi s = advanceMasterMeter(MasterMeterUi{}, {0.5, 0.5, 0.5, false}, kTick);
CHECK(grLampLit(s));
int litFrames = 1;
for (int i = 0; i < 20 && grLampLit(s); ++i) {
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, kTick);
if (grLampLit(s)) ++litFrames;
}
// 1.5 s of hold spans the tick that armed it plus three more, and the release then takes
// 6.02 dB below the 0.5 dB floor within one further 10 dB step.
CHECK(litFrames == 4);
CHECK(!grLampLit(s));
// A catch the previous frame does not shorten: a SECOND catch re-arms the full window.
MasterMeterUi t = advanceMasterMeter(MasterMeterUi{}, {0.5, 0.5, 0.5, false}, kTick);
t = advanceMasterMeter(t, {0.5, 0.5, 1.0, false}, kTick);
t = advanceMasterMeter(t, {0.5, 0.5, 0.5, false}, kTick);
CHECK(t.reductionHoldSeconds == kMeterPeakHoldSeconds);
}
// The one bar a single-lane column draws folds the two channels per FIELD. Picking whichever
// channel won on level would draw the OTHER channel's hold tick and clip nowhere.
static void testSingleLaneStateFoldsBothChannelsPerField() {
MasterMeterUi m;
m.left.levelDb = -30.0;
m.left.holdDb = -2.0; // left is quieter now but held the loudest peak
m.right.levelDb = -10.0;
m.right.holdDb = -8.0;
m.left.clip = true; // and only left ever clipped
m.right.clip = false;
const instrument::engine::MeterState s = meterSingleLaneState(m);
CHECK(s.levelDb == -10.0); // the louder channel's bar
CHECK(s.holdDb == -2.0); // but the higher hold tick, which is the other channel's
CHECK(s.clip); // and the clip, which a level pick would have dropped
}
// The tick repaints only on a change, so what counts as a change has to cover every drawn
// quantity — and only those.
static void testDrawEqualityCoversTheDrawnQuantities() {
MasterMeterUi a;
CHECK(meterDrawEqual(a, a));
MasterMeterUi loud = advanceMasterMeter(a, {1.0, 0.0, 1.0, false}, 0.1);
CHECK(!meterDrawEqual(a, loud)); // bar + hold tick moved
MasterMeterUi clipped = a;
clipped.left.clip = true;
CHECK(!meterDrawEqual(a, clipped)); // the cap appeared
MasterMeterUi lamp = a;
lamp.reductionDb = kGrLampFloorDb;
CHECK(!meterDrawEqual(a, lamp)); // the lamp lit
// Reduction that does not cross the lamp's floor draws identically — the state differs,
// the picture does not, and a repaint there would be pure cost.
MasterMeterUi graze = a;
graze.reductionDb = kGrLampFloorDb / 2.0;
CHECK(meterDrawEqual(a, graze));
}
static void testDegenerateColumnYieldsNothing() {
const MeterRects m = meterRects(Rect::ltrb(0, 0, 0, 0), LaneSplit::Stereo);
CHECK(m.field.empty() && m.barA.empty() && m.barB.empty());
}
int main() {
testColumnDividesIntoGutterAndBarField();
testMonoDrawsOneWideBarAndStereoDrawsTwo();
testBarCountFollowsTheWaveformsOwnLaneSplit();
testDbAxisSpansTheFieldAndClamps();
testEveryOtherTickCarriesANumeral();
testTheFloorNumeralStaysInsideTheGutter();
testAColumnTooNarrowForTheInteriorDrawsNothing();
testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond();
testPeakHoldSitsForItsFullWindowThenReleases();
testClipLatchesFromThePublishedFlagAndClearsOnDemand();
testSingleLaneStateFoldsBothChannelsPerField();
testGrLampLitOnlyWhileTheLimiterReduces();
testGrLampSurvivesTheFiveHundredMillisecondTick();
testDrawEqualityCoversTheDrawnQuantities();
testDegenerateColumnYieldsNothing();
if (g_fail) {
std::printf("%d FAILURE(S)\n", g_fail);
return 1;
}
std::printf("master_meter tests passed\n");
return 0;
}