Files
feuerwerk/src/editor/soundfx/hardwareView.ts
T
adminandClaude Opus 5.5 389d9555fa
game-maker / gate (push) Successful in 4m13s
game-maker / suite (push) Failing after 15m23s
Show a Pan node as a one-knob device in cyan (#392)
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>
2026-10-10 18:26:17 +02:00

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