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>
228 lines
8.8 KiB
TypeScript
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;
|
|
}
|