/** * The editor of a Compressor node (#378), in a tab of its own like a Parametric EQ's. A knob for * each of its numbers and a switch for its auto-gain; the curve from level in to level out, whose threshold and ratio are dragged * on it; and the level coming in and going out over the last seconds, tinted where it is squeezed. * A drag is one undo step. A number fed from a link has its knob greyed: the link decides it. */ import { Container, type FederatedPointerEvent, Graphics, type Text } from 'pixi.js'; import { SetNodeParamCommand } from '../../core/commands/graphCommands'; import type { GraphNode } from '../../core/graph'; import { findGraphAsset, type Project } from '../../core/model/Project'; import type { PropertyValue } from '../../core/model/Properties'; import { AUTO_GAIN_REFERENCE_DB, compressorAutoGain } from '../../core/soundfx/dynamics'; import { autoGainOn, COMPRESS_AUTO_GAIN, COMPRESS_ROWS, SoundFxCompressor, tapId, } from '../../core/soundfx/nodes'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { Checkbox, clamp, crispText, HStack, Knob, Label, snapToStep, Spacer, Theme, textStyle, VStack, Widget, } from '../../ui'; import { FramePreview } from '../framePreview'; import type { EditorState } from '../state'; import { TabHeader } from '../TabHeader'; import { type CompressorNumbers, compressorGainDb, CURVE_FLOOR_DB, curveDbAtX, curveDbAtY, curveOutDb, curvePoints, curveX, curveY, historyX, LEVEL_FLOOR_DB, LEVEL_WINDOW, LevelHistory, levelY, MIN_CURVE, MIN_LEVELS_WIDTH, picturesLayout, ratioFor, ratioHandle, reductionShapes, thresholdHandle, } from './compressorView'; type Key = keyof typeof COMPRESS_ROWS; const KEYS = Object.keys(COMPRESS_ROWS) as Key[]; /** The rows with the bounds every one of them has. */ const ROWS = Object.fromEntries( 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. */ const 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 }, }; const HANDLE_RADIUS = 6; const GRAB_RADIUS = HANDLE_RADIUS + 4; const CURVE_STEPS = 120; /** The dB lines of both pictures. */ const GRID_DB = [0, -12, -24, -36, -48]; /** What the pictures ask of the tab around them. */ interface CompressorHost { numbers(): CompressorNumbers; preview(key: Key, value: number): void; commit(key: Key, value: number): void; wired(key: Key): boolean; } /** Whether a press is near a point. */ const near = (at: { x: number; y: number }, x: number, y: number) => Math.hypot(at.x - x, at.y - y) <= GRAB_RADIUS; /** dB labels at the lines of a picture; hidden where the line is not in it. */ function gridLabels(parent: Container, count: number): Text[] { const muted = textStyle('small').clone(); muted.fill = Theme.colors.textMuted; return Array.from({ length: count }, () => { const text = crispText({ text: '', style: muted }); text.eventMode = 'none'; parent.addChild(text); return text; }); } /** The level coming in (grey) and going out (coloured) over the last seconds, newest at the right. */ class LevelPicture extends Widget { private readonly bg = new Graphics(); private readonly lines = new Graphics(); private readonly labels: Text[]; readonly history = new LevelHistory(); /** The time of the newest reading, in seconds. */ private now = 0; constructor(private readonly host: CompressorHost) { super(); this.minWidth = MIN_LEVELS_WIDTH; this.minHeight = MIN_CURVE; this.addChild(this.bg, this.lines); this.labels = gridLabels(this, GRID_DB.length); } read(now: number, levels: Parameters[1]): void { this.now = now; this.history.push(now, levels, compressorGainDb(this.host.numbers())); this.redraw(); } clear(): void { this.history.clear(); this.redraw(); } override redraw(): void { const c = Theme.colors; const { w, h } = this; this.bg.clear().rect(0, 0, w, h).fill(c.graphBackground).stroke({ color: c.border, width: 1 }); GRID_DB.forEach((db, i) => { const y = Math.round(levelY(db, h)) + 0.5; this.bg.moveTo(0, y).lineTo(w, y); const text = this.labels[i]!; text.text = `${db}`; text.position.set(3, y - text.height - 1); }); this.bg.stroke({ color: c.border, width: 1, alpha: 0.6 }); const numbers = this.host.numbers(); const threshold = Math.round(levelY(numbers.threshold, h)) + 0.5; this.bg.moveTo(0, threshold).lineTo(w, threshold).stroke({ color: c.accent, width: 1, alpha: 0.5 }); this.lines.clear(); const points = this.history.all; if (points.length < 2) return; for (const shape of reductionShapes(points, this.now, w, h)) this.lines.poly(shape).fill({ color: c.accent, alpha: 0.25 }); for (const [side, color, width] of [ ['in', c.textMuted, 1], ['out', c.portAudio, 2], ] as const) { points.forEach((p, i) => { const x = historyX(p.t, this.now, w); const y = levelY(p[side], h); if (i === 0) this.lines.moveTo(x, y); else this.lines.lineTo(x, y); }); this.lines.stroke({ color, width }); } } } /** The curve from level in (across) to level out (up), with its threshold and ratio to drag. */ class CurvePicture extends Widget { /** The curve's square, inset so a handle on its edge is grabbed whole. */ private readonly square = new Container(); private readonly bg = new Graphics(); private readonly lines = new Graphics(); private readonly labels: Text[]; private drag: Key | null = null; /** The level coming in now, for the point on the curve. */ private heard = -Infinity; constructor(private readonly host: CompressorHost) { super(); this.minWidth = MIN_CURVE; this.minHeight = MIN_CURVE; this.cursor = 'crosshair'; this.square.position.set(GRAB_RADIUS, GRAB_RADIUS); this.square.addChild(this.bg, this.lines); this.addChild(this.square); this.labels = gridLabels(this.square, GRID_DB.length); this.on('pointerdown', (e: FederatedPointerEvent) => this.press(e)); this.on('globalpointermove', (e: FederatedPointerEvent) => this.move(e)); this.onPointerRelease(() => this.release()); } private get size(): number { return Math.min(this.w, this.h) - GRAB_RADIUS * 2; } /** Where a handle is on screen – the check scripts aim with it. */ handleGlobal(key: 'threshold' | 'ratio'): { x: number; y: number } { const n = this.host.numbers(); const at = key === 'threshold' ? thresholdHandle(n, this.size) : ratioHandle(n, this.size); const origin = this.square.getGlobalPosition(); return { x: origin.x + at.x, y: origin.y + at.y }; } /** Where a level in and a level out are on screen. */ pointGlobal(inDb: number, outDb: number): { x: number; y: number } { const origin = this.square.getGlobalPosition(); return { x: origin.x + curveX(inDb, this.size), y: origin.y + curveY(outDb, this.size) }; } hear(inDb: number): void { if (inDb === this.heard) return; this.heard = inDb; this.redraw(); } private press(e: FederatedPointerEvent): void { if (e.button !== 0) return; const at = this.square.toLocal(e.global); const n = this.host.numbers(); if (!this.host.wired('ratio') && near(ratioHandle(n, this.size), at.x, at.y)) this.drag = 'ratio'; else if (!this.host.wired('threshold') && near(thresholdHandle(n, this.size), at.x, at.y)) this.drag = 'threshold'; } private move(e: FederatedPointerEvent): void { if (!this.drag) return; const at = this.square.toLocal(e.global); const n = this.host.numbers(); if (this.drag === 'threshold') { const row = ROWS.threshold; this.host.preview( 'threshold', clamp(snapToStep(curveDbAtX(at.x, this.size), row.step), row.min, row.max), ); return; } this.host.preview('ratio', ratioFor(curveDbAtY(at.y, this.size), n, ROWS.ratio)); } private release(): void { const key = this.drag; this.drag = null; if (key) this.host.commit(key, this.host.numbers()[key]); } override redraw(): void { const c = Theme.colors; const size = this.size; this.bg.clear().rect(0, 0, size, size).fill(c.graphBackground).stroke({ color: c.border, width: 1 }); GRID_DB.forEach((db, i) => { const at = Math.round(curveX(db, size)) + 0.5; this.bg.moveTo(at, 0).lineTo(at, size); const y = Math.round(curveY(db, size)) + 0.5; this.bg.moveTo(0, y).lineTo(size, y); const text = this.labels[i]!; text.text = `${db}`; text.visible = db > CURVE_FLOOR_DB; text.position.set(3, y - text.height - 1); }); this.bg.stroke({ color: c.border, width: 1, alpha: 0.6 }); // What a curve without compressing would be. this.bg.moveTo(0, size).lineTo(size, 0).stroke({ color: c.textMuted, width: 1, alpha: 0.4 }); const n = this.host.numbers(); this.lines.clear(); this.lines.poly(curvePoints(n, size, CURVE_STEPS), false).stroke({ color: c.accent, width: 2 }); for (const [key, at] of [ ['threshold', thresholdHandle(n, size)], ['ratio', ratioHandle(n, size)], ] as const) { this.lines.circle(at.x, at.y, HANDLE_RADIUS).fill(this.host.wired(key) ? c.control : c.accent); if (this.drag === key) this.lines.circle(at.x, at.y, HANDLE_RADIUS + 3).stroke({ color: c.symbol, width: 2 }); } // The level auto-gain keeps as loud as it came in, and how far it is lifted to it. if (n.autoGain) { const x = curveX(AUTO_GAIN_REFERENCE_DB, size); const y = curveY(curveOutDb(AUTO_GAIN_REFERENCE_DB, n.threshold, n.knee, n.ratio), size); const to = curveY(AUTO_GAIN_REFERENCE_DB, size); this.lines.moveTo(x, y).lineTo(x, to).stroke({ color: c.symbol, width: 1, alpha: 0.6 }); this.lines.circle(x, to, 3).stroke({ color: c.symbol, width: 1 }); } if (this.heard > CURVE_FLOOR_DB) { const x = curveX(this.heard, size); const y = curveY(curveOutDb(this.heard, n.threshold, n.knee, n.ratio), size); this.lines.circle(x, y, 4).fill(c.portAudio); } } } /** The levels and the curve side by side, as `picturesLayout` places them. */ class Pictures extends Widget { constructor( private readonly levels: LevelPicture, private readonly curve: CurvePicture, ) { super(); this.addChild(levels, curve); } override measure(): { w: number; h: number } { return { w: MIN_LEVELS_WIDTH + Theme.padding + MIN_CURVE, h: MIN_CURVE }; } override layout(): void { const at = picturesLayout(this.w, this.h, Theme.padding); this.levels.position.set(0, 0); this.levels.resize(at.levels.w, at.levels.h); this.curve.position.set(at.curve.x, at.curve.y); this.curve.resize(at.curve.side, at.curve.side); } } /** * The tab of a Compressor node (#378): one editor, pointed at the node whose tab is showing, as * the EQ's is (#370). */ export class CompressorEditor extends VStack implements CompressorHost { readonly levels: LevelPicture; readonly curve: CurvePicture; readonly knobs = {} as Record; /** Auto-gain: a switch, with the dB it gives below it where a knob has its number. */ readonly autoGain = new Checkbox(false); readonly autoGainDb = new Label('', { style: 'small' }); private readonly autoColumn = new VStack({ gap: 2, align: 'center' }); private assetId = ''; private nodeId = ''; /** The value a drag began with – its undo step's other side; undefined when none was typed in. */ private before: { key: Key; value: PropertyValue | undefined } | null = null; private readonly title = new Label('Compressor', { style: 'bodyBold' }); private readonly header = new TabHeader(); private readonly body = new VStack({ gap: Theme.padding, padding: Theme.padding * 2 }); private readonly perFrame: FramePreview; /** Readings so far, and the most taken away in one (checks). */ seen = { frames: 0, in: -Infinity, out: -Infinity, reduction: 0 }; constructor(private readonly state: EditorState) { super(); this.perFrame = new FramePreview(state, 'compressor-knob'); this.levels = new LevelPicture(this); this.curve = new CurvePicture(this); this.header.add(this.title); this.header.add(new Spacer(), 1); // The knobs in the middle of the bottom line, whatever the tab's width. const knobs = new HStack({ gap: Theme.padding * 2, align: 'center' }); knobs.add(new Spacer(), 1); for (const key of KEYS) { const row = ROWS[key]; const look = KNOBS[key]; const knob = new Knob(look.label, row.default, { min: row.min, max: row.max, step: row.step, taper: look.taper, default: row.default, decimals: look.decimals, onInput: (value) => this.preview(key, value), onChange: (value) => this.commit(key, value), }); this.knobs[key] = knob; knobs.add(knob); } // As tall as a knob, the switch where its dial is. const auto = this.autoColumn; auto.add(new Label(COMPRESS_AUTO_GAIN.label, { style: 'small' })); auto.add(new Spacer(), 1); auto.add(this.autoGain); auto.add(new Spacer(), 1); auto.add(this.autoGainDb); auto.minHeight = this.knobs.threshold.measure().h; auto.minWidth = this.knobs.threshold.measure().w; this.autoGain.onChange = (on) => this.switchAutoGain(on); knobs.add(auto); knobs.add(new Spacer(), 1); // The pictures take what height there is; the knobs stay on the bottom line. const pictures = new Pictures(this.levels, this.curve); this.body.add(pictures, 1); this.body.add( new Label('Knob: drag (Shift: fine), wheel, double-click: default. Curve: drag threshold and ratio.', { style: 'muted', }), ); this.body.add(knobs); this.add(this.header); this.add(this.body, 1); // What a window of the tab needs at the least: every knob and the smallest pictures. The help // line is left out of the width: it is cut short instead. this.minWidth = Math.max(knobs.measure().w, pictures.measure().w) + this.body.padding * 2; this.minHeight = this.measure().h; state.on('project', () => this.sync()); } show(assetId: string, nodeId: string): void { if (assetId !== this.assetId || nodeId !== this.nodeId) { this.perFrame.cancel(); this.hideLevels(); this.levels.clear(); this.before = null; this.assetId = assetId; this.nodeId = nodeId; this.seen = { frames: 0, in: -Infinity, out: -Infinity, reduction: 0 }; } this.sync(); } close(): void { this.perFrame.cancel(); this.hideLevels(); const node = this.node(); if (node) this.restore(node); this.assetId = ''; this.nodeId = ''; } get shownFor(): { assetId: string; nodeId: string } | null { return this.nodeId ? { assetId: this.assetId, nodeId: this.nodeId } : null; } /** Only called while the tab is seen: a reading of both sides, a frame on. */ tick(): void { if (!this.nodeId) return; const id = tapId(this.assetId, this.nodeId); soundLibrary.listen(id); const levels = soundLibrary.nodeLevels(id, LEVEL_WINDOW); if (!levels && !this.levels.history.heard) return; this.levels.read(performance.now() / 1000, levels); this.curve.hear(levels?.in ?? -Infinity); if (levels && levels.in > LEVEL_FLOOR_DB) { this.seen = { frames: this.seen.frames + 1, in: Math.max(this.seen.in, levels.in), out: Math.max(this.seen.out, levels.out), reduction: Math.max(this.seen.reduction, levels.in - (levels.out - compressorGainDb(this.numbers()))), }; } } /** The tab is not seen: no analyser is fed for it. */ hideLevels(): void { if (this.nodeId) soundLibrary.unlisten(tapId(this.assetId, this.nodeId)); this.curve.hear(-Infinity); } /** Whether the level picture is moving, so the tab has to be drawn every frame. */ get moving(): boolean { return this.levels.history.heard; } private node(): GraphNode | null { return compressorNodeOf(this.state.project, this.assetId, this.nodeId); } numbers(): CompressorNumbers { const params = this.node()?.params ?? {}; const numbers = {} as CompressorNumbers; for (const key of KEYS) { const value = params[key]; numbers[key] = typeof value === 'number' && Number.isFinite(value) ? value : ROWS[key].default; } numbers.autoGain = autoGainOn(params, COMPRESS_AUTO_GAIN); return numbers; } /** The dB auto-gain gives at these numbers, or that it is off. */ private showAutoGainDb(n: CompressorNumbers): void { const db = compressorAutoGain(n.threshold, n.knee, n.ratio); const text = n.autoGain ? `+${db.toFixed(1)} dB` : 'off'; if (text === this.autoGainDb.text) return; this.autoGainDb.setText(text); this.autoColumn.relayout(); } /** Auto-gain on or off: one undo step, as a click on a knob is. */ private switchAutoGain(on: boolean): void { if (!this.node()) return; this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, COMPRESS_AUTO_GAIN.key, on)); } wired(key: Key): boolean { const graph = findGraphAsset(this.state.project, this.assetId)?.graph; return Boolean(graph?.edges.some((e) => e.to.node === this.nodeId && e.to.port === key)); } /** Writes a number into the node as it is dragged; `commit` makes the undo step of it. */ preview(key: Key, value: number): void { const node = this.node(); if (!node) return; if (this.before?.key !== key) { this.restore(node); this.before = { key, value: node.params[key] }; } node.params[key] = value; this.perFrame.request(); this.showAutoGainDb(this.numbers()); this.levels.invalidate(); this.curve.invalidate(); } commit(key: Key, value: number): void { this.perFrame.cancel(); const node = this.node(); if (!node) return; this.restore(node); if (node.params[key] === value) { this.state.preview(); return; } this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, key, value)); } private restore(node: GraphNode): void { const before = this.before; if (!before) return; if (before.value === undefined) delete node.params[before.key]; else node.params[before.key] = before.value; this.before = null; } private sync(): void { if (!this.node()) return; const numbers = this.numbers(); for (const key of KEYS) { this.knobs[key].setValue(numbers[key]); this.knobs[key].setEnabled(!this.wired(key)); } this.autoGain.setChecked(numbers.autoGain); this.showAutoGainDb(numbers); const title = `Compressor – ${findGraphAsset(this.state.project, this.assetId)?.name ?? ''}`; if (title !== this.title.text) { this.title.setText(title); this.relayout(); } this.levels.invalidate(); this.curve.invalidate(); } } /** The Compressor node a tab is about, or null once it, or its asset, is gone. */ export function compressorNodeOf(project: Project, assetId: string, nodeId: string): GraphNode | null { const node = findGraphAsset(project, assetId)?.graph.nodes.find((n) => n.id === nodeId); return node?.type === SoundFxCompressor.type ? node : null; } /** What a Compressor's tab says: its asset's name, and which one it is when the asset has more. */ export function compressorTabLabel(project: Project, assetId: string, nodeId: string): string { const asset = findGraphAsset(project, assetId); if (!asset) return ''; const all = asset.graph.nodes.filter((n) => n.type === SoundFxCompressor.type); const index = all.findIndex((n) => n.id === nodeId); return all.length > 1 ? `${asset.name} · Compressor ${index + 1}` : `${asset.name} · Compressor`; }