Files
feuerwerk/tests/editor/hardware-view.test.ts
T
adminandClaude Opus 5.5 9917253751 Open a Compressor as a device in a window of one size (#379)
The tab from #378 shows a Compressor as an editor. The user wanted it
to look like a device as well, after a brushed aluminium plug-in, with
a meter of the gain reduction instead of the pictures. The tab stays
and is switched to per node.

- Hardware look is a switch under Settings, on by default for new and
  existing Compressors. Only the editor reads it.
- The panel is 820x280 px and opens straight in a window of that size.
  The window has no sizing corner and no Dock. A pane says so through
  `fixedSize`, and `keptSize` keeps the window's middle when it
  changes to that size.
- A Compressor in this look never sits in the tab row. Opening one
  floats it, and switching the look on moves its tab into the window.
- The panel has chrome knobs (threshold and makeup large), a meter
  with a needle that moves like a VU's, an LCD with the name and the
  reading, a lever with a lamp for auto-gain, and LEDs for the level
  going out. Everything is drawn in code.
- The Knob takes a size and a chrome look, and leaves out its caption
  when it has no name, so the panel engraves its own.
- `autoGainOn` is now `switchOn`, because the new switch reads the
  same way.
- Where parts sit, the meter's scale, the needle's speed, the LEDs and
  the plate's grain are in `hardwareView.ts`, apart from the drawing.

Decided myself:
- The meter's scale runs by the square root, so the first dB is about
  as wide as the last nine. It is red from 10 dB on.
- The LEDs light one every 3 dB from -39 to 0 dB and fall by 20 dB a
  second.
- Switching the look changes the graph's fingerprint, like any other
  setting of a node. The sound FX is built again once.

Findings, fixed on the way:
- Pixi's `arc` goes on from the last point of the path. The meter's
  scale had a line from the corner of the face until each arc got a
  `moveTo` to its own start.
- A text put into a Graphics makes Pixi warn that only Containers take
  children. The LCD is a Container now.
- The first browser check read the needle at one moment and missed the
  short sound. The panel keeps the most its needle and LEDs showed.

Tests:
- Unit: hardware-view (10 new), documents (1 new)
- e2e 34 (melektaus-atx): ALL PASSED, 122 checks (4 new). Played,
  the needle reached 20.9 dB and 5 LEDs lit.
- Gate (melektaus-atx): CHECK EXIT 0, 283 files, 4355 tests (11 new)
- Counter-checks: a window that keeps its corner instead of its
  middle, and a linear meter scale, each turn exactly their test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:06:56 +02:00

132 lines
4.9 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.
*/
import { describe, expect, it } from 'vitest';
import {
type Box,
brushStrokes,
gainReduction,
knobBox,
LED_COUNT,
ledColour,
ledDb,
ledsLit,
ledStep,
METER_LABELS,
METER_RANGE_DB,
meterPosition,
needleAngle,
needleStep,
PANEL,
PANEL_LAYOUT,
} from '../../src/editor/soundfx/hardwareView';
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);
});
});