After the Gain, the Pitch and the Mix, the Pan is the next node that is just one number. It now gets the same device in the graph instead of a row for its pan. - PAN_DEVICE: the Gain's 160x160 plate in cyan with one bipolar knob for Pan. The middle stands straight up, and the turn goes evenly to L and R, which are engraved at its ends like A and B on the Mix. - PAN_TAPER in hardwareView is 1, so left and right are heard alike. Device and unit test read the same constant. - PAN_ROW is exported from the core, so the knob has the node's range. - A linked Pan greys the knob, as for the Gain. - The test hook __gm.pan.panel returns the same shape as the Gain's. - No instrument preset uses a Pan, so no preset columns change. Tests: - Unit hardware-view: 17 tests, 2 new (the turn, and L and R inside the plate and clear of the screws). Counter-check: PAN_TAPER 2 turns the turn test red. - e2e 34 (melektaus-atx): 135 checks, 1 new. - Gate (melektaus-atx): 287 files, 4399 tests. - Pictures looked at: Gain, Pitch, Mix and Pan in a row, Pan at -0.35. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
223 lines
7.9 KiB
TypeScript
223 lines
7.9 KiB
TypeScript
/**
|
|
* What the Compressor's hardware panel (#379) draws, as numbers: where its parts sit on a plate of
|
|
* one size, how the needle of its meter reads and moves, which LEDs are lit, and the grain of the
|
|
* brushed plate. Kept apart from the drawing so it is checked without a browser.
|
|
*/
|
|
import { KNOB_START, KNOB_SWEEP } from '../../ui/knob';
|
|
import { clamp } from '../../ui/math';
|
|
import type { Levels } from '../../viewport/spectrum';
|
|
import { LEVEL_FLOOR_DB } from './compressorView';
|
|
|
|
export interface Box {
|
|
x: number;
|
|
y: number;
|
|
w: number;
|
|
h: number;
|
|
}
|
|
|
|
/** The plate: the panel never takes another size. */
|
|
export const PANEL = { w: 820, h: 280 };
|
|
|
|
/** The engraved name above a knob, and the number below its dial. */
|
|
export const KNOB_CAPTION = 14;
|
|
export const KNOB_FIELD = 24;
|
|
/** The narrowest a knob is: its number below. */
|
|
const KNOB_LEAST_WIDTH = 60;
|
|
export const BIG_DIAL = 100;
|
|
export const SMALL_DIAL = 48;
|
|
|
|
export type PanelKnob = 'threshold' | 'ratio' | 'knee' | 'attack' | 'release' | 'makeup';
|
|
|
|
/** A knob by the middle of its dial. */
|
|
export interface KnobSpot {
|
|
x: number;
|
|
y: number;
|
|
size: number;
|
|
}
|
|
|
|
export interface PanelLayout {
|
|
title: Box;
|
|
lcd: Box;
|
|
autoGain: Box;
|
|
meter: Box;
|
|
leds: Box;
|
|
knobs: Record<PanelKnob, KnobSpot>;
|
|
screws: { x: number; y: number }[];
|
|
}
|
|
|
|
const SCREW_INSET = 14;
|
|
|
|
/** A screw in each corner of a plate. */
|
|
export function screwsOf(plate: { w: number; h: number }): { x: number; y: number }[] {
|
|
return [
|
|
{ x: SCREW_INSET, y: SCREW_INSET },
|
|
{ x: plate.w - SCREW_INSET, y: SCREW_INSET },
|
|
{ x: SCREW_INSET, y: plate.h - SCREW_INSET },
|
|
{ x: plate.w - SCREW_INSET, y: plate.h - SCREW_INSET },
|
|
];
|
|
}
|
|
|
|
/** Threshold big on the left, makeup big on the right, the times and the shape small beside the meter. */
|
|
export const PANEL_LAYOUT: PanelLayout = {
|
|
title: { x: 34, y: 14, w: 220, h: 30 },
|
|
lcd: { x: (PANEL.w - 240) / 2, y: 14, w: 240, h: 30 },
|
|
autoGain: { x: 660, y: 10, w: 118, h: 40 },
|
|
meter: { x: 270, y: 62, w: 280, h: 190 },
|
|
leds: { x: 782, y: 74, w: 14, h: 168 },
|
|
knobs: {
|
|
threshold: { x: 100, y: 160, size: BIG_DIAL },
|
|
ratio: { x: 222, y: 105, size: SMALL_DIAL },
|
|
knee: { x: 222, y: 205, size: SMALL_DIAL },
|
|
attack: { x: 598, y: 105, size: SMALL_DIAL },
|
|
release: { x: 598, y: 205, size: SMALL_DIAL },
|
|
makeup: { x: 712, y: 160, size: BIG_DIAL },
|
|
},
|
|
screws: screwsOf(PANEL),
|
|
};
|
|
|
|
/** The Gain as a device (#384): an orange plate with one big knob and no name, as low as it allows. */
|
|
export const GAIN_PANEL = { w: 160, h: 160 };
|
|
|
|
/** The air in a caption's line above its capitals, and the hair a tucked number hangs below the dial. */
|
|
export const CAPTION_AIR = 4;
|
|
|
|
export const GAIN_LAYOUT = {
|
|
// Its number hangs in the dial's open bottom (#382), so the caption's ink and the dial are what to centre.
|
|
knob: { x: GAIN_PANEL.w / 2, y: (GAIN_PANEL.h + KNOB_CAPTION - CAPTION_AIR) / 2, size: BIG_DIAL },
|
|
screws: screwsOf(GAIN_PANEL),
|
|
};
|
|
|
|
/**
|
|
* How the Gain's knob turns, from -120 to +24 dB: below 1 the loud end gets more of the turn, and
|
|
* 0.26 puts 0 dB straight up, so the quiet tail, which is all but silence, goes by quickly.
|
|
*/
|
|
export const GAIN_TAPER = 0.26;
|
|
|
|
/** The Pitch's knob (#390), ±64 semitones round 0: an octave either way takes 43 % of a half turn. */
|
|
export const PITCH_TAPER = 2;
|
|
|
|
/** The Pan's knob (#392), -1 to 1 round the middle: evenly, so left and right are heard alike. */
|
|
export const PAN_TAPER = 1;
|
|
|
|
/** How much lower the Mix's knob sits than the Gain's, for its curve lever above it. */
|
|
const MIX_LEVER_ROOM = 40;
|
|
|
|
/** The Mix (#390): the Gain's plate, a little wider and taller by its curve lever on top. */
|
|
export const MIX_PANEL = { w: 180, h: GAIN_PANEL.h + MIX_LEVER_ROOM };
|
|
|
|
export const MIX_LAYOUT = {
|
|
knob: { ...GAIN_LAYOUT.knob, x: MIX_PANEL.w / 2, y: GAIN_LAYOUT.knob.y + MIX_LEVER_ROOM },
|
|
// Between the top screws, its lamp, lever and words in the middle.
|
|
curve: { x: (MIX_PANEL.w - 130) / 2, y: 12, w: 130, h: 40 },
|
|
screws: screwsOf(MIX_PANEL),
|
|
};
|
|
|
|
/** Where the ends of a knob's turn are named, just outside its dial: `a` at the start, `b` at the end. */
|
|
export function knobEnds(spot: KnobSpot): { a: { x: number; y: number }; b: { x: number; y: number } } {
|
|
const r = spot.size / 2 + 8;
|
|
const at = (angle: number) => ({
|
|
x: Math.round(spot.x + Math.cos(angle) * r),
|
|
y: Math.round(spot.y + Math.sin(angle) * r),
|
|
});
|
|
return { a: at(KNOB_START), b: at(KNOB_START + KNOB_SWEEP) };
|
|
}
|
|
|
|
/** All a knob takes on the plate: its name above, its dial and its number below. */
|
|
export function knobBox(spot: KnobSpot): Box {
|
|
const w = Math.max(spot.size, KNOB_LEAST_WIDTH);
|
|
return {
|
|
x: spot.x - w / 2,
|
|
y: spot.y - spot.size / 2 - KNOB_CAPTION,
|
|
w,
|
|
h: KNOB_CAPTION + spot.size + KNOB_FIELD,
|
|
};
|
|
}
|
|
|
|
// --- The meter: what is taken away, 0 dB at the right, swinging left as more is. ---
|
|
|
|
export const METER_RANGE_DB = 24;
|
|
/** How far the needle swings from end to end, in radians. */
|
|
export const METER_SWEEP = (96 * Math.PI) / 180;
|
|
/** The marks with a number, and the ones between. */
|
|
export const METER_LABELS = [0, 1, 3, 6, 10, 15, 24];
|
|
export const METER_TICKS = [2, 4, 5, 8, 12, 20];
|
|
/** From here on the scale is red. */
|
|
export const METER_RED_DB = 10;
|
|
/** How fast the needle follows: a VU's, which is near its reading after 300 ms. */
|
|
const NEEDLE_TIME = 0.065;
|
|
|
|
/**
|
|
* Where on the scale a reduction is, from 1 (none) to 0 (the range or more). The root spreads the
|
|
* first dB, where most of the work is heard, about as wide as the last nine.
|
|
*/
|
|
export function meterPosition(reductionDb: number): number {
|
|
return 1 - Math.sqrt(clamp(reductionDb, 0, METER_RANGE_DB) / METER_RANGE_DB);
|
|
}
|
|
|
|
/** The needle's angle at a position: straight up in the middle, the screen's y going down. */
|
|
export function needleAngle(position: number): number {
|
|
return -Math.PI / 2 + (clamp(position, 0, 1) - 0.5) * METER_SWEEP;
|
|
}
|
|
|
|
/** The reduction the needle shows `dt` seconds on, moving towards `target` as a VU's does. */
|
|
export function needleStep(shown: number, target: number, dt: number): number {
|
|
const share = 1 - Math.exp(-clamp(dt, 0, 0.25) / NEEDLE_TIME);
|
|
const next = shown + (target - shown) * share;
|
|
return Math.abs(next - target) < 0.01 ? target : next;
|
|
}
|
|
|
|
/** What the node takes away now: the level in, less the level out without the gain after it. */
|
|
export function gainReduction(levels: Levels | null, gainDb: number): number {
|
|
if (!levels || !(levels.in > LEVEL_FLOOR_DB)) return 0;
|
|
return Math.max(0, levels.in - (levels.out - gainDb));
|
|
}
|
|
|
|
// --- The LEDs: the level going out, one every 3 dB up to 0. ---
|
|
|
|
export const LED_COUNT = 14;
|
|
const LED_STEP_DB = 3;
|
|
/** How fast a lit LED goes out again, in dB a second. */
|
|
const LED_FALL = 20;
|
|
|
|
/** The level an LED lights at, from the bottom one up. */
|
|
export const ledDb = (index: number): number => (index - (LED_COUNT - 1)) * LED_STEP_DB;
|
|
|
|
export function ledColour(index: number): 'green' | 'amber' | 'red' {
|
|
const db = ledDb(index);
|
|
if (db >= -3) return 'red';
|
|
return db >= -12 ? 'amber' : 'green';
|
|
}
|
|
|
|
export function ledsLit(db: number): number {
|
|
let lit = 0;
|
|
for (let i = 0; i < LED_COUNT; i++) if (db >= ledDb(i)) lit = i + 1;
|
|
return lit;
|
|
}
|
|
|
|
/** The level the LEDs show `dt` seconds on: up at once, down slowly. */
|
|
export function ledStep(shown: number, reading: number, dt: number): number {
|
|
return Math.max(reading, shown - LED_FALL * clamp(dt, 0, 0.25));
|
|
}
|
|
|
|
// --- The plate. ---
|
|
|
|
export interface Stroke {
|
|
y: number;
|
|
alpha: number;
|
|
light: boolean;
|
|
}
|
|
|
|
/** One stroke of the brush per row of the plate, each a little lighter or darker, the same every time. */
|
|
export function brushStrokes(h: number, seed = 379): Stroke[] {
|
|
let state = seed >>> 0;
|
|
const next = () => {
|
|
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
|
return state / 2 ** 32;
|
|
};
|
|
return Array.from({ length: Math.max(0, Math.floor(h)) }, (_, y) => ({
|
|
y,
|
|
alpha: 0.03 + next() * 0.09,
|
|
light: next() < 0.5,
|
|
}));
|
|
}
|