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

+50 -24
View File
@@ -98,7 +98,9 @@ import { PluginsPane } from './plugins/PluginsPane';
import { ManagementPane } from './users/ManagementPane';
import { UsersPane } from './users/UsersPane';
import { RoutingEditor } from './routing/RoutingEditor';
import { EqEditor, eqNodeOf, eqTabLabel } from './soundfx/EqEditor';
import { CompressorEditor } from './soundfx/CompressorEditor';
import { EqEditor } from './soundfx/EqEditor';
import { NODE_TAB_KINDS, nodeOfTab, nodeTabLabel, type NodeTabKind, nodeTabOf } from './soundfx/nodeTabs';
export type CentreTab =
| 'scene'
@@ -122,6 +124,7 @@ export type CentreTab =
| 'variables'
| 'routing'
| 'eq'
| 'compressor'
| 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */
@@ -131,9 +134,11 @@ export type TabId = CentreTab | PluginTabId;
/**
* An open document (#171): an asset by its id, or what a Variables tab shows (#252) by its
* `valuesKey` – as many of those as were opened. The user management (#179) and the master routing
* (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370).
* (#351) are no documents, but they open and close like one. A Parametric EQ has a tab per node (#370),
* and so has a Compressor (#378).
*/
export type DocumentId = `asset:${string}` | `values:${string}` | `eq:${string}` | 'routing' | 'management';
export type DocumentId =
`asset:${string}` | `values:${string}` | `${NodeTabKind}:${string}` | 'routing' | 'management';
/** The account behind this session; the menu for it sits at the far right of the toolbar. */
export interface EditorAccount {
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */
@@ -151,7 +156,7 @@ export type StripTab = FixedTab | PluginTabId | DocumentId;
type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | 'eq' | 'management'>;
type AssetKind = Exclude<CentreTab, FixedTab | 'variables' | 'routing' | NodeTabKind | 'management'>;
const FIXED_TABS = ['scene', 'game'] as const;
@@ -177,6 +182,7 @@ const PANE_ORDER = [
'variables',
'routing',
'eq',
'compressor',
'management',
] as const;
@@ -203,6 +209,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
variables: 'Variables',
routing: 'Master routing',
eq: 'Parametric EQ',
compressor: 'Compressor',
management: 'User management',
};
@@ -218,10 +225,15 @@ function isDocument(tab: StripTab): tab is DocumentId {
tab === 'routing' ||
tab.startsWith('asset:') ||
tab.startsWith('values:') ||
tab.startsWith('eq:')
nodeTabOfDocument(tab) !== null
);
}
/** The kind of a node's tab (#370, #378) a document is, or null for any other. */
function nodeTabOfDocument(doc: string): NodeTabKind | null {
return NODE_TAB_KINDS.find((kind) => doc.startsWith(`${kind}:`)) ?? null;
}
const MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500;
const MIN_RIGHT_WIDTH = 200;
@@ -266,10 +278,11 @@ export class Editor implements PlayHost {
readonly variablesEditor: VariablesEditor;
readonly routingEditor: RoutingEditor;
readonly eqEditor: EqEditor;
readonly compressorEditor: CompressorEditor;
/** What each open Variables tab shows, by its document. */
private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
/** The node behind each EQ tab (#370). */
private readonly eqTargets = new Map<DocumentId, { assetId: string; nodeId: string }>();
/** The node behind each tab of an EQ (#370) or a Compressor (#378). */
private readonly nodeTargets = new Map<DocumentId, { assetId: string; nodeId: string }>();
/** The switch per file in `data/plugins/`, in the user management tab (#179). */
readonly pluginsPane = new PluginsPane();
/** The accounts, above the plugins; null unless an administrator is signed in. */
@@ -368,6 +381,7 @@ export class Editor implements PlayHost {
this.variablesEditor = new VariablesEditor(state);
this.routingEditor = new RoutingEditor(state);
this.eqEditor = new EqEditor(state);
this.compressorEditor = new CompressorEditor(state);
this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state);
@@ -491,6 +505,7 @@ export class Editor implements PlayHost {
variables: this.variablesEditor,
routing: this.routingEditor,
eq: this.eqEditor,
compressor: this.compressorEditor,
management: this.managementPane,
};
// Following a call goes on in the same tab: the tab changes its document, not its place.
@@ -597,12 +612,13 @@ export class Editor implements PlayHost {
if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
this.openDocument(doc);
});
// A tab per Parametric EQ node (#370), opened by a double-click on it.
state.on('eq', () => {
const target = state.openedEq;
if (!target) return;
const doc: DocumentId = `eq:${target.assetId}:${target.nodeId}`;
if (!this.eqTargets.has(doc)) this.eqTargets.set(doc, target);
// A tab per Parametric EQ (#370) or Compressor (#378) node, opened by a double-click on it.
state.on('nodeTab', () => {
const target = state.openedNode;
const kind = target && nodeTabOf(target.type);
if (!target || !kind) return;
const doc: DocumentId = `${kind}:${target.assetId}:${target.nodeId}`;
if (!this.nodeTargets.has(doc)) this.nodeTargets.set(doc, target);
this.openDocument(doc);
});
// The tabs play through the buses and the master as the game would (#325).
@@ -621,8 +637,9 @@ export class Editor implements PlayHost {
for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null };
this.valueTargets.clear();
this.eqTargets.clear();
this.nodeTargets.clear();
this.eqEditor.close();
this.compressorEditor.close();
this.showTab('scene');
});
state.on('mode', () => this.editorRun.modeChanged());
@@ -886,7 +903,10 @@ export class Editor implements PlayHost {
if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
this.valueTargets.delete(doc);
if (this.eqTargets.delete(doc) && kind === 'eq') this.eqEditor.close();
if (this.nodeTargets.delete(doc)) {
if (kind === 'eq') this.eqEditor.close();
if (kind === 'compressor') this.compressorEditor.close();
}
if (doc === 'routing') this.routingEditor.close();
}
@@ -1091,9 +1111,10 @@ export class Editor implements PlayHost {
return target && valuesTitle(this.state, target) !== null ? 'variables' : null;
}
if (doc === 'management' || doc === 'routing') return doc;
if (doc.startsWith('eq:')) {
const target = this.eqTargets.get(doc);
return target && eqNodeOf(this.state.project, target.assetId, target.nodeId) ? 'eq' : null;
const nodeTab = nodeTabOfDocument(doc);
if (nodeTab) {
const target = this.nodeTargets.get(doc);
return target && nodeOfTab(this.state.project, nodeTab, target.assetId, target.nodeId) ? nodeTab : null;
}
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined;
@@ -1119,16 +1140,18 @@ export class Editor implements PlayHost {
// Read on every showing: an account may have been changed from another tab in the meantime.
if (kind === 'management') void this.usersPane?.reload();
if (kind === 'routing') this.routingEditor.open();
const eq = this.eqTargets.get(doc);
if (kind === 'eq' && eq) this.eqEditor.show(eq.assetId, eq.nodeId);
const node = this.nodeTargets.get(doc);
if (kind === 'eq' && node) this.eqEditor.show(node.assetId, node.nodeId);
if (kind === 'compressor' && node) this.compressorEditor.show(node.assetId, node.nodeId);
}
/** What a document's tab says: the asset's name, or what a Variables tab shows, as its header says. */
private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
if (doc === 'routing') return TAB_LABELS.routing;
const eq = this.eqTargets.get(doc);
if (eq) return eqTabLabel(this.state.project, eq.assetId, eq.nodeId);
const node = this.nodeTargets.get(doc);
const nodeTab = nodeTabOfDocument(doc);
if (node && nodeTab) return nodeTabLabel(this.state.project, nodeTab, node.assetId, node.nodeId);
const target = this.valueTargets.get(doc);
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
const asset = findAsset(this.state.project, doc.slice('asset:'.length));
@@ -1139,7 +1162,7 @@ export class Editor implements PlayHost {
private iconOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
if (doc === 'routing') return ASSET_ICON_SVG.bus;
if (doc.startsWith('eq:')) return ASSET_ICON_SVG.soundfx;
if (nodeTabOfDocument(doc)) return ASSET_ICON_SVG.soundfx;
const target = this.valueTargets.get(doc);
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
@@ -1305,6 +1328,8 @@ export class Editor implements PlayHost {
if (this.shows('sound')) this.soundEditor.tick();
if (this.shows('eq')) this.eqEditor.tick();
else this.eqEditor.hideSpectrum();
if (this.shows('compressor')) this.compressorEditor.tick();
else this.compressorEditor.hideLevels();
// Play repaints the machine tab itself (see `PlaySession.tick`); an editor run has no session
// to do it, and its states move just the same.
if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw();
@@ -1341,7 +1366,8 @@ export class Editor implements PlayHost {
(this.shows('pattern') && this.patternEditor.isPlaying) ||
(this.shows('song') && this.songEditor.isPlaying) ||
(this.shows('sound') && this.soundEditor.isPlaying) ||
(this.shows('eq') && this.eqEditor.hasSpectrum)
(this.shows('eq') && this.eqEditor.hasSpectrum) ||
(this.shows('compressor') && this.compressorEditor.moving)
);
}