Edit a Compressor in a tab of its own, with knobs and levels (#378)

A Compressor had only its number rows in the node. Its numbers are
easier to set by feel on a dial, and what it does is easier to judge
when the level going in and the level going out are seen side by side.

- Knob is a new widget in the toolkit: a slider bent into a circle,
  its name above and a number field below. A drag up or down turns
  it, Shift a tenth as far, a notch of the wheel a fiftieth of the
  turn, a double-click sets the default, the number takes a typed
  value. A taper gives the low end more of the turn.
- The Compressor's tab opens on a double-click, one per node, like
  the Parametric EQ's. It has a knob for each of the seven numbers,
  greyed while its input is linked.
- Its curve shows level in to level out from -60 to 0 dB. The point
  where it bends sets the threshold, the end of the curve sets the
  ratio that ends there.
- Its levels show the last 4 s on both sides of the node. Where it
  takes more than 0.5 dB away, the stretch below the level coming in
  is tinted. The makeup is left out of that.
- The Compressor is tapped like the EQ. SpectrumTaps listens to more
  than one node at once and reads the loudest sample of the last
  25 ms on each side.
- The node tabs share one map in the editor (nodeTabs), so a third
  kind needs no new plumbing.

Findings:

- CI task 614 for 23922ee was red on the memory check of the stream:
  the tab's renderer grew by 94 MB with nothing decoded. Play mode
  made garbage of its own and was read before a collection. Both
  readings in play now come after one, and the check compares the
  growth streamed with the growth decoded (3.7 MB against 59.8 MB).
- The ratio handle sat on the right edge of the picture, where a
  widget's hit area ends. The curve is inset by the grab radius.

Tests:

- Gate (melektaus-atx): 282 files, 4338 tests (17 new)
- e2e 34 (melektaus-atx): ALL PASSED, 116 checks (6 new)
- Counter-checks: an inverted ratio search, stop() letting go of
  every node and a drag without its least step each fail exactly
  their test.
- Looked at the tab in a screenshot: a blip at -4.4 dB came out at
  -16.7 dB, the gap tinted, the threshold line at -24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 22:40:12 +02:00
1 parent 23922ee4a2
commit 1df38bd430
20 files changed
+1471 -103

No files matched your search

+484
View File
@@ -0,0 +1,484 @@
/**
* 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; 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 { COMPRESS_ROWS, SoundFxCompressor, tapId } from '../../core/soundfx/nodes';
import { soundLibrary } from '../../viewport/SoundLibrary';
import {
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,
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 },
autoGain: { label: 'Auto-gain', taper: 1, decimals: 2 },
};
const MIN_LEVELS_WIDTH = 240;
/** The side of the curve's square, and the height of the level picture beside it. */
const PICTURE_SIZE = 240;
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 = PICTURE_SIZE;
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);
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, compressorGainDb(numbers), 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 = PICTURE_SIZE;
this.minHeight = PICTURE_SIZE;
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 });
}
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 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>;
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);
const pictures = new HStack({ gap: Theme.padding });
pictures.add(this.levels, 1);
pictures.add(this.curve);
const knobs = new HStack({ gap: Theme.padding * 2, align: 'center' });
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);
}
this.body.add(pictures);
this.body.add(knobs);
this.body.add(
new Label('Knob: drag (Shift: fine), wheel, double-click: default. Curve: drag threshold and ratio.', {
style: 'muted',
}),
);
this.body.add(new Spacer(), 1);
this.add(this.header);
this.add(this.body, 1);
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;
}
return numbers;
}
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.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));
}
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`;
}