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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -148,6 +148,30 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
*/
|
||||
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: {
|
||||
current: () => editor.instrumentEditor.currentAssetId,
|
||||
info: () => editor.instrumentEditor.shownInfo,
|
||||
|
||||
@@ -30,7 +30,7 @@ import { graphLookup } from '../../core/model/graphLookup';
|
||||
import { particleLibrary } from '../../viewport/ParticleLibrary';
|
||||
import { shaderLibrary } from '../../viewport/ShaderLibrary';
|
||||
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 { masterRoutingProblems } from '../../core/music/projectRouting';
|
||||
import { routingProblems } from '../../core/music/routing';
|
||||
@@ -670,8 +670,8 @@ export class GraphCanvas extends Widget {
|
||||
}
|
||||
|
||||
private openCalledBehavior(node: GraphNode): boolean {
|
||||
if (node.type === SoundFxParametricEq.type) {
|
||||
this.state.openEq(this.assetId, node.id);
|
||||
if (nodeTabOf(node.type)) {
|
||||
this.state.openNodeTab(this.assetId, node.id, node.type);
|
||||
return true;
|
||||
}
|
||||
// A node of the master routing (#351) stands for its asset; a bus has no tab, its node shows it already.
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -462,7 +462,7 @@ export class EqEditor extends VStack implements EqHost {
|
||||
if (assetId !== this.assetId || nodeId !== this.nodeId) {
|
||||
this.curve.settle();
|
||||
this.perFrame.cancel();
|
||||
this.curve.showSpectrum(null);
|
||||
this.hideSpectrum();
|
||||
this.before = null;
|
||||
this.assetId = assetId;
|
||||
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. */
|
||||
close(): void {
|
||||
this.perFrame.cancel();
|
||||
this.hideSpectrum();
|
||||
const node = this.node();
|
||||
if (node) this.restore(node);
|
||||
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). */
|
||||
tick(): void {
|
||||
if (!this.nodeId) return;
|
||||
soundLibrary.listen(tapId(this.assetId, this.nodeId));
|
||||
this.curve.showSpectrum(soundLibrary.spectrum());
|
||||
const id = tapId(this.assetId, this.nodeId);
|
||||
soundLibrary.listen(id);
|
||||
this.curve.showSpectrum(soundLibrary.spectrum(id));
|
||||
}
|
||||
|
||||
/** The tab is not seen: no analyser is fed for it. */
|
||||
hideSpectrum(): void {
|
||||
soundLibrary.unlisten();
|
||||
if (this.nodeId) soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
|
||||
this.curve.showSpectrum(null);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -228,8 +228,8 @@ export type EditorEvent =
|
||||
| 'selection'
|
||||
/** A Variables tab was asked for (`openedValues`). */
|
||||
| 'values'
|
||||
/** A Parametric EQ's tab was asked for (`openedEq`). */
|
||||
| 'eq'
|
||||
/** The tab of a Parametric EQ or a Compressor node was asked for (`openedNode`). */
|
||||
| 'nodeTab'
|
||||
/** An asset was opened (double-click, Enter, or just created): its tab should show. */
|
||||
| 'open'
|
||||
/** An asset should light up where it is listed (`pingedAssetId`), without being selected. */
|
||||
@@ -258,8 +258,8 @@ export class EditorState {
|
||||
selection: Selection = null;
|
||||
/** The last target asked for by `openValues`; the tabs themselves are the editor's. */
|
||||
openedValues: ValuesTarget | null = null;
|
||||
/** The last Parametric EQ node asked for by `openEq`. */
|
||||
openedEq: { assetId: string; nodeId: string } | null = null;
|
||||
/** The last node asked for by `openNodeTab`. */
|
||||
openedNode: { assetId: string; nodeId: string; type: string } | null = null;
|
||||
mode: EditorMode = 'edit';
|
||||
/**
|
||||
* 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.selection = null;
|
||||
this.openedValues = null;
|
||||
this.openedEq = null;
|
||||
this.openedNode = null;
|
||||
this.clipboard = null;
|
||||
this.patternPlaces.clear();
|
||||
this.saveStatus = 'saved';
|
||||
@@ -538,10 +538,13 @@ export class EditorState {
|
||||
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 {
|
||||
this.openedEq = { assetId, nodeId };
|
||||
this.emit('eq');
|
||||
/**
|
||||
* Asks for the tab of a Parametric EQ (#370) or Compressor (#378) node, the way `openValues` asks
|
||||
* for a Variables tab.
|
||||
*/
|
||||
openNodeTab(assetId: string, nodeId: string, type: string): void {
|
||||
this.openedNode = { assetId, nodeId, type };
|
||||
this.emit('nodeTab');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user