Files
feuerwerk/src/editor/soundfx/compressorView.ts
T
adminandClaude Opus 5.5 9917253751 Open a Compressor as a device in a window of one size (#379)
The tab from #378 shows a Compressor as an editor. The user wanted it
to look like a device as well, after a brushed aluminium plug-in, with
a meter of the gain reduction instead of the pictures. The tab stays
and is switched to per node.

- Hardware look is a switch under Settings, on by default for new and
  existing Compressors. Only the editor reads it.
- The panel is 820x280 px and opens straight in a window of that size.
  The window has no sizing corner and no Dock. A pane says so through
  `fixedSize`, and `keptSize` keeps the window's middle when it
  changes to that size.
- A Compressor in this look never sits in the tab row. Opening one
  floats it, and switching the look on moves its tab into the window.
- The panel has chrome knobs (threshold and makeup large), a meter
  with a needle that moves like a VU's, an LCD with the name and the
  reading, a lever with a lamp for auto-gain, and LEDs for the level
  going out. Everything is drawn in code.
- The Knob takes a size and a chrome look, and leaves out its caption
  when it has no name, so the panel engraves its own.
- `autoGainOn` is now `switchOn`, because the new switch reads the
  same way.
- Where parts sit, the meter's scale, the needle's speed, the LEDs and
  the plate's grain are in `hardwareView.ts`, apart from the drawing.

Decided myself:
- The meter's scale runs by the square root, so the first dB is about
  as wide as the last nine. It is red from 10 dB on.
- The LEDs light one every 3 dB from -39 to 0 dB and fall by 20 dB a
  second.
- Switching the look changes the graph's fingerprint, like any other
  setting of a node. The sound FX is built again once.

Findings, fixed on the way:
- Pixi's `arc` goes on from the last point of the path. The meter's
  scale had a line from the corner of the face until each arc got a
  `moveTo` to its own start.
- A text put into a Graphics makes Pixi warn that only Containers take
  children. The LCD is a Container now.
- The first browser check read the needle at one moment and missed the
  short sound. The panel keeps the most its needle and LEDs showed.

Tests:
- Unit: hardware-view (10 new), documents (1 new)
- e2e 34 (melektaus-atx): ALL PASSED, 122 checks (4 new). Played,
  the needle reached 20.9 dB and 5 LEDs lit.
- Gate (melektaus-atx): CHECK EXIT 0, 283 files, 4355 tests (11 new)
- Counter-checks: a window that keeps its corner instead of its
  middle, and a linear meter scale, each turn exactly their test red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:06:56 +02:00

228 lines
8.8 KiB
TypeScript

/**
* What the tab of a Compressor (#378) draws, as numbers: the level coming in and going out over
* the last seconds, the curve from level in to level out, and where its threshold and ratio are
* grabbed. Kept apart from the drawing so it is checked without a browser.
*/
import { compressorAutoGain, compressorCurve } from '../../core/soundfx/dynamics';
import { COMPRESS_ROWS } from '../../core/soundfx/nodes';
import { clamp, snapToStep } from '../../ui/math';
import type { Levels } from '../../viewport/spectrum';
/** The dB the level picture spans, bottom to top. */
export const LEVEL_FLOOR_DB = -60;
export const LEVEL_TOP_DB = 6;
/** How long the level picture looks back. */
export const HISTORY_SECONDS = 4;
/** How long a level is the loudest sample of: a frame and a bit, so no peak falls between two. */
export const LEVEL_WINDOW = 0.025;
/** The dB the curve spans on both axes, from here to 0. */
export const CURVE_FLOOR_DB = -60;
/** Less taken away than this is not tinted: the two sides are read a few ms apart. */
export const REDUCTION_SHOWN_DB = 0.5;
export type CompressorKey = keyof typeof COMPRESS_ROWS;
export const COMPRESSOR_KEYS = Object.keys(COMPRESS_ROWS) as CompressorKey[];
/** The rows with the bounds every one of them has. */
export const COMPRESSOR_ROWS = Object.fromEntries(
COMPRESSOR_KEYS.map((key) => {
const row = COMPRESS_ROWS[key];
return [key, { min: row.min ?? 0, max: row.max ?? 0, step: row.step ?? 1, default: row.default }];
}),
) as Record<CompressorKey, { min: number; max: number; step: number; default: number }>;
/** How the knobs turn: the times and the ratio have their useful part low in the range. */
export const COMPRESSOR_KNOBS: Record<CompressorKey, { label: string; taper: number; decimals: number }> = {
threshold: { label: 'Threshold dB', taper: 1, decimals: 1 },
ratio: { label: 'Ratio', taper: 2, decimals: 1 },
knee: { label: 'Knee dB', taper: 1, decimals: 1 },
attack: { label: 'Attack s', taper: 3, decimals: 3 },
release: { label: 'Release s', taper: 2, decimals: 3 },
makeup: { label: 'Makeup dB', taper: 1, decimals: 1 },
};
export interface CompressorNumbers {
threshold: number;
knee: number;
ratio: number;
attack: number;
release: number;
makeup: number;
autoGain: boolean;
}
const dbToLinear = (db: number) => 10 ** (db / 20);
const linearToDb = (value: number) => (value <= 0 ? -Infinity : 20 * Math.log10(value));
/** What the gain after the compressing adds, in dB: the makeup, and the auto-gain where it is on. */
export function compressorGainDb(n: CompressorNumbers): number {
return n.makeup + (n.autoGain ? compressorAutoGain(n.threshold, n.knee, n.ratio) : 0);
}
/** The level a steady one of `inDb` comes out at, before the gain after it. */
export function curveOutDb(inDb: number, threshold: number, knee: number, ratio: number): number {
return linearToDb(compressorCurve(threshold, knee, ratio)(dbToLinear(inDb)));
}
// --- The curve: a square, level in across, level out up, both CURVE_FLOOR_DB..0. ---
export const curveX = (db: number, size: number): number =>
((clamp(db, CURVE_FLOOR_DB, 0) - CURVE_FLOOR_DB) / -CURVE_FLOOR_DB) * size;
export const curveY = (db: number, size: number): number => size - curveX(db, size);
export const curveDbAtX = (x: number, size: number): number => CURVE_FLOOR_DB + (x / size) * -CURVE_FLOOR_DB;
export const curveDbAtY = (y: number, size: number): number => curveDbAtX(size - y, size);
/** The curve as points across the square. */
export function curvePoints(n: CompressorNumbers, size: number, steps: number): number[] {
const curve = compressorCurve(n.threshold, n.knee, n.ratio);
const points: number[] = [];
for (let i = 0; i <= steps; i++) {
const db = CURVE_FLOOR_DB + (i / steps) * -CURVE_FLOOR_DB;
points.push(curveX(db, size), curveY(linearToDb(curve(dbToLinear(db))), size));
}
return points;
}
/** Where the threshold is grabbed: on the curve where it starts to bend. */
export function thresholdHandle(n: CompressorNumbers, size: number): { x: number; y: number } {
return { x: curveX(n.threshold, size), y: curveY(n.threshold, size) };
}
/** Where the ratio is grabbed: the end of the curve, at a level of 0 dB going in. */
export function ratioHandle(n: CompressorNumbers, size: number): { x: number; y: number } {
return { x: size, y: curveY(curveOutDb(0, n.threshold, n.knee, n.ratio), size) };
}
/**
* The ratio whose curve comes out at `outDb` for 0 dB going in, held to `min`..`max` and rounded
* to `step`. The higher the ratio, the lower the end of the curve, so it is found by halving.
*/
export function ratioFor(
outDb: number,
n: CompressorNumbers,
range: { min: number; max: number; step: number },
): number {
const end = (ratio: number) => curveOutDb(0, n.threshold, n.knee, ratio);
if (outDb >= end(range.min)) return range.min;
if (outDb <= end(range.max)) return range.max;
let low = range.min;
let high = range.max;
for (let i = 0; i < 40; i++) {
const mid = (low + high) / 2;
if (end(mid) > outDb) low = mid;
else high = mid;
}
return clamp(snapToStep((low + high) / 2, range.step), range.min, range.max);
}
// --- Where the two pictures go in the space the tab leaves them. ---
export const MIN_LEVELS_WIDTH = 240;
/** The side of the curve's square: it grows with the tab up to the largest, then stays centred. */
export const MIN_CURVE = 180;
export const MAX_CURVE = 360;
/** The most of the width the curve takes, so the levels keep the larger part. */
const CURVE_SHARE = 0.4;
/**
* The levels fill the height on the left; the curve's square is on the right, centred up and
* down, as large as the height, the largest and its share of the width allow.
*/
export function picturesLayout(
w: number,
h: number,
gap: number,
): { levels: { w: number; h: number }; curve: { x: number; y: number; side: number } } {
const side = Math.round(Math.max(MIN_CURVE, Math.min(h, MAX_CURVE, w * CURVE_SHARE)));
return {
levels: { w: Math.max(0, w - side - gap), h },
curve: { x: w - side, y: Math.round(Math.max(0, h - side) / 2), side },
};
}
// --- The level picture: time across, the newest at the right edge; dB up. ---
export const levelY = (db: number, h: number): number =>
((LEVEL_TOP_DB - clamp(db, LEVEL_FLOOR_DB, LEVEL_TOP_DB)) / (LEVEL_TOP_DB - LEVEL_FLOOR_DB)) * h;
export const historyX = (t: number, now: number, w: number): number => w - ((now - t) / HISTORY_SECONDS) * w;
/** One reading of both sides, and the gain after the compressing as it was then, in dB. */
export interface LevelPoint extends Levels {
t: number;
gain: number;
}
/** The readings of the last seconds, oldest first. */
export class LevelHistory {
private points: LevelPoint[] = [];
/**
* A reading at `t` seconds, with the gain after the compressing at the time: a reading from
* before the makeup or the auto-gain changed is read by what it was. None heard is silence.
*/
push(t: number, levels: Levels | null, gain: number): void {
this.points.push({ t, in: levels?.in ?? -Infinity, out: levels?.out ?? -Infinity, gain });
const old = this.points.findIndex((p) => p.t >= t - HISTORY_SECONDS);
if (old > 0) this.points.splice(0, old);
}
clear(): void {
this.points = [];
}
get all(): readonly LevelPoint[] {
return this.points;
}
/** Whether anything above the floor is still in the picture, so it has to move on. */
get heard(): boolean {
return this.points.some((p) => p.in > LEVEL_FLOOR_DB || p.out > LEVEL_FLOOR_DB);
}
get latest(): LevelPoint | null {
return this.points.at(-1) ?? null;
}
}
/** How much the compressing takes away at a reading, in dB: the gain after it left out. */
export function reductionOf(point: Levels, gainDb: number): number {
if (!Number.isFinite(point.in) || !Number.isFinite(point.out)) return 0;
return Math.max(0, point.in - (point.out - gainDb));
}
/**
* The tinted shapes where the compressor works: from the level coming in down by what it takes
* away, one shape for each stretch where it takes away more than `REDUCTION_SHOWN_DB`.
*/
export function reductionShapes(
points: readonly LevelPoint[],
now: number,
w: number,
h: number,
): number[][] {
const shapes: number[][] = [];
let top: number[] = [];
let bottom: number[] = [];
const close = () => {
if (top.length >= 4) {
const back: number[] = [];
for (let i = bottom.length - 2; i >= 0; i -= 2) back.push(bottom[i]!, bottom[i + 1]!);
shapes.push([...top, ...back]);
}
top = [];
bottom = [];
};
for (const point of points) {
const reduction = reductionOf(point, point.gain);
if (reduction < REDUCTION_SHOWN_DB) {
close();
continue;
}
const x = historyX(point.t, now, w);
top.push(x, levelY(point.in, h));
bottom.push(x, levelY(point.in - reduction, h));
}
close();
return shapes;
}