The small curve below the EQ node's rows (#375) only showed the bands. Like the Compressor since #380, the node now shows the editor of its tab between its inputs and its output, and is worked right there. A double-click on the node still opens the same editor in a tab. Two wishes for the nodes with a panel came on top and share the same files, so they are in this commit as well: the numbers below the Compressor's knobs read as plain labels (#382), and the wheel over a node zooms the graph instead of turning a value (#383). #381, the EQ in its node: - EqPanel holds the curve, the band's numbers and the help line, taken out of EqEditor. The tab puts its one panel under a header, each EQ node has a panel of its own at 720 x 360 (EqFace). - A press on the curve neither picks nor moves the node. A double-click on the curve adds a band, one on the title or the edge opens the tab. - In the node a press on the Type or Slope button stays with the button, so the node is not picked or moved. - The spectrum runs in the node while it is in the picture. The tab lets go of the tap once, like the Compressor's, and Editor.tick lets every unseen tab and graph go before any seen one listens. - The count of bands moved from the tab's header into the band row, so the node shows it as well. - The node picture of #375 is gone with its only user: NodePicture, EQ_PICTURE and NodeView's drawing of it. #382, the numbers below the Compressor's knobs in its node: - A Knob with typed: false shows its number as a centred Label without a box, in the panel's ink. The ratio reads 4:1 (ratioText). - The label is as high as its text and sits up in the dial's open bottom, right under the ends of the scale. - Label takes a colour of its own. #383, the wheel in a graph: - GraphCanvas takes the wheel on the way down and stops it there. It zooms over a node too, and no knob or curve in a node sees it. - The tabs keep their wheel: a knob's step, a band's Q and the zoom of the EQ's dB. Found on the way: - A Menu opened below a button in the zoomed graph would have been placed by the button's own height. openBelow now takes the bottom edge as drawn. - The band row did not lay out its labels after a text change when its own size stayed. In the tab a later resize hid it, in the node "Band 1" and the count were missing. The row now lays out itself. - Handle and point positions for the checks were unscaled. They now go through toGlobal and hold at the graph's zoom. Tests: - Unit parametric-eq: 16 tests. The #375 tests now check eqCurvePoints and eqRangeOf directly. compressor-view: ratioText. - e2e 34 (atx): ALL PASSED, 125 checks (3 new: the EQ panel and its ports, editing in the node, the spectrum in the node with the tab open). The Compressor node's check reads its numbers as labels and 8:1. Both node checks now expect the wheel to zoom the graph and leave the ratio, the Q and the dB zoom alone. - The unseen-tab check now leaves for the Scene tab, since the node in the graph listens to the same tap. - Counter-check: the tab letting go of the tap every unseen frame stalls the node's spectrum with the tab open (1 frame against 22, probe on stein2). - Gate (atx): CHECK EXIT 0, 283 files, 4357 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
249 lines
9.7 KiB
TypeScript
249 lines
9.7 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 type { PropertyBag } from '../../core/model/Properties';
|
|
import { COMPRESS_AUTO_GAIN, COMPRESS_ROWS, switchOn } from '../../core/soundfx/nodes';
|
|
import { clamp, formatNumber, 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 },
|
|
};
|
|
|
|
/** A ratio as a device prints it: `4:1` (#382). */
|
|
export const ratioText = (ratio: number): string =>
|
|
`${formatNumber(ratio, COMPRESSOR_KNOBS.ratio.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));
|
|
|
|
/** A Compressor's numbers as its node has them: the defaults where none is set. */
|
|
export function compressorNumbers(params: PropertyBag): CompressorNumbers {
|
|
const numbers = {} as CompressorNumbers;
|
|
for (const key of COMPRESSOR_KEYS) {
|
|
const value = params[key];
|
|
numbers[key] = typeof value === 'number' && Number.isFinite(value) ? value : COMPRESSOR_ROWS[key].default;
|
|
}
|
|
numbers.autoGain = switchOn(params, COMPRESS_AUTO_GAIN);
|
|
return numbers;
|
|
}
|
|
|
|
/** What auto-gain gives at these numbers, or that it is off. */
|
|
export function autoGainText(n: CompressorNumbers): string {
|
|
return n.autoGain ? `+${compressorAutoGain(n.threshold, n.knee, n.ratio).toFixed(1)} dB` : 'off';
|
|
}
|
|
|
|
/** 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;
|
|
}
|