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
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1949 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1953 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}));
|
||||
}
|
||||
+77
-4
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { closeDocument, neighbourOf, openDocument, replaceDocument } from '../../src/editor/documents';
|
||||
import {
|
||||
keptSize,
|
||||
leastBox,
|
||||
MIN_WINDOW_HEIGHT,
|
||||
MIN_WINDOW_WIDTH,
|
||||
@@ -104,6 +105,13 @@ describe('a tab in a window (#371)', () => {
|
||||
expect(onScreen({ ...box, w: 400, h: 300 }, 1280, 800, least)).toEqual({ ...box, w: 701, h: 450 });
|
||||
expect(onScreen({ ...box, w: 400, h: 300 }, 600, 400, least)).toEqual({ x: 0, y: 0, w: 600, h: 400 });
|
||||
});
|
||||
|
||||
it('takes the size of a pane of one size around the middle it had, and keeps a box of that size (#379)', () => {
|
||||
const box = { x: 100, y: 100, w: 800, h: 600 };
|
||||
expect(keptSize(box, { w: 400, h: 200 })).toEqual({ x: 300, y: 300, w: 400, h: 200 });
|
||||
const kept = { x: 7, y: 9, w: 400, h: 200 };
|
||||
expect(keptSize(kept, { w: 400, h: 200 })).toBe(kept);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab row without an active tab', () => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* The Compressor's hardware panel (#379): every part on the plate and none over another, the meter's
|
||||
* scale and needle, the LEDs, and the grain of the plate the same every time.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
type Box,
|
||||
brushStrokes,
|
||||
gainReduction,
|
||||
knobBox,
|
||||
LED_COUNT,
|
||||
ledColour,
|
||||
ledDb,
|
||||
ledsLit,
|
||||
ledStep,
|
||||
METER_LABELS,
|
||||
METER_RANGE_DB,
|
||||
meterPosition,
|
||||
needleAngle,
|
||||
needleStep,
|
||||
PANEL,
|
||||
PANEL_LAYOUT,
|
||||
} from '../../src/editor/soundfx/hardwareView';
|
||||
|
||||
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
|
||||
describe('the plate', () => {
|
||||
const L = PANEL_LAYOUT;
|
||||
const parts: [string, Box][] = [
|
||||
['title', L.title],
|
||||
['lcd', L.lcd],
|
||||
['autoGain', L.autoGain],
|
||||
['meter', L.meter],
|
||||
['leds', L.leds],
|
||||
...Object.entries(L.knobs).map(([key, spot]): [string, Box] => [key, knobBox(spot)]),
|
||||
];
|
||||
|
||||
it('holds every part inside it, clear of the screws', () => {
|
||||
for (const [, box] of parts) {
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.y).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.w).toBeLessThanOrEqual(PANEL.w);
|
||||
expect(box.y + box.h).toBeLessThanOrEqual(PANEL.h);
|
||||
for (const screw of L.screws)
|
||||
expect(overlap(box, { x: screw.x - 7, y: screw.y - 7, w: 14, h: 14 })).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('puts no part over another', () => {
|
||||
const over = parts.flatMap(([a, boxA], i) =>
|
||||
parts.slice(i + 1).flatMap(([b, boxB]) => (overlap(boxA, boxB) ? [`${a}/${b}`] : [])),
|
||||
);
|
||||
expect(over).toEqual([]);
|
||||
});
|
||||
|
||||
it('has the big knobs at its ends and the small ones beside the meter', () => {
|
||||
const { threshold, makeup, ratio, attack } = L.knobs;
|
||||
expect(threshold.size).toBeGreaterThan(ratio.size);
|
||||
expect(threshold.x).toBeLessThan(ratio.x);
|
||||
expect(ratio.x).toBeLessThan(L.meter.x);
|
||||
expect(attack.x).toBeGreaterThan(L.meter.x + L.meter.w);
|
||||
expect(makeup.x).toBeGreaterThan(attack.x);
|
||||
});
|
||||
|
||||
it('is brushed the same every time, a stroke per row', () => {
|
||||
const strokes = brushStrokes(PANEL.h);
|
||||
expect(strokes).toHaveLength(PANEL.h);
|
||||
expect(brushStrokes(PANEL.h)).toEqual(strokes);
|
||||
const alphas = strokes.map((s) => s.alpha);
|
||||
expect(Math.max(...alphas) - Math.min(...alphas)).toBeGreaterThan(0.07);
|
||||
expect(strokes.some((s) => s.light) && strokes.some((s) => !s.light)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the meter', () => {
|
||||
it('reads 0 dB at the right end, the range at the left, and the first dB about as wide as the last nine', () => {
|
||||
expect(meterPosition(0)).toBe(1);
|
||||
expect(meterPosition(METER_RANGE_DB)).toBe(0);
|
||||
expect(meterPosition(40)).toBe(0);
|
||||
expect(meterPosition(-3)).toBe(1);
|
||||
expect(1 - meterPosition(1)).toBeCloseTo(meterPosition(15) - meterPosition(24), 1);
|
||||
const spots = METER_LABELS.map(meterPosition);
|
||||
for (let i = 1; i < spots.length; i++) expect(spots[i]).toBeLessThan(spots[i - 1]!);
|
||||
});
|
||||
|
||||
it('swings the needle symmetrically about straight up', () => {
|
||||
expect(needleAngle(0.5)).toBeCloseTo(-Math.PI / 2);
|
||||
expect(needleAngle(0) + needleAngle(1)).toBeCloseTo(-Math.PI);
|
||||
expect(needleAngle(1)).toBeGreaterThan(needleAngle(0));
|
||||
});
|
||||
|
||||
it('moves the needle as a VU: most of the way in 300 ms, and there in the end', () => {
|
||||
let shown = 0;
|
||||
for (let t = 0; t < 0.3; t += 1 / 60) shown = needleStep(shown, 10, 1 / 60);
|
||||
expect(shown).toBeGreaterThan(9);
|
||||
expect(shown).toBeLessThan(10);
|
||||
expect(needleStep(0, 10, 1 / 60)).toBeLessThan(3);
|
||||
for (let i = 0; i < 60; i++) shown = needleStep(shown, 10, 1 / 60);
|
||||
expect(shown).toBe(10);
|
||||
// A long pause between two frames moves it no further than a quarter of a second does.
|
||||
expect(needleStep(5, 0, 10)).toBe(needleStep(5, 0, 0.25));
|
||||
});
|
||||
|
||||
it('takes the gain after the compressing out of what it reads, and nothing from silence', () => {
|
||||
expect(gainReduction({ in: -10, out: -6 }, 10)).toBeCloseTo(6);
|
||||
expect(gainReduction({ in: -10, out: -10 }, 0)).toBe(0);
|
||||
expect(gainReduction({ in: -10, out: -2 }, 0)).toBe(0);
|
||||
expect(gainReduction({ in: -Infinity, out: -Infinity }, 0)).toBe(0);
|
||||
expect(gainReduction(null, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the LEDs', () => {
|
||||
it('light one every 3 dB up to 0, red at the top and green at the bottom', () => {
|
||||
expect(ledDb(LED_COUNT - 1)).toBe(0);
|
||||
expect(ledDb(LED_COUNT - 2)).toBe(-3);
|
||||
expect(ledColour(LED_COUNT - 1)).toBe('red');
|
||||
expect(ledColour(LED_COUNT - 2)).toBe('red');
|
||||
expect(ledColour(LED_COUNT - 4)).toBe('amber');
|
||||
expect(ledColour(0)).toBe('green');
|
||||
expect(ledsLit(-Infinity)).toBe(0);
|
||||
expect(ledsLit(0)).toBe(LED_COUNT);
|
||||
expect(ledsLit(-4)).toBe(LED_COUNT - 2);
|
||||
});
|
||||
|
||||
it('go up at once and down by 20 dB a second', () => {
|
||||
expect(ledStep(-30, -6, 1 / 60)).toBe(-6);
|
||||
expect(ledStep(-6, -30, 0.1)).toBeCloseTo(-8);
|
||||
expect(ledStep(-6, -Infinity, 10)).toBe(-11);
|
||||
});
|
||||
});
|
||||
+105
-1
@@ -687,7 +687,12 @@ export async function sound(t) {
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes.push(${JSON.stringify(
|
||||
node('fx-comp', 'SoundFxCompressor', -240, 0, { threshold: -30, ratio: 4, knee: 0 }),
|
||||
node('fx-comp', 'SoundFxCompressor', -240, 0, {
|
||||
threshold: -30,
|
||||
ratio: 4,
|
||||
knee: 0,
|
||||
hardwareLook: false,
|
||||
}),
|
||||
)});
|
||||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
|
||||
graph.edges.push(
|
||||
@@ -849,6 +854,105 @@ export async function sound(t) {
|
||||
!compDocsAfter.docs.some((doc) => doc.startsWith('compressor:')),
|
||||
JSON.stringify({ compLinked, unseen: compUnseen.taps, compDocsAfter }),
|
||||
);
|
||||
// #379: in its hardware look – a Compressor's default – the double-click opens a window of one
|
||||
// size, with no corner to size it, no Dock and no tab. Its knobs and lever work as the tab's,
|
||||
// and played, the meter's needle swings and the LEDs light. The look switched off, the
|
||||
// window can be docked; switched on again, the tab leaves the row for a window.
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes.push(${JSON.stringify(node('fx-hw', 'SoundFxCompressor', -240, 0, { threshold: -50, ratio: 8, knee: 0, makeup: 20 }))});
|
||||
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e3');
|
||||
graph.edges.push(
|
||||
${JSON.stringify(edge('fx-e15', 'fx-gain', 'out', 'fx-hw', 'in'))},
|
||||
${JSON.stringify(edge('fx-e16', 'fx-hw', 'out', 'fx-out', 'in'))},
|
||||
);`);
|
||||
const hwDoc = `compressor:${fxId}:fx-hw`;
|
||||
const hwNode = await json("__gm.soundfx.nodeAt('fx-hw')");
|
||||
await t.doubleClick(hwNode.x, hwNode.y);
|
||||
await sleep(100);
|
||||
const hwWindow = async () => (await json('__gm.floatWindows()')).find((w) => w.kind === 'compressor');
|
||||
const hwOpened = {
|
||||
window: await hwWindow(),
|
||||
docs: await json('__gm.documents()'),
|
||||
panel: await json('__gm.compressor.panel()'),
|
||||
};
|
||||
await t.drag(
|
||||
hwOpened.window.grip.x,
|
||||
hwOpened.window.grip.y,
|
||||
hwOpened.window.grip.x + 300,
|
||||
hwOpened.window.grip.y + 200,
|
||||
);
|
||||
const hwSized = await hwWindow();
|
||||
check(
|
||||
'a Compressor in its hardware look opens in a window of one size, with no tab, no Dock and no corner to size it',
|
||||
hwOpened.window?.doc === hwDoc &&
|
||||
hwOpened.window.fixed &&
|
||||
hwOpened.window.box.w === hwOpened.panel.rect.w + 2 &&
|
||||
hwOpened.panel.shown &&
|
||||
hwOpened.panel.rect.w === 820 &&
|
||||
hwOpened.panel.rect.h === 280 &&
|
||||
!hwOpened.docs.docs.includes(hwDoc) &&
|
||||
hwSized.box.w === hwOpened.window.box.w &&
|
||||
hwSized.box.h === hwOpened.window.box.h,
|
||||
JSON.stringify({ hwOpened, hwSized: hwSized.box }),
|
||||
);
|
||||
const hwThreshold = hwOpened.panel.dials.threshold;
|
||||
await drag(hwThreshold.x, hwThreshold.y, hwThreshold.x, hwThreshold.y - 20);
|
||||
const hwDragged = await json('__gm.compressor.numbers()');
|
||||
await press('z', { ctrl: true });
|
||||
const hwUndone = await json('__gm.compressor.numbers()');
|
||||
await click(hwOpened.panel.autoGain.at.x, hwOpened.panel.autoGain.at.y);
|
||||
await sleep(100);
|
||||
const hwLever = await json('({ numbers: __gm.compressor.numbers(), panel: __gm.compressor.panel() })');
|
||||
check(
|
||||
'a chrome knob turns with a drag up as one undo step, and the lever switches auto-gain and says what it gives',
|
||||
hwDragged.threshold > -50 &&
|
||||
hwUndone.threshold === -50 &&
|
||||
hwLever.numbers.autoGain === true &&
|
||||
hwLever.panel.autoGain.on &&
|
||||
/^\+\d+\.\d dB$/.test(hwLever.panel.autoGain.says),
|
||||
JSON.stringify({ hwDragged, hwUndone, hwLever }),
|
||||
);
|
||||
await press('z', { ctrl: true });
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
await evaluate('__gm.soundfx.play()');
|
||||
const hwPlayed = await waitFor(
|
||||
() => json('__gm.compressor.panel()'),
|
||||
(p) => p.seen.needle > 3 && p.seen.leds > 0,
|
||||
);
|
||||
await shot('34-compressor-hardware');
|
||||
check(
|
||||
'played, the needle swings towards what is taken away and the LEDs light with the level going out',
|
||||
hwPlayed.seen.needle > 3 && hwPlayed.seen.leds > 0 && /GR +\d+\.\d dB/.test(hwPlayed.lcd),
|
||||
JSON.stringify(hwPlayed),
|
||||
);
|
||||
await evaluate('__gm.sound.stopAll()');
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes.find((n) => n.id === 'fx-hw').params.hardwareLook = false;`);
|
||||
const hwStandard = { window: await hwWindow(), panel: await json('__gm.compressor.panel()') };
|
||||
await click(hwStandard.window.dock.x, hwStandard.window.dock.y);
|
||||
const hwDocked = { windows: await json('__gm.floatWindows()'), docs: await json('__gm.documents()') };
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes.find((n) => n.id === 'fx-hw').params.hardwareLook = true;`);
|
||||
const hwBack = { window: await hwWindow(), docs: await json('__gm.documents()') };
|
||||
check(
|
||||
'the look switched off, the window can be sized and docked; switched on again, the tab leaves the row for a window',
|
||||
!hwStandard.window.fixed &&
|
||||
!hwStandard.panel.shown &&
|
||||
hwDocked.windows.length === 0 &&
|
||||
hwDocked.docs.active === hwDoc &&
|
||||
hwBack.window?.doc === hwDoc &&
|
||||
hwBack.window.fixed &&
|
||||
!hwBack.docs.docs.includes(hwDoc),
|
||||
JSON.stringify({ hwStandard, hwDocked, hwBack }),
|
||||
);
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-hw');
|
||||
graph.edges = graph.edges.filter((e) => !['fx-e15', 'fx-e16'].includes(e.id));
|
||||
graph.edges.push(${JSON.stringify(edge('fx-e3', 'fx-gain', 'out', 'fx-out', 'in'))});`);
|
||||
await showTab('scene');
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||
|
||||
Reference in new issue
Block a user