A Gain node now draws a small device in the graph, as the Compressor does since #380: the brushed plate in orange, 160x220 graph units, with one chrome knob between its inputs and its output. - GainPanel and GainFace join the node faces. The knob drags, resets on a double-click and keeps the wheel for the graph (#383). Each turn is one undo step, a linked Gain (dB) greys it. - The knob turns with a taper of 0.26, so 0 dB stands straight up and the range below -24 dB takes a fifth of the turn. - drawPlate, engrave and the brushed look move out of CompressorPanel so both devices share them. The Knob takes an arc colour, because the orange arc vanished on the orange plate. - No meter and no tap. Gain sits in every voice of an instrument, and a tap would add two audio nodes to each note for a display nobody asked for. Found on the way: - Knob.dialGlobal added half the dial unscaled. In a zoomed graph it pointed beside the dial. The Compressor's e2e only hit by luck. Tests: - Unit: 2 new for the Gain plate and its taper. - e2e 34 on atx: ALL PASSED, 126 checks (1 new). - Gate on atx: CHECK EXIT 0, 283 files, 4359 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
160 lines
6.0 KiB
TypeScript
160 lines
6.0 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,
|
|
GAIN_LAYOUT,
|
|
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 inside the plate, clear of the screws and below the title', () => {
|
|
expect(knob.x).toBeGreaterThanOrEqual(0);
|
|
expect(knob.x + knob.w).toBeLessThanOrEqual(GAIN_PANEL.w);
|
|
expect(knob.y + knob.h).toBeLessThanOrEqual(GAIN_PANEL.h);
|
|
expect(knob.y).toBeGreaterThanOrEqual(L.title.y + 30);
|
|
expect(knob.x + knob.w / 2).toBe(GAIN_PANEL.w / 2);
|
|
for (const screw of L.screws)
|
|
expect(overlap(knob, { 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);
|
|
});
|
|
});
|