Phase S FB1 (r11): Sample-view recomposition — knob deck + elastic full-width hero + curve popup w/ right-click delete + post-mixer master gain (ComponentState v8)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// Standalone tests for reasampler::vst::curve_popup — no VST3, no REAPER, no framework. Same
|
||||
// fast assert loop as the sibling pure tests. Assert the r11 popup-sheet geometry at the size
|
||||
// clamps (the spec's width clamp(60%, 360..520) / height clamp(55%, 260..380)), the centering,
|
||||
// the title-row/close-button placement, the curve-box remainder, and the outside-sheet
|
||||
// dismissal test.
|
||||
|
||||
#include "../src/vst/curve_popup.h"
|
||||
|
||||
#include <cstdio>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
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 void testDefaultWindowMidClamp() {
|
||||
// 840x620: 60% = 504 (inside 360..520), 55% = 341 (inside 260..380).
|
||||
const CurvePopupLayout pl = computeCurvePopup(840, 620);
|
||||
CHECK(pl.sheet.width() == 504);
|
||||
CHECK(pl.sheet.height() == 341);
|
||||
// Centered (within the integer-division pixel).
|
||||
CHECK(pl.sheet.left == (840 - 504) / 2);
|
||||
CHECK(pl.sheet.top == (620 - 341) / 2);
|
||||
}
|
||||
|
||||
static void testMinClamp() {
|
||||
// The 560x460 constraint floor: 60% = 336 -> clamps UP to 360; 55% = 253 -> up to 260.
|
||||
const CurvePopupLayout pl = computeCurvePopup(560, 460);
|
||||
CHECK(pl.sheet.width() == kCurvePopupMinW);
|
||||
CHECK(pl.sheet.height() == kCurvePopupMinH);
|
||||
CHECK(pl.sheet.left >= 0 && pl.sheet.right <= 560);
|
||||
CHECK(pl.sheet.top >= 0 && pl.sheet.bottom <= 460);
|
||||
}
|
||||
|
||||
static void testMaxClamp() {
|
||||
// A large window: 60% of 1600 = 960 -> clamps DOWN to 520; 55% of 900 = 495 -> down to 380.
|
||||
const CurvePopupLayout pl = computeCurvePopup(1600, 900);
|
||||
CHECK(pl.sheet.width() == kCurvePopupMaxW);
|
||||
CHECK(pl.sheet.height() == kCurvePopupMaxH);
|
||||
}
|
||||
|
||||
static void testDegenerateWindowNeverOverhangs() {
|
||||
// A window smaller than the min clamp: the sheet caps at the window dimension (defensive —
|
||||
// below checkSizeConstraint, but geometry must stay sane).
|
||||
const CurvePopupLayout pl = computeCurvePopup(300, 200);
|
||||
CHECK(pl.sheet.width() == 300);
|
||||
CHECK(pl.sheet.height() == 200);
|
||||
CHECK(pl.sheet.left == 0 && pl.sheet.top == 0);
|
||||
}
|
||||
|
||||
static void testTitleRowAndCurveBox() {
|
||||
const CurvePopupLayout pl = computeCurvePopup(840, 620);
|
||||
// Close: 18x18, right-anchored inside the title row.
|
||||
CHECK(pl.close.width() == kCurvePopupCloseSize && pl.close.height() == kCurvePopupCloseSize);
|
||||
CHECK(pl.close.right == pl.sheet.right - kCurvePopupPad);
|
||||
CHECK(pl.close.top >= pl.sheet.top);
|
||||
CHECK(pl.close.bottom <= pl.sheet.top + kCurvePopupTitleH);
|
||||
// Title text: left of the close button, in the title row.
|
||||
CHECK(pl.title.left == pl.sheet.left + kCurvePopupPad);
|
||||
CHECK(pl.title.right <= pl.close.left);
|
||||
// Curve box: fills the remainder below the title row, inside the sheet margins.
|
||||
CHECK(pl.curveBox.top >= pl.sheet.top + kCurvePopupTitleH);
|
||||
CHECK(pl.curveBox.left == pl.sheet.left + kCurvePopupPad);
|
||||
CHECK(pl.curveBox.right == pl.sheet.right - kCurvePopupPad);
|
||||
CHECK(pl.curveBox.bottom == pl.sheet.bottom - kCurvePopupPad);
|
||||
CHECK(pl.curveBox.width() > 0 && pl.curveBox.height() > 0);
|
||||
}
|
||||
|
||||
static void testOutsideSheetDismissTest() {
|
||||
const CurvePopupLayout pl = computeCurvePopup(840, 620);
|
||||
// On the wash: outside.
|
||||
CHECK(popupOutsideSheet(pl, 0, 0));
|
||||
CHECK(popupOutsideSheet(pl, pl.sheet.left - 1, pl.sheet.top + 10));
|
||||
CHECK(popupOutsideSheet(pl, pl.sheet.right, pl.sheet.top + 10)); // half-open right edge
|
||||
// On the sheet (title row, curve box, padding): inside.
|
||||
CHECK(!popupOutsideSheet(pl, pl.sheet.left, pl.sheet.top));
|
||||
CHECK(!popupOutsideSheet(pl, pl.curveBox.left + 5, pl.curveBox.top + 5));
|
||||
CHECK(!popupOutsideSheet(pl, pl.sheet.right - 1, pl.sheet.bottom - 1));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testDefaultWindowMidClamp();
|
||||
testMinClamp();
|
||||
testMaxClamp();
|
||||
testDegenerateWindowNeverOverhangs();
|
||||
testTitleRowAndCurveBox();
|
||||
testOutsideSheetDismissTest();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
std::printf("curve_popup tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
// Standalone tests for reasampler::vst::knob_deck — no VST3, no REAPER, no framework. Same fast
|
||||
// assert loop as the sibling pure tests. Assert the r11 deck layout HARD:
|
||||
//
|
||||
// * group width — caption row vs knob row max + padding; row-toggle and caption-toggle widths.
|
||||
// * layout — caption toggle right-anchored IN the caption row; cells fixed 48x58 left-to-right
|
||||
// inside the box; knob square centered; label band beneath; row toggle after the cells.
|
||||
// * wrap — deterministic whole-group wrap at a narrowing width (the r11 "PITCH ENV onto row
|
||||
// two at the 560 floor" behavior); the first group of a row always places; deckHeight
|
||||
// consistency with deckRowCount.
|
||||
// * hit-test — knob cell hit (whole cell), toggle segment 0/1 boundaries, blank (-1) cells
|
||||
// and fence padding miss, outside-deck miss.
|
||||
|
||||
#include "../src/vst/knob_deck.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// A representative deck shaped like the shell's: AMP (5 cells + caption toggle), PITCH
|
||||
// (1 cell + caption toggle), PITCH ENV (3 cells + caption toggle), VOICE (1 cell + caption
|
||||
// toggle + row toggle), MASTER (1 cell, no toggle).
|
||||
static std::vector<DeckGroupDesc> shellLikeDeck() {
|
||||
std::vector<DeckGroupDesc> g;
|
||||
g.push_back({0, 78, {100, 44}, {1, 2, 3, 4, 5}, {}});
|
||||
g.push_back({1, 38, {101, 48}, {6}, {}});
|
||||
g.push_back({2, 58, {102, 32}, {7, 8, 9}, {}});
|
||||
g.push_back({3, 38, {103, 40}, {10}, {104, 44}});
|
||||
g.push_back({4, 46, {}, {11}, {}});
|
||||
return g;
|
||||
}
|
||||
|
||||
static void testGroupWidth() {
|
||||
// Knob row dominates: 5 cells (240) > caption row (78 + 4 + 88 = 170) -> 240 + 2*6.
|
||||
DeckGroupDesc amp{0, 78, {100, 44}, {1, 2, 3, 4, 5}, {}};
|
||||
CHECK(deckGroupWidth(amp) == 5 * kDeckCellW + 2 * kDeckGroupPadX);
|
||||
// Caption row dominates: 38 + 4 + 96 = 138 > 48 -> 138 + 12.
|
||||
DeckGroupDesc pitch{1, 38, {101, 48}, {6}, {}};
|
||||
CHECK(deckGroupWidth(pitch) == 38 + kDeckToggleGap + 2 * 48 + 2 * kDeckGroupPadX);
|
||||
// Row toggle counts into the knob row: 48 + 4 + 88 = 140 > caption 38+4+80=122.
|
||||
DeckGroupDesc voice{3, 38, {103, 40}, {10}, {104, 44}};
|
||||
CHECK(deckGroupWidth(voice) ==
|
||||
kDeckCellW + kDeckToggleGap + 2 * 44 + 2 * kDeckGroupPadX);
|
||||
// No toggles: max(caption, cells) + padding.
|
||||
DeckGroupDesc master{4, 46, {}, {11}, {}};
|
||||
CHECK(deckGroupWidth(master) == kDeckCellW + 2 * kDeckGroupPadX);
|
||||
}
|
||||
|
||||
static void testShellDeckFitsOneRowAtDefaultWidth() {
|
||||
// The r11 default window is 840 with kPad=8 margins -> 824 available. The five shell
|
||||
// groups must fit ONE deck row there (the elastic hero keeps ~430px — the layout spec's
|
||||
// premise). Locks the constants against accidental growth.
|
||||
const auto deck = shellLikeDeck();
|
||||
int total = 0;
|
||||
for (const auto& g : deck) total += deckGroupWidth(g);
|
||||
total += (static_cast<int>(deck.size()) - 1) * kDeckGroupGap;
|
||||
CHECK(total <= 824);
|
||||
CHECK(deckRowCount(deck, 824) == 1);
|
||||
CHECK(deckHeight(deck, 824) == kDeckGroupH);
|
||||
}
|
||||
|
||||
static void testWrapAtNarrowWidthIsDeterministic() {
|
||||
// At the 560x460 checkSizeConstraint floor (544 available) the deck wraps to TWO rows,
|
||||
// whole trailing groups only.
|
||||
const auto deck = shellLikeDeck();
|
||||
CHECK(deckRowCount(deck, 544) == 2);
|
||||
CHECK(deckHeight(deck, 544) == 2 * kDeckGroupH + kDeckRowGap);
|
||||
const DeckLayout dl = layoutDeck(deck, 8, 100, 544);
|
||||
CHECK(dl.rowCount == 2);
|
||||
CHECK(dl.height == deckHeight(deck, 544));
|
||||
CHECK(dl.groups.size() == 5);
|
||||
// Row membership: groups on row 1 share the first top; the wrapped groups sit one row
|
||||
// pitch lower and restart at the left margin.
|
||||
const int row0Top = dl.groups[0].box.top;
|
||||
const int row1Top = row0Top + kDeckGroupH + kDeckRowGap;
|
||||
CHECK(dl.groups[0].box.top == row0Top);
|
||||
CHECK(dl.groups[1].box.top == row0Top);
|
||||
bool sawWrap = false;
|
||||
for (std::size_t i = 1; i < dl.groups.size(); ++i) {
|
||||
if (dl.groups[i].box.top == row1Top && dl.groups[i - 1].box.top == row0Top) {
|
||||
CHECK(dl.groups[i].box.left == 8); // wrapped row restarts at the left edge
|
||||
sawWrap = true;
|
||||
}
|
||||
}
|
||||
CHECK(sawWrap);
|
||||
// Every box stays within the available width (no group straddles the right edge).
|
||||
for (const auto& g : dl.groups) CHECK(g.box.right <= 8 + 544);
|
||||
}
|
||||
|
||||
static void testFirstGroupAlwaysPlaces() {
|
||||
// A group wider than the row still places (degenerate width) — exactly one row per group.
|
||||
const auto deck = shellLikeDeck();
|
||||
CHECK(deckRowCount(deck, 100) == 5);
|
||||
CHECK(deckHeight(deck, 100) == 5 * kDeckGroupH + 4 * kDeckRowGap);
|
||||
}
|
||||
|
||||
static void testGroupInnerGeometry() {
|
||||
const auto deck = shellLikeDeck();
|
||||
const DeckLayout dl = layoutDeck(deck, 8, 50, 824);
|
||||
const DeckGroupLayout& amp = dl.groups[0];
|
||||
// Caption row at the top padding; caption toggle right-anchored inside the box.
|
||||
CHECK(amp.caption.top == amp.box.top + kDeckGroupPadY);
|
||||
CHECK(amp.captionToggle.id == 100);
|
||||
CHECK(amp.captionToggle.seg1.right == amp.box.right - kDeckGroupPadX);
|
||||
CHECK(amp.captionToggle.seg0.right == amp.captionToggle.seg1.left);
|
||||
CHECK(amp.captionToggle.seg0.width() == 44 && amp.captionToggle.seg1.width() == 44);
|
||||
CHECK(amp.captionToggle.seg0.height() == kDeckToggleH);
|
||||
// The caption text rect stops before the toggle.
|
||||
CHECK(amp.caption.right <= amp.captionToggle.seg0.left);
|
||||
// Cells: five, fixed size, abutting, inside the box, below the caption row.
|
||||
CHECK(static_cast<int>(amp.cells.size()) == 5);
|
||||
for (std::size_t i = 0; i < amp.cells.size(); ++i) {
|
||||
const DeckCellLayout& c = amp.cells[i];
|
||||
CHECK(c.cell.width() == kDeckCellW && c.cell.height() == kDeckCellH);
|
||||
CHECK(c.cell.top == amp.box.top + kDeckGroupPadY + kDeckCaptionH + kDeckCaptionGap);
|
||||
if (i > 0) CHECK(c.cell.left == amp.cells[i - 1].cell.right);
|
||||
// Knob square centered horizontally, label band beneath it, both inside the cell.
|
||||
CHECK(c.knob.width() == kDeckKnobSize && c.knob.height() == kDeckKnobSize);
|
||||
CHECK(c.knob.left - c.cell.left == c.cell.right - c.knob.right);
|
||||
CHECK(c.label.top >= c.knob.bottom);
|
||||
CHECK(c.label.bottom <= c.cell.bottom);
|
||||
}
|
||||
// VOICE group's row toggle sits after its cell, vertically centered in the cell row.
|
||||
const DeckGroupLayout& voice = dl.groups[3];
|
||||
CHECK(voice.rowToggle.id == 104);
|
||||
CHECK(voice.rowToggle.seg0.left == voice.cells[0].cell.right + kDeckToggleGap);
|
||||
CHECK(voice.rowToggle.seg0.height() == kDeckToggleH);
|
||||
CHECK(voice.rowToggle.seg0.top > voice.cells[0].cell.top);
|
||||
// MASTER has no toggles.
|
||||
CHECK(dl.groups[4].captionToggle.id == -1);
|
||||
CHECK(dl.groups[4].rowToggle.id == -1);
|
||||
}
|
||||
|
||||
static void testHitTest() {
|
||||
const auto deck = shellLikeDeck();
|
||||
const DeckLayout dl = layoutDeck(deck, 8, 50, 824);
|
||||
const DeckGroupLayout& amp = dl.groups[0];
|
||||
|
||||
// Knob hit: anywhere in the cell (including the label band) resolves to the cell id.
|
||||
const DeckCellLayout& c0 = amp.cells[0];
|
||||
DeckHit h = hitTestDeck(dl, c0.cell.left + 1, c0.cell.top + 1);
|
||||
CHECK(h.kind == DeckHitKind::Knob && h.id == 1 && h.segment == -1);
|
||||
h = hitTestDeck(dl, c0.label.left + 2, c0.label.top + 2);
|
||||
CHECK(h.kind == DeckHitKind::Knob && h.id == 1);
|
||||
|
||||
// Caption toggle segments 0/1 at their boundary: last px of seg0, first px of seg1.
|
||||
h = hitTestDeck(dl, amp.captionToggle.seg0.right - 1, amp.captionToggle.seg0.top + 1);
|
||||
CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 0);
|
||||
h = hitTestDeck(dl, amp.captionToggle.seg1.left, amp.captionToggle.seg1.top + 1);
|
||||
CHECK(h.kind == DeckHitKind::CaptionToggle && h.id == 100 && h.segment == 1);
|
||||
|
||||
// Row toggle.
|
||||
const DeckGroupLayout& voice = dl.groups[3];
|
||||
h = hitTestDeck(dl, voice.rowToggle.seg1.left + 1, voice.rowToggle.seg1.top + 1);
|
||||
CHECK(h.kind == DeckHitKind::RowToggle && h.id == 104 && h.segment == 1);
|
||||
|
||||
// A blank cell (id -1) misses even though its rect exists.
|
||||
std::vector<DeckGroupDesc> trig;
|
||||
trig.push_back({0, 78, {100, 44}, {20, 21, 22, -1, -1}, {}});
|
||||
const DeckLayout tl = layoutDeck(trig, 0, 0, 824);
|
||||
const DeckCellLayout& blank = tl.groups[0].cells[4];
|
||||
CHECK(blank.id == -1);
|
||||
h = hitTestDeck(tl, blank.cell.left + 5, blank.cell.top + 5);
|
||||
CHECK(h.kind == DeckHitKind::None);
|
||||
|
||||
// The fence padding inside the box misses; outside the deck misses.
|
||||
h = hitTestDeck(dl, amp.box.left + 1, amp.box.bottom - 1);
|
||||
CHECK(h.kind == DeckHitKind::None);
|
||||
h = hitTestDeck(dl, -50, -50);
|
||||
CHECK(h.kind == DeckHitKind::None);
|
||||
}
|
||||
|
||||
static void testEmptyDeck() {
|
||||
const std::vector<DeckGroupDesc> none;
|
||||
CHECK(deckRowCount(none, 800) == 0);
|
||||
CHECK(deckHeight(none, 800) == 0);
|
||||
const DeckLayout dl = layoutDeck(none, 0, 0, 800);
|
||||
CHECK(dl.groups.empty() && dl.rowCount == 0 && dl.height == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testGroupWidth();
|
||||
testShellDeckFitsOneRowAtDefaultWidth();
|
||||
testWrapAtNarrowWidthIsDeterministic();
|
||||
testFirstGroupAlwaysPlaces();
|
||||
testGroupInnerGeometry();
|
||||
testHitTest();
|
||||
testEmptyDeck();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
std::printf("knob_deck tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// Standalone tests for reasampler::vst::master_gain — no VST3, no REAPER, no framework. Same
|
||||
// fast assert loop as the sibling pure tests. Assert the FB1 post-mixer gain taper HARD:
|
||||
//
|
||||
// * -inf bottom — norm 0 maps to -infinity dB and TRUE ZERO linear (silence, not an epsilon);
|
||||
// linear <= 0 maps back to norm 0.
|
||||
// * endpoints — norm 1 = +24 dB = masterGainMaxLinear() (~15.849); the finite floor at -60 dB.
|
||||
// * unity — 0 dB round-trips exactly through norm<->dB<->linear (the knob's unity detent
|
||||
// position 60/84 of the travel).
|
||||
// * monotonicity — more norm never means less gain.
|
||||
// * label — "-inf" at the bottom, signed one-decimal dB elsewhere.
|
||||
|
||||
#include "../src/vst/master_gain.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
|
||||
using namespace reasampler::vst;
|
||||
|
||||
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 void testBottomIsTrueSilence() {
|
||||
CHECK(std::isinf(masterGainDbFromNorm(0.0)) && masterGainDbFromNorm(0.0) < 0.0);
|
||||
CHECK(masterGainLinearFromNorm(0.0) == 0.0); // exactly zero — the -inf contract
|
||||
CHECK(masterGainNormFromLinear(0.0) == 0.0);
|
||||
CHECK(masterGainNormFromLinear(-1.0) == 0.0);
|
||||
CHECK(masterGainNormFromDb(-1e9) == 0.0);
|
||||
}
|
||||
|
||||
static void testEndpoints() {
|
||||
CHECK(near(masterGainDbFromNorm(1.0), kMasterGainMaxDb));
|
||||
CHECK(near(masterGainLinearFromNorm(1.0), masterGainMaxLinear()));
|
||||
CHECK(near(masterGainMaxLinear(), std::pow(10.0, 24.0 / 20.0)));
|
||||
CHECK(near(masterGainNormFromDb(kMasterGainMaxDb), 1.0));
|
||||
CHECK(near(masterGainNormFromLinear(masterGainMaxLinear()), 1.0));
|
||||
// Out-of-range norms clamp.
|
||||
CHECK(near(masterGainDbFromNorm(2.0), kMasterGainMaxDb));
|
||||
CHECK(masterGainLinearFromNorm(-0.5) == 0.0);
|
||||
}
|
||||
|
||||
static void testUnityRoundTrip() {
|
||||
// 0 dB sits at norm -minDb/(maxDb-minDb) = 60/84 of the travel.
|
||||
const double unityNorm = masterGainNormFromDb(0.0);
|
||||
CHECK(near(unityNorm, 60.0 / 84.0, 1e-12));
|
||||
CHECK(near(masterGainDbFromNorm(unityNorm), 0.0, 1e-9));
|
||||
CHECK(near(masterGainLinearFromNorm(unityNorm), 1.0, 1e-9));
|
||||
CHECK(near(masterGainNormFromLinear(1.0), unityNorm, 1e-9));
|
||||
}
|
||||
|
||||
static void testNormLinearRoundTripAcrossTravel() {
|
||||
for (int i = 1; i <= 20; ++i) {
|
||||
const double norm = i / 20.0;
|
||||
const double lin = masterGainLinearFromNorm(norm);
|
||||
CHECK(lin > 0.0);
|
||||
CHECK(near(masterGainNormFromLinear(lin), norm, 1e-9));
|
||||
}
|
||||
}
|
||||
|
||||
static void testMonotonic() {
|
||||
double prev = -1.0;
|
||||
for (int i = 0; i <= 100; ++i) {
|
||||
const double lin = masterGainLinearFromNorm(i / 100.0);
|
||||
CHECK(lin > prev); // strictly increasing over the whole travel (0 at the bottom)
|
||||
prev = lin;
|
||||
}
|
||||
}
|
||||
|
||||
static void testNonFiniteLinearClamps() {
|
||||
CHECK(masterGainNormFromLinear(std::nan("")) == 0.0);
|
||||
CHECK(near(masterGainNormFromLinear(1e9), 1.0)); // above the cap clamps to 1
|
||||
}
|
||||
|
||||
static void testLabels() {
|
||||
char buf[24];
|
||||
formatMasterGainLabel(0.0, buf, sizeof(buf));
|
||||
CHECK(std::strcmp(buf, "-inf") == 0);
|
||||
formatMasterGainLabel(1.0, buf, sizeof(buf));
|
||||
CHECK(std::strcmp(buf, "+24.0dB") == 0);
|
||||
formatMasterGainLabel(masterGainNormFromDb(0.0), buf, sizeof(buf));
|
||||
CHECK(std::strcmp(buf, "+0.0dB") == 0);
|
||||
formatMasterGainLabel(masterGainNormFromDb(-12.0), buf, sizeof(buf));
|
||||
CHECK(std::strcmp(buf, "-12.0dB") == 0);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testBottomIsTrueSilence();
|
||||
testEndpoints();
|
||||
testUnityRoundTrip();
|
||||
testNormLinearRoundTripAcrossTravel();
|
||||
testMonotonic();
|
||||
testNonFiniteLinearClamps();
|
||||
testLabels();
|
||||
if (g_fail) {
|
||||
std::printf("%d FAILURE(S)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
std::printf("master_gain tests passed\n");
|
||||
return 0;
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
#include "../src/bank_book.h"
|
||||
#include "../src/bank_model.h"
|
||||
#include "../src/vst/master_gain.h" // masterGainMaxLinear (the v8 master-gain wire cap)
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
@@ -1220,6 +1221,105 @@ static void testComponentStateV7TruncatedVoiceBytes() {
|
||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||
}
|
||||
|
||||
// --- v8 component state: the FB1 post-mixer master gain (linear double) -----------------------
|
||||
|
||||
static void testComponentStateMasterGainRoundTrip() {
|
||||
// A non-default gain proves the bytes are read back, not defaulted; the envelope
|
||||
// neighbours (voice bytes, velocity, selection, zones) ride alongside intact.
|
||||
ComponentState s;
|
||||
s.selectionId = "pick";
|
||||
s.previewVelocity = 99;
|
||||
s.voiceCount = 5;
|
||||
s.masterGainLinear = 0.25; // -12.04 dB
|
||||
s.map.zones.push_back(zone("z0", 0, 127, /*override=*/std::nullopt));
|
||||
const ComponentState back = deserializeComponentState(serializeComponentState(s), 44100.0);
|
||||
CHECK(std::fabs(back.masterGainLinear - 0.25) < 1e-12); // an exact double round-trip
|
||||
CHECK(back.voiceCount == 5);
|
||||
CHECK(back.previewVelocity == 99);
|
||||
CHECK(back.selectionId == "pick");
|
||||
CHECK(back.map.zones.size() == 1 && back.map.zones[0].sampleId == "z0");
|
||||
}
|
||||
|
||||
static void testComponentStateMasterGainDefaultAndZeroRoundTrip() {
|
||||
// Default unity round-trips (pre-FB1 output); the -inf bottom (TRUE zero) round-trips
|
||||
// exactly — a user who pulled the gain to silence gets silence back after a save/load.
|
||||
const ComponentState defBack =
|
||||
deserializeComponentState(serializeComponentState(ComponentState{}), 44100.0);
|
||||
CHECK(defBack.masterGainLinear == 1.0);
|
||||
ComponentState zero;
|
||||
zero.masterGainLinear = 0.0;
|
||||
const ComponentState zeroBack =
|
||||
deserializeComponentState(serializeComponentState(zero), 44100.0);
|
||||
CHECK(zeroBack.masterGainLinear == 0.0);
|
||||
}
|
||||
|
||||
static void testComponentStateMasterGainWriterClamps() {
|
||||
// The WRITER never emits an out-of-range value: above the +24 dB cap clamps to the cap;
|
||||
// a negative/non-finite value (a programming error upstream) falls back to unity.
|
||||
ComponentState hi;
|
||||
hi.masterGainLinear = 1000.0;
|
||||
CHECK(std::fabs(deserializeComponentState(serializeComponentState(hi), 44100.0)
|
||||
.masterGainLinear -
|
||||
vst::masterGainMaxLinear()) < 1e-9);
|
||||
ComponentState lo;
|
||||
lo.masterGainLinear = -5.0;
|
||||
CHECK(deserializeComponentState(serializeComponentState(lo), 44100.0).masterGainLinear ==
|
||||
1.0);
|
||||
}
|
||||
|
||||
static void testComponentStateV7LiftsUnityMasterGain() {
|
||||
// A GENUINE v7 blob (version tag 7: mode, marker, velocity, voice bytes, id, zones — NO
|
||||
// master-gain double) lifts to unity, its other fields intact. Hand-built
|
||||
// (serializeComponentState now emits v8, so it cannot make a v7 blob). Proves an
|
||||
// already-saved pre-FB1 instance restores playing at exactly its old output level.
|
||||
std::vector<std::uint8_t> v7;
|
||||
v7.push_back(7); v7.push_back(0); v7.push_back(0); v7.push_back(0); // version 7
|
||||
v7.push_back(1); // channel mode = stereo
|
||||
for (int i = 0; i < 8; ++i) v7.push_back(0); // marker = 0
|
||||
v7.push_back(111); // preview velocity
|
||||
v7.push_back(5); // voice count
|
||||
v7.push_back(1); // voice mode = mono
|
||||
v7.push_back(1); // trigger = legato
|
||||
const std::string id = "saved";
|
||||
v7.push_back(static_cast<std::uint8_t>(id.size())); v7.push_back(0); v7.push_back(0); v7.push_back(0);
|
||||
v7.insert(v7.end(), id.begin(), id.end());
|
||||
v7.push_back(0); v7.push_back(0); v7.push_back(0); v7.push_back(0); // zone count 0
|
||||
const ComponentState back = deserializeComponentState(v7, 44100.0);
|
||||
CHECK(back.masterGainLinear == 1.0);
|
||||
CHECK(back.voiceCount == 5);
|
||||
CHECK(back.voiceMode == VoiceMode::Mono);
|
||||
CHECK(back.monoTrigger == MonoTrigger::Legato);
|
||||
CHECK(back.previewVelocity == 111);
|
||||
CHECK(back.channelMode == ChannelMode::Stereo);
|
||||
CHECK(back.selectionId == "saved");
|
||||
CHECK(back.map.zones.empty());
|
||||
}
|
||||
|
||||
static void testComponentStateV8CorruptMasterGainFallsBack() {
|
||||
// A corrupt gain double (NaN) in a v8 blob falls back to unity (the previewVelocity
|
||||
// corrupt-byte precedent) — never silences or blasts the instance. Build v8 by
|
||||
// serializing, then vandalize the 8 gain bytes in place (offsets: 4 version + 1 mode +
|
||||
// 8 marker + 1 velocity + 3 voice bytes = 17..24).
|
||||
ComponentState s;
|
||||
s.masterGainLinear = 0.5;
|
||||
std::vector<std::uint8_t> bytes = serializeComponentState(s);
|
||||
for (int i = 0; i < 8; ++i) bytes[17 + i] = 0xFF; // 0xFFFF... = a negative NaN pattern
|
||||
const ComponentState back = deserializeComponentState(bytes, 44100.0);
|
||||
CHECK(back.masterGainLinear == 1.0);
|
||||
}
|
||||
|
||||
static void testComponentStateV8TruncatedMasterGain() {
|
||||
// A v8 blob cut INSIDE the gain double -> empty, defaults holding (bounded read).
|
||||
std::vector<std::uint8_t> t{8, 0, 0, 0, 0}; // version 8, mode byte
|
||||
for (int i = 0; i < 8; ++i) t.push_back(0); // full marker
|
||||
t.push_back(64); // velocity byte
|
||||
t.push_back(16); t.push_back(0); t.push_back(0); // the three voice bytes
|
||||
t.push_back(0); t.push_back(0); t.push_back(0); // gain cut mid-double
|
||||
const ComponentState back = deserializeComponentState(t, 44100.0);
|
||||
CHECK(back.masterGainLinear == 1.0);
|
||||
CHECK(back.selectionId.empty() && back.map.zones.empty());
|
||||
}
|
||||
|
||||
// --- MERGE COMPOSITION (S9 v5 marker envelope x S15/S16 v3 play-param payload) ----------------
|
||||
//
|
||||
// The merge of ps-w9-t1-sync (envelope v5, adds the consumed-assignment marker) and
|
||||
@@ -2030,6 +2130,12 @@ int main() {
|
||||
testComponentStateV6LiftsVoiceDefaults();
|
||||
testComponentStateV7CorruptVoiceBytesFallBack();
|
||||
testComponentStateV7TruncatedVoiceBytes();
|
||||
testComponentStateMasterGainRoundTrip();
|
||||
testComponentStateMasterGainDefaultAndZeroRoundTrip();
|
||||
testComponentStateMasterGainWriterClamps();
|
||||
testComponentStateV7LiftsUnityMasterGain();
|
||||
testComponentStateV8CorruptMasterGainFallsBack();
|
||||
testComponentStateV8TruncatedMasterGain();
|
||||
testV5EnvelopeWithMarkerAndPlayParamsRoundTrip();
|
||||
testV4BlobWithPlayParamsLiftsMarkerZeroKeepsPlay();
|
||||
testReconcileKeepsOnlySelectedFullRangeZone();
|
||||
|
||||
Reference in New Issue
Block a user