0627398bbb
Row membership is now the group's own property, not a wrap outcome. FILTER's Band|Notch moves to its caption slack, which is what makes the sound row fit. MASTER gains the limiter toggle, the output meter and the GR lamp.
216 lines
9.2 KiB
C++
216 lines
9.2 KiB
C++
// Standalone tests for reasampler::instrument::ui::master_meter — no VST3, no REAPER, no
|
|
// framework. Assert:
|
|
//
|
|
// * column interior — the 22/4/36 decomposition, the mono bar taking the whole field, the
|
|
// two stereo bars at 17 px and kMeterBarGap apart, all inside the column.
|
|
// * bar count — the SAME LaneSplit waveformSurface folds, over channel mode x source
|
|
// channel count, so it can never become a second rule.
|
|
// * the dB axis — top/floor land on the field's edges, it is monotone, and it clamps.
|
|
// * ballistics — instantaneous rise, 20 dB/s fall, the 1.5 s hold and its release; the
|
|
// audio thread's clip latch surviving a UI frame that never sampled the loud block.
|
|
// * the GR lamp — lit only while the limiter actually reduces, and decaying afterwards.
|
|
|
|
#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.
|
|
CHECK(kMeterLabelW + kMeterLabelGap + kMeterFieldW == 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 whatever waveformSurface resolved for the same
|
|
// (mode, source) pair. 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 WaveformSurface s = waveformSurface(band, stereoMode, sourceChannels);
|
|
const LaneSplit split =
|
|
s.laneCount == 2 ? LaneSplit::Stereo : LaneSplit::Single;
|
|
const MeterRects m = meterRects(kColumn, split);
|
|
const int bars = m.barB.empty() ? 1 : 2;
|
|
CHECK(bars == s.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());
|
|
}
|
|
|
|
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.
|
|
s = advanceMasterMeter(s, {0.0, 0.0, 1.0, false}, 0.5);
|
|
CHECK(s.left.holdDb < held);
|
|
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.
|
|
s = advanceMasterMeter(s, {0.5, 0.5, 0.5, false}, 0.1);
|
|
CHECK(std::fabs(s.reductionDb - 6.0206) < 1e-3);
|
|
CHECK(grLampLit(s));
|
|
|
|
// It decays at the meter's own rate rather than snapping dark, so a transient catch is
|
|
// visible for more than the single frame it happened on.
|
|
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 0.1);
|
|
CHECK(grLampLit(s));
|
|
CHECK(s.reductionDb < 6.0206);
|
|
s = advanceMasterMeter(s, {0.5, 0.5, 1.0, false}, 1.0);
|
|
CHECK(!grLampLit(s));
|
|
CHECK(s.reductionDb == 0.0);
|
|
}
|
|
|
|
// 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();
|
|
testPeakRisesAtOnceAndFallsAtTwentyDbPerSecond();
|
|
testPeakHoldSitsForItsFullWindowThenReleases();
|
|
testClipLatchesFromThePublishedFlagAndClearsOnDemand();
|
|
testGrLampLitOnlyWhileTheLimiterReduces();
|
|
testDrawEqualityCoversTheDrawnQuantities();
|
|
testDegenerateColumnYieldsNothing();
|
|
if (g_fail) {
|
|
std::printf("%d FAILURE(S)\n", g_fail);
|
|
return 1;
|
|
}
|
|
std::printf("master_meter tests passed\n");
|
|
return 0;
|
|
}
|