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:
1 parent
23922ee4a2
commit
1df38bd430
20 files changed
+1471
-103
No files matched your search
+50
-24
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user