/** * 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), the * Pan's knob and ends on the Gain's plate (#392). */ 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, PAN_TAPER, PANEL, PANEL_LAYOUT, PITCH_TAPER, } from '../../src/editor/soundfx/hardwareView'; import { GAIN_ROW, PAN_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); }); }); describe('the Pan (#392)', () => { it('turns from the middle straight up, as far left as right, the whole turn for -1 to 1', () => { const range = { min: PAN_ROW.min ?? 0, max: PAN_ROW.max ?? 0, step: 0.01, taper: PAN_TAPER, bipolar: true, }; expect(knobPosition(0, range)).toBeCloseTo(0.5, 5); expect(knobPosition(0.5, range)).toBeCloseTo(0.75, 5); expect(knobPosition(-0.5, range)).toBeCloseTo(0.25, 5); expect(knobPosition(-1, range)).toBe(0); expect(knobPosition(1, range)).toBe(1); }); it('names L and R at the ends of the turn inside the Gain’s plate, clear of the screws', () => { const spot = GAIN_LAYOUT.knob; const { a, b } = knobEnds(spot); for (const end of [a, b]) { expect(end.x).toBeGreaterThan(0); expect(end.x).toBeLessThan(GAIN_PANEL.w); expect(end.y).toBeLessThan(GAIN_PANEL.h); for (const screw of GAIN_LAYOUT.screws) expect(Math.hypot(end.x - screw.x, end.y - screw.y)).toBeGreaterThan(14); } expect(a.x).toBeLessThan(spot.x); expect(b.x).toBeGreaterThan(spot.x); }); });