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

+15 -7
View File
@@ -1015,12 +1015,20 @@ export const COMPRESS_AUTO_GAIN = {
default: false,
};
/** Whether a node's auto-gain is on: its switch, or its default where none was ever set. */
export function autoGainOn(settings: PropertyBag, row: { key: string; default: boolean }): boolean {
/** Whether a node's switch is on: as it was set, or its default where it never was. */
export function switchOn(settings: PropertyBag, row: { key: string; default: boolean }): boolean {
const value = settings[row.key];
return value === undefined ? row.default : booleanOf(value);
}
/** Whether a Compressor's editor is the hardware panel (#379) or the tab of #378. Only its editor reads it. */
export const COMPRESS_HARDWARE_LOOK = {
key: 'hardwareLook',
label: 'Hardware look',
kind: 'boolean' as const,
default: true,
};
/** The numbers of a Compressor, in the order its tab shows them as knobs (#378). */
export const COMPRESS_ROWS = {
threshold: COMPRESS_THRESHOLD,
@@ -1038,11 +1046,11 @@ export const SoundFxCompressor: SoundFxNodeDef = effect(
category: 'sound',
tapped: true,
description:
'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "Auto-gain" on gives back what it takes from a sound at -18 dB, so that one comes out as loud as it went in; the makeup is added on top. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays. Double-click the node to open its editor: a knob for each number, its curve with threshold and ratio to drag, and the level coming in and going out over the last seconds.' +
'Squeezes the loudness of the sound together: what goes above "threshold" (dB) comes out only 1/"ratio" as much louder, so 4 makes 8 dB over it 2 dB. "knee" (dB) eases into that from the threshold on instead of at once. "attack" is how fast it turns down once the sound is over the threshold, "release" how fast it lets go, both in seconds. "makeup" (dB) raises it again afterwards. "Auto-gain" on gives back what it takes from a sound at -18 dB, so that one comes out as loud as it went in; the makeup is added on top. It is 6 ms late, which a voice, a Mix and a bus make up for. All of it can change while the sound plays. Double-click the node to open its editor: with "Hardware look" on, a panel of a fixed size in a window, with a meter of what it takes away; off, a tab with a knob for each number, its curve with threshold and ratio to drag, and the level coming in and going out over the last seconds.' +
BLENDS,
inputs: [audioIn, ...Object.values(COMPRESS_ROWS).map(numberIn)],
outputs: [audioOut],
params: [COMPRESS_AUTO_GAIN],
params: [COMPRESS_AUTO_GAIN, COMPRESS_HARDWARE_LOOK],
emit: (sounds, amounts, settings) =>
withStages(arriving(sounds), [
{
@@ -1053,7 +1061,7 @@ export const SoundFxCompressor: SoundFxNodeDef = effect(
attack: held(amounts.attack, COMPRESS_ATTACK),
release: held(amounts.release, COMPRESS_RELEASE),
makeup: held(amounts.makeup, MAKEUP),
autoGain: autoGainOn(settings, COMPRESS_AUTO_GAIN),
autoGain: switchOn(settings, COMPRESS_AUTO_GAIN),
},
]),
},
@@ -1088,7 +1096,7 @@ export const SoundFxLimiter: SoundFxNodeDef = effect(
threshold: held(amounts.threshold, LIMIT_THRESHOLD),
ceiling: held(amounts.ceiling, CEILING),
release: held(amounts.release, LIMIT_RELEASE),
autoGain: autoGainOn(settings, LIMIT_AUTO_GAIN),
autoGain: switchOn(settings, LIMIT_AUTO_GAIN),
},
]),
},
@@ -1139,7 +1147,7 @@ export const SoundFxSaturation: SoundFxNodeDef = effect(
drive: held(amounts.drive, DRIVE),
tone: held(amounts.tone, TONE),
output: held(amounts.output, OUTPUT),
autoGain: autoGainOn(settings, SATURATION_AUTO_GAIN),
autoGain: switchOn(settings, SATURATION_AUTO_GAIN),
mix,
},
]);
+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,
}));
}
+77 -4
View File
@@ -1,4 +1,4 @@
import { type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
import { FillGradient, type FederatedPointerEvent, type FederatedWheelEvent, Graphics } from 'pixi.js';
import { pressCount } from '../clicks';
import {
draggedValue,
@@ -27,8 +27,27 @@ export interface KnobOptions extends KnobRange {
onInput?: (value: number) => void;
/** Fires once a drag ends, on a notch of the wheel, a double-click or a typed number. */
onChange?: (value: number) => void;
/** The dial's width and height. */
size?: number;
/** `chrome`: a metal cap on a black skirt in a ring of marks, as on a device (#379). */
look?: 'flat' | 'chrome';
}
/** One for all chrome dials, laid over each cap's own bounds: light from the upper left. */
let chromeCap: FillGradient | null = null;
const chromeGradient = () =>
(chromeCap ??= new FillGradient({
type: 'linear',
start: { x: 0.15, y: 0.1 },
end: { x: 0.85, y: 0.95 },
colorStops: [
{ offset: 0, color: 0xfafbfc },
{ offset: 0.45, color: 0xc9ccd1 },
{ offset: 1, color: 0x7d8288 },
],
textureSpace: 'local',
}));
/** The round part: a drag up or down turns it, the wheel notches it, a double-click resets it. */
class Dial extends Widget {
private readonly arc = new Graphics();
@@ -36,8 +55,8 @@ class Dial extends Widget {
constructor(private readonly knob: Knob) {
super();
this.minWidth = DIAL_SIZE;
this.minHeight = DIAL_SIZE;
this.minWidth = knob.options.size ?? DIAL_SIZE;
this.minHeight = knob.options.size ?? DIAL_SIZE;
this.cursor = 'ns-resize';
this.addChild(this.arc);
this.trackHover();
@@ -77,6 +96,10 @@ class Dial extends Widget {
}
override redraw(): void {
if (this.knob.options.look === 'chrome') {
this.drawChrome();
return;
}
const c = Theme.colors;
const size = Math.min(this.w, this.h);
const x = this.w / 2;
@@ -102,8 +125,57 @@ class Dial extends Widget {
.lineTo(x + Math.cos(a) * inner, y + Math.sin(a) * inner)
.stroke({ color: c.symbol, width: 2, cap: 'round' });
}
private drawChrome(): void {
const r = Math.min(this.w, this.h) / 2;
const x = this.w / 2;
const y = this.h / 2;
const angle = (position: number) => KNOB_START + position * KNOB_SWEEP;
const at = knobPosition(this.knob.value, this.knob.options);
const origin = knobOrigin(this.knob.options);
const g = this.arc.clear();
for (let i = 0; i <= 10; i++) {
const a = angle(i / 10);
const inner = i % 5 === 0 ? r * 0.84 : r * 0.89;
g.moveTo(x + Math.cos(a) * inner, y + Math.sin(a) * inner).lineTo(
x + Math.cos(a) * r,
y + Math.sin(a) * r,
);
}
g.stroke({ color: 0x34373c, width: 1.5 });
const track = r * 0.8;
g.arc(x, y, track, angle(0), angle(1)).stroke({ color: 0x55595f, width: 3, alpha: 0.35 });
if (at !== origin)
g.arc(x, y, track, angle(Math.min(at, origin)), angle(Math.max(at, origin))).stroke({
color: this.knob.enabled ? CHROME_ARC : 0x8b8f95,
width: 3,
});
// The black skirt with its grip, the metal cap on it.
const skirt = r * 0.72;
g.circle(x, y, skirt).fill(0x1d1f22);
for (let i = 0; i < 40; i++) {
const a = (i / 40) * Math.PI * 2;
g.moveTo(x + Math.cos(a) * skirt * 0.86, y + Math.sin(a) * skirt * 0.86).lineTo(
x + Math.cos(a) * skirt,
y + Math.sin(a) * skirt,
);
}
g.stroke({ color: 0x40444a, width: 1 });
const cap = skirt * 0.8;
g.circle(x, y, cap)
.fill(chromeGradient())
.stroke({ color: this.hovered || this.dragging ? CHROME_ARC : 0x5d6168, width: 1 });
g.ellipse(x - cap * 0.3, y - cap * 0.38, cap * 0.42, cap * 0.2).fill({ color: 0xffffff, alpha: 0.45 });
const a = angle(at);
g.moveTo(x + Math.cos(a) * cap * 0.25, y + Math.sin(a) * cap * 0.25)
.lineTo(x + Math.cos(a) * cap * 0.92, y + Math.sin(a) * cap * 0.92)
.stroke({ color: 0x1b1d20, width: Math.max(2, r / 16), cap: 'round' });
}
}
/** The value's arc on a chrome dial: the orange of a lit scale. */
const CHROME_ARC = 0xff8a1e;
/**
* A number on a round dial (#378): a slider bent into a circle, its name above and the number
* below, which takes a typed value on a click.
@@ -132,7 +204,8 @@ export class Knob extends VStack {
onInput: (typed) => this.preview(typed),
onChange: (typed) => this.commit(typed),
});
this.add(this.caption);
// Without a name the knob has none above it: a panel engraves its own.
if (label) this.add(this.caption);
this.add(this.dial);
this.add(this.field);
}