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
@@ -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`;
|
||||
}
|
||||
Reference in new issue
Block a user