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

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU: The browser suite and the three example games need a machine with a GPU:
```bash ```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1941 checks (GM_STEPS=20 for one step) node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1947 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+4 -2
View File
@@ -1005,7 +1005,8 @@ const COMPRESS_ATTACK = { ...numberRow('attack', 'Attack (s)', 0, 1, 0.001), def
const COMPRESS_RELEASE = { ...numberRow('release', 'Release (s)', 0, 1, 0.001), default: 0.25 }; const COMPRESS_RELEASE = { ...numberRow('release', 'Release (s)', 0, 1, 0.001), default: 0.25 };
const MAKEUP = numberRow('makeup', 'Makeup (dB)', -24, 24, 0.5); const MAKEUP = numberRow('makeup', 'Makeup (dB)', -24, 24, 0.5);
const AUTO_GAIN = numberRow('autoGain', 'Auto-gain', 0, 1, 0.01); const AUTO_GAIN = numberRow('autoGain', 'Auto-gain', 0, 1, 0.01);
const COMPRESS_ROWS = { /** The numbers of a Compressor, in the order its tab shows them as knobs (#378). */
export const COMPRESS_ROWS = {
threshold: COMPRESS_THRESHOLD, threshold: COMPRESS_THRESHOLD,
ratio: COMPRESS_RATIO, ratio: COMPRESS_RATIO,
knee: COMPRESS_KNEE, knee: COMPRESS_KNEE,
@@ -1020,8 +1021,9 @@ export const SoundFxCompressor: SoundFxNodeDef = effect(
type: 'SoundFxCompressor', type: 'SoundFxCompressor',
label: 'Compressor', label: 'Compressor',
category: 'sound', category: 'sound',
tapped: true,
description: description:
'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "auto-gain" from 0 to 1 blends that with the gain that brings a sound at full scale back to full scale: 0 is the makeup alone, 1 the auto-gain alone. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays.' + 'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "auto-gain" from 0 to 1 blends that with the gain that brings a sound at full scale back to full scale: 0 is the makeup alone, 1 the auto-gain alone. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays. Double-click the node to open its editor: a knob for each number, its curve with threshold and ratio to drag, and the level coming in and going out over the last seconds.' +
BLENDS, BLENDS,
inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)], inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)],
outputs: [audioOut], outputs: [audioOut],
+50 -24
View File
@@ -98,7 +98,9 @@ import { PluginsPane } from './plugins/PluginsPane';
import { ManagementPane } from './users/ManagementPane'; import { ManagementPane } from './users/ManagementPane';
import { UsersPane } from './users/UsersPane'; import { UsersPane } from './users/UsersPane';
import { RoutingEditor } from './routing/RoutingEditor'; 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 = export type CentreTab =
| 'scene' | 'scene'
@@ -122,6 +124,7 @@ export type CentreTab =
| 'variables' | 'variables'
| 'routing' | 'routing'
| 'eq' | 'eq'
| 'compressor'
| 'management'; | 'management';
/** A tab a plugin added; the prefix keeps it apart from every built-in one. */ /** 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 * 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 * `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. */ /** The account behind this session; the menu for it sits at the far right of the toolbar. */
export interface EditorAccount { export interface EditorAccount {
/** Who is signed in – the menu says so, since the toolbar has no room for a second label. */ /** 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]; type FixedTab = (typeof FIXED_TABS)[number];
/** The kinds whose documents are assets, each shown by one editor that is pointed at them in turn. */ /** 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; const FIXED_TABS = ['scene', 'game'] as const;
@@ -177,6 +182,7 @@ const PANE_ORDER = [
'variables', 'variables',
'routing', 'routing',
'eq', 'eq',
'compressor',
'management', 'management',
] as const; ] as const;
@@ -203,6 +209,7 @@ const TAB_LABELS: Record<CentreTab, string> = {
variables: 'Variables', variables: 'Variables',
routing: 'Master routing', routing: 'Master routing',
eq: 'Parametric EQ', eq: 'Parametric EQ',
compressor: 'Compressor',
management: 'User management', management: 'User management',
}; };
@@ -218,10 +225,15 @@ function isDocument(tab: StripTab): tab is DocumentId {
tab === 'routing' || tab === 'routing' ||
tab.startsWith('asset:') || tab.startsWith('asset:') ||
tab.startsWith('values:') || 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 MIN_LEFT_WIDTH = 160;
const MAX_LEFT_WIDTH = 500; const MAX_LEFT_WIDTH = 500;
const MIN_RIGHT_WIDTH = 200; const MIN_RIGHT_WIDTH = 200;
@@ -266,10 +278,11 @@ export class Editor implements PlayHost {
readonly variablesEditor: VariablesEditor; readonly variablesEditor: VariablesEditor;
readonly routingEditor: RoutingEditor; readonly routingEditor: RoutingEditor;
readonly eqEditor: EqEditor; readonly eqEditor: EqEditor;
readonly compressorEditor: CompressorEditor;
/** What each open Variables tab shows, by its document. */ /** What each open Variables tab shows, by its document. */
private readonly valueTargets = new Map<DocumentId, ValuesTarget>(); private readonly valueTargets = new Map<DocumentId, ValuesTarget>();
/** The node behind each EQ tab (#370). */ /** The node behind each tab of an EQ (#370) or a Compressor (#378). */
private readonly eqTargets = new Map<DocumentId, { assetId: string; nodeId: string }>(); private readonly nodeTargets = new Map<DocumentId, { assetId: string; nodeId: string }>();
/** The switch per file in `data/plugins/`, in the user management tab (#179). */ /** The switch per file in `data/plugins/`, in the user management tab (#179). */
readonly pluginsPane = new PluginsPane(); readonly pluginsPane = new PluginsPane();
/** The accounts, above the plugins; null unless an administrator is signed in. */ /** 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.variablesEditor = new VariablesEditor(state);
this.routingEditor = new RoutingEditor(state); this.routingEditor = new RoutingEditor(state);
this.eqEditor = new EqEditor(state); this.eqEditor = new EqEditor(state);
this.compressorEditor = new CompressorEditor(state);
this.hierarchy = new HierarchyPanel(state); this.hierarchy = new HierarchyPanel(state);
this.inspector = new Inspector(state); this.inspector = new Inspector(state);
this.tilemapPanel = new TilemapPanel(state); this.tilemapPanel = new TilemapPanel(state);
@@ -491,6 +505,7 @@ export class Editor implements PlayHost {
variables: this.variablesEditor, variables: this.variablesEditor,
routing: this.routingEditor, routing: this.routingEditor,
eq: this.eqEditor, eq: this.eqEditor,
compressor: this.compressorEditor,
management: this.managementPane, management: this.managementPane,
}; };
// Following a call goes on in the same tab: the tab changes its document, not its place. // 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); if (!this.valueTargets.has(doc)) this.valueTargets.set(doc, target);
this.openDocument(doc); this.openDocument(doc);
}); });
// A tab per Parametric EQ node (#370), opened by a double-click on it. // A tab per Parametric EQ (#370) or Compressor (#378) node, opened by a double-click on it.
state.on('eq', () => { state.on('nodeTab', () => {
const target = state.openedEq; const target = state.openedNode;
if (!target) return; const kind = target && nodeTabOf(target.type);
const doc: DocumentId = `eq:${target.assetId}:${target.nodeId}`; if (!target || !kind) return;
if (!this.eqTargets.has(doc)) this.eqTargets.set(doc, target); const doc: DocumentId = `${kind}:${target.assetId}:${target.nodeId}`;
if (!this.nodeTargets.has(doc)) this.nodeTargets.set(doc, target);
this.openDocument(doc); this.openDocument(doc);
}); });
// The tabs play through the buses and the master as the game would (#325). // 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); for (const doc of this.documents.docs) this.tabs.removeTab(doc);
this.documents = { docs: [], active: null }; this.documents = { docs: [], active: null };
this.valueTargets.clear(); this.valueTargets.clear();
this.eqTargets.clear(); this.nodeTargets.clear();
this.eqEditor.close(); this.eqEditor.close();
this.compressorEditor.close();
this.showTab('scene'); this.showTab('scene');
}); });
state.on('mode', () => this.editorRun.modeChanged()); state.on('mode', () => this.editorRun.modeChanged());
@@ -886,7 +903,10 @@ export class Editor implements PlayHost {
if (kind && kind in this.assetEditors) if (kind && kind in this.assetEditors)
this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length)); this.assetEditors[kind as AssetKind].forgetView(doc.slice('asset:'.length));
this.valueTargets.delete(doc); 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(); if (doc === 'routing') this.routingEditor.close();
} }
@@ -1091,9 +1111,10 @@ export class Editor implements PlayHost {
return target && valuesTitle(this.state, target) !== null ? 'variables' : null; return target && valuesTitle(this.state, target) !== null ? 'variables' : null;
} }
if (doc === 'management' || doc === 'routing') return doc; if (doc === 'management' || doc === 'routing') return doc;
if (doc.startsWith('eq:')) { const nodeTab = nodeTabOfDocument(doc);
const target = this.eqTargets.get(doc); if (nodeTab) {
return target && eqNodeOf(this.state.project, target.assetId, target.nodeId) ? 'eq' : null; 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 type = findAsset(this.state.project, doc.slice('asset:'.length))?.type;
const kind = type ? TAB_OF_ASSET[type] : undefined; 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. // 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 === 'management') void this.usersPane?.reload();
if (kind === 'routing') this.routingEditor.open(); if (kind === 'routing') this.routingEditor.open();
const eq = this.eqTargets.get(doc); const node = this.nodeTargets.get(doc);
if (kind === 'eq' && eq) this.eqEditor.show(eq.assetId, eq.nodeId); 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. */ /** 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 { private labelOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins'; if (doc === 'management') return this.usersPane ? TAB_LABELS.management : 'Plugins';
if (doc === 'routing') return TAB_LABELS.routing; if (doc === 'routing') return TAB_LABELS.routing;
const eq = this.eqTargets.get(doc); const node = this.nodeTargets.get(doc);
if (eq) return eqTabLabel(this.state.project, eq.assetId, eq.nodeId); const nodeTab = nodeTabOfDocument(doc);
if (node && nodeTab) return nodeTabLabel(this.state.project, nodeTab, node.assetId, node.nodeId);
const target = this.valueTargets.get(doc); const target = this.valueTargets.get(doc);
if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? ''; if (target) return valuesTitle(this.state, target, this.inspector.liveVariables) ?? '';
const asset = findAsset(this.state.project, doc.slice('asset:'.length)); const asset = findAsset(this.state.project, doc.slice('asset:'.length));
@@ -1139,7 +1162,7 @@ export class Editor implements PlayHost {
private iconOf(doc: DocumentId): string { private iconOf(doc: DocumentId): string {
if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG; if (doc === 'management') return this.usersPane ? USERS_ICON_SVG : PLUGINS_ICON_SVG;
if (doc === 'routing') return ASSET_ICON_SVG.bus; 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); const target = this.valueTargets.get(doc);
if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables; if (target) return target.kind === 'list' ? LIST_ICON_SVG : ASSET_ICON_SVG.variables;
const type = findAsset(this.state.project, doc.slice('asset:'.length))?.type; 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('sound')) this.soundEditor.tick();
if (this.shows('eq')) this.eqEditor.tick(); if (this.shows('eq')) this.eqEditor.tick();
else this.eqEditor.hideSpectrum(); 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 // 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. // to do it, and its states move just the same.
if (this.shows('statemachine') && this.editorRun.isRunning) this.stateMachineEditor.canvas.redraw(); 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('pattern') && this.patternEditor.isPlaying) ||
(this.shows('song') && this.songEditor.isPlaying) || (this.shows('song') && this.songEditor.isPlaying) ||
(this.shows('sound') && this.soundEditor.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)
); );
} }
+24
View File
@@ -148,6 +148,30 @@ export function graphHooks({ state, editor }: HookContext) {
*/ */
spectrum: () => ({ ...editor.eqEditor.curve.seen, taps: soundLibrary.spectrumTaps() }), spectrum: () => ({ ...editor.eqEditor.curve.seen, taps: soundLibrary.spectrumTaps() }),
}, },
/** The Compressor's tab (#378): its node, its numbers and knobs, and where to aim on the curve. */
compressor: {
shown: () => (editor.tab === 'compressor' ? editor.compressorEditor.shownFor : null),
numbers: () => editor.compressorEditor.numbers(),
/** Each knob's value, and whether it can be turned (a linked input greys it). */
knobs: () =>
Object.fromEntries(
Object.entries(editor.compressorEditor.knobs).map(([key, knob]) => [
key,
{ value: knob.value, enabled: knob.enabled, field: knob.field.value },
]),
),
dialAt: (key: keyof typeof editor.compressorEditor.knobs) =>
editor.compressorEditor.knobs[key].dialGlobal(),
fieldAt: (key: keyof typeof editor.compressorEditor.knobs) =>
centreOf(editor.compressorEditor.knobs[key].field),
handleAt: (key: 'threshold' | 'ratio') => editor.compressorEditor.curve.handleGlobal(key),
pointAt: (inDb: number, outDb: number) => editor.compressorEditor.curve.pointGlobal(inDb, outDb),
/**
* The levels read so far (#378): how many readings with something coming in, the loudest on
* either side, the most taken away in one, and the taps as for the EQ.
*/
levels: () => ({ ...editor.compressorEditor.seen, taps: soundLibrary.spectrumTaps() }),
},
instrument: { instrument: {
current: () => editor.instrumentEditor.currentAssetId, current: () => editor.instrumentEditor.currentAssetId,
info: () => editor.instrumentEditor.shownInfo, info: () => editor.instrumentEditor.shownInfo,
+3 -3
View File
@@ -30,7 +30,7 @@ import { graphLookup } from '../../core/model/graphLookup';
import { particleLibrary } from '../../viewport/ParticleLibrary'; import { particleLibrary } from '../../viewport/ParticleLibrary';
import { shaderLibrary } from '../../viewport/ShaderLibrary'; import { shaderLibrary } from '../../viewport/ShaderLibrary';
import { soundFxBuildFor } from '../../core/soundfx/compile'; import { soundFxBuildFor } from '../../core/soundfx/compile';
import { SoundFxParametricEq } from '../../core/soundfx/nodes'; import { nodeTabOf } from '../soundfx/nodeTabs';
import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile'; import { instrumentCheck, instrumentSourcesOf } from '../../core/instrument/compile';
import { masterRoutingProblems } from '../../core/music/projectRouting'; import { masterRoutingProblems } from '../../core/music/projectRouting';
import { routingProblems } from '../../core/music/routing'; import { routingProblems } from '../../core/music/routing';
@@ -670,8 +670,8 @@ export class GraphCanvas extends Widget {
} }
private openCalledBehavior(node: GraphNode): boolean { private openCalledBehavior(node: GraphNode): boolean {
if (node.type === SoundFxParametricEq.type) { if (nodeTabOf(node.type)) {
this.state.openEq(this.assetId, node.id); this.state.openNodeTab(this.assetId, node.id, node.type);
return true; return true;
} }
// A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already. // A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already.
+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`;
}
+6 -4
View File
@@ -462,7 +462,7 @@ export class EqEditor extends VStack implements EqHost {
if (assetId !== this.assetId || nodeId !== this.nodeId) { if (assetId !== this.assetId || nodeId !== this.nodeId) {
this.curve.settle(); this.curve.settle();
this.perFrame.cancel(); this.perFrame.cancel();
this.curve.showSpectrum(null); this.hideSpectrum();
this.before = null; this.before = null;
this.assetId = assetId; this.assetId = assetId;
this.nodeId = nodeId; this.nodeId = nodeId;
@@ -474,6 +474,7 @@ export class EqEditor extends VStack implements EqHost {
/** The tab is closed: the editor lets go of its node. */ /** The tab is closed: the editor lets go of its node. */
close(): void { close(): void {
this.perFrame.cancel(); this.perFrame.cancel();
this.hideSpectrum();
const node = this.node(); const node = this.node();
if (node) this.restore(node); if (node) this.restore(node);
this.assetId = ''; this.assetId = '';
@@ -489,13 +490,14 @@ export class EqEditor extends VStack implements EqHost {
/** Only called while the tab is seen: the spectrum of what passes the node, a frame on (#374). */ /** Only called while the tab is seen: the spectrum of what passes the node, a frame on (#374). */
tick(): void { tick(): void {
if (!this.nodeId) return; if (!this.nodeId) return;
soundLibrary.listen(tapId(this.assetId, this.nodeId)); const id = tapId(this.assetId, this.nodeId);
this.curve.showSpectrum(soundLibrary.spectrum()); soundLibrary.listen(id);
this.curve.showSpectrum(soundLibrary.spectrum(id));
} }
/** The tab is not seen: no analyser is fed for it. */ /** The tab is not seen: no analyser is fed for it. */
hideSpectrum(): void { hideSpectrum(): void {
soundLibrary.unlisten(); if (this.nodeId) soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
this.curve.showSpectrum(null); this.curve.showSpectrum(null);
} }
+179
View File
@@ -0,0 +1,179 @@
/**
* What the tab of a Compressor (#378) draws, as numbers: the level coming in and going out over
* the last seconds, the curve from level in to level out, and where its threshold and ratio are
* grabbed. Kept apart from the drawing so it is checked without a browser.
*/
import { compressorAutoGain, compressorCurve } from '../../core/soundfx/dynamics';
import { clamp, snapToStep } from '../../ui/math';
import type { Levels } from '../../viewport/spectrum';
/** The dB the level picture spans, bottom to top. */
export const LEVEL_FLOOR_DB = -60;
export const LEVEL_TOP_DB = 6;
/** How long the level picture looks back. */
export const HISTORY_SECONDS = 4;
/** How long a level is the loudest sample of: a frame and a bit, so no peak falls between two. */
export const LEVEL_WINDOW = 0.025;
/** The dB the curve spans on both axes, from here to 0. */
export const CURVE_FLOOR_DB = -60;
/** Less taken away than this is not tinted: the two sides are read a few ms apart. */
export const REDUCTION_SHOWN_DB = 0.5;
export interface CompressorNumbers {
threshold: number;
knee: number;
ratio: number;
attack: number;
release: number;
makeup: number;
autoGain: number;
}
const dbToLinear = (db: number) => 10 ** (db / 20);
const linearToDb = (value: number) => (value <= 0 ? -Infinity : 20 * Math.log10(value));
/** What the gain after the compressing adds, in dB: makeup and auto-gain blended. */
export function compressorGainDb(n: CompressorNumbers): number {
const w = clamp(n.autoGain, 0, 1);
return (1 - w) * n.makeup + w * compressorAutoGain(n.threshold, n.knee, n.ratio);
}
/** The level a steady one of `inDb` comes out at, before the gain after it. */
export function curveOutDb(inDb: number, threshold: number, knee: number, ratio: number): number {
return linearToDb(compressorCurve(threshold, knee, ratio)(dbToLinear(inDb)));
}
// --- The curve: a square, level in across, level out up, both CURVE_FLOOR_DB..0. ---
export const curveX = (db: number, size: number): number =>
((clamp(db, CURVE_FLOOR_DB, 0) - CURVE_FLOOR_DB) / -CURVE_FLOOR_DB) * size;
export const curveY = (db: number, size: number): number => size - curveX(db, size);
export const curveDbAtX = (x: number, size: number): number => CURVE_FLOOR_DB + (x / size) * -CURVE_FLOOR_DB;
export const curveDbAtY = (y: number, size: number): number => curveDbAtX(size - y, size);
/** The curve as points across the square. */
export function curvePoints(n: CompressorNumbers, size: number, steps: number): number[] {
const curve = compressorCurve(n.threshold, n.knee, n.ratio);
const points: number[] = [];
for (let i = 0; i <= steps; i++) {
const db = CURVE_FLOOR_DB + (i / steps) * -CURVE_FLOOR_DB;
points.push(curveX(db, size), curveY(linearToDb(curve(dbToLinear(db))), size));
}
return points;
}
/** Where the threshold is grabbed: on the curve where it starts to bend. */
export function thresholdHandle(n: CompressorNumbers, size: number): { x: number; y: number } {
return { x: curveX(n.threshold, size), y: curveY(n.threshold, size) };
}
/** Where the ratio is grabbed: the end of the curve, at a level of 0 dB going in. */
export function ratioHandle(n: CompressorNumbers, size: number): { x: number; y: number } {
return { x: size, y: curveY(curveOutDb(0, n.threshold, n.knee, n.ratio), size) };
}
/**
* The ratio whose curve comes out at `outDb` for 0 dB going in, held to `min`..`max` and rounded
* to `step`. The higher the ratio, the lower the end of the curve, so it is found by halving.
*/
export function ratioFor(
outDb: number,
n: CompressorNumbers,
range: { min: number; max: number; step: number },
): number {
const end = (ratio: number) => curveOutDb(0, n.threshold, n.knee, ratio);
if (outDb >= end(range.min)) return range.min;
if (outDb <= end(range.max)) return range.max;
let low = range.min;
let high = range.max;
for (let i = 0; i < 40; i++) {
const mid = (low + high) / 2;
if (end(mid) > outDb) low = mid;
else high = mid;
}
return clamp(snapToStep((low + high) / 2, range.step), range.min, range.max);
}
// --- The level picture: time across, the newest at the right edge; dB up. ---
export const levelY = (db: number, h: number): number =>
((LEVEL_TOP_DB - clamp(db, LEVEL_FLOOR_DB, LEVEL_TOP_DB)) / (LEVEL_TOP_DB - LEVEL_FLOOR_DB)) * h;
export const historyX = (t: number, now: number, w: number): number => w - ((now - t) / HISTORY_SECONDS) * w;
/** One reading of both sides. */
export interface LevelPoint extends Levels {
t: number;
}
/** The readings of the last seconds, oldest first. */
export class LevelHistory {
private points: LevelPoint[] = [];
/** A reading at `t` seconds; none heard counts as silence on both sides. */
push(t: number, levels: Levels | null): void {
this.points.push({ t, in: levels?.in ?? -Infinity, out: levels?.out ?? -Infinity });
const old = this.points.findIndex((p) => p.t >= t - HISTORY_SECONDS);
if (old > 0) this.points.splice(0, old);
}
clear(): void {
this.points = [];
}
get all(): readonly LevelPoint[] {
return this.points;
}
/** Whether anything above the floor is still in the picture, so it has to move on. */
get heard(): boolean {
return this.points.some((p) => p.in > LEVEL_FLOOR_DB || p.out > LEVEL_FLOOR_DB);
}
get latest(): LevelPoint | null {
return this.points.at(-1) ?? null;
}
}
/** How much the compressing takes away at a reading, in dB: the gain after it left out. */
export function reductionOf(point: Levels, gainDb: number): number {
if (!Number.isFinite(point.in) || !Number.isFinite(point.out)) return 0;
return Math.max(0, point.in - (point.out - gainDb));
}
/**
* The tinted shapes where the compressor works: from the level coming in down by what it takes
* away, one shape for each stretch where it takes away more than `REDUCTION_SHOWN_DB`.
*/
export function reductionShapes(
points: readonly LevelPoint[],
gainDb: number,
now: number,
w: number,
h: number,
): number[][] {
const shapes: number[][] = [];
let top: number[] = [];
let bottom: number[] = [];
const close = () => {
if (top.length >= 4) {
const back: number[] = [];
for (let i = bottom.length - 2; i >= 0; i -= 2) back.push(bottom[i]!, bottom[i + 1]!);
shapes.push([...top, ...back]);
}
top = [];
bottom = [];
};
for (const point of points) {
const reduction = reductionOf(point, gainDb);
if (reduction < REDUCTION_SHOWN_DB) {
close();
continue;
}
const x = historyX(point.t, now, w);
top.push(x, levelY(point.in, h));
bottom.push(x, levelY(point.in - reduction, h));
}
close();
return shapes;
}
+36
View File
@@ -0,0 +1,36 @@
/**
* The nodes of a sound FX graph with a tab of their own, opened by a double-click on them: a
* Parametric EQ (#370) and a Compressor (#378). A tab is a document named by its kind, the asset
* and the node (`eq:<asset>:<node>`).
*/
import type { GraphNode } from '../../core/graph';
import type { Project } from '../../core/model/Project';
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
import { compressorNodeOf, compressorTabLabel } from './CompressorEditor';
import { eqNodeOf, eqTabLabel } from './EqEditor';
export type NodeTabKind = 'eq' | 'compressor';
export const NODE_TAB_KINDS: readonly NodeTabKind[] = ['eq', 'compressor'];
const KIND_OF_TYPE: Readonly<Record<string, NodeTabKind>> = {
[SoundFxParametricEq.type]: 'eq',
[SoundFxCompressor.type]: 'compressor',
};
/** The kind of tab a node opens, or null for one without. */
export const nodeTabOf = (type: string): NodeTabKind | null => KIND_OF_TYPE[type] ?? null;
/** The node a tab is about, or null once it, or its asset, is gone. */
export function nodeOfTab(
project: Project,
kind: NodeTabKind,
assetId: string,
nodeId: string,
): GraphNode | null {
return kind === 'eq' ? eqNodeOf(project, assetId, nodeId) : compressorNodeOf(project, assetId, nodeId);
}
export function nodeTabLabel(project: Project, kind: NodeTabKind, assetId: string, nodeId: string): string {
return kind === 'eq' ? eqTabLabel(project, assetId, nodeId) : compressorTabLabel(project, assetId, nodeId);
}
+12 -9
View File
@@ -228,8 +228,8 @@ export type EditorEvent =
| 'selection' | 'selection'
/** A Variables tab was asked for (`openedValues`). */ /** A Variables tab was asked for (`openedValues`). */
| 'values' | 'values'
/** A Parametric EQ's tab was asked for (`openedEq`). */ /** The tab of a Parametric EQ or a Compressor node was asked for (`openedNode`). */
| 'eq' | 'nodeTab'
/** An asset was opened (double-click, Enter, or just created): its tab should show. */ /** An asset was opened (double-click, Enter, or just created): its tab should show. */
| 'open' | 'open'
/** An asset should light up where it is listed (`pingedAssetId`), without being selected. */ /** An asset should light up where it is listed (`pingedAssetId`), without being selected. */
@@ -258,8 +258,8 @@ export class EditorState {
selection: Selection = null; selection: Selection = null;
/** The last target asked for by `openValues`; the tabs themselves are the editor's. */ /** The last target asked for by `openValues`; the tabs themselves are the editor's. */
openedValues: ValuesTarget | null = null; openedValues: ValuesTarget | null = null;
/** The last Parametric EQ node asked for by `openEq`. */ /** The last node asked for by `openNodeTab`. */
openedEq: { assetId: string; nodeId: string } | null = null; openedNode: { assetId: string; nodeId: string; type: string } | null = null;
mode: EditorMode = 'edit'; mode: EditorMode = 'edit';
/** /**
* A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all * A sub-state of play rather than a third mode: the runtime copy, the keyboard and unsaved edits all
@@ -323,7 +323,7 @@ export class EditorState {
this.commands = new CommandStack(project); this.commands = new CommandStack(project);
this.selection = null; this.selection = null;
this.openedValues = null; this.openedValues = null;
this.openedEq = null; this.openedNode = null;
this.clipboard = null; this.clipboard = null;
this.patternPlaces.clear(); this.patternPlaces.clear();
this.saveStatus = 'saved'; this.saveStatus = 'saved';
@@ -538,10 +538,13 @@ export class EditorState {
this.emit('values'); this.emit('values');
} }
/** Asks for the tab of a Parametric EQ node (#370), the way `openValues` asks for a Variables tab. */ /**
openEq(assetId: string, nodeId: string): void { * Asks for the tab of a Parametric EQ (#370) or Compressor (#378) node, the way `openValues` asks
this.openedEq = { assetId, nodeId }; * for a Variables tab.
this.emit('eq'); */
openNodeTab(assetId: string, nodeId: string, type: string): void {
this.openedNode = { assetId, nodeId, type };
this.emit('nodeTab');
} }
/** /**
+1
View File
@@ -56,6 +56,7 @@ export { UI_ICON_SVG } from './icons';
export { Checkbox } from './widgets/Checkbox'; export { Checkbox } from './widgets/Checkbox';
export { Panel } from './widgets/Panel'; export { Panel } from './widgets/Panel';
export { Slider } from './widgets/Slider'; export { Slider } from './widgets/Slider';
export { Knob, type KnobOptions } from './widgets/Knob';
export { Splitter } from './widgets/Splitter'; export { Splitter } from './widgets/Splitter';
export { ScrollContainer } from './widgets/ScrollContainer'; export { ScrollContainer } from './widgets/ScrollContainer';
export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView'; export { type DropSpot, ListView, type ListItem, type PickModifiers } from './widgets/ListView';
+56
View File
@@ -0,0 +1,56 @@
/**
* The rules of a Knob (#378): where on its turn a value sits, and what a drag or a wheel makes of
* it. A turn is a position from 0 to 1; `taper` bends it, so a range whose useful part is small –
* an attack of a few milliseconds out of a second – gets most of the turn.
*/
import { clamp, snapToStep } from './math';
export interface KnobRange {
min: number;
max: number;
/** The grid a value snaps to. */
step: number;
/** 1 turns evenly; above it the low end gets more of the turn. */
taper?: number;
}
/** Pixels of a vertical drag that take the knob from one end to the other. */
export const KNOB_TRAVEL = 200;
/** How much of the turn Shift leaves to a drag. */
export const KNOB_FINE = 0.1;
/** How far one notch of the wheel turns the knob. */
export const KNOB_NOTCH = 0.02;
/** The turn as an angle: from down-left round over the top to down-right, in radians. */
export const KNOB_START = Math.PI * 0.75;
export const KNOB_SWEEP = Math.PI * 1.5;
export function knobPosition(value: number, range: KnobRange): number {
const share = clamp((value - range.min) / (range.max - range.min), 0, 1);
return share ** (1 / (range.taper ?? 1));
}
export function knobValue(position: number, range: KnobRange): number {
const share = clamp(position, 0, 1) ** (range.taper ?? 1);
return clamp(snapToStep(range.min + share * (range.max - range.min), range.step), range.min, range.max);
}
/**
* The value a vertical drag reaches from `start`: up turns it up. A drag too short to reach the
* next step still moves it by one, so a fine drag over a coarse range is never stuck.
*/
export function draggedValue(start: number, dy: number, fine: boolean, range: KnobRange): number {
const travel = (-dy / KNOB_TRAVEL) * (fine ? KNOB_FINE : 1);
const value = knobValue(knobPosition(start, range) + travel, range);
if (value !== start || travel === 0) return value;
return clamp(snapToStep(start + Math.sign(travel) * range.step, range.step), range.min, range.max);
}
/** The value one notch of the wheel reaches: up turns it up, by at least one step. */
export function wheeledValue(value: number, deltaY: number, range: KnobRange): number {
return draggedValue(value, Math.sign(deltaY) * KNOB_NOTCH * KNOB_TRAVEL, false, range);
}
/** Where the filled arc starts: at 0 when the range has it inside, else at the low end. */
export function knobOrigin(range: KnobRange): number {
return range.min < 0 && range.max > 0 ? knobPosition(0, range) : 0;
}
+173
View File
@@ -0,0 +1,173 @@
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
import { pressCount } from '../clicks';
import {
draggedValue,
KNOB_START,
KNOB_SWEEP,
knobOrigin,
knobPosition,
type KnobRange,
wheeledValue,
} from '../knob';
import { VStack } from '../layout/Stack';
import { Theme } from '../Theme';
import { Widget } from '../Widget';
import { Label } from './Label';
import { NumberField } from './NumberField';
const DIAL_SIZE = 44;
const TRACK_WIDTH = 4;
export interface KnobOptions extends KnobRange {
/** What a double-click on the dial goes back to. */
default: number;
/** Decimal places the number below shows. */
decimals: number;
/** Fires on every step of a drag, and as a number is typed. */
onInput?: (value: number) => void;
/** Fires once a drag ends, on a notch of the wheel, a double-click or a typed number. */
onChange?: (value: number) => void;
}
/** The round part: a drag up or down turns it, the wheel notches it, a double-click resets it. */
class Dial extends Widget {
private readonly arc = new Graphics();
private drag: { startY: number; start: number; moved: boolean } | null = null;
constructor(private readonly knob: Knob) {
super();
this.minWidth = DIAL_SIZE;
this.minHeight = DIAL_SIZE;
this.cursor = 'ns-resize';
this.addChild(this.arc);
this.trackHover();
this.on('pointerdown', (e: FederatedPointerEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
if (pressCount(e, this) === 2) {
this.drag = null;
knob.commit(knob.options.default);
return;
}
this.drag = { startY: e.global.y, start: knob.value, moved: false };
});
this.on('globalpointermove', (e: FederatedPointerEvent) => {
const drag = this.drag;
if (!drag) return;
const dy = e.global.y - drag.startY;
if (!drag.moved && dy === 0) return;
drag.moved = true;
knob.preview(draggedValue(drag.start, dy, e.shiftKey, knob.options));
});
this.onPointerRelease(() => {
const drag = this.drag;
this.drag = null;
if (drag?.moved) knob.commit(knob.value);
this.invalidate();
});
this.on('wheel', (e: FederatedWheelEvent) => {
if (e.deltaY === 0) return;
e.stopPropagation();
knob.commit(wheeledValue(knob.value, e.deltaY, knob.options));
});
}
get dragging(): boolean {
return this.drag !== null;
}
override redraw(): void {
const c = Theme.colors;
const size = Math.min(this.w, this.h);
const x = this.w / 2;
const y = this.h / 2;
const r = size / 2 - TRACK_WIDTH;
const angle = (position: number) => KNOB_START + position * KNOB_SWEEP;
const at = knobPosition(this.knob.value, this.knob.options);
const origin = knobOrigin(this.knob.options);
this.arc.clear();
this.arc.arc(x, y, r, angle(0), angle(1)).stroke({ color: c.control, width: TRACK_WIDTH, cap: 'round' });
if (at !== origin)
this.arc
.arc(x, y, r, angle(Math.min(at, origin)), angle(Math.max(at, origin)))
.stroke({ color: c.accent, width: TRACK_WIDTH, cap: 'round' });
const inner = r - TRACK_WIDTH - 2;
this.arc
.circle(x, y, inner)
.fill(this.hovered || this.dragging ? c.controlHover : c.control)
.stroke({ width: 1, color: c.border });
const a = angle(at);
this.arc
.moveTo(x + Math.cos(a) * inner * 0.3, y + Math.sin(a) * inner * 0.3)
.lineTo(x + Math.cos(a) * inner, y + Math.sin(a) * inner)
.stroke({ color: c.symbol, width: 2, cap: 'round' });
}
}
/**
* A number on a round dial (#378): a slider bent into a circle, its name above and the number
* below, which takes a typed value on a click.
*/
export class Knob extends VStack {
readonly dial: Dial;
readonly field: NumberField;
private readonly caption: Label;
private current: number;
constructor(
label: string,
value: number,
readonly options: KnobOptions,
) {
super({ gap: 2, align: 'center' });
this.current = value;
this.caption = new Label(label, { style: 'small' });
this.dial = new Dial(this);
this.field = new NumberField(value, {
width: 56,
min: options.min,
max: options.max,
step: options.step,
decimals: options.decimals,
onInput: (typed) => this.preview(typed),
onChange: (typed) => this.commit(typed),
});
this.add(this.caption);
this.add(this.dial);
this.add(this.field);
}
get value(): number {
return this.current;
}
/** Shows a value from outside; fires nothing. A drag going on keeps its own. */
setValue(value: number): void {
if (this.dial.dragging || value === this.current) return;
this.current = value;
this.field.setNumber(value);
this.dial.invalidate();
}
preview(value: number): void {
this.show(value);
this.options.onInput?.(value);
}
commit(value: number): void {
this.show(value);
this.options.onChange?.(value);
}
private show(value: number): void {
this.current = value;
this.field.setNumber(value);
this.dial.invalidate();
}
/** Where the dial is on screen, for the check scripts. */
dialGlobal(): { x: number; y: number } {
const at = this.dial.getGlobalPosition();
return { x: at.x + this.dial.w / 2, y: at.y + this.dial.h / 2 };
}
}
+12 -7
View File
@@ -58,7 +58,7 @@ import {
releaseEnvelope, releaseEnvelope,
stopProcessor, stopProcessor,
} from './fxNodes'; } from './fxNodes';
import { type Spectrum, SpectrumTaps } from './spectrum'; import { type Levels, type Spectrum, SpectrumTaps } from './spectrum';
import { decodeSpan, StreamFeed, StreamFile } from './streaming'; import { decodeSpan, StreamFeed, StreamFile } from './streaming';
import { wake } from './wake'; import { wake } from './wake';
@@ -620,19 +620,24 @@ export class SoundLibrary implements AudioSink {
} }
/** /**
* Listens to the sound in front of the node `id` (`tapId`) and behind it (#374): a Parametric * Listens to the sound in front of the node `id` (`tapId`) and behind it (#374): the tab of a
* EQ's tab while it is seen. `spectrum` reads it, `unlisten` lets it go. * Parametric EQ or a Compressor (#378) while it is seen. `spectrum` and `nodeLevels` read it,
* `unlisten` lets it go.
*/ */
listen(id: string): void { listen(id: string): void {
this.spectra.listen(id); this.spectra.listen(id);
} }
unlisten(): void { unlisten(id: string): void {
this.spectra.stop(); this.spectra.stop(id);
} }
spectrum(): Spectrum | null { spectrum(id: string): Spectrum | null {
return this.spectra.read(); return this.spectra.read(id);
}
nodeLevels(id: string, seconds: number): Levels | null {
return this.spectra.levels(id, seconds);
} }
/** The node listened to, the taps playing, and how many of them feed an analyser (test hook). */ /** The node listened to, the taps playing, and how many of them feed an analyser (test hook). */
+71 -32
View File
@@ -1,7 +1,8 @@
/** /**
* The sound in front of a node and behind it, as an analyser hears it (#374). Every voice and bus * The sound in front of a node and behind it, as an analyser hears it (#374). Every voice and bus
* passing a tap hands its node in for as long as it plays; while the node is listened to, they all * passing a tap hands its node in for as long as it plays; while the node is listened to, they all
* feed one analyser for each side. Nothing is made before a tap of that node plays. * feed one analyser for each side. Nothing is made before a tap of that node plays. More than one
* node can be listened to at once: a Parametric EQ and a Compressor (#378) each in a tab of their own.
*/ */
import type { FxTap } from '../core/soundfx/nodes'; import type { FxTap } from '../core/soundfx/nodes';
@@ -15,6 +16,8 @@ interface Analysis {
sum: GainNode; sum: GainNode;
analyser: AnalyserNode; analyser: AnalyserNode;
data: Float32Array<ArrayBuffer>; data: Float32Array<ArrayBuffer>;
/** The latest samples, for `levels`. */
wave: Float32Array<ArrayBuffer>;
} }
/** Both sides of the node in dB, one level every `binHz`. */ /** Both sides of the node in dB, one level every `binHz`. */
@@ -24,6 +27,12 @@ export interface Spectrum {
binHz: number; binHz: number;
} }
/** The loudest sample on each side of the node over a short while, in dB; -Infinity for silence. */
export interface Levels {
in: number;
out: number;
}
const keyOf = (id: string, at: Side) => `${at}:${id}`; const keyOf = (id: string, at: Side) => `${at}:${id}`;
/** Cuts one link; it may be gone with the rest of a voice already. */ /** Cuts one link; it may be gone with the rest of a voice already. */
@@ -38,9 +47,11 @@ function cut(from: AudioNode, to: AudioNode): void {
export class SpectrumTaps { export class SpectrumTaps {
/** The nodes of the taps that play now, by node and side. */ /** The nodes of the taps that play now, by node and side. */
private readonly held = new Map<string, Set<AudioNode>>(); private readonly held = new Map<string, Set<AudioNode>>();
private listened = ''; /**
/** Made once a tap of the node listened to plays; kept while it is listened to. */ * The nodes listened to, each with its analysers: null until a tap of it plays, then kept while
private sides: Record<Side, Analysis> | null = null; * it is listened to.
*/
private readonly listened = new Map<string, Record<Side, Analysis> | null>();
/** A tap of a voice or a bus as it starts to play; what it hands back is called once it stops. */ /** A tap of a voice or a bus as it starts to play; what it hands back is called once it stops. */
hold(tap: FxTap, node: AudioNode): () => void { hold(tap: FxTap, node: AudioNode): () => void {
@@ -48,42 +59,43 @@ export class SpectrumTaps {
const nodes = this.held.get(key) ?? new Set<AudioNode>(); const nodes = this.held.get(key) ?? new Set<AudioNode>();
this.held.set(key, nodes); this.held.set(key, nodes);
nodes.add(node); nodes.add(node);
if (tap.id === this.listened) node.connect(this.analysis(node.context)[tap.at].sum); if (this.listened.has(tap.id)) node.connect(this.analysis(tap.id, node.context)[tap.at].sum);
return () => { return () => {
if (!nodes.delete(node)) return; if (!nodes.delete(node)) return;
if (nodes.size === 0 && this.held.get(key) === nodes) this.held.delete(key); if (nodes.size === 0 && this.held.get(key) === nodes) this.held.delete(key);
if (tap.id === this.listened && this.sides) cut(node, this.sides[tap.at].sum); const sides = this.listened.get(tap.id);
if (sides) cut(node, sides[tap.at].sum);
}; };
} }
/** Listens to the node `id` (`tapId`) and to no other. */ /** Listens to the node `id` (`tapId`), besides those listened to already. */
listen(id: string): void { listen(id: string): void {
if (id === this.listened) return; if (this.listened.has(id)) return;
this.stop(); this.listened.set(id, null);
this.listened = id;
for (const at of SIDES) for (const at of SIDES)
for (const node of this.held.get(keyOf(id, at)) ?? []) for (const node of this.held.get(keyOf(id, at)) ?? [])
node.connect(this.analysis(node.context)[at].sum); node.connect(this.analysis(id, node.context)[at].sum);
} }
/** Lets the analysers go: no tap feeds one any more. */ /** Lets the analysers of the node `id` go, or of every node: no tap feeds one any more. */
stop(): void { stop(id?: string): void {
const { sides, listened } = this; for (const listened of id === undefined ? [...this.listened.keys()] : [id]) {
this.listened = ''; const sides = this.listened.get(listened);
this.sides = null; this.listened.delete(listened);
if (!sides) return; if (!sides) continue;
for (const at of SIDES) { for (const at of SIDES) {
for (const node of this.held.get(keyOf(listened, at)) ?? []) cut(node, sides[at].sum); for (const node of this.held.get(keyOf(listened, at)) ?? []) cut(node, sides[at].sum);
sides[at].sum.disconnect(); sides[at].sum.disconnect();
}
} }
} }
/** /**
* The spectrum on both sides of the node listened to, smoothed by the analyser: once its sound has * The spectrum on both sides of the node `id`, smoothed by the analyser: once its sound has gone
* gone it falls away. Null before anything has played through it. * it falls away. Null before anything has played through it, or while it is not listened to.
*/ */
read(): Spectrum | null { read(id: string): Spectrum | null {
const sides = this.sides; const sides = this.listened.get(id);
if (!sides) return null; if (!sides) return null;
for (const at of SIDES) sides[at].analyser.getFloatFrequencyData(sides[at].data); for (const at of SIDES) sides[at].analyser.getFloatFrequencyData(sides[at].data);
return { return {
@@ -93,18 +105,40 @@ export class SpectrumTaps {
}; };
} }
/** The node listened to, the taps playing and how many of them feed an analyser (test hook). */ /**
* The loudest sample on both sides of the node `id` over the last `seconds` (#378). Null before
* anything has played through it, or while it is not listened to.
*/
levels(id: string, seconds: number): Levels | null {
const sides = this.listened.get(id);
if (!sides) return null;
const count = Math.max(
1,
Math.min(SPECTRUM_SIZE, Math.round(seconds * sides.in.analyser.context.sampleRate)),
);
const peakOf = ({ analyser, wave }: Analysis) => {
analyser.getFloatTimeDomainData(wave);
let peak = 0;
for (let i = wave.length - count; i < wave.length; i++) peak = Math.max(peak, Math.abs(wave[i]!));
return peak > 0 ? 20 * Math.log10(peak) : -Infinity;
};
return { in: peakOf(sides.in), out: peakOf(sides.out) };
}
/** The nodes listened to, the taps playing and how many of them feed an analyser (test hook). */
info(): { listened: string; held: number; feeding: number } { info(): { listened: string; held: number; feeding: number } {
let held = 0; let held = 0;
for (const nodes of this.held.values()) held += nodes.size; for (const nodes of this.held.values()) held += nodes.size;
const feeding = this.sides let feeding = 0;
? SIDES.reduce((sum, at) => sum + (this.held.get(keyOf(this.listened, at))?.size ?? 0), 0) for (const [id, sides] of this.listened)
: 0; if (sides) for (const at of SIDES) feeding += this.held.get(keyOf(id, at))?.size ?? 0;
return { listened: this.listened, held, feeding }; return { listened: [...this.listened.keys()].join(' '), held, feeding };
} }
private analysis(context: BaseAudioContext): Record<Side, Analysis> { private analysis(id: string, context: BaseAudioContext): Record<Side, Analysis> {
return (this.sides ??= { in: analysisOf(context), out: analysisOf(context) }); const made = this.listened.get(id) ?? { in: analysisOf(context), out: analysisOf(context) };
this.listened.set(id, made);
return made;
} }
} }
@@ -117,5 +151,10 @@ function analysisOf(context: BaseAudioContext): Analysis {
const analyser = context.createAnalyser(); const analyser = context.createAnalyser();
analyser.fftSize = SPECTRUM_SIZE; analyser.fftSize = SPECTRUM_SIZE;
sum.connect(analyser); sum.connect(analyser);
return { sum, analyser, data: new Float32Array(analyser.frequencyBinCount) }; return {
sum,
analyser,
data: new Float32Array(analyser.frequencyBinCount),
wave: new Float32Array(SPECTRUM_SIZE),
};
} }
+18 -4
View File
@@ -442,8 +442,20 @@ describe('a Pan in a sound FX graph (#319)', () => {
}); });
describe('a Compressor and a Limiter in a sound FX graph (#326)', () => { describe('a Compressor and a Limiter in a sound FX graph (#326)', () => {
// The taps a Compressor keeps for its tab do nothing to the sound (#378).
const at = (type: string, params?: Record<string, number>) => const at = (type: string, params?: Record<string, number>) =>
fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages; fxChainOf(compileSoundFx(line([{ type, params }]).fx.graph).compiled!).stages.filter(
(s) => s.kind !== 'tap',
);
it('tap a Compressor on both sides for its tab, a Limiter not (#378)', () => {
const kinds = (type: string) =>
fxChainOf(compileSoundFx(line([{ type }]).fx.graph).compiled!).stages.map((s) =>
s.kind === 'tap' ? `tap ${s.at}` : s.kind,
);
expect(kinds('SoundFxCompressor')).toEqual(['tap in', 'compress', 'tap out']);
expect(kinds('SoundFxLimiter')).toEqual(['limit']);
});
it('compress at their defaults, the gain after it worked out from makeup and auto-gain', () => { it('compress at their defaults, the gain after it worked out from makeup and auto-gain', () => {
const [stage] = at('SoundFxCompressor', { threshold: -12, knee: 0, ratio: 4, autoGain: 1 }); const [stage] = at('SoundFxCompressor', { threshold: -12, knee: 0, ratio: 4, autoGain: 1 });
@@ -676,9 +688,11 @@ describe('Bypass and Wet (#354)', () => {
f.input.ports = [{ key: 'blend', kind: 'number', default: 0.5 }]; f.input.ports = [{ key: 'blend', kind: 'number', default: 0.5 }];
f.edit.connect(f.input, 'blend', compressor, 'wet'); f.edit.connect(f.input, 'blend', compressor, 'wet');
const plan = compileSoundFx(f.fx.graph).compiled!; const plan = compileSoundFx(f.fx.graph).compiled!;
expect(fxChainOf(plan).stages).toMatchObject([{ kind: 'wet', wet: 0.5, stages: [{ kind: 'compress' }] }]); const stages = (params?: Record<string, number>) =>
expect(fxChainOf(plan, { blend: 0 }).stages).toMatchObject([{ kind: 'wet', wet: 0 }]); fxChainOf(plan, params).stages.filter((s) => s.kind !== 'tap');
expect(fxChainOf(plan, { blend: 7 }).stages).toMatchObject([{ kind: 'wet', wet: 1 }]); expect(stages()).toMatchObject([{ kind: 'wet', wet: 0.5, stages: [{ kind: 'compress' }] }]);
expect(stages({ blend: 0 })).toMatchObject([{ kind: 'wet', wet: 0 }]);
expect(stages({ blend: 7 })).toMatchObject([{ kind: 'wet', wet: 1 }]);
}); });
it('a blend is as late as what it blends and rings as long', () => { it('a blend is as late as what it blends and rings as long', () => {
+114
View File
@@ -0,0 +1,114 @@
/**
* What the tab of a Compressor draws (#378): its curve and the handles on it, the ratio a drag of
* the end of the curve asks for, and the level picture tinted where the compressor works.
*/
import { describe, expect, it } from 'vitest';
import {
type CompressorNumbers,
compressorGainDb,
curveDbAtX,
curveDbAtY,
curveOutDb,
curveX,
curveY,
HISTORY_SECONDS,
LevelHistory,
levelY,
ratioFor,
ratioHandle,
reductionOf,
reductionShapes,
thresholdHandle,
} from '../../src/editor/soundfx/compressorView';
const numbers: CompressorNumbers = {
threshold: -24,
knee: 0,
ratio: 4,
attack: 0.003,
release: 0.25,
makeup: 0,
autoGain: 0,
};
const RATIO = { min: 1, max: 20, step: 0.1 };
describe('the curve of a Compressor', () => {
it('leaves a level below the threshold as it is and squeezes one above it by the ratio', () => {
expect(curveOutDb(-40, -24, 0, 4)).toBeCloseTo(-40, 6);
// 24 dB over a threshold of -24 come out 6 dB over it at a ratio of 4.
expect(curveOutDb(0, -24, 0, 4)).toBeCloseTo(-18, 1);
});
it('spans -60 to 0 dB on both axes, level out going up', () => {
expect(curveX(-60, 300)).toBe(0);
expect(curveX(0, 300)).toBe(300);
expect(curveY(0, 300)).toBe(0);
expect(curveDbAtX(150, 300)).toBe(-30);
expect(curveDbAtY(100, 300)).toBe(-20);
});
it('has the threshold where it starts to bend and the ratio at its end', () => {
expect(thresholdHandle(numbers, 300)).toEqual({ x: 180, y: 120 });
const end = ratioHandle(numbers, 300);
expect(end.x).toBe(300);
expect(curveDbAtY(end.y, 300)).toBeCloseTo(-18, 1);
});
it('finds the ratio whose end is where it is dragged to, held to the range', () => {
expect(ratioFor(-18, numbers, RATIO)).toBe(4);
expect(ratioFor(-12, numbers, RATIO)).toBe(2);
expect(ratioFor(5, numbers, RATIO)).toBe(1);
expect(ratioFor(-40, numbers, RATIO)).toBe(20);
// With a knee the curve ends lower than the straight line would, and the ratio found still lands there.
const kneed = { ...numbers, knee: 6 };
const ratio = ratioFor(-16, kneed, RATIO);
expect(curveOutDb(0, -24, 6, ratio)).toBeCloseTo(-16, 0);
});
it('counts the makeup, the auto-gain or a blend of both as the gain after it', () => {
expect(compressorGainDb({ ...numbers, makeup: 6 })).toBe(6);
expect(compressorGainDb({ ...numbers, makeup: 6, autoGain: 1 })).toBeCloseTo(18, 1);
expect(compressorGainDb({ ...numbers, makeup: 6, autoGain: 0.5 })).toBeCloseTo(12, 1);
});
});
describe('the level picture of a Compressor', () => {
it('spans +6 to -60 dB top to bottom, silence at the bottom', () => {
expect(levelY(6, 660)).toBe(0);
expect(levelY(-60, 660)).toBe(660);
expect(levelY(-Infinity, 660)).toBe(660);
expect(levelY(0, 660)).toBe(60);
});
it('keeps the readings of the last seconds and knows when all of them are silent', () => {
const history = new LevelHistory();
history.push(0, { in: -6, out: -9 });
history.push(1, null);
expect(history.heard).toBe(true);
history.push(1 + HISTORY_SECONDS, null);
expect(history.all.map((p) => p.t)).toEqual([1, 1 + HISTORY_SECONDS]);
expect(history.heard).toBe(false);
});
it('takes away what came in less what went out, the gain after it left out', () => {
expect(reductionOf({ in: -6, out: -12 }, 0)).toBe(6);
expect(reductionOf({ in: -6, out: -6 }, 6)).toBe(6);
expect(reductionOf({ in: -30, out: -29 }, 0)).toBe(0);
expect(reductionOf({ in: -Infinity, out: -Infinity }, 0)).toBe(0);
});
it('tints from the level coming in down by what is taken away, one shape per stretch', () => {
const points = [
{ t: 0, in: -6, out: -12 },
{ t: 1, in: -6, out: -12 },
{ t: 2, in: -30, out: -30 },
{ t: 3, in: 0, out: -3 },
{ t: 4, in: 0, out: -3 },
];
const shapes = reductionShapes(points, 0, 4, 400, 660);
expect(shapes).toHaveLength(2);
// Along the level coming in, then back along it less what is taken away.
expect(shapes[0]).toEqual([0, 120, 100, 120, 100, 180, 0, 180]);
expect(shapes[1]).toEqual([300, 60, 400, 60, 400, 90, 300, 90]);
});
});
+54
View File
@@ -0,0 +1,54 @@
/**
* The rules of a Knob (#378): where a value sits on the turn, and what a drag and the wheel make of it.
*/
import { describe, expect, it } from 'vitest';
import {
draggedValue,
KNOB_TRAVEL,
knobOrigin,
knobPosition,
knobValue,
wheeledValue,
} from '../../src/ui/knob';
const threshold = { min: -100, max: 0, step: 0.5 };
const attack = { min: 0, max: 1, step: 0.001, taper: 3 };
const makeup = { min: -24, max: 24, step: 0.5 };
describe('a knob', () => {
it('turns evenly without a taper, and gives the low end more of the turn with one', () => {
expect(knobPosition(-50, threshold)).toBeCloseTo(0.5, 9);
expect(knobValue(0.5, threshold)).toBe(-50);
// An attack of 3 ms is an eighth of the way round, not three thousandths.
expect(knobPosition(0.003, attack)).toBeCloseTo(0.144, 3);
expect(knobValue(knobPosition(0.003, attack), attack)).toBe(0.003);
expect(knobValue(-1, attack)).toBe(0);
expect(knobValue(2, attack)).toBe(1);
});
it('turns up as it is dragged up, a tenth as far with Shift', () => {
expect(draggedValue(-50, -KNOB_TRAVEL / 2, false, threshold)).toBe(0);
expect(draggedValue(-50, KNOB_TRAVEL / 4, false, threshold)).toBe(-75);
expect(draggedValue(-50, -KNOB_TRAVEL / 2, true, threshold)).toBe(-45);
expect(draggedValue(-50, KNOB_TRAVEL * 10, false, threshold)).toBe(-100);
});
it('moves at least one step, however short the drag', () => {
expect(draggedValue(-50, -1, true, threshold)).toBe(-49.5);
expect(draggedValue(-50, 1, true, threshold)).toBe(-50.5);
expect(draggedValue(0, -1, true, threshold)).toBe(0);
expect(draggedValue(-50, 0, false, threshold)).toBe(-50);
});
it('notches up on the wheel turned up, and down on it turned down', () => {
expect(wheeledValue(-50, -100, threshold)).toBe(-48);
expect(wheeledValue(-50, 100, threshold)).toBe(-52);
expect(wheeledValue(0.003, -100, attack)).toBeGreaterThan(0.003);
});
it('fills its arc from 0 when the range has it inside, else from the low end', () => {
expect(knobOrigin(makeup)).toBeCloseTo(0.5, 9);
expect(knobOrigin(threshold)).toBe(0);
expect(knobOrigin(attack)).toBe(0);
});
});
+44 -3
View File
@@ -23,6 +23,12 @@ class FakeNode {
getFloatFrequencyData(data: Float32Array): void { getFloatFrequencyData(data: Float32Array): void {
data.fill(this.context.made.length); data.fill(this.context.made.length);
} }
/** What the analyser heard last: `wave`, at the end of `data` like the newest samples. */
wave: number[] = [];
getFloatTimeDomainData(data: Float32Array): void {
data.fill(0);
data.set(this.wave, data.length - this.wave.length);
}
} }
class FakeContext { class FakeContext {
@@ -50,7 +56,7 @@ describe('the taps of the spectrum', () => {
taps.hold(tap('fx:eq', 'in'), new FakeNode(context) as never); taps.hold(tap('fx:eq', 'in'), new FakeNode(context) as never);
expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 }); expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 });
expect(context.made).toHaveLength(0); expect(context.made).toHaveLength(0);
expect(taps.read()).toBeNull(); expect(taps.read('fx:eq')).toBeNull();
}); });
it('feed the analyser of their side while listened to, a voice started before as well', () => { it('feed the analyser of their side while listened to, a voice started before as well', () => {
@@ -69,7 +75,7 @@ describe('the taps of the spectrum', () => {
expect([...before.links]).toEqual([sumIn]); expect([...before.links]).toEqual([sumIn]);
expect([...after.links]).toEqual([sumOut]); expect([...after.links]).toEqual([sumOut]);
expect(other.links.size).toBe(0); expect(other.links.size).toBe(0);
expect(taps.read()?.binHz).toBeCloseTo(48000 / 8192, 9); expect(taps.read('fx:eq')?.binHz).toBeCloseTo(48000 / 8192, 9);
}); });
it('let go of the analysers once nobody listens, or once the voice stops', () => { it('let go of the analysers once nobody listens, or once the voice stops', () => {
@@ -87,6 +93,41 @@ describe('the taps of the spectrum', () => {
taps.stop(); taps.stop();
expect(bus.links.size).toBe(0); expect(bus.links.size).toBe(0);
expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 }); expect(taps.info()).toEqual({ listened: '', held: 1, feeding: 0 });
expect(taps.read()).toBeNull(); expect(taps.read('fx:eq')).toBeNull();
});
it('listen to two nodes at once, and let one go without the other (#378)', () => {
const context = new FakeContext();
const taps = new SpectrumTaps();
const eq = new FakeNode(context);
const compressor = new FakeNode(context);
taps.hold(tap('fx:eq', 'in'), eq as never);
taps.hold(tap('fx:comp', 'in'), compressor as never);
taps.listen('fx:eq');
taps.listen('fx:comp');
expect(taps.info()).toEqual({ listened: 'fx:eq fx:comp', held: 2, feeding: 2 });
expect(eq.links.size).toBe(1);
expect(compressor.links.size).toBe(1);
taps.stop('fx:eq');
expect(eq.links.size).toBe(0);
expect(compressor.links.size).toBe(1);
expect(taps.info()).toEqual({ listened: 'fx:comp', held: 2, feeding: 1 });
expect(taps.read('fx:comp')).not.toBeNull();
});
it('give the loudest sample of the last stretch on each side, in dB (#378)', () => {
const context = new FakeContext();
const taps = new SpectrumTaps();
expect(taps.levels('fx:comp', 0.01)).toBeNull();
taps.listen('fx:comp');
taps.hold(tap('fx:comp', 'in'), new FakeNode(context) as never);
const [, analyserIn, , analyserOut] = context.made;
// A peak of 1 long ago, then 0.5: a window of 10 ms (480 samples) only sees the 0.5.
analyserIn!.wave = [1, ...new Array<number>(1000).fill(0), -0.5, 0.25];
analyserOut!.wave = [];
const levels = taps.levels('fx:comp', 0.01);
expect(levels?.in).toBeCloseTo(20 * Math.log10(0.5), 9);
expect(levels?.out).toBe(-Infinity);
expect(taps.levels('fx:comp', 1)?.in).toBeCloseTo(0, 9);
}); });
}); });
+129 -14
View File
@@ -680,6 +680,120 @@ export async function sound(t) {
eqHeard.last?.stages === 2 && !eqDocsAfter.docs.some((doc) => doc.startsWith('eq:')), eqHeard.last?.stages === 2 && !eqDocsAfter.docs.some((doc) => doc.startsWith('eq:')),
JSON.stringify({ last: eqHeard.last, eqDocsAfter }), JSON.stringify({ last: eqHeard.last, eqDocsAfter }),
); );
// #378: a Compressor behind the gain opens a tab of its own as well. Its knobs turn with a drag up
// (one undo step), a notch of the wheel and a double-click back to the default, and take a
// typed number. The end of its curve is dragged for the ratio. Played, the level going out
// stays under the one coming in, and a knob whose input is linked is greyed.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(
node('fx-comp', 'SoundFxCompressor', -240, 0, { threshold: -30, ratio: 4, knee: 0 }),
)});
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
graph.edges.push(
${JSON.stringify(edge('fx-e12', 'fx-gain', 'out', 'fx-comp', 'in'))},
${JSON.stringify(edge('fx-e13', 'fx-comp', 'out', 'fx-out', 'in'))},
);`);
const compNode = await json("__gm.soundfx.nodeAt('fx-comp')");
await t.doubleClick(compNode.x, compNode.y);
const compShown = await json(
'({ shown: __gm.compressor.shown(), docs: __gm.documents(), knobs: __gm.compressor.knobs() })',
);
check(
'a double-click opens the Compressor in a tab, a knob for each of its numbers',
compShown.shown?.nodeId === 'fx-comp' &&
compShown.docs.active === `compressor:${fxId}:fx-comp` &&
compShown.knobs.threshold.value === -30 &&
compShown.knobs.ratio.value === 4 &&
compShown.knobs.knee.value === 0 &&
compShown.knobs.attack.value === 0.003 &&
Object.keys(compShown.knobs).length === 7,
JSON.stringify(compShown),
);
const thresholdDial = await json("__gm.compressor.dialAt('threshold')");
await drag(thresholdDial.x, thresholdDial.y, thresholdDial.x, thresholdDial.y - 20);
const compDragged = await json('__gm.compressor.numbers()');
await press('z', { ctrl: true });
const compUndone = await json('__gm.compressor.numbers()');
await press('z', { ctrl: true, shift: true });
const ratioDial = await json("__gm.compressor.dialAt('ratio')");
await t.wheel(ratioDial.x, ratioDial.y, -100);
const kneeDial = await json("__gm.compressor.dialAt('knee')");
await t.doubleClick(kneeDial.x, kneeDial.y);
const makeupField = await json("__gm.compressor.fieldAt('makeup')");
await click(makeupField.x, makeupField.y);
await t.typeText('3');
await press('Enter');
await sleep(150);
const compTurned = await json('({ numbers: __gm.compressor.numbers(), knobs: __gm.compressor.knobs() })');
check(
'a knob turns up with a drag up as one undo step, a notch of the wheel, a double-click and a typed number',
compDragged.threshold === -20 &&
compUndone.threshold === -30 &&
compTurned.numbers.threshold === -20 &&
compTurned.numbers.ratio > 4 &&
compTurned.numbers.ratio < 5 &&
compTurned.numbers.knee === 6 &&
compTurned.numbers.makeup === 3 &&
compTurned.knobs.makeup.value === 3,
JSON.stringify({ compDragged, compUndone, compTurned }),
);
const ratioHandle = await json("__gm.compressor.handleAt('ratio')");
const ratioTo = await json('__gm.compressor.pointAt(0, -10)');
await drag(ratioHandle.x, ratioHandle.y, ratioTo.x, ratioTo.y);
const compCurve = await json(
"({ numbers: __gm.compressor.numbers(), handle: __gm.compressor.handleAt('ratio'), at: __gm.compressor.pointAt(0, -10) })",
);
await press('z', { ctrl: true });
const compCurveUndone = await json('__gm.compressor.numbers()');
check(
'dragging the end of the curve sets the ratio that ends there, as one undo step',
compCurve.numbers.ratio < compTurned.numbers.ratio &&
Math.abs(compCurve.handle.y - compCurve.at.y) < 2 &&
compCurveUndone.ratio === compTurned.numbers.ratio,
JSON.stringify({ compCurve, compCurveUndone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
Object.assign(graph.nodes.find((n) => n.id === 'fx-comp').params, { threshold: -50, ratio: 8, knee: 0, makeup: 0 });`);
await evaluate('__gm.sound.stopAll()');
const levelsBefore = (await json('__gm.compressor.levels()')).frames;
await evaluate('__gm.soundfx.play()');
const compLevels = await waitFor(
() => json('__gm.compressor.levels()'),
(l) => l.frames >= levelsBefore + 3 && l.reduction > 3,
);
await shot('34-compressor');
check(
'played, the tab reads the level on both sides of the node, the one going out squeezed under the one coming in',
compLevels.taps.listened === `${fxId}:fx-comp` &&
compLevels.in > -50 &&
compLevels.out < compLevels.in &&
compLevels.reduction > 3,
JSON.stringify(compLevels),
);
await evaluate('__gm.sound.stopAll()');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.edges.push(${JSON.stringify(edge('fx-e14', 'fx-in', 'depth', 'fx-comp', 'ratio'))});`);
const compLinked = await json('__gm.compressor.knobs()');
await evaluate(`__gm.openDocument(${JSON.stringify(`asset:${fxId}`)})`);
await sleep(100);
const compUnseen = await json('__gm.compressor.levels()');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-comp');
graph.edges = graph.edges.filter((e) => !['fx-e12', 'fx-e13', 'fx-e14'].includes(e.id));
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
const compDocsAfter = await json('__gm.documents()');
check(
'a linked input greys its knob, an unseen tab feeds no analyser, and the tab goes with its node',
!compLinked.ratio.enabled &&
compLinked.threshold.enabled &&
compUnseen.taps.listened === '' &&
!compDocsAfter.docs.some((doc) => doc.startsWith('compressor:')),
JSON.stringify({ compLinked, unseen: compUnseen.taps, compDocsAfter }),
);
await showTab('scene'); await showTab('scene');
await mutate(` await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
@@ -2664,8 +2778,9 @@ export async function sound(t) {
() => json(`__gm.routing.peaks(${tapsLong})`), () => json(`__gm.routing.peaks(${tapsLong})`),
(peaks) => Math.min(Math.max(...(peaks.mix ?? [0])), Math.max(...(peaks['mix:sn-bus'] ?? [0]))) > 0.01, (peaks) => Math.min(Math.max(...(peaks.mix ?? [0])), Math.max(...(peaks['mix:sn-bus'] ?? [0]))) > 0.01,
); );
await sleep(2500); await sleep(1000);
const playingStreamed = rendererMb(); // After a garbage collection as at rest: play mode makes garbage of its own (#378, CI task 614).
const playingStreamed = await restingMb();
const renderersPlaying = renderers(); const renderersPlaying = renderers();
await t.clickToolbar('pause'); await t.clickToolbar('pause');
await sleep(500); await sleep(500);
@@ -2678,20 +2793,12 @@ export async function sound(t) {
await stop(); await stop();
const stoppedLong = await stats(); const stoppedLong = await stats();
check( check(
'played, the streamed sound is heard on its bus and the master, the renderer growing by less than 20 MB, none of the file decoded', 'played, the streamed sound is heard on its bus and the master, none of the file decoded',
streaming?.last?.file === long.file && streaming?.last?.file === long.file &&
streaming.last.bus === 'mix:sn-bus' && streaming.last.bus === 'mix:sn-bus' &&
streaming.decoded === decodedBefore && streaming.decoded === decodedBefore &&
playingStreamed - restStreamed < 20 &&
Math.max(...(heardLong?.mix ?? [0])) > 0.01, Math.max(...(heardLong?.mix ?? [0])) > 0.01,
JSON.stringify({ JSON.stringify({ streaming, heardLong }),
restStreamed,
playingStreamed,
renderersBefore,
renderersPlaying,
streaming,
heardLong,
}),
); );
check( check(
'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it', 'the pause holds the stream where it is, it plays on after it without a sample lost, and stopping ends it',
@@ -2712,8 +2819,8 @@ export async function sound(t) {
await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = false;`); await mutate(`__gm.state.project.assets.find((a) => a.id === 'sn-long').props.stream = false;`);
await play(); await play();
const decodedNow = await waitFor(stats, (s) => s.playing > 0 && s.last?.file === long.file, 60); const decodedNow = await waitFor(stats, (s) => s.playing > 0 && s.last?.file === long.file, 60);
await sleep(2500); await sleep(1000);
const playingDecoded = rendererMb(); const playingDecoded = await restingMb();
await stop(); await stop();
await evaluate('__gm.streams.idle()'); await evaluate('__gm.streams.idle()');
check( check(
@@ -2724,6 +2831,14 @@ export async function sound(t) {
(await json("__gm.streams.copies('sn-long')"))?.sound === null, (await json("__gm.streams.copies('sn-long')"))?.sound === null,
JSON.stringify({ restDecoded, playingDecoded, decodedNow }), JSON.stringify({ restDecoded, playingDecoded, decodedNow }),
); );
// What play mode makes itself is in both, so it is the difference that tells (#378, CI task 614).
const grownStreamed = playingStreamed - restStreamed;
const grownDecoded = playingDecoded - restDecoded;
check(
'streamed, the renderer grows by at least 30 MB less than decoded',
grownStreamed + 30 < grownDecoded,
JSON.stringify({ grownStreamed, grownDecoded, renderersBefore, renderersPlaying }),
);
// The Sound tab points a long sound at the switch, and says once it streams. // The Sound tab points a long sound at the switch, and says once it streams.
await evaluate("__gm.state.openAsset('sn-long')"); await evaluate("__gm.state.openAsset('sn-long')");