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:
adminandClaude Opus 5.5 committed 2026-10-10 02:06:56 +02:00
1 parent 2d4909d248
commit 9917253751
14 files changed
+1153 -52

No files matched your search

+24 -3
View File
@@ -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.
+33 -4
View File
@@ -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);
}
+25
View File
@@ -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,
+11
View File
@@ -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,
+72 -32
View File
@@ -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);
+461
View File
@@ -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 });
}
}
+21
View File
@@ -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;
+169
View File
@@ -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,
}));
}