Open a Compressor as a device in a window of one size (#379)
The tab from #378 shows a Compressor as an editor. The user wanted it to look like a device as well, after a brushed aluminium plug-in, with a meter of the gain reduction instead of the pictures. The tab stays and is switched to per node. - Hardware look is a switch under Settings, on by default for new and existing Compressors. Only the editor reads it. - The panel is 820x280 px and opens straight in a window of that size. The window has no sizing corner and no Dock. A pane says so through `fixedSize`, and `keptSize` keeps the window's middle when it changes to that size. - A Compressor in this look never sits in the tab row. Opening one floats it, and switching the look on moves its tab into the window. - The panel has chrome knobs (threshold and makeup large), a meter with a needle that moves like a VU's, an LCD with the name and the reading, a lever with a lamp for auto-gain, and LEDs for the level going out. Everything is drawn in code. - The Knob takes a size and a chrome look, and leaves out its caption when it has no name, so the panel engraves its own. - `autoGainOn` is now `switchOn`, because the new switch reads the same way. - Where parts sit, the meter's scale, the needle's speed, the LEDs and the plate's grain are in `hardwareView.ts`, apart from the drawing. Decided myself: - The meter's scale runs by the square root, so the first dB is about as wide as the last nine. It is red from 10 dB on. - The LEDs light one every 3 dB from -39 to 0 dB and fall by 20 dB a second. - Switching the look changes the graph's fingerprint, like any other setting of a node. The sound FX is built again once. Findings, fixed on the way: - Pixi's `arc` goes on from the last point of the path. The meter's scale had a line from the corner of the face until each arc got a `moveTo` to its own start. - A text put into a Graphics makes Pixi warn that only Containers take children. The LCD is a Container now. - The first browser check read the needle at one moment and missed the short sound. The panel keeps the most its needle and LEDs showed. Tests: - Unit: hardware-view (10 new), documents (1 new) - e2e 34 (melektaus-atx): ALL PASSED, 122 checks (4 new). Played, the needle reached 20.9 dB and 5 LEDs lit. - Gate (melektaus-atx): CHECK EXIT 0, 283 files, 4355 tests (11 new) - Counter-checks: a window that keeps its corner instead of its middle, and a linear meter scale, each turn exactly their test red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2d4909d248
commit
9917253751
14 files changed
+1153
-52
No files matched your search
+24
-3
@@ -98,7 +98,8 @@ import { PluginsPane } from './plugins/PluginsPane';
|
||||
import { ManagementPane } from './users/ManagementPane';
|
||||
import { UsersPane } from './users/UsersPane';
|
||||
import { RoutingEditor } from './routing/RoutingEditor';
|
||||
import { CompressorEditor } from './soundfx/CompressorEditor';
|
||||
import { CompressorEditor, compressorNodeOf } from './soundfx/CompressorEditor';
|
||||
import { COMPRESS_HARDWARE_LOOK, switchOn } from '../core/soundfx/nodes';
|
||||
import { EqEditor } from './soundfx/EqEditor';
|
||||
import { NODE_TAB_KINDS, nodeOfTab, nodeTabLabel, type NodeTabKind, nodeTabOf } from './soundfx/nodeTabs';
|
||||
|
||||
@@ -866,6 +867,10 @@ export class Editor implements PlayHost {
|
||||
this.showInWindow(kind, doc);
|
||||
return;
|
||||
}
|
||||
if (this.windowOnly(doc)) {
|
||||
this.floatDocument(doc);
|
||||
return;
|
||||
}
|
||||
if (this.documents.active === doc) {
|
||||
// Still worth a look at the name: a Variables tab's owner may have been renamed.
|
||||
this.syncStrip();
|
||||
@@ -959,7 +964,7 @@ export class Editor implements PlayHost {
|
||||
/** Puts a window's document back into the tab row, showing, and its pane back below the row. */
|
||||
dockWindow(kind: CentreTab): void {
|
||||
const float = this.floats.get(kind);
|
||||
if (!float) return;
|
||||
if (!float || this.windowOnly(float.doc)) return;
|
||||
this.takeBack(kind);
|
||||
this.documents = openDocument(this.documents, float.doc);
|
||||
this.syncStrip();
|
||||
@@ -1002,6 +1007,7 @@ export class Editor implements PlayHost {
|
||||
this.forget(old);
|
||||
}
|
||||
this.pointPane(kind, doc);
|
||||
float.window.refit();
|
||||
float.window.setTitle(this.labelOf(doc), this.iconOf(doc));
|
||||
float.window.toFront();
|
||||
this.pressedWindow = kind;
|
||||
@@ -1018,6 +1024,7 @@ export class Editor implements PlayHost {
|
||||
dock: { x: number; y: number };
|
||||
close: { x: number; y: number };
|
||||
front: boolean;
|
||||
fixed: boolean;
|
||||
}[] {
|
||||
const centre = (w: Widget) => {
|
||||
const at = w.getGlobalPosition();
|
||||
@@ -1033,6 +1040,7 @@ export class Editor implements PlayHost {
|
||||
dock: centre(window.dockButton),
|
||||
close: centre(window.closeButton),
|
||||
front: window.parent?.children.at(-1) === window,
|
||||
fixed: window.fixedSize !== null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1190,12 +1198,25 @@ export class Editor implements PlayHost {
|
||||
/** After an edit: a deleted asset takes its tab along, a renamed one renames it. */
|
||||
private syncDocuments(): void {
|
||||
for (const doc of this.documents.docs) if (!this.kindOf(doc)) this.closeDocument(doc);
|
||||
// Switched to its hardware look, a Compressor leaves the tab row for a window (#379).
|
||||
for (const doc of [...this.documents.docs]) if (this.windowOnly(doc)) this.floatDocument(doc);
|
||||
for (const [kind, { doc, window }] of [...this.floats])
|
||||
if (!this.kindOf(doc)) this.closeWindow(kind);
|
||||
else window.setTitle(this.labelOf(doc), this.iconOf(doc));
|
||||
else {
|
||||
window.refit();
|
||||
window.setTitle(this.labelOf(doc), this.iconOf(doc));
|
||||
}
|
||||
this.syncStrip();
|
||||
}
|
||||
|
||||
/** Whether a document has no place in the tab row: a Compressor in its hardware look (#379). */
|
||||
private windowOnly(doc: DocumentId): boolean {
|
||||
const target = this.nodeTargets.get(doc);
|
||||
if (nodeTabOfDocument(doc) !== 'compressor' || !target) return false;
|
||||
const node = compressorNodeOf(this.state.project, target.assetId, target.nodeId);
|
||||
return node !== null && switchOn(node.params, COMPRESS_HARDWARE_LOOK);
|
||||
}
|
||||
|
||||
/** Shows the pane for `tab`, with `strip` lit in the row – none for an empty kind reached by `showTab`. */
|
||||
private display(tab: TabId, strip: StripTab | null): void {
|
||||
// Fullscreen is the game view's; a tab that takes its place (Stop goes back to the scene) ends it.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Container, type FederatedPointerEvent, Graphics, Rectangle } from 'pixi.js';
|
||||
import { Button, crisp, drawFloatingBox, Icon, Label, Spacer, Theme, ui, Widget } from '../ui';
|
||||
import { leastBox, onScreen, sized, type WindowBox } from './floating';
|
||||
import { keptSize, leastBox, onScreen, sized, type WindowBox } from './floating';
|
||||
import { TabHeader } from './TabHeader';
|
||||
|
||||
/** The corner taken hold of to size the window. */
|
||||
@@ -13,6 +13,11 @@ export interface FloatWindowHost {
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
/** A pane that is one size only (#379); null while it takes any. */
|
||||
export interface SizedPane {
|
||||
readonly fixedSize: { w: number; h: number } | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab's pane in a window of its own (#371), above the editor. The pane is lent, not owned: the
|
||||
* editor puts it in with `setContent` and takes it back before the window goes. The title bar
|
||||
@@ -73,7 +78,28 @@ export class FloatWindow extends Widget {
|
||||
/** Where the title bar can be taken hold of: left of the buttons, clear of the name. */
|
||||
barPoint(): { x: number; y: number } {
|
||||
const at = this.bar.getGlobalPosition();
|
||||
return { x: at.x + this.dockButton.x - Theme.padding * 2, y: at.y + this.bar.h / 2 };
|
||||
const first = this.dockButton.visible ? this.dockButton : this.closeButton;
|
||||
return { x: at.x + first.x - Theme.padding * 2, y: at.y + this.bar.h / 2 };
|
||||
}
|
||||
|
||||
/** The size of a window around a pane of one size, or null where it can be sized. */
|
||||
get fixedSize(): { w: number; h: number } | null {
|
||||
const size = (this.content as Partial<SizedPane> | null)?.fixedSize;
|
||||
return size ? { w: size.w + 2, h: size.h + this.bar.measure().h + 2 } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* After the pane has changed what it is: a pane of one size has the window at that size, with no
|
||||
* corner to size it and no Dock (#379) – it cannot go into the tab row.
|
||||
*/
|
||||
refit(): void {
|
||||
const fixed = this.fixedSize !== null;
|
||||
this.grip.visible = !fixed;
|
||||
if (this.dockButton.visible === fixed) {
|
||||
this.dockButton.visible = !fixed;
|
||||
this.bar.relayout();
|
||||
}
|
||||
if (this.isOpen) this.place(this.box);
|
||||
}
|
||||
|
||||
/** The middle of the sizing corner, for the check scripts. */
|
||||
@@ -94,7 +120,7 @@ export class FloatWindow extends Widget {
|
||||
this.addChildAt(content, this.getChildIndex(this.bar));
|
||||
this.layout();
|
||||
// A pane that needs more than the window has grows the window.
|
||||
if (this.isOpen) this.place(this.box);
|
||||
this.refit();
|
||||
}
|
||||
|
||||
/** The name and the icon of the document shown, as its tab had them. */
|
||||
@@ -156,7 +182,10 @@ export class FloatWindow extends Widget {
|
||||
|
||||
private place(box: WindowBox): void {
|
||||
const screen = ui().app.screen;
|
||||
const fit = onScreen(box, screen.width, screen.height, this.least());
|
||||
const fixed = this.fixedSize;
|
||||
const fit = fixed
|
||||
? onScreen(keptSize(box, fixed), screen.width, screen.height, fixed)
|
||||
: onScreen(box, screen.width, screen.height, this.least());
|
||||
this.position.set(fit.x, fit.y);
|
||||
this.resize(fit.w, fit.h);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Container } from 'pixi.js';
|
||||
import { NameTransitionCommand } from '../../core/commands/graphCommands';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import { currentNodePalette } from '../graph/NodePalette';
|
||||
@@ -182,6 +183,30 @@ export function graphHooks({ state, editor }: HookContext) {
|
||||
* either side, the most taken away in one, and the taps as for the EQ.
|
||||
*/
|
||||
levels: () => ({ ...editor.compressorEditor.seen, taps: soundLibrary.spectrumTaps() }),
|
||||
/**
|
||||
* The hardware panel (#379): whether it is shown and where, its knobs' dials, the reduction the
|
||||
* needle shows and is heading for, the LEDs lit, the most of both so far, the auto-gain lever
|
||||
* and the LCD.
|
||||
*/
|
||||
panel: () => {
|
||||
const panel = editor.compressorEditor.panel;
|
||||
const seen = (node: Container | null): boolean => !node || (node.visible && seen(node.parent));
|
||||
return {
|
||||
shown: seen(panel),
|
||||
rect: rectOf(panel),
|
||||
dials: Object.fromEntries(
|
||||
Object.entries(panel.knobs).map(([key, knob]) => [key, knob.dialGlobal()]),
|
||||
),
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(panel.knobs).map(([key, knob]) => [key, centreOf(knob.field)]),
|
||||
),
|
||||
needle: { shown: panel.meter.shown, target: panel.meter.target },
|
||||
leds: panel.leds.litCount,
|
||||
seen: panel.seen,
|
||||
autoGain: { on: panel.autoGain.lit, says: panel.autoGain.says.text, at: centreOf(panel.autoGain) },
|
||||
lcd: panel.lcdText,
|
||||
};
|
||||
},
|
||||
},
|
||||
instrument: {
|
||||
current: () => editor.instrumentEditor.currentAssetId,
|
||||
|
||||
@@ -43,6 +43,17 @@ export function onScreen(box: WindowBox, screenW: number, screenH: number, least
|
||||
return { x: Math.round(x), y: Math.round(y), w: Math.round(w), h: Math.round(h) };
|
||||
}
|
||||
|
||||
/** `box` at `size`, around the same middle: a window of one size (#379) opened in a box of another. */
|
||||
export function keptSize(box: WindowBox, size: { w: number; h: number }): WindowBox {
|
||||
if (box.w === size.w && box.h === size.h) return box;
|
||||
return {
|
||||
x: Math.round(box.x + (box.w - size.w) / 2),
|
||||
y: Math.round(box.y + (box.h - size.h) / 2),
|
||||
w: size.w,
|
||||
h: size.h,
|
||||
};
|
||||
}
|
||||
|
||||
/** `box` with its lower right corner pulled by `dx`, `dy`: at least the minimum, at most to the screen's edge. */
|
||||
export function sized(
|
||||
box: WindowBox,
|
||||
|
||||
@@ -11,9 +11,9 @@ import { findGraphAsset, type Project } from '../../core/model/Project';
|
||||
import type { PropertyValue } from '../../core/model/Properties';
|
||||
import { AUTO_GAIN_REFERENCE_DB, compressorAutoGain } from '../../core/soundfx/dynamics';
|
||||
import {
|
||||
autoGainOn,
|
||||
switchOn,
|
||||
COMPRESS_AUTO_GAIN,
|
||||
COMPRESS_ROWS,
|
||||
COMPRESS_HARDWARE_LOOK,
|
||||
SoundFxCompressor,
|
||||
tapId,
|
||||
} from '../../core/soundfx/nodes';
|
||||
@@ -33,9 +33,14 @@ import {
|
||||
Widget,
|
||||
} from '../../ui';
|
||||
import { FramePreview } from '../framePreview';
|
||||
import type { SizedPane } from '../FloatWindow';
|
||||
import type { EditorState } from '../state';
|
||||
import { TabHeader } from '../TabHeader';
|
||||
import {
|
||||
COMPRESSOR_KEYS,
|
||||
COMPRESSOR_KNOBS,
|
||||
COMPRESSOR_ROWS,
|
||||
type CompressorKey,
|
||||
type CompressorNumbers,
|
||||
compressorGainDb,
|
||||
CURVE_FLOOR_DB,
|
||||
@@ -58,26 +63,13 @@ import {
|
||||
reductionShapes,
|
||||
thresholdHandle,
|
||||
} from './compressorView';
|
||||
import { CompressorPanel, type PanelHost } from './CompressorPanel';
|
||||
import { gainReduction, PANEL } from './hardwareView';
|
||||
|
||||
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 },
|
||||
};
|
||||
type Key = CompressorKey;
|
||||
const KEYS = COMPRESSOR_KEYS;
|
||||
const ROWS = COMPRESSOR_ROWS;
|
||||
const KNOBS = COMPRESSOR_KNOBS;
|
||||
|
||||
const HANDLE_RADIUS = 6;
|
||||
const GRAB_RADIUS = HANDLE_RADIUS + 4;
|
||||
@@ -324,8 +316,13 @@ class Pictures extends Widget {
|
||||
* 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 {
|
||||
export class CompressorEditor extends VStack implements CompressorHost, PanelHost, SizedPane {
|
||||
readonly levels: LevelPicture;
|
||||
/** The node's hardware look (#379), shown instead of the tab's header and body. */
|
||||
readonly panel: CompressorPanel;
|
||||
/** What the tab needs at the least, for when it is shown again after the panel. */
|
||||
private readonly tabLeast: { w: number; h: number };
|
||||
private lastTick = 0;
|
||||
readonly curve: CurvePicture;
|
||||
readonly knobs = {} as Record<Key, Knob>;
|
||||
/** Auto-gain: a switch, with the dB it gives below it where a knob has its number. */
|
||||
@@ -397,6 +394,10 @@ export class CompressorEditor extends VStack implements CompressorHost {
|
||||
// line is left out of the width: it is cut short instead.
|
||||
this.minWidth = Math.max(knobs.measure().w, pictures.measure().w) + this.body.padding * 2;
|
||||
this.minHeight = this.measure().h;
|
||||
this.tabLeast = { w: this.minWidth, h: this.minHeight };
|
||||
this.panel = new CompressorPanel(this);
|
||||
this.panel.visible = false;
|
||||
this.add(this.panel, 1);
|
||||
state.on('project', () => this.sync());
|
||||
}
|
||||
|
||||
@@ -405,6 +406,7 @@ export class CompressorEditor extends VStack implements CompressorHost {
|
||||
this.perFrame.cancel();
|
||||
this.hideLevels();
|
||||
this.levels.clear();
|
||||
this.panel.reset();
|
||||
this.before = null;
|
||||
this.assetId = assetId;
|
||||
this.nodeId = nodeId;
|
||||
@@ -432,9 +434,15 @@ export class CompressorEditor extends VStack implements CompressorHost {
|
||||
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);
|
||||
const now = performance.now() / 1000;
|
||||
const dt = this.lastTick ? now - this.lastTick : 0;
|
||||
this.lastTick = now;
|
||||
if (this.panel.visible)
|
||||
this.panel.read(gainReduction(levels, compressorGainDb(this.numbers())), levels?.out ?? -Infinity, dt);
|
||||
else if (levels || this.levels.history.heard) {
|
||||
this.levels.read(now, levels);
|
||||
this.curve.hear(levels?.in ?? -Infinity);
|
||||
}
|
||||
if (levels && levels.in > LEVEL_FLOOR_DB) {
|
||||
this.seen = {
|
||||
frames: this.seen.frames + 1,
|
||||
@@ -449,11 +457,35 @@ export class CompressorEditor extends VStack implements CompressorHost {
|
||||
hideLevels(): void {
|
||||
if (this.nodeId) soundLibrary.unlisten(tapId(this.assetId, this.nodeId));
|
||||
this.curve.hear(-Infinity);
|
||||
this.lastTick = 0;
|
||||
}
|
||||
|
||||
/** Whether the level picture is moving, so the tab has to be drawn every frame. */
|
||||
/** Whether the level picture or the meter is moving, so the tab has to be drawn every frame. */
|
||||
get moving(): boolean {
|
||||
return this.levels.history.heard;
|
||||
return this.panel.visible ? this.panel.moving : this.levels.history.heard;
|
||||
}
|
||||
|
||||
/** Whether the node shown is in its hardware look (#379). */
|
||||
get hardware(): boolean {
|
||||
const node = this.node();
|
||||
return node !== null && switchOn(node.params, COMPRESS_HARDWARE_LOOK);
|
||||
}
|
||||
|
||||
/** The panel is one size; the tab takes any. */
|
||||
get fixedSize(): { w: number; h: number } | null {
|
||||
return this.panel.visible ? { ...PANEL } : null;
|
||||
}
|
||||
|
||||
/** The panel or the tab, as the node's look says. */
|
||||
private showFace(hardware: boolean): void {
|
||||
if (this.panel.visible === hardware) return;
|
||||
this.panel.visible = hardware;
|
||||
this.header.visible = !hardware;
|
||||
this.body.visible = !hardware;
|
||||
const least = hardware ? PANEL : this.tabLeast;
|
||||
this.minWidth = least.w;
|
||||
this.minHeight = least.h;
|
||||
this.relayout();
|
||||
}
|
||||
|
||||
private node(): GraphNode | null {
|
||||
@@ -467,21 +499,21 @@ export class CompressorEditor extends VStack implements CompressorHost {
|
||||
const value = params[key];
|
||||
numbers[key] = typeof value === 'number' && Number.isFinite(value) ? value : ROWS[key].default;
|
||||
}
|
||||
numbers.autoGain = autoGainOn(params, COMPRESS_AUTO_GAIN);
|
||||
numbers.autoGain = switchOn(params, COMPRESS_AUTO_GAIN);
|
||||
return numbers;
|
||||
}
|
||||
|
||||
/** The dB auto-gain gives at these numbers, or that it is off. */
|
||||
private showAutoGainDb(n: CompressorNumbers): void {
|
||||
const db = compressorAutoGain(n.threshold, n.knee, n.ratio);
|
||||
const text = n.autoGain ? `+${db.toFixed(1)} dB` : 'off';
|
||||
const text = autoGainText(n);
|
||||
this.panel.autoGain.set(n.autoGain, text);
|
||||
if (text === this.autoGainDb.text) return;
|
||||
this.autoGainDb.setText(text);
|
||||
this.autoColumn.relayout();
|
||||
}
|
||||
|
||||
/** Auto-gain on or off: one undo step, as a click on a knob is. */
|
||||
private switchAutoGain(on: boolean): void {
|
||||
switchAutoGain(on: boolean): void {
|
||||
if (!this.node()) return;
|
||||
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, COMPRESS_AUTO_GAIN.key, on));
|
||||
}
|
||||
@@ -535,7 +567,10 @@ export class CompressorEditor extends VStack implements CompressorHost {
|
||||
}
|
||||
this.autoGain.setChecked(numbers.autoGain);
|
||||
this.showAutoGainDb(numbers);
|
||||
const title = `Compressor – ${findGraphAsset(this.state.project, this.assetId)?.name ?? ''}`;
|
||||
const name = findGraphAsset(this.state.project, this.assetId)?.name ?? '';
|
||||
this.panel.sync(numbers, (key) => this.wired(key), name);
|
||||
this.showFace(this.hardware);
|
||||
const title = `Compressor – ${name}`;
|
||||
if (title !== this.title.text) {
|
||||
this.title.setText(title);
|
||||
this.relayout();
|
||||
@@ -545,6 +580,11 @@ export class CompressorEditor extends VStack implements CompressorHost {
|
||||
}
|
||||
}
|
||||
|
||||
/** What auto-gain gives at these numbers, or that it is off. */
|
||||
function autoGainText(n: CompressorNumbers): string {
|
||||
return n.autoGain ? `+${compressorAutoGain(n.threshold, n.knee, n.ratio).toFixed(1)} dB` : 'off';
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* The Compressor as a device (#379): a brushed plate of one size with chrome knobs, a meter whose
|
||||
* needle shows what is taken away, a lever for auto-gain and LEDs for the level going out. Its
|
||||
* node says whether it is shown instead of the tab of #378.
|
||||
*/
|
||||
import {
|
||||
Container,
|
||||
FillGradient,
|
||||
type FederatedPointerEvent,
|
||||
Graphics,
|
||||
type Text,
|
||||
type TextStyle,
|
||||
} from 'pixi.js';
|
||||
import { crispText, Knob, textStyle, Widget } from '../../ui';
|
||||
import {
|
||||
COMPRESSOR_KEYS,
|
||||
COMPRESSOR_KNOBS,
|
||||
COMPRESSOR_ROWS,
|
||||
type CompressorKey,
|
||||
type CompressorNumbers,
|
||||
} from './compressorView';
|
||||
import {
|
||||
type Box,
|
||||
brushStrokes,
|
||||
KNOB_CAPTION,
|
||||
LED_COUNT,
|
||||
ledColour,
|
||||
ledsLit,
|
||||
ledStep,
|
||||
METER_LABELS,
|
||||
METER_RANGE_DB,
|
||||
METER_RED_DB,
|
||||
METER_TICKS,
|
||||
meterPosition,
|
||||
needleAngle,
|
||||
needleStep,
|
||||
PANEL,
|
||||
PANEL_LAYOUT,
|
||||
} from './hardwareView';
|
||||
|
||||
/** What the panel asks of the editor around it. */
|
||||
export interface PanelHost {
|
||||
preview(key: CompressorKey, value: number): void;
|
||||
commit(key: CompressorKey, value: number): void;
|
||||
switchAutoGain(on: boolean): void;
|
||||
}
|
||||
|
||||
const INK = 0x26282c;
|
||||
const LCD_GLOW = 0x9cf58c;
|
||||
const LED_LIT = { green: 0x4dff5a, amber: 0xffc21a, red: 0xff3b2a };
|
||||
|
||||
/** Captions are engraved: dark letters with a light edge under them. */
|
||||
function engrave(parent: Container, text: string, size = 10): { ink: Text; edge: Text } {
|
||||
const style = (fill: number): TextStyle => {
|
||||
const s = textStyle('small').clone();
|
||||
s.fill = fill;
|
||||
s.fontSize = size;
|
||||
s.fontWeight = 'bold';
|
||||
s.letterSpacing = 1;
|
||||
return s;
|
||||
};
|
||||
const edge = crispText({ text, style: style(0xffffff) });
|
||||
edge.alpha = 0.55;
|
||||
const ink = crispText({ text, style: style(INK) });
|
||||
for (const t of [edge, ink]) {
|
||||
t.eventMode = 'none';
|
||||
t.anchor.set(0.5, 0);
|
||||
parent.addChild(t);
|
||||
}
|
||||
return { ink, edge };
|
||||
}
|
||||
|
||||
function placeEngraving(e: { ink: Text; edge: Text }, x: number, y: number): void {
|
||||
e.ink.position.set(Math.round(x), Math.round(y));
|
||||
e.edge.position.set(Math.round(x), Math.round(y) + 1);
|
||||
}
|
||||
|
||||
let plateFill: FillGradient | null = null;
|
||||
let faceFill: FillGradient | null = null;
|
||||
const gradient = (stops: [number, number][], end = { x: 0, y: 1 }) =>
|
||||
new FillGradient({
|
||||
type: 'linear',
|
||||
start: { x: 0, y: 0 },
|
||||
end,
|
||||
colorStops: stops.map(([offset, color]) => ({ offset, color })),
|
||||
textureSpace: 'local',
|
||||
});
|
||||
|
||||
/** The meter: a lit face, a scale from 0 to the range, and a needle that swings as a VU's does. */
|
||||
class ReductionMeter extends Widget {
|
||||
private readonly face = new Graphics();
|
||||
private readonly needle = new Graphics();
|
||||
private readonly labels: Text[];
|
||||
private readonly caption: Text;
|
||||
/** The reduction the needle shows, and the one it moves towards. */
|
||||
shown = 0;
|
||||
target = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.face);
|
||||
const style = textStyle('small').clone();
|
||||
style.fill = 0x2b3324;
|
||||
style.fontSize = 11;
|
||||
this.labels = METER_LABELS.map((db) => {
|
||||
const text = crispText({ text: `${db}`, style });
|
||||
text.anchor.set(0.5);
|
||||
this.addChild(text);
|
||||
return text;
|
||||
});
|
||||
const captionStyle = style.clone();
|
||||
captionStyle.fontSize = 9;
|
||||
captionStyle.letterSpacing = 1;
|
||||
this.caption = crispText({ text: 'GAIN REDUCTION dB', style: captionStyle });
|
||||
this.caption.anchor.set(0.5);
|
||||
this.addChild(this.caption, this.needle);
|
||||
}
|
||||
|
||||
private get hub(): { x: number; y: number; r: number } {
|
||||
return { x: this.w / 2, y: this.h - 22, r: this.h - 72 };
|
||||
}
|
||||
|
||||
/** `dt` seconds on: the needle a step nearer its reading. Whether it still moves. */
|
||||
step(dt: number): boolean {
|
||||
const next = needleStep(this.shown, this.target, dt);
|
||||
if (next === this.shown) return false;
|
||||
this.shown = next;
|
||||
this.drawNeedle();
|
||||
return true;
|
||||
}
|
||||
|
||||
get moving(): boolean {
|
||||
return this.shown !== this.target;
|
||||
}
|
||||
|
||||
rest(): void {
|
||||
this.shown = this.target = 0;
|
||||
this.drawNeedle();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const { w, h } = this;
|
||||
const p = this.hub;
|
||||
const g = this.face.clear();
|
||||
g.roundRect(0, 0, w, h, 8).fill(0x2a2c30).stroke({ color: 0x4b4e54, width: 2 });
|
||||
faceFill ??= gradient([
|
||||
[0, 0xf1fad4],
|
||||
[0.6, 0xcfe6a0],
|
||||
[1, 0xa9c97a],
|
||||
]);
|
||||
g.roundRect(8, 8, w - 16, h - 16, 5).fill(faceFill);
|
||||
const at = (pos: number, r: number) => {
|
||||
const a = needleAngle(pos);
|
||||
return { x: p.x + Math.cos(a) * r, y: p.y + Math.sin(a) * r };
|
||||
};
|
||||
// An arc goes on from wherever the path is: each starts at its own first point.
|
||||
const arc = (r: number, from: number, to: number) => {
|
||||
const a = at(from, r);
|
||||
g.moveTo(a.x, a.y).arc(p.x, p.y, r, needleAngle(from), needleAngle(to));
|
||||
};
|
||||
arc(p.r + 3, 0, meterPosition(METER_RED_DB));
|
||||
g.stroke({ color: 0xc8321e, width: 4 });
|
||||
arc(p.r, 0, 1);
|
||||
g.stroke({ color: 0x2b3324, width: 1.5 });
|
||||
for (const [marks, length, width] of [
|
||||
[METER_LABELS, 10, 2],
|
||||
[METER_TICKS, 6, 1],
|
||||
] as const) {
|
||||
for (const db of marks) {
|
||||
const pos = meterPosition(db);
|
||||
const a = at(pos, p.r);
|
||||
const b = at(pos, p.r + length);
|
||||
g.moveTo(a.x, a.y).lineTo(b.x, b.y);
|
||||
}
|
||||
g.stroke({ color: 0x2b3324, width });
|
||||
}
|
||||
METER_LABELS.forEach((db, i) => {
|
||||
const spot = at(meterPosition(db), p.r + 22);
|
||||
this.labels[i]!.position.set(Math.round(spot.x), Math.round(spot.y));
|
||||
});
|
||||
this.caption.position.set(Math.round(p.x), Math.round(p.y - 40));
|
||||
// The glass over the face.
|
||||
g.poly([10, 10, w - 10, 10, w - 10, h * 0.32, 10, h * 0.5]).fill({ color: 0xffffff, alpha: 0.12 });
|
||||
this.drawNeedle();
|
||||
}
|
||||
|
||||
private drawNeedle(): void {
|
||||
const p = this.hub;
|
||||
const a = needleAngle(meterPosition(this.shown));
|
||||
this.needle
|
||||
.clear()
|
||||
.moveTo(p.x, p.y)
|
||||
.lineTo(p.x + Math.cos(a) * (p.r + 12), p.y + Math.sin(a) * (p.r + 12))
|
||||
.stroke({ color: 0x15171a, width: 2, cap: 'round' })
|
||||
.circle(p.x, p.y, 9)
|
||||
.fill(0x1d1f22)
|
||||
.circle(p.x - 2, p.y - 2, 3)
|
||||
.fill({ color: 0xffffff, alpha: 0.25 });
|
||||
}
|
||||
}
|
||||
|
||||
/** The level going out, as a column of LEDs: lit at once, going out slowly. */
|
||||
class OutputLeds extends Widget {
|
||||
private readonly lights = new Graphics();
|
||||
shown = -Infinity;
|
||||
private lit = -1;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.lights);
|
||||
}
|
||||
|
||||
/** `dt` seconds on, with the level read now. Whether the LEDs still move. */
|
||||
step(reading: number, dt: number): boolean {
|
||||
this.shown = ledStep(this.shown, reading, dt);
|
||||
const lit = ledsLit(this.shown);
|
||||
if (lit !== this.lit) {
|
||||
this.lit = lit;
|
||||
this.invalidate();
|
||||
}
|
||||
return this.shown > reading;
|
||||
}
|
||||
|
||||
darken(): void {
|
||||
this.shown = -Infinity;
|
||||
this.lit = 0;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
get litCount(): number {
|
||||
return Math.max(0, this.lit);
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const g = this.lights.clear();
|
||||
const pitch = this.h / LED_COUNT;
|
||||
for (let i = 0; i < LED_COUNT; i++) {
|
||||
const y = this.h - (i + 1) * pitch + 1;
|
||||
const colour = LED_LIT[ledColour(i)];
|
||||
g.roundRect(0, y, this.w, pitch - 2, 2).fill(0x1a1b1e);
|
||||
if (i < this.lit) {
|
||||
g.roundRect(1, y + 1, this.w - 2, pitch - 4, 2).fill(colour);
|
||||
g.rect(3, y + 2, this.w - 6, 2).fill({ color: 0xffffff, alpha: 0.5 });
|
||||
} else g.roundRect(1, y + 1, this.w - 2, pitch - 4, 2).fill({ color: colour, alpha: 0.18 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Auto-gain: a lever and a lamp, and what it gives beside them. */
|
||||
class AutoGainLever extends Widget {
|
||||
private readonly g = new Graphics();
|
||||
private readonly caption: { ink: Text; edge: Text };
|
||||
readonly says: Text;
|
||||
lit = false;
|
||||
|
||||
constructor(onToggle: (on: boolean) => void) {
|
||||
super();
|
||||
this.cursor = 'pointer';
|
||||
this.addChild(this.g);
|
||||
this.caption = engrave(this, 'AUTO GAIN', 9);
|
||||
const style = textStyle('small').clone();
|
||||
style.fill = INK;
|
||||
style.fontSize = 11;
|
||||
this.says = crispText({ text: 'off', style });
|
||||
this.says.eventMode = 'none';
|
||||
this.addChild(this.says);
|
||||
this.on('pointertap', (e: FederatedPointerEvent) => {
|
||||
if (e.button === 0) onToggle(!this.lit);
|
||||
});
|
||||
}
|
||||
|
||||
set(on: boolean, says: string): void {
|
||||
if (on === this.lit && says === this.says.text) return;
|
||||
this.lit = on;
|
||||
this.says.text = says;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const g = this.g.clear();
|
||||
const lamp = { x: 10, y: this.h / 2 + 4 };
|
||||
if (this.lit) g.circle(lamp.x, lamp.y, 10).fill({ color: 0xff5a2a, alpha: 0.25 });
|
||||
g.circle(lamp.x, lamp.y, 6)
|
||||
.fill(this.lit ? 0xff5a2a : 0x5a2a20)
|
||||
.stroke({ color: 0x2a2c30, width: 1.5 });
|
||||
g.circle(lamp.x - 2, lamp.y - 2, 2).fill({ color: 0xffffff, alpha: this.lit ? 0.7 : 0.2 });
|
||||
// The lever: up is on.
|
||||
const body = { x: 24, y: 10, w: 20, h: this.h - 10 };
|
||||
g.roundRect(body.x, body.y, body.w, body.h, 4).fill(0x2a2c30).stroke({ color: 0x55595f, width: 1 });
|
||||
const cx = body.x + body.w / 2;
|
||||
const cy = body.y + body.h / 2;
|
||||
const tip = this.lit ? body.y - 4 : body.y + body.h + 4;
|
||||
g.moveTo(cx, cy).lineTo(cx, tip).stroke({ color: 0xb9bdc3, width: 5, cap: 'round' });
|
||||
g.circle(cx, tip, 4).fill(0xe8eaed).stroke({ color: 0x6d7178, width: 1 });
|
||||
placeEngraving(this.caption, 52 + 33, 2);
|
||||
this.says.position.set(52, 20);
|
||||
}
|
||||
}
|
||||
|
||||
export class CompressorPanel extends Widget {
|
||||
private readonly plate = new Graphics();
|
||||
private readonly lcd = new Container();
|
||||
private readonly lcdBox = new Graphics();
|
||||
private readonly lcdName: Text;
|
||||
private readonly lcdReading: Text;
|
||||
readonly meter = new ReductionMeter();
|
||||
readonly leds = new OutputLeds();
|
||||
readonly autoGain: AutoGainLever;
|
||||
readonly knobs = {} as Record<CompressorKey, Knob>;
|
||||
private readonly captions: Record<CompressorKey, { ink: Text; edge: Text }>;
|
||||
private readonly title: { ink: Text; edge: Text };
|
||||
private readonly ledCaption: { ink: Text; edge: Text };
|
||||
/** The most the needle and the LEDs have shown since the node was shown (checks). */
|
||||
seen = { needle: 0, leds: 0 };
|
||||
|
||||
constructor(host: PanelHost) {
|
||||
super();
|
||||
this.minWidth = PANEL.w;
|
||||
this.minHeight = PANEL.h;
|
||||
this.lcd.addChild(this.lcdBox);
|
||||
this.addChild(this.plate, this.lcd);
|
||||
const lcdStyle = textStyle('body').clone();
|
||||
lcdStyle.fill = LCD_GLOW;
|
||||
lcdStyle.fontFamily = 'monospace';
|
||||
this.lcdName = crispText({ text: '', style: lcdStyle });
|
||||
this.lcdReading = crispText({ text: '', style: lcdStyle });
|
||||
this.lcdReading.anchor.set(1, 0);
|
||||
this.lcd.addChild(this.lcdName, this.lcdReading);
|
||||
this.title = engrave(this, 'COMPRESSOR', 14);
|
||||
this.ledCaption = engrave(this, 'OUT', 9);
|
||||
this.captions = {} as Record<CompressorKey, { ink: Text; edge: Text }>;
|
||||
for (const key of COMPRESSOR_KEYS) {
|
||||
const row = COMPRESSOR_ROWS[key];
|
||||
const look = COMPRESSOR_KNOBS[key];
|
||||
const knob = new Knob('', row.default, {
|
||||
min: row.min,
|
||||
max: row.max,
|
||||
step: row.step,
|
||||
taper: look.taper,
|
||||
default: row.default,
|
||||
decimals: look.decimals,
|
||||
size: PANEL_LAYOUT.knobs[key].size,
|
||||
look: 'chrome',
|
||||
onInput: (value) => host.preview(key, value),
|
||||
onChange: (value) => host.commit(key, value),
|
||||
});
|
||||
this.knobs[key] = knob;
|
||||
this.captions[key] = engrave(
|
||||
this,
|
||||
look.label.toUpperCase(),
|
||||
PANEL_LAYOUT.knobs[key].size > 60 ? 11 : 9,
|
||||
);
|
||||
this.addChild(knob);
|
||||
}
|
||||
this.autoGain = new AutoGainLever((on) => host.switchAutoGain(on));
|
||||
this.addChild(this.meter, this.leds, this.autoGain);
|
||||
}
|
||||
|
||||
override measure(): { w: number; h: number } {
|
||||
return { ...PANEL };
|
||||
}
|
||||
|
||||
/** The numbers and the links of the node shown; auto-gain is set apart, with what it gives. */
|
||||
sync(numbers: CompressorNumbers, wired: (key: CompressorKey) => boolean, name: string): void {
|
||||
for (const key of COMPRESSOR_KEYS) {
|
||||
this.knobs[key].setValue(numbers[key]);
|
||||
this.knobs[key].setEnabled(!wired(key));
|
||||
}
|
||||
if (this.lcdName.text !== name) this.lcdName.text = name;
|
||||
}
|
||||
|
||||
/** Another node: the needle at rest, the LEDs out. */
|
||||
reset(): void {
|
||||
this.meter.rest();
|
||||
this.leds.darken();
|
||||
this.seen = { needle: 0, leds: 0 };
|
||||
}
|
||||
|
||||
/** A reading, `dt` seconds after the last: the needle, the LEDs and the LCD move. Whether they still do. */
|
||||
read(reduction: number, out: number, dt: number): boolean {
|
||||
this.meter.target = Math.min(reduction, METER_RANGE_DB);
|
||||
const needle = this.meter.step(dt);
|
||||
const leds = this.leds.step(out, dt);
|
||||
this.seen = {
|
||||
needle: Math.max(this.seen.needle, this.meter.shown),
|
||||
leds: Math.max(this.seen.leds, this.leds.litCount),
|
||||
};
|
||||
const text = `GR ${this.meter.shown.toFixed(1).padStart(4)} dB`;
|
||||
if (this.lcdReading.text !== text) this.lcdReading.text = text;
|
||||
return needle || leds;
|
||||
}
|
||||
|
||||
get lcdText(): string {
|
||||
return `${this.lcdName.text} | ${this.lcdReading.text}`;
|
||||
}
|
||||
|
||||
/** Nothing heard: the needle goes home and the LEDs out. */
|
||||
get moving(): boolean {
|
||||
return this.meter.moving || this.leds.litCount > 0;
|
||||
}
|
||||
|
||||
override layout(): void {
|
||||
const L = PANEL_LAYOUT;
|
||||
const put = (w: Widget, b: Box) => {
|
||||
w.position.set(b.x, b.y);
|
||||
w.resize(b.w, b.h);
|
||||
};
|
||||
put(this.meter, L.meter);
|
||||
put(this.leds, L.leds);
|
||||
put(this.autoGain, L.autoGain);
|
||||
for (const key of COMPRESSOR_KEYS) {
|
||||
const spot = L.knobs[key];
|
||||
const knob = this.knobs[key];
|
||||
const size = knob.measure();
|
||||
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
|
||||
knob.resize(size.w, size.h);
|
||||
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
|
||||
}
|
||||
placeEngraving(this.title, L.title.x + 60, L.title.y + 6);
|
||||
placeEngraving(this.ledCaption, L.leds.x + L.leds.w / 2, L.leds.y - 14);
|
||||
this.lcd.position.set(L.lcd.x, L.lcd.y);
|
||||
this.lcdName.position.set(8, Math.round((L.lcd.h - this.lcdName.height) / 2));
|
||||
this.lcdReading.position.set(L.lcd.w - 8, Math.round((L.lcd.h - this.lcdReading.height) / 2));
|
||||
}
|
||||
|
||||
override redraw(): void {
|
||||
const { w, h } = PANEL;
|
||||
const g = this.plate.clear();
|
||||
plateFill ??= gradient([
|
||||
[0, 0xe2e4e7],
|
||||
[0.5, 0xcfd2d6],
|
||||
[1, 0xb4b8bd],
|
||||
]);
|
||||
g.roundRect(0, 0, w, h, 6).fill(plateFill);
|
||||
for (const stroke of brushStrokes(h - 4))
|
||||
g.rect(3, stroke.y + 2, w - 6, 1).fill({
|
||||
color: stroke.light ? 0xffffff : 0x6c7076,
|
||||
alpha: stroke.alpha,
|
||||
});
|
||||
g.roundRect(0.5, 0.5, w - 1, h - 1, 6).stroke({ color: 0x8a8e94, width: 1 });
|
||||
g.moveTo(3, 1.5)
|
||||
.lineTo(w - 3, 1.5)
|
||||
.stroke({ color: 0xffffff, width: 1, alpha: 0.8 });
|
||||
for (const screw of PANEL_LAYOUT.screws) {
|
||||
g.circle(screw.x, screw.y, 6).fill(0x9a9ea4).stroke({ color: 0x5f6369, width: 1 });
|
||||
g.circle(screw.x - 1, screw.y - 1, 4).fill({ color: 0xffffff, alpha: 0.35 });
|
||||
g.moveTo(screw.x - 4, screw.y + 3)
|
||||
.lineTo(screw.x + 4, screw.y - 3)
|
||||
.stroke({ color: 0x45484d, width: 1.5 });
|
||||
}
|
||||
const lcd = PANEL_LAYOUT.lcd;
|
||||
this.lcdBox
|
||||
.clear()
|
||||
.roundRect(-2, -2, lcd.w + 4, lcd.h + 4, 4)
|
||||
.fill(0x3a3d42)
|
||||
.roundRect(0, 0, lcd.w, lcd.h, 3)
|
||||
.fill(0x0d1a10)
|
||||
.rect(2, 2, lcd.w - 4, lcd.h / 2 - 2)
|
||||
.fill({ color: LCD_GLOW, alpha: 0.05 });
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
* grabbed. Kept apart from the drawing so it is checked without a browser.
|
||||
*/
|
||||
import { compressorAutoGain, compressorCurve } from '../../core/soundfx/dynamics';
|
||||
import { COMPRESS_ROWS } from '../../core/soundfx/nodes';
|
||||
import { clamp, snapToStep } from '../../ui/math';
|
||||
import type { Levels } from '../../viewport/spectrum';
|
||||
|
||||
@@ -19,6 +20,26 @@ 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 type CompressorKey = keyof typeof COMPRESS_ROWS;
|
||||
export const COMPRESSOR_KEYS = Object.keys(COMPRESS_ROWS) as CompressorKey[];
|
||||
/** The rows with the bounds every one of them has. */
|
||||
export const COMPRESSOR_ROWS = Object.fromEntries(
|
||||
COMPRESSOR_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<CompressorKey, { 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. */
|
||||
export const COMPRESSOR_KNOBS: Record<CompressorKey, { 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 },
|
||||
};
|
||||
|
||||
export interface CompressorNumbers {
|
||||
threshold: number;
|
||||
knee: number;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* What the Compressor's hardware panel (#379) draws, as numbers: where its parts sit on a plate of
|
||||
* one size, how the needle of its meter reads and moves, which LEDs are lit, and the grain of the
|
||||
* brushed plate. Kept apart from the drawing so it is checked without a browser.
|
||||
*/
|
||||
import { clamp } from '../../ui/math';
|
||||
import type { Levels } from '../../viewport/spectrum';
|
||||
import { LEVEL_FLOOR_DB } from './compressorView';
|
||||
|
||||
export interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** The plate: the panel never takes another size. */
|
||||
export const PANEL = { w: 820, h: 280 };
|
||||
|
||||
/** The engraved name above a knob, and the number below its dial. */
|
||||
export const KNOB_CAPTION = 14;
|
||||
export const KNOB_FIELD = 24;
|
||||
/** The narrowest a knob is: its number below. */
|
||||
const KNOB_LEAST_WIDTH = 60;
|
||||
export const BIG_DIAL = 100;
|
||||
export const SMALL_DIAL = 48;
|
||||
|
||||
export type PanelKnob = 'threshold' | 'ratio' | 'knee' | 'attack' | 'release' | 'makeup';
|
||||
|
||||
/** A knob by the middle of its dial. */
|
||||
export interface KnobSpot {
|
||||
x: number;
|
||||
y: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface PanelLayout {
|
||||
title: Box;
|
||||
lcd: Box;
|
||||
autoGain: Box;
|
||||
meter: Box;
|
||||
leds: Box;
|
||||
knobs: Record<PanelKnob, KnobSpot>;
|
||||
screws: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
const SCREW_INSET = 14;
|
||||
|
||||
/** Threshold big on the left, makeup big on the right, the times and the shape small beside the meter. */
|
||||
export const PANEL_LAYOUT: PanelLayout = {
|
||||
title: { x: 34, y: 14, w: 220, h: 30 },
|
||||
lcd: { x: (PANEL.w - 240) / 2, y: 14, w: 240, h: 30 },
|
||||
autoGain: { x: 660, y: 10, w: 118, h: 40 },
|
||||
meter: { x: 270, y: 62, w: 280, h: 190 },
|
||||
leds: { x: 782, y: 74, w: 14, h: 168 },
|
||||
knobs: {
|
||||
threshold: { x: 100, y: 160, size: BIG_DIAL },
|
||||
ratio: { x: 222, y: 105, size: SMALL_DIAL },
|
||||
knee: { x: 222, y: 205, size: SMALL_DIAL },
|
||||
attack: { x: 598, y: 105, size: SMALL_DIAL },
|
||||
release: { x: 598, y: 205, size: SMALL_DIAL },
|
||||
makeup: { x: 712, y: 160, size: BIG_DIAL },
|
||||
},
|
||||
screws: [
|
||||
{ x: SCREW_INSET, y: SCREW_INSET },
|
||||
{ x: PANEL.w - SCREW_INSET, y: SCREW_INSET },
|
||||
{ x: SCREW_INSET, y: PANEL.h - SCREW_INSET },
|
||||
{ x: PANEL.w - SCREW_INSET, y: PANEL.h - SCREW_INSET },
|
||||
],
|
||||
};
|
||||
|
||||
/** All a knob takes on the plate: its name above, its dial and its number below. */
|
||||
export function knobBox(spot: KnobSpot): Box {
|
||||
const w = Math.max(spot.size, KNOB_LEAST_WIDTH);
|
||||
return {
|
||||
x: spot.x - w / 2,
|
||||
y: spot.y - spot.size / 2 - KNOB_CAPTION,
|
||||
w,
|
||||
h: KNOB_CAPTION + spot.size + KNOB_FIELD,
|
||||
};
|
||||
}
|
||||
|
||||
// --- The meter: what is taken away, 0 dB at the right, swinging left as more is. ---
|
||||
|
||||
export const METER_RANGE_DB = 24;
|
||||
/** How far the needle swings from end to end, in radians. */
|
||||
export const METER_SWEEP = (96 * Math.PI) / 180;
|
||||
/** The marks with a number, and the ones between. */
|
||||
export const METER_LABELS = [0, 1, 3, 6, 10, 15, 24];
|
||||
export const METER_TICKS = [2, 4, 5, 8, 12, 20];
|
||||
/** From here on the scale is red. */
|
||||
export const METER_RED_DB = 10;
|
||||
/** How fast the needle follows: a VU's, which is near its reading after 300 ms. */
|
||||
const NEEDLE_TIME = 0.065;
|
||||
|
||||
/**
|
||||
* Where on the scale a reduction is, from 1 (none) to 0 (the range or more). The root spreads the
|
||||
* first dB, where most of the work is heard, about as wide as the last nine.
|
||||
*/
|
||||
export function meterPosition(reductionDb: number): number {
|
||||
return 1 - Math.sqrt(clamp(reductionDb, 0, METER_RANGE_DB) / METER_RANGE_DB);
|
||||
}
|
||||
|
||||
/** The needle's angle at a position: straight up in the middle, the screen's y going down. */
|
||||
export function needleAngle(position: number): number {
|
||||
return -Math.PI / 2 + (clamp(position, 0, 1) - 0.5) * METER_SWEEP;
|
||||
}
|
||||
|
||||
/** The reduction the needle shows `dt` seconds on, moving towards `target` as a VU's does. */
|
||||
export function needleStep(shown: number, target: number, dt: number): number {
|
||||
const share = 1 - Math.exp(-clamp(dt, 0, 0.25) / NEEDLE_TIME);
|
||||
const next = shown + (target - shown) * share;
|
||||
return Math.abs(next - target) < 0.01 ? target : next;
|
||||
}
|
||||
|
||||
/** What the node takes away now: the level in, less the level out without the gain after it. */
|
||||
export function gainReduction(levels: Levels | null, gainDb: number): number {
|
||||
if (!levels || !(levels.in > LEVEL_FLOOR_DB)) return 0;
|
||||
return Math.max(0, levels.in - (levels.out - gainDb));
|
||||
}
|
||||
|
||||
// --- The LEDs: the level going out, one every 3 dB up to 0. ---
|
||||
|
||||
export const LED_COUNT = 14;
|
||||
const LED_STEP_DB = 3;
|
||||
/** How fast a lit LED goes out again, in dB a second. */
|
||||
const LED_FALL = 20;
|
||||
|
||||
/** The level an LED lights at, from the bottom one up. */
|
||||
export const ledDb = (index: number): number => (index - (LED_COUNT - 1)) * LED_STEP_DB;
|
||||
|
||||
export function ledColour(index: number): 'green' | 'amber' | 'red' {
|
||||
const db = ledDb(index);
|
||||
if (db >= -3) return 'red';
|
||||
return db >= -12 ? 'amber' : 'green';
|
||||
}
|
||||
|
||||
export function ledsLit(db: number): number {
|
||||
let lit = 0;
|
||||
for (let i = 0; i < LED_COUNT; i++) if (db >= ledDb(i)) lit = i + 1;
|
||||
return lit;
|
||||
}
|
||||
|
||||
/** The level the LEDs show `dt` seconds on: up at once, down slowly. */
|
||||
export function ledStep(shown: number, reading: number, dt: number): number {
|
||||
return Math.max(reading, shown - LED_FALL * clamp(dt, 0, 0.25));
|
||||
}
|
||||
|
||||
// --- The plate. ---
|
||||
|
||||
export interface Stroke {
|
||||
y: number;
|
||||
alpha: number;
|
||||
light: boolean;
|
||||
}
|
||||
|
||||
/** One stroke of the brush per row of the plate, each a little lighter or darker, the same every time. */
|
||||
export function brushStrokes(h: number, seed = 379): Stroke[] {
|
||||
let state = seed >>> 0;
|
||||
const next = () => {
|
||||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||||
return state / 2 ** 32;
|
||||
};
|
||||
return Array.from({ length: Math.max(0, Math.floor(h)) }, (_, y) => ({
|
||||
y,
|
||||
alpha: 0.03 + next() * 0.09,
|
||||
light: next() < 0.5,
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user