instrument: one VELOCITY deck for all three velocity curves, bipolar and off by default for pitch and filter

Payload v12 appends the new velocity->pitch curve and folds the retired filter velAmount into its now-bipolar curve, so pre-v12 projects reopen sounding identical. Preview button takes a drawn play triangle.
This commit is contained in:
2026-07-31 19:15:17 -04:00
parent 4fecb58c0a
commit 9d38f87a2d
37 changed files with 1020 additions and 320 deletions
+40 -11
View File
@@ -2,10 +2,11 @@
// test framework.
//
// Covers: the chrome band's two rows (toolbar over strip row, tiling the band exactly); the
// toolbar's fixed right-anchored run in order (preview, velocity cell, curve button,
// Mono|Stereo, Browse) with the title taking the remainder; the velocity knob centred in its
// toolbar's fixed right-anchored run in order (preview, velocity cell, Mono|Stereo,
// Browse) with the title taking the remainder; the velocity knob centred in its
// cell above its label; the piano strip owning its whole row at every width; no rect on the
// toolbar overlapping any other; and degenerate bands yielding no inverted rects.
// toolbar overlapping any other; degenerate bands yielding no inverted rects; and the preview
// button's play-triangle glyph, which sits inside the button without changing its rect.
#include "../src/core/instrument/ui/sample_bands.h"
#include "../src/core/instrument/ui/sample_chrome.h"
@@ -48,20 +49,19 @@ static void testRowsTileTheBandExactly() {
static void testToolbarRunIsOrderedRightToLeftWithoutOverlap() {
const Rect band = chromeBand();
const ChromeRects r = chromeRects(band, kKnob);
// Rightmost first: Browse, stereo, mono, curve button, velocity cell, preview, title.
// Rightmost first: Browse, stereo, mono, velocity cell, preview, title.
CHECK(r.navBrowse.right() == band.right() - kPad);
CHECK(r.navBrowse.width == kNavButtonWidth);
CHECK(r.chanStereo.right() <= r.navBrowse.x);
CHECK(r.chanMono.right() == r.chanStereo.x);
CHECK(r.curveBtn.right() <= r.chanMono.x);
CHECK(r.velCell.right() <= r.curveBtn.x);
CHECK(r.velCell.right() <= r.chanMono.x);
CHECK(r.preview.right() <= r.velCell.x);
CHECK(r.title.right() <= r.preview.x);
CHECK(r.title.x == band.x + kPad);
CHECK(r.title.width > 0);
// Every toolbar rect sits inside the toolbar row.
const Rect items[] = {r.title, r.preview, r.velCell, r.curveBtn, r.chanMono,
const Rect items[] = {r.title, r.preview, r.velCell, r.chanMono,
r.chanStereo, r.navBrowse};
for (const Rect& it : items) {
CHECK(it.y >= r.toolbar.y && it.bottom() <= r.toolbar.bottom());
@@ -75,7 +75,7 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
// stay inside its own row, clear of every control.
CHECK(!overlaps(r.toolbar, r.rootStrip));
CHECK(r.rootStrip.y >= r.controls.y && r.rootStrip.bottom() <= r.controls.bottom());
const Rect items[] = {r.preview, r.velCell, r.curveBtn, r.chanMono, r.chanStereo,
const Rect items[] = {r.preview, r.velCell, r.chanMono, r.chanStereo,
r.navBrowse};
for (const Rect& it : items) {
CHECK(!overlaps(it, r.rootStrip));
@@ -83,8 +83,8 @@ static void testChromePartsNeverOverlapAtAnyWidth() {
}
// The run's own members are pairwise disjoint (velKnob/velLabel are inside velCell,
// so they are checked against the cell's neighbours, not the cell).
for (int i = 0; i < 6; ++i) {
for (int j = i + 1; j < 6; ++j) CHECK(!overlaps(items[i], items[j]));
for (int i = 0; i < 5; ++i) {
for (int j = i + 1; j < 5; ++j) CHECK(!overlaps(items[i], items[j]));
}
}
}
@@ -127,11 +127,38 @@ static void testDegenerateBandYieldsNoInvertedRects() {
const ChromeRects tiny = chromeRects(Rect::ltrb(0, 0, 40, kTitleHeight + kChromeRowHeight),
kKnob);
const Rect items[] = {tiny.title, tiny.preview, tiny.velCell, tiny.velKnob, tiny.velLabel,
tiny.curveBtn, tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
tiny.chanMono, tiny.chanStereo, tiny.navBrowse,
tiny.rootStrip};
for (const Rect& it : items) CHECK(it.right() >= it.x && it.bottom() >= it.y);
}
static void testPreviewGlyphSitsInsideTheButtonAndPointsRight() {
const ChromeRects r = chromeRects(chromeBand(), kKnob);
const PreviewGlyph g = previewGlyph(r.preview);
CHECK(!g.empty());
// Wholly inside the button — the glyph replaces the label, it does not resize the target.
CHECK(g.leftX >= r.preview.x && g.apexX <= r.preview.right());
CHECK(g.topY >= r.preview.y && g.bottomY <= r.preview.bottom());
// Right-pointing, and the apex on the button's own centre line so it reads as balanced.
CHECK(g.apexX > g.leftX);
CHECK(g.apexY == r.preview.y + r.preview.height / 2);
CHECK(g.apexY - g.topY == g.bottomY - g.apexY); // isosceles about the centre line
// The button's rect is what the hit-test uses, and the glyph must not have moved it: the
// preview button still sits at the run's fixed size, exactly where the text button did.
CHECK(r.preview.width == 64 && r.preview.height == 24);
}
static void testPreviewGlyphDegradesRatherThanOverflowing() {
// An unusably small button yields an empty glyph (draw nothing) rather than a triangle
// spilling past the button edge.
CHECK(previewGlyph(Rect{}).empty());
CHECK(previewGlyph(Rect::ltrb(0, 0, 6, 6)).empty());
// A tall, wide button caps the glyph instead of scaling without bound.
const PreviewGlyph big = previewGlyph(Rect::ltrb(0, 0, 400, 200));
CHECK(!big.empty());
CHECK(big.bottomY - big.topY <= 14);
}
static void testToolbarOnlyBandStillPlacesTheNav() {
// A band clipped to just the toolbar row: the strip row is empty but Browse still
// resolves, so the empty state's call-to-action is never unreachable.
@@ -149,6 +176,8 @@ int main() {
testStripOwnsItsWholeRowAndGrowsWithTheWindow();
testVelocityKnobIsCentredInItsCellAboveTheLabel();
testDegenerateBandYieldsNoInvertedRects();
testPreviewGlyphSitsInsideTheButtonAndPointsRight();
testPreviewGlyphDegradesRatherThanOverflowing();
testToolbarOnlyBandStillPlacesTheNav();
if (g_fail == 0) {