The plate was taller than its one knob needs, and the engraved "GAIN" only repeated the node's title. Without it the plate is square, and the knob sits in its middle. - The plate is 160x160 instead of 160x220, without the "GAIN" engraving. - The knob is centred on what it shows: the "DB" caption and the dial. Since #382 its number hangs inside the dial's open bottom, so it adds no height. - CAPTION_AIR (4) takes out the empty space above the caption's capitals. Without it the knob sat a little too low. Measured on a picture at zoom 0.8: 19 px of air above "DB" and 19 px below the number. The user's screenshot showed about 14 above and 35 below. Tests: - Unit hardware-view: 12 tests green. The layout test now asks for the same air above and below. - e2e 34 (melektaus-atx): ALL PASSED, 126 checks. - Gate (melektaus-atx): CHECK EXIT 0, 283 files, 4359 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
6.3 KiB
TypeScript
166 lines
6.3 KiB
TypeScript
/**
|
|
* The Compressor's hardware panel (#379): every part on the plate and none over another, the meter's
|
|
* scale and needle, the LEDs, and the grain of the plate the same every time. The Gain's plate
|
|
* (#384) and how its one knob turns.
|
|
*/
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
type Box,
|
|
brushStrokes,
|
|
CAPTION_AIR,
|
|
GAIN_LAYOUT,
|
|
KNOB_CAPTION,
|
|
GAIN_PANEL,
|
|
GAIN_TAPER,
|
|
gainReduction,
|
|
knobBox,
|
|
LED_COUNT,
|
|
ledColour,
|
|
ledDb,
|
|
ledsLit,
|
|
ledStep,
|
|
METER_LABELS,
|
|
METER_RANGE_DB,
|
|
meterPosition,
|
|
needleAngle,
|
|
needleStep,
|
|
PANEL,
|
|
PANEL_LAYOUT,
|
|
} from '../../src/editor/soundfx/hardwareView';
|
|
import { GAIN_ROW } from '../../src/core/soundfx/nodes';
|
|
import { knobPosition } from '../../src/ui/knob';
|
|
|
|
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
|
|
|
describe('the plate', () => {
|
|
const L = PANEL_LAYOUT;
|
|
const parts: [string, Box][] = [
|
|
['title', L.title],
|
|
['lcd', L.lcd],
|
|
['autoGain', L.autoGain],
|
|
['meter', L.meter],
|
|
['leds', L.leds],
|
|
...Object.entries(L.knobs).map(([key, spot]): [string, Box] => [key, knobBox(spot)]),
|
|
];
|
|
|
|
it('holds every part inside it, clear of the screws', () => {
|
|
for (const [, box] of parts) {
|
|
expect(box.x).toBeGreaterThanOrEqual(0);
|
|
expect(box.y).toBeGreaterThanOrEqual(0);
|
|
expect(box.x + box.w).toBeLessThanOrEqual(PANEL.w);
|
|
expect(box.y + box.h).toBeLessThanOrEqual(PANEL.h);
|
|
for (const screw of L.screws)
|
|
expect(overlap(box, { x: screw.x - 7, y: screw.y - 7, w: 14, h: 14 })).toBe(false);
|
|
}
|
|
});
|
|
|
|
it('puts no part over another', () => {
|
|
const over = parts.flatMap(([a, boxA], i) =>
|
|
parts.slice(i + 1).flatMap(([b, boxB]) => (overlap(boxA, boxB) ? [`${a}/${b}`] : [])),
|
|
);
|
|
expect(over).toEqual([]);
|
|
});
|
|
|
|
it('has the big knobs at its ends and the small ones beside the meter', () => {
|
|
const { threshold, makeup, ratio, attack } = L.knobs;
|
|
expect(threshold.size).toBeGreaterThan(ratio.size);
|
|
expect(threshold.x).toBeLessThan(ratio.x);
|
|
expect(ratio.x).toBeLessThan(L.meter.x);
|
|
expect(attack.x).toBeGreaterThan(L.meter.x + L.meter.w);
|
|
expect(makeup.x).toBeGreaterThan(attack.x);
|
|
});
|
|
|
|
it('is brushed the same every time, a stroke per row', () => {
|
|
const strokes = brushStrokes(PANEL.h);
|
|
expect(strokes).toHaveLength(PANEL.h);
|
|
expect(brushStrokes(PANEL.h)).toEqual(strokes);
|
|
const alphas = strokes.map((s) => s.alpha);
|
|
expect(Math.max(...alphas) - Math.min(...alphas)).toBeGreaterThan(0.07);
|
|
expect(strokes.some((s) => s.light) && strokes.some((s) => !s.light)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('the meter', () => {
|
|
it('reads 0 dB at the right end, the range at the left, and the first dB about as wide as the last nine', () => {
|
|
expect(meterPosition(0)).toBe(1);
|
|
expect(meterPosition(METER_RANGE_DB)).toBe(0);
|
|
expect(meterPosition(40)).toBe(0);
|
|
expect(meterPosition(-3)).toBe(1);
|
|
expect(1 - meterPosition(1)).toBeCloseTo(meterPosition(15) - meterPosition(24), 1);
|
|
const spots = METER_LABELS.map(meterPosition);
|
|
for (let i = 1; i < spots.length; i++) expect(spots[i]).toBeLessThan(spots[i - 1]!);
|
|
});
|
|
|
|
it('swings the needle symmetrically about straight up', () => {
|
|
expect(needleAngle(0.5)).toBeCloseTo(-Math.PI / 2);
|
|
expect(needleAngle(0) + needleAngle(1)).toBeCloseTo(-Math.PI);
|
|
expect(needleAngle(1)).toBeGreaterThan(needleAngle(0));
|
|
});
|
|
|
|
it('moves the needle as a VU: most of the way in 300 ms, and there in the end', () => {
|
|
let shown = 0;
|
|
for (let t = 0; t < 0.3; t += 1 / 60) shown = needleStep(shown, 10, 1 / 60);
|
|
expect(shown).toBeGreaterThan(9);
|
|
expect(shown).toBeLessThan(10);
|
|
expect(needleStep(0, 10, 1 / 60)).toBeLessThan(3);
|
|
for (let i = 0; i < 60; i++) shown = needleStep(shown, 10, 1 / 60);
|
|
expect(shown).toBe(10);
|
|
// A long pause between two frames moves it no further than a quarter of a second does.
|
|
expect(needleStep(5, 0, 10)).toBe(needleStep(5, 0, 0.25));
|
|
});
|
|
|
|
it('takes the gain after the compressing out of what it reads, and nothing from silence', () => {
|
|
expect(gainReduction({ in: -10, out: -6 }, 10)).toBeCloseTo(6);
|
|
expect(gainReduction({ in: -10, out: -10 }, 0)).toBe(0);
|
|
expect(gainReduction({ in: -10, out: -2 }, 0)).toBe(0);
|
|
expect(gainReduction({ in: -Infinity, out: -Infinity }, 0)).toBe(0);
|
|
expect(gainReduction(null, 0)).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('the LEDs', () => {
|
|
it('light one every 3 dB up to 0, red at the top and green at the bottom', () => {
|
|
expect(ledDb(LED_COUNT - 1)).toBe(0);
|
|
expect(ledDb(LED_COUNT - 2)).toBe(-3);
|
|
expect(ledColour(LED_COUNT - 1)).toBe('red');
|
|
expect(ledColour(LED_COUNT - 2)).toBe('red');
|
|
expect(ledColour(LED_COUNT - 4)).toBe('amber');
|
|
expect(ledColour(0)).toBe('green');
|
|
expect(ledsLit(-Infinity)).toBe(0);
|
|
expect(ledsLit(0)).toBe(LED_COUNT);
|
|
expect(ledsLit(-4)).toBe(LED_COUNT - 2);
|
|
});
|
|
|
|
it('go up at once and down by 20 dB a second', () => {
|
|
expect(ledStep(-30, -6, 1 / 60)).toBe(-6);
|
|
expect(ledStep(-6, -30, 0.1)).toBeCloseTo(-8);
|
|
expect(ledStep(-6, -Infinity, 10)).toBe(-11);
|
|
});
|
|
});
|
|
|
|
describe('the Gain (#384)', () => {
|
|
const L = GAIN_LAYOUT;
|
|
const knob = knobBox(L.knob);
|
|
|
|
it('holds its knob in the middle of the plate, clear of the screws', () => {
|
|
expect(knob.x).toBeGreaterThanOrEqual(0);
|
|
expect(knob.x + knob.w).toBeLessThanOrEqual(GAIN_PANEL.w);
|
|
// Its number hangs inside the dial (#382): from the caption down to the dial's edge is all it shows.
|
|
const top = L.knob.y - L.knob.size / 2 - KNOB_CAPTION;
|
|
const bottom = L.knob.y + L.knob.size / 2;
|
|
expect(top).toBeGreaterThan(0);
|
|
expect(top + CAPTION_AIR).toBe(GAIN_PANEL.h - bottom);
|
|
expect(knob.x + knob.w / 2).toBe(GAIN_PANEL.w / 2);
|
|
const shown = { ...knob, y: top, h: bottom - top };
|
|
for (const screw of L.screws)
|
|
expect(overlap(shown, { x: screw.x - 7, y: screw.y - 7, w: 14, h: 14 })).toBe(false);
|
|
});
|
|
|
|
it('turns 0 dB straight up and the quiet tail by in a fifth of the turn', () => {
|
|
const range = { min: GAIN_ROW.min ?? 0, max: GAIN_ROW.max ?? 0, step: 0.5, taper: GAIN_TAPER };
|
|
expect(knobPosition(0, range)).toBeCloseTo(0.5, 2);
|
|
expect(knobPosition(-24, range)).toBeLessThan(0.22);
|
|
expect(knobPosition(-6, range)).toBeGreaterThan(0.4);
|
|
});
|
|
});
|