Two things came up on the new tab. Floated and sized down, it spilled its knobs out of its window, and sized up it left the window empty. Auto-gain lifted far too much: it brought 0 dB back to 0 dB, which at -40 dB, 4:1 and knee 6 is +28.1 dB, so a sound at -24 dB came out at -6 dB. And as a knob from 0 to 1 it blended between makeup and auto-gain, so at 1 the makeup knob did nothing. Layout: - The knobs stay on the bottom line, centred, with the help line above them. - The levels fill the height left above. The curve is a square at their right, centred up and down. It grows with the tab from 180 to 360 px and takes no more than 40 % of its width (picturesLayout). - A floated window cannot be sized below what its pane needs. A pane says so in its minWidth and minHeight, and the Compressor's is every knob and the smallest pictures. Other panes keep 320 x 200. - A pane put into an open window that is too small grows the window. Auto-gain, as decided in #378: - A Compressor's auto-gain gives back what it takes from a sound at -18 dB, so that one comes out as loud as it went in. At the numbers above that is +14.6 dB. - Auto-gain is a switch on the Compressor, the Limiter and the Saturation alike, a setting and no longer an input. The makeup or output is added on top of it instead of being blended with it. - A project from before reads a number as on above 0. Nodes never set keep their defaults: off on the Compressor, on on the other two. - The tab shows the switch at the right of the knobs, with the dB it gives below it. With it on, the curve has a line at -18 dB from the curve up to the diagonal. - Every level reading keeps the gain of its time. Before, the whole picture was read by the current makeup, so after a change the tinting of the last seconds was wrong or missing. Findings: - My first e2e run of the layout clicked Dock where it was before the window was sized up, so the window stayed open and the next check went red. The check reads the button again. Tests: - Gate (melektaus-atx): 282 files, 4344 tests (6 new) - e2e 34 (melektaus-atx): ALL PASSED, 118 checks (2 new) - Offline in Chrome: a sine at -18 dB through -24 dB and 4:1 came out at -17.92 dB with auto-gain on and -22.42 dB with it off. - Counter-checks: the curve set at the top instead of the middle, and every reading read at a gain of 0, each fail exactly their test. - Looked at the window in screenshots: at its least every knob and the switch show. At 1280 x 797 the levels fill the height, the curve is centred at the right and the knobs are at the bottom. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
562 lines
20 KiB
TypeScript
562 lines
20 KiB
TypeScript
/**
|
||
* 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<Key, { 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. */
|
||
const KNOBS: Record<Key, { 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 },
|
||
};
|
||
|
||
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<LevelHistory['push']>[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<Key, Knob>;
|
||
/** 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`;
|
||
}
|