Show a Gain node as an orange device with one knob (#384)
A Gain node now draws a small device in the graph, as the Compressor does since #380: the brushed plate in orange, 160x220 graph units, with one chrome knob between its inputs and its output. - GainPanel and GainFace join the node faces. The knob drags, resets on a double-click and keeps the wheel for the graph (#383). Each turn is one undo step, a linked Gain (dB) greys it. - The knob turns with a taper of 0.26, so 0 dB stands straight up and the range below -24 dB takes a fifth of the turn. - drawPlate, engrave and the brushed look move out of CompressorPanel so both devices share them. The Knob takes an arc colour, because the orange arc vanished on the orange plate. - No meter and no tap. Gain sits in every voice of an instrument, and a tap would add two audio nodes to each note for a display nobody asked for. Found on the way: - Knob.dialGlobal added half the dial unscaled. In a zoomed graph it pointed beside the dial. The Compressor's e2e only hit by luck. Tests: - Unit: 2 new for the Gain plate and its taper. - e2e 34 on atx: ALL PASSED, 126 checks (1 new). - Gate on atx: CHECK EXIT 0, 283 files, 4359 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
79a04e6764
commit
01ee61868b
12 files changed
+348
-48
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:
|
The browser suite and the three example games need a machine with a GPU:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1956 checks (GM_STEPS=20 for one step)
|
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1957 checks (GM_STEPS=20 for one step)
|
||||||
# every tool signs in first – see the Accounts page
|
# every tool signs in first – see the Accounts page
|
||||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||||
|
|||||||
@@ -535,7 +535,7 @@ export const SoundFxOutput: SoundFxNodeDef = {
|
|||||||
emit: arriving,
|
emit: arriving,
|
||||||
};
|
};
|
||||||
|
|
||||||
const GAIN = numberRow('gain', 'Gain (dB)', -120, 24, 0.5);
|
export const GAIN_ROW = numberRow('gain', 'Gain (dB)', -120, 24, 0.5);
|
||||||
|
|
||||||
/** How an effect with a Bypass and a Wet says so, at the end of its description (#354). */
|
/** How an effect with a Bypass and a Wet says so, at the end of its description (#354). */
|
||||||
const BLENDS =
|
const BLENDS =
|
||||||
@@ -549,11 +549,11 @@ export const SoundFxGain: SoundFxNodeDef = effect(
|
|||||||
description:
|
description:
|
||||||
'Makes the sound louder or quieter, in decibels: 0 leaves it as it is, -6 is about half as loud, +6 twice. Above 0 it can clip.' +
|
'Makes the sound louder or quieter, in decibels: 0 leaves it as it is, -6 is about half as loud, +6 twice. Above 0 it can clip.' +
|
||||||
BLENDS,
|
BLENDS,
|
||||||
inputs: [audioIn, followingIn(GAIN)],
|
inputs: [audioIn, followingIn(GAIN_ROW)],
|
||||||
outputs: [audioOut],
|
outputs: [audioOut],
|
||||||
params: [],
|
params: [],
|
||||||
emit: (sounds, amounts) => {
|
emit: (sounds, amounts) => {
|
||||||
const db = held(amounts.gain, GAIN);
|
const db = held(amounts.gain, GAIN_ROW);
|
||||||
return withStages(arriving(sounds), isNone(db) ? [] : [{ kind: 'gain', db }]);
|
return withStages(arriving(sounds), isNone(db) ? [] : [{ kind: 'gain', db }]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { soundLibrary } from '../../viewport/SoundLibrary';
|
|||||||
import type { Widget } from '../../ui';
|
import type { Widget } from '../../ui';
|
||||||
import { currentNodePalette } from '../graph/NodePalette';
|
import { currentNodePalette } from '../graph/NodePalette';
|
||||||
import { CompressorPanel } from '../soundfx/CompressorPanel';
|
import { CompressorPanel } from '../soundfx/CompressorPanel';
|
||||||
|
import { GainPanel } from '../soundfx/GainPanel';
|
||||||
import { EqPanel } from '../soundfx/EqPanel';
|
import { EqPanel } from '../soundfx/EqPanel';
|
||||||
import { rectOf } from '../textProbe';
|
import { rectOf } from '../textProbe';
|
||||||
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
import { centreOf, snapButton, type HookContext } from './hookHelpers';
|
||||||
@@ -243,6 +244,34 @@ export function graphHooks({ state, editor }: HookContext) {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* The panel a Gain node shows in the sound FX graph (#384): whether it is shown and where, its
|
||||||
|
* knob, and the node's ports.
|
||||||
|
*/
|
||||||
|
gain: {
|
||||||
|
panel: (nodeId: string) => {
|
||||||
|
const canvas = editor.soundFxEditor.canvas;
|
||||||
|
const panel = canvas.faceOf(nodeId)?.widget;
|
||||||
|
if (!(panel instanceof GainPanel)) return null;
|
||||||
|
const node = canvas.nodeScreen(nodeId);
|
||||||
|
const ports = (keys: string[], isOutput: boolean) =>
|
||||||
|
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
|
||||||
|
return {
|
||||||
|
shown: shownOnScreen(panel),
|
||||||
|
rect: screenRect(panel),
|
||||||
|
size: { w: panel.w, h: panel.h },
|
||||||
|
node: node?.rect ?? null,
|
||||||
|
inputs: ports(node?.inputs ?? [], false),
|
||||||
|
outputs: ports(node?.outputs ?? [], true),
|
||||||
|
knob: {
|
||||||
|
value: panel.knob.value,
|
||||||
|
enabled: panel.knob.enabled,
|
||||||
|
number: panel.knob.shown,
|
||||||
|
dial: panel.knob.dialGlobal(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
instrument: {
|
instrument: {
|
||||||
current: () => editor.instrumentEditor.currentAssetId,
|
current: () => editor.instrumentEditor.currentAssetId,
|
||||||
info: () => editor.instrumentEditor.shownInfo,
|
info: () => editor.instrumentEditor.shownInfo,
|
||||||
|
|||||||
@@ -501,8 +501,8 @@ export class CompressorEditor extends VStack implements CompressorHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a link decides one of a Compressor's numbers: its knob is greyed then. */
|
/** Whether a link decides one of a Compressor's (or a Gain's, #384) numbers: its knob is greyed then. */
|
||||||
export function compressorWired(project: Project, assetId: string, nodeId: string, key: Key): boolean {
|
export function compressorWired(project: Project, assetId: string, nodeId: string, key: string): boolean {
|
||||||
const graph = findGraphAsset(project, assetId)?.graph;
|
const graph = findGraphAsset(project, assetId)?.graph;
|
||||||
return Boolean(graph?.edges.some((e) => e.to.node === nodeId && e.to.port === key));
|
return Boolean(graph?.edges.some((e) => e.to.node === nodeId && e.to.port === key));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ export interface PanelHost {
|
|||||||
switchAutoGain(on: boolean): void;
|
switchAutoGain(on: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INK = 0x26282c;
|
export const INK = 0x26282c;
|
||||||
const LCD_GLOW = 0x9cf58c;
|
const LCD_GLOW = 0x9cf58c;
|
||||||
const LED_LIT = { green: 0x4dff5a, amber: 0xffc21a, red: 0xff3b2a };
|
const LED_LIT = { green: 0x4dff5a, amber: 0xffc21a, red: 0xff3b2a };
|
||||||
|
|
||||||
/** Captions are engraved: dark letters with a light edge under them. */
|
/** Captions are engraved: dark letters with a light edge under them. */
|
||||||
function engrave(parent: Container, text: string, size = 10): { ink: Text; edge: Text } {
|
export function engrave(parent: Container, text: string, size = 10): { ink: Text; edge: Text } {
|
||||||
const style = (fill: number): TextStyle => {
|
const style = (fill: number): TextStyle => {
|
||||||
const s = textStyle('small').clone();
|
const s = textStyle('small').clone();
|
||||||
s.fill = fill;
|
s.fill = fill;
|
||||||
@@ -71,12 +71,11 @@ function engrave(parent: Container, text: string, size = 10): { ink: Text; edge:
|
|||||||
return { ink, edge };
|
return { ink, edge };
|
||||||
}
|
}
|
||||||
|
|
||||||
function placeEngraving(e: { ink: Text; edge: Text }, x: number, y: number): void {
|
export function placeEngraving(e: { ink: Text; edge: Text }, x: number, y: number): void {
|
||||||
e.ink.position.set(Math.round(x), Math.round(y));
|
e.ink.position.set(Math.round(x), Math.round(y));
|
||||||
e.edge.position.set(Math.round(x), Math.round(y) + 1);
|
e.edge.position.set(Math.round(x), Math.round(y) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let plateFill: FillGradient | null = null;
|
|
||||||
let faceFill: FillGradient | null = null;
|
let faceFill: FillGradient | null = null;
|
||||||
const gradient = (stops: [number, number][], end = { x: 0, y: 1 }) =>
|
const gradient = (stops: [number, number][], end = { x: 0, y: 1 }) =>
|
||||||
new FillGradient({
|
new FillGradient({
|
||||||
@@ -87,6 +86,55 @@ const gradient = (stops: [number, number][], end = { x: 0, y: 1 }) =>
|
|||||||
textureSpace: 'local',
|
textureSpace: 'local',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The metal of a plate: its shade from top to bottom, its rim, and the dark strokes of the brush. */
|
||||||
|
export interface PlateLook {
|
||||||
|
stops: [number, number][];
|
||||||
|
rim: number;
|
||||||
|
grain: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brushed aluminium, the Compressor's. */
|
||||||
|
const ALUMINIUM: PlateLook = {
|
||||||
|
stops: [
|
||||||
|
[0, 0xe2e4e7],
|
||||||
|
[0.5, 0xcfd2d6],
|
||||||
|
[1, 0xb4b8bd],
|
||||||
|
],
|
||||||
|
rim: 0x8a8e94,
|
||||||
|
grain: 0x6c7076,
|
||||||
|
};
|
||||||
|
|
||||||
|
const plateFills = new WeakMap<PlateLook, FillGradient>();
|
||||||
|
|
||||||
|
/** A brushed plate with a screw in each corner. */
|
||||||
|
export function drawPlate(
|
||||||
|
g: Graphics,
|
||||||
|
size: { w: number; h: number },
|
||||||
|
look: PlateLook,
|
||||||
|
screws: readonly { x: number; y: number }[],
|
||||||
|
): void {
|
||||||
|
const { w, h } = size;
|
||||||
|
let fill = plateFills.get(look);
|
||||||
|
if (!fill) plateFills.set(look, (fill = gradient(look.stops)));
|
||||||
|
g.roundRect(0, 0, w, h, 6).fill(fill);
|
||||||
|
for (const stroke of brushStrokes(h - 4))
|
||||||
|
g.rect(3, stroke.y + 2, w - 6, 1).fill({
|
||||||
|
color: stroke.light ? 0xffffff : look.grain,
|
||||||
|
alpha: stroke.alpha,
|
||||||
|
});
|
||||||
|
g.roundRect(0.5, 0.5, w - 1, h - 1, 6).stroke({ color: look.rim, width: 1 });
|
||||||
|
g.moveTo(3, 1.5)
|
||||||
|
.lineTo(w - 3, 1.5)
|
||||||
|
.stroke({ color: 0xffffff, width: 1, alpha: 0.8 });
|
||||||
|
for (const screw of 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** The meter: a lit face, a scale from 0 to the range, and a needle that swings as a VU's does. */
|
/** 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 {
|
class ReductionMeter extends Widget {
|
||||||
private readonly face = new Graphics();
|
private readonly face = new Graphics();
|
||||||
@@ -201,7 +249,7 @@ class ReductionMeter extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** The level going out, as a column of LEDs: lit at once, going out slowly. */
|
/** The level going out, as a column of LEDs: lit at once, going out slowly. */
|
||||||
class OutputLeds extends Widget {
|
export class OutputLeds extends Widget {
|
||||||
private readonly lights = new Graphics();
|
private readonly lights = new Graphics();
|
||||||
shown = -Infinity;
|
shown = -Infinity;
|
||||||
private lit = -1;
|
private lit = -1;
|
||||||
@@ -430,30 +478,7 @@ export class CompressorPanel extends Widget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override redraw(): void {
|
override redraw(): void {
|
||||||
const { w, h } = PANEL;
|
drawPlate(this.plate.clear(), PANEL, ALUMINIUM, PANEL_LAYOUT.screws);
|
||||||
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;
|
const lcd = PANEL_LAYOUT.lcd;
|
||||||
this.lcdBox
|
this.lcdBox
|
||||||
.clear()
|
.clear()
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { GraphNode } from '../../core/graph';
|
||||||
|
import { findGraphAsset } from '../../core/model/Project';
|
||||||
|
import { GAIN_ROW, SoundFxGain } from '../../core/soundfx/nodes';
|
||||||
|
import type { NodeFace } from '../graph/nodeFace';
|
||||||
|
import type { EditorState } from '../state';
|
||||||
|
import { compressorWired } from './CompressorEditor';
|
||||||
|
import { type GainHost, GainPanel } from './GainPanel';
|
||||||
|
import { NodeParamDrag } from './nodeParamDrag';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Gain node drawn as its device (#384): one knob, turned right in the graph. It listens to
|
||||||
|
* nothing, so it has nothing to move between changes.
|
||||||
|
*/
|
||||||
|
export class GainFace implements NodeFace, GainHost {
|
||||||
|
readonly widget: GainPanel;
|
||||||
|
private readonly drag: NodeParamDrag;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly state: EditorState,
|
||||||
|
private readonly assetId: string,
|
||||||
|
private readonly nodeId: string,
|
||||||
|
) {
|
||||||
|
this.widget = new GainPanel(this);
|
||||||
|
this.drag = new NodeParamDrag(state, 'gain-face');
|
||||||
|
}
|
||||||
|
|
||||||
|
private node(): GraphNode | null {
|
||||||
|
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
|
||||||
|
(n) => n.id === this.nodeId,
|
||||||
|
);
|
||||||
|
return node?.type === SoundFxGain.type ? node : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
sync(): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (!node) return;
|
||||||
|
const db = node.params[GAIN_ROW.key];
|
||||||
|
this.widget.sync(
|
||||||
|
typeof db === 'number' && Number.isFinite(db) ? db : GAIN_ROW.default,
|
||||||
|
compressorWired(this.state.project, this.assetId, this.nodeId, GAIN_ROW.key),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide(): void {}
|
||||||
|
|
||||||
|
preview(value: number): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (node) this.drag.preview(node, GAIN_ROW.key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
commit(value: number): void {
|
||||||
|
const node = this.node();
|
||||||
|
if (node) this.drag.commit(this.assetId, node, GAIN_ROW.key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* The Gain as a device (#384), what its node shows in the graph: the Compressor's plate (#379) in
|
||||||
|
* orange, with one big chrome knob.
|
||||||
|
*/
|
||||||
|
import { Graphics, type Text } from 'pixi.js';
|
||||||
|
import { Knob, Widget } from '../../ui';
|
||||||
|
import { GAIN_ROW } from '../../core/soundfx/nodes';
|
||||||
|
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
|
||||||
|
import { GAIN_LAYOUT, GAIN_PANEL, GAIN_TAPER, KNOB_CAPTION } from './hardwareView';
|
||||||
|
|
||||||
|
/** What the panel asks of the node it sits in. */
|
||||||
|
export interface GainHost {
|
||||||
|
preview(value: number): void;
|
||||||
|
commit(value: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brushed anodised orange. */
|
||||||
|
const ORANGE: PlateLook = {
|
||||||
|
stops: [
|
||||||
|
[0, 0xffb25e],
|
||||||
|
[0.5, 0xf39433],
|
||||||
|
[1, 0xd8761c],
|
||||||
|
],
|
||||||
|
rim: 0xa85616,
|
||||||
|
grain: 0x8a3e08,
|
||||||
|
};
|
||||||
|
/** The knob's value arc: the usual orange would vanish on this plate. */
|
||||||
|
const DARK_ARC = 0x3b1a06;
|
||||||
|
|
||||||
|
export class GainPanel extends Widget {
|
||||||
|
private readonly plate = new Graphics();
|
||||||
|
readonly knob: Knob;
|
||||||
|
private readonly title: { ink: Text; edge: Text };
|
||||||
|
private readonly caption: { ink: Text; edge: Text };
|
||||||
|
|
||||||
|
constructor(host: GainHost) {
|
||||||
|
super();
|
||||||
|
this.minWidth = GAIN_PANEL.w;
|
||||||
|
this.minHeight = GAIN_PANEL.h;
|
||||||
|
this.addChild(this.plate);
|
||||||
|
this.title = engrave(this, 'GAIN', 14);
|
||||||
|
this.caption = engrave(this, 'DB', 11);
|
||||||
|
const row = GAIN_ROW;
|
||||||
|
this.knob = new Knob('', row.default, {
|
||||||
|
min: row.min ?? 0,
|
||||||
|
max: row.max ?? 0,
|
||||||
|
step: row.step ?? 1,
|
||||||
|
taper: GAIN_TAPER,
|
||||||
|
default: row.default,
|
||||||
|
decimals: 1,
|
||||||
|
size: GAIN_LAYOUT.knob.size,
|
||||||
|
look: 'chrome',
|
||||||
|
typed: false,
|
||||||
|
ink: INK,
|
||||||
|
arc: DARK_ARC,
|
||||||
|
onInput: (value) => host.preview(value),
|
||||||
|
onChange: (value) => host.commit(value),
|
||||||
|
});
|
||||||
|
this.addChild(this.knob);
|
||||||
|
}
|
||||||
|
|
||||||
|
override measure(): { w: number; h: number } {
|
||||||
|
return { ...GAIN_PANEL };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The node's gain, and whether a link feeds it, which greys the knob. */
|
||||||
|
sync(db: number, wired: boolean): void {
|
||||||
|
this.knob.setValue(db);
|
||||||
|
this.knob.setEnabled(!wired);
|
||||||
|
}
|
||||||
|
|
||||||
|
override layout(): void {
|
||||||
|
const L = GAIN_LAYOUT;
|
||||||
|
const spot = L.knob;
|
||||||
|
const size = this.knob.measure();
|
||||||
|
this.knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
|
||||||
|
this.knob.resize(size.w, size.h);
|
||||||
|
placeEngraving(this.caption, spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
|
||||||
|
placeEngraving(this.title, L.title.x, L.title.y + 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
override redraw(): void {
|
||||||
|
drawPlate(this.plate.clear(), GAIN_PANEL, ORANGE, GAIN_LAYOUT.screws);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,16 @@ export interface PanelLayout {
|
|||||||
|
|
||||||
const SCREW_INSET = 14;
|
const SCREW_INSET = 14;
|
||||||
|
|
||||||
|
/** A screw in each corner of a plate. */
|
||||||
|
export function screwsOf(plate: { w: number; h: number }): { x: number; y: number }[] {
|
||||||
|
return [
|
||||||
|
{ x: SCREW_INSET, y: SCREW_INSET },
|
||||||
|
{ x: plate.w - SCREW_INSET, y: SCREW_INSET },
|
||||||
|
{ x: SCREW_INSET, y: plate.h - SCREW_INSET },
|
||||||
|
{ x: plate.w - SCREW_INSET, y: plate.h - SCREW_INSET },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/** Threshold big on the left, makeup big on the right, the times and the shape small beside the meter. */
|
/** Threshold big on the left, makeup big on the right, the times and the shape small beside the meter. */
|
||||||
export const PANEL_LAYOUT: PanelLayout = {
|
export const PANEL_LAYOUT: PanelLayout = {
|
||||||
title: { x: 34, y: 14, w: 220, h: 30 },
|
title: { x: 34, y: 14, w: 220, h: 30 },
|
||||||
@@ -61,14 +71,24 @@ export const PANEL_LAYOUT: PanelLayout = {
|
|||||||
release: { x: 598, y: 205, size: SMALL_DIAL },
|
release: { x: 598, y: 205, size: SMALL_DIAL },
|
||||||
makeup: { x: 712, y: 160, size: BIG_DIAL },
|
makeup: { x: 712, y: 160, size: BIG_DIAL },
|
||||||
},
|
},
|
||||||
screws: [
|
screws: screwsOf(PANEL),
|
||||||
{ 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 },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The Gain as a device (#384): an orange plate with one big knob. */
|
||||||
|
export const GAIN_PANEL = { w: 160, h: 220 };
|
||||||
|
|
||||||
|
export const GAIN_LAYOUT = {
|
||||||
|
title: { x: GAIN_PANEL.w / 2, y: 14 },
|
||||||
|
knob: { x: GAIN_PANEL.w / 2, y: 120, size: BIG_DIAL },
|
||||||
|
screws: screwsOf(GAIN_PANEL),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the Gain's knob turns, from -120 to +24 dB: below 1 the loud end gets more of the turn, and
|
||||||
|
* 0.26 puts 0 dB straight up, so the quiet tail, which is all but silence, goes by quickly.
|
||||||
|
*/
|
||||||
|
export const GAIN_TAPER = 0.26;
|
||||||
|
|
||||||
/** All a knob takes on the plate: its name above, its dial and its number below. */
|
/** All a knob takes on the plate: its name above, its dial and its number below. */
|
||||||
export function knobBox(spot: KnobSpot): Box {
|
export function knobBox(spot: KnobSpot): Box {
|
||||||
const w = Math.max(spot.size, KNOB_LEAST_WIDTH);
|
const w = Math.max(spot.size, KNOB_LEAST_WIDTH);
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import type { GraphNode } from '../../core/graph';
|
import type { GraphNode } from '../../core/graph';
|
||||||
import { SoundFxCompressor, SoundFxParametricEq } from '../../core/soundfx/nodes';
|
import { SoundFxCompressor, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
|
||||||
import type { NodeFace } from '../graph/nodeFace';
|
import type { NodeFace } from '../graph/nodeFace';
|
||||||
import type { EditorState } from '../state';
|
import type { EditorState } from '../state';
|
||||||
import { CompressorFace } from './CompressorFace';
|
import { CompressorFace } from './CompressorFace';
|
||||||
import { EqFace } from './EqFace';
|
import { EqFace } from './EqFace';
|
||||||
|
import { GainFace } from './GainFace';
|
||||||
|
|
||||||
/** The nodes drawn as a face of their own instead of rows (#380, #381), by type. */
|
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384), by type. */
|
||||||
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
|
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
|
||||||
if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id);
|
if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id);
|
||||||
if (node.type === SoundFxParametricEq.type) return new EqFace(state, assetId, node.id);
|
if (node.type === SoundFxParametricEq.type) return new EqFace(state, assetId, node.id);
|
||||||
|
if (node.type === SoundFxGain.type) return new GainFace(state, assetId, node.id);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,8 @@ export interface KnobOptions extends KnobRange {
|
|||||||
format?: (value: number) => string;
|
format?: (value: number) => string;
|
||||||
/** The shown number's colour, to stand on a panel's own plate. */
|
/** The shown number's colour, to stand on a panel's own plate. */
|
||||||
ink?: number;
|
ink?: number;
|
||||||
|
/** The colour of a chrome dial's value arc, when the plate is too near its orange (#384). */
|
||||||
|
arc?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One for all chrome dials, laid over each cap's own bounds: light from the upper left. */
|
/** One for all chrome dials, laid over each cap's own bounds: light from the upper left. */
|
||||||
@@ -157,7 +159,7 @@ class Dial extends Widget {
|
|||||||
g.arc(x, y, track, angle(0), angle(1)).stroke({ color: 0x55595f, width: 3, alpha: 0.35 });
|
g.arc(x, y, track, angle(0), angle(1)).stroke({ color: 0x55595f, width: 3, alpha: 0.35 });
|
||||||
if (at !== origin)
|
if (at !== origin)
|
||||||
g.arc(x, y, track, angle(Math.min(at, origin)), angle(Math.max(at, origin))).stroke({
|
g.arc(x, y, track, angle(Math.min(at, origin)), angle(Math.max(at, origin))).stroke({
|
||||||
color: this.knob.enabled ? CHROME_ARC : 0x8b8f95,
|
color: this.knob.enabled ? (this.knob.options.arc ?? CHROME_ARC) : 0x8b8f95,
|
||||||
width: 3,
|
width: 3,
|
||||||
});
|
});
|
||||||
// The black skirt with its grip, the metal cap on it.
|
// The black skirt with its grip, the metal cap on it.
|
||||||
@@ -278,9 +280,9 @@ export class Knob extends VStack {
|
|||||||
return this.options.format?.(shown) ?? formatNumber(shown, this.options.decimals);
|
return this.options.format?.(shown) ?? formatNumber(shown, this.options.decimals);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Where the dial is on screen, for the check scripts. */
|
/** Where the dial's middle is on screen, for the check scripts; also in a zoomed graph (#384). */
|
||||||
dialGlobal(): { x: number; y: number } {
|
dialGlobal(): { x: number; y: number } {
|
||||||
const at = this.dial.getGlobalPosition();
|
const at = this.dial.toGlobal({ x: this.dial.w / 2, y: this.dial.h / 2 });
|
||||||
return { x: at.x + this.dial.w / 2, y: at.y + this.dial.h / 2 };
|
return { x: at.x, y: at.y };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* The Compressor's hardware panel (#379): every part on the plate and none over another, the meter's
|
* 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.
|
* scale and needle, the LEDs, and the grain of the plate the same every time. The Gain's plate
|
||||||
|
* (#384) and how its one knob turns.
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
type Box,
|
type Box,
|
||||||
brushStrokes,
|
brushStrokes,
|
||||||
|
GAIN_LAYOUT,
|
||||||
|
GAIN_PANEL,
|
||||||
|
GAIN_TAPER,
|
||||||
gainReduction,
|
gainReduction,
|
||||||
knobBox,
|
knobBox,
|
||||||
LED_COUNT,
|
LED_COUNT,
|
||||||
@@ -21,6 +25,8 @@ import {
|
|||||||
PANEL,
|
PANEL,
|
||||||
PANEL_LAYOUT,
|
PANEL_LAYOUT,
|
||||||
} from '../../src/editor/soundfx/hardwareView';
|
} from '../../src/editor/soundfx/hardwareView';
|
||||||
|
import { GAIN_ROW } from '../../src/core/soundfx/nodes';
|
||||||
|
import { knobPosition } from '../../src/ui/knob';
|
||||||
|
|
||||||
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;
|
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;
|
||||||
|
|
||||||
@@ -129,3 +135,25 @@ describe('the LEDs', () => {
|
|||||||
expect(ledStep(-6, -Infinity, 10)).toBe(-11);
|
expect(ledStep(-6, -Infinity, 10)).toBe(-11);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the Gain (#384)', () => {
|
||||||
|
const L = GAIN_LAYOUT;
|
||||||
|
const knob = knobBox(L.knob);
|
||||||
|
|
||||||
|
it('holds its knob inside the plate, clear of the screws and below the title', () => {
|
||||||
|
expect(knob.x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(knob.x + knob.w).toBeLessThanOrEqual(GAIN_PANEL.w);
|
||||||
|
expect(knob.y + knob.h).toBeLessThanOrEqual(GAIN_PANEL.h);
|
||||||
|
expect(knob.y).toBeGreaterThanOrEqual(L.title.y + 30);
|
||||||
|
expect(knob.x + knob.w / 2).toBe(GAIN_PANEL.w / 2);
|
||||||
|
for (const screw of L.screws)
|
||||||
|
expect(overlap(knob, { x: screw.x - 7, y: screw.y - 7, w: 14, h: 14 })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('turns 0 dB straight up and the quiet tail by in a fifth of the turn', () => {
|
||||||
|
const range = { min: GAIN_ROW.min ?? 0, max: GAIN_ROW.max ?? 0, step: 0.5, taper: GAIN_TAPER };
|
||||||
|
expect(knobPosition(0, range)).toBeCloseTo(0.5, 2);
|
||||||
|
expect(knobPosition(-24, range)).toBeLessThan(0.22);
|
||||||
|
expect(knobPosition(-6, range)).toBeGreaterThan(0.4);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1026,6 +1026,56 @@ export async function sound(t) {
|
|||||||
hwBesideTab.seen.needle > 3,
|
hwBesideTab.seen.needle > 3,
|
||||||
JSON.stringify({ hwTab, seen: hwBesideTab.seen }),
|
JSON.stringify({ hwTab, seen: hwBesideTab.seen }),
|
||||||
);
|
);
|
||||||
|
// #384: a Gain node is drawn as an orange device with its one knob, worked as the Compressor's.
|
||||||
|
// The Compressor's node is wide enough to lie over the Gain's: the Gain goes below it here.
|
||||||
|
const gainUnder = await json("__gm.compressor.panel('fx-hw').node");
|
||||||
|
await mutate(`
|
||||||
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
|
graph.nodes.find((n) => n.id === 'fx-gain').y = 400;`);
|
||||||
|
await evaluate('__gm.soundfx.fit()');
|
||||||
|
await sleep(100);
|
||||||
|
const gainPanel = () => json("__gm.gain.panel('fx-gain')");
|
||||||
|
const gainShown = await gainPanel();
|
||||||
|
const gainRect = gainShown.rect;
|
||||||
|
const gainBeside = (p) => p.y > gainRect.y && p.y < gainRect.y + gainRect.h;
|
||||||
|
const gainDb = () =>
|
||||||
|
json(
|
||||||
|
`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph.nodes.find((n) => n.id === 'fx-gain').params.gain`,
|
||||||
|
);
|
||||||
|
const gainDial = gainShown.knob.dial;
|
||||||
|
await drag(gainDial.x, gainDial.y, gainDial.x, gainDial.y - 20);
|
||||||
|
const gainDragged = await gainDb();
|
||||||
|
await press('z', { ctrl: true });
|
||||||
|
const gainUndone = await gainDb();
|
||||||
|
await t.wheel(gainDial.x, gainDial.y, -100);
|
||||||
|
const gainWheeled = { db: await gainDb(), panel: await gainPanel() };
|
||||||
|
await evaluate('__gm.soundfx.fit()');
|
||||||
|
await sleep(100);
|
||||||
|
const gainRefit = (await gainPanel()).knob.dial;
|
||||||
|
await t.doubleClick(gainRefit.x, gainRefit.y);
|
||||||
|
const gainReset = { db: await gainDb(), docs: await json('__gm.documents()') };
|
||||||
|
await press('z', { ctrl: true });
|
||||||
|
check(
|
||||||
|
'a Gain node shows its panel with one knob between its ports; a drag turns it as one undo step, the wheel zooms the graph, a double-click resets it without a tab',
|
||||||
|
gainShown.shown &&
|
||||||
|
gainShown.size.w === 160 &&
|
||||||
|
gainShown.size.h === 220 &&
|
||||||
|
gainShown.knob.number === '-6' &&
|
||||||
|
gainShown.knob.enabled &&
|
||||||
|
Object.values(gainShown.inputs).length === 3 &&
|
||||||
|
Object.values(gainShown.inputs).every((p) => p.x < gainRect.x && gainBeside(p)) &&
|
||||||
|
Object.values(gainShown.outputs).every((p) => p.x > gainRect.x + gainRect.w && gainBeside(p)) &&
|
||||||
|
gainDragged > -6 &&
|
||||||
|
gainUndone === -6 &&
|
||||||
|
gainWheeled.db === -6 &&
|
||||||
|
gainWheeled.panel.rect.w > gainRect.w + 5 &&
|
||||||
|
gainReset.db === 0 &&
|
||||||
|
gainReset.docs.active === `asset:${fxId}`,
|
||||||
|
JSON.stringify({ gainUnder, gainShown, gainDragged, gainUndone, gainWheeled, gainReset }),
|
||||||
|
);
|
||||||
|
await mutate(`
|
||||||
|
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
||||||
|
graph.nodes.find((n) => n.id === 'fx-gain').y = 0;`);
|
||||||
await mutate(`
|
await mutate(`
|
||||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
|
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.nodes = graph.nodes.filter((n) => n.id !== 'fx-hw');
|
||||||
|
|||||||
Reference in new issue
Block a user