Files
feuerwerk/tests/editor/hardware-view.test.ts
T
adminandClaude Opus 5.5 e70954115c Show Pitch and Mix nodes as one-knob devices in green and blue (#390)
The Gain has been a small device with one big knob since #384. The
Pitch and the Mix are just as much a single number, so they now look
and work the same way. The Mix also gets a lever for its curve, which
until now was only a row in the inspector.

- GainPanel and GainFace become KnobPanel and KnobFace. Each node is
  described by a KnobDevice: its plate, its row, how the knob turns,
  and an optional lever. The Gain looks as before.
- Pitch: a green 160x160 plate with a bipolar knob for Semitones. 0
  stands straight up and taper 2 puts an octave at 43 % of each half,
  so small steps near 0 are easy and the full +-64 is still there.
- Mix: a blue 180x200 plate, the lever on top and the knob below.
  The knob has A and B engraved at the ends of its turn. The lever
  reads "crossfade" down and "both at center" up with its lamp lit.
  A flip is one undo step.
- A wired Semitones or Mix greys the knob, as for the Gain.
- The instrument presets give Pitch and Mix two columns. In the graph
  they are 281 and 261 units wide, more than one column of 240.
- The test hooks __gm.pitch.panel and __gm.mix.panel return the same
  shape as __gm.gain.panel, plus the lever.

Tests:
- Unit hardware-view: 15 tests, 3 new (Pitch turn, lever above the
  knob and ends on the Mix plate). Counter-check: taper 1 turns the
  Pitch test red.
- e2e 34 (melektaus-atx): 134 checks, 1 new.
- Gate (melektaus-atx, together with #391): 287 files, 4397 tests.
- Pictures looked at: Gain, Pitch and Mix in a row, Pitch at -24.5,
  Mix with both curves, the Bell preset without overlaps.

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

218 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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, the Pitch's knob and the Mix's lever and ends (#390).
*/
import { describe, expect, it } from 'vitest';
import {
type Box,
brushStrokes,
CAPTION_AIR,
GAIN_LAYOUT,
KNOB_CAPTION,
GAIN_PANEL,
GAIN_TAPER,
gainReduction,
knobBox,
knobEnds,
LED_COUNT,
ledColour,
ledDb,
ledsLit,
ledStep,
METER_LABELS,
METER_RANGE_DB,
meterPosition,
MIX_LAYOUT,
MIX_PANEL,
needleAngle,
needleStep,
PANEL,
PANEL_LAYOUT,
PITCH_TAPER,
} from '../../src/editor/soundfx/hardwareView';
import { GAIN_ROW, PITCH_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);
});
});
describe('the Pitch and the Mix (#390)', () => {
it('turns the Pitch from 0 straight up, an octave either way alike and well inside the turn', () => {
const range = {
min: PITCH_ROW.min ?? 0,
max: PITCH_ROW.max ?? 0,
step: 0.1,
taper: PITCH_TAPER,
bipolar: true,
};
expect(knobPosition(0, range)).toBeCloseTo(0.5, 5);
const up = knobPosition(12, range);
expect(up - 0.5).toBeCloseTo(0.5 - knobPosition(-12, range), 5);
expect(up).toBeGreaterThan(0.7);
expect(up).toBeLessThan(0.75);
expect(knobPosition(64, range)).toBe(1);
});
it('holds the Mix’s lever above the knob, centred and clear of it and of the screws', () => {
const lever = MIX_LAYOUT.curve;
const spot = MIX_LAYOUT.knob;
const knob = knobBox(spot);
expect(lever.x).toBeGreaterThanOrEqual(0);
expect(lever.x + lever.w).toBeLessThanOrEqual(MIX_PANEL.w);
expect(lever.x + lever.w / 2).toBe(MIX_PANEL.w / 2);
expect(spot.x).toBe(MIX_PANEL.w / 2);
// A lever that is down reaches 8 below its box (`Lever.redraw`).
expect(lever.y + lever.h + 8).toBeLessThanOrEqual(knob.y);
for (const screw of MIX_LAYOUT.screws)
expect(overlap(lever, { x: screw.x - 7, y: screw.y - 7, w: 14, h: 14 })).toBe(false);
// Below the dial as much air as on the Gain.
expect(MIX_PANEL.h - (spot.y + spot.size / 2)).toBe(GAIN_PANEL.h - (GAIN_LAYOUT.knob.y + spot.size / 2));
expect(MIX_PANEL.w).toBeLessThan(MIX_PANEL.h);
});
it('names the ends of the Mix’s turn just outside the dial, left and right below its middle', () => {
const spot = MIX_LAYOUT.knob;
const { a, b } = knobEnds(spot);
for (const end of [a, b]) {
expect(Math.hypot(end.x - spot.x, end.y - spot.y)).toBeGreaterThan(spot.size / 2);
expect(end.y).toBeGreaterThan(spot.y);
expect(end.y).toBeLessThan(MIX_PANEL.h);
}
expect(a.x).toBeLessThan(spot.x);
expect(b.x).toBeGreaterThan(spot.x);
expect(a.y).toBe(b.y);
});
});