/** * 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; 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, })); }