/** * 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; /** How the knobs turn: the times and the ratio have their useful part low in the range. */ export const COMPRESSOR_KNOBS: Record = { 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; }