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>
218 lines
8.3 KiB
TypeScript
218 lines
8.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, 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);
|
||
});
|
||
});
|