Show a Saturation node as a device that glows with its drive (#396)
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Successful in 16m2s

A Saturation in the graph was a stack of rows. The user wanted a device
of its own style and size, like the Delay and the Reverb, that shows
warmth and heat the harder the sound is driven.

- The node is a copper plate of 720x280 with a button per type, the
  auto-gain lever, a big drive knob and small knobs for tone, output
  and wet. Each change is one undo step, wired numbers lock their knob.
- The screen draws a sine at -12 dBFS, the level auto-gain is reckoned
  at, as a grey ghost. Over it is the wave that comes out: bent by the
  type, its DC taken out, auto-gain and output after it, blended by
  wet. Under it stand the auto-gain and the peak.
- The heat is the drive over 48 dB, as far as wet lets it be heard.
  The wave goes from cold steel through ember red to white, a glow
  rises from below and flickers, and embers fly up from about 8 dB on.
  A lamp beside the name glows with it.
- The node keeps the editor drawing only while there is heat. Without
  drive it draws nothing, unlike the Delay, which always animates.
- The Reverb's private type button is now PickButton with its own
  colours, and the Saturation's type buttons use it as well.
- No instrument preset uses a Saturation, so no preset needs more
  columns.

Tests:
- Unit saturation-view: 14 new tests.
- Counter-check: without taking out the DC, exactly the DC test fails.
- e2e 34 (atx): ALL PASSED, 137 checks, one new for type, lever and
  drive with undo.
- Gate (atx): CHECK EXIT 0, 289 files, 4431 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 20:05:11 +02:00
1 parent 8c53ce0cbe
commit 80b25488ce
11 files changed
+1092 -21

No files matched your search

+54 -2
View File
@@ -8,6 +8,7 @@ import { EnvelopePanel } from '../soundfx/EnvelopePanel';
import { CompressorPanel } from '../soundfx/CompressorPanel';
import { KnobPanel } from '../soundfx/KnobPanel';
import { DelayPanel } from '../soundfx/DelayPanel';
import { SaturationPanel } from '../soundfx/SaturationPanel';
import { ReverbPanel } from '../soundfx/ReverbPanel';
import { CurvePanel } from '../soundfx/CurvePanel';
import type { EqEditor } from '../soundfx/EqEditor';
@@ -404,8 +405,8 @@ export function graphHooks({ state, editor }: HookContext) {
]),
),
idle: [...panel.idle],
room: panel.roomButtons.find((b) => b.picked)?.room ?? null,
rooms: Object.fromEntries(panel.roomButtons.map((b) => [b.room, screenCentre(b)])),
room: panel.roomButtons.find((b) => b.picked)?.value ?? null,
rooms: Object.fromEntries(panel.roomButtons.map((b) => [b.value, screenCentre(b)])),
highs: panel.highs.text,
sound: { text: panel.soundButton.text, at: screenCentre(panel.soundButton) },
scope: {
@@ -470,6 +471,57 @@ export function graphHooks({ state, editor }: HookContext) {
};
},
},
/**
* The panel a Saturation node shows (#396), in the sound FX graph or the instrument graph: whether
* it is shown and where, its knobs, buttons and lever, which settings are idle, how hot it runs,
* what the screen draws and says, and the node's ports.
*/
saturation: {
panel: (nodeId: string) => {
const canvas = [editor.soundFxEditor.canvas, editor.instrumentEditor.canvas].find(
(c) => c.faceOf(nodeId)?.widget instanceof SaturationPanel,
);
const panel = canvas?.faceOf(nodeId)?.widget;
if (!canvas || !(panel instanceof SaturationPanel)) return null;
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
const trace = panel.scope.trace;
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),
knobs: Object.fromEntries(
Object.entries(panel.knobs).map(([key, knob]) => [
key,
{ value: knob.value, number: knob.shown, enabled: knob.enabled, dial: knob.dialGlobal() },
]),
),
idle: [...panel.idle],
type: panel.typeButtons.find((b) => b.picked)?.value ?? null,
types: Object.fromEntries(panel.typeButtons.map((b) => [b.value, screenCentre(b)])),
autoGain: {
lit: panel.autoGain.lit,
says: panel.autoGain.says.text,
at: screenCentre(panel.autoGain),
},
heat: { share: panel.heat, word: panel.heatText.text, burning: panel.burning },
scope: {
says: panel.scope.texts,
/** The loudest point out, the gain after the curve, and whether the wave leaves the screen. */
peak: trace?.peak ?? null,
gain: trace?.gain ?? null,
over: panel.scope.over,
/** The embers drawn now and how the glow flickers: both move by themselves. */
embers: panel.scope.embers,
flare: panel.scope.flare,
},
};
},
},
instrument: {
current: () => editor.instrumentEditor.currentAssetId,
info: () => editor.instrumentEditor.shownInfo,
+23 -10
View File
@@ -104,16 +104,27 @@ function bezelStyle(): TextStyle {
const tint = (bright: number | null): number =>
bright === null ? UNKNOWN_TINT : mixColor(SCOPE.dull, SCOPE.bright, bright);
/** A type's button: its name on it, lit while it is the one. */
class RoomButton extends Widget {
/** The colours of a row of type buttons: the face, hovered, picked, and the shadow under them. */
export interface PickLook {
face: number;
hover: number;
picked: number;
shadow: number;
}
const LAVENDER_BUTTONS: PickLook = { face: 0x45435a, hover: 0x55536a, picked: PICKED, shadow: 0x24222f };
/** A type's button: its name on it, lit while it is the one. The Reverb's (#389), the Saturation's (#396). */
export class PickButton<T extends string> extends Widget {
private readonly g = new Graphics();
private readonly caption: Text;
picked = false;
constructor(
readonly room: ReverbRoom,
readonly value: T,
name: string,
onPick: (room: ReverbRoom) => void,
private readonly look: PickLook,
onPick: (value: T) => void,
) {
super();
this.cursor = 'pointer';
@@ -125,7 +136,7 @@ class RoomButton extends Widget {
// A press on a button is not one on the node: it neither picks nor moves it.
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
this.on('pointertap', (e: FederatedPointerEvent) => {
if (e.button === 0) onPick(room);
if (e.button === 0) onPick(value);
});
}
@@ -141,9 +152,9 @@ class RoomButton extends Widget {
this.g
.clear()
.roundRect(0, 2, w, h - 2, 5)
.fill(0x24222f)
.fill(this.look.shadow)
.roundRect(0, down, w, h - 2, 5)
.fill(this.picked ? PICKED : this.hovered ? 0x55536a : 0x45435a)
.fill(this.picked ? this.look.picked : this.hovered ? this.look.hover : this.look.face)
.stroke({ color: 0x1e1d27, width: 1 })
.roundRect(3, down + 2, w - 6, 4, 2)
.fill({ color: 0xffffff, alpha: this.picked ? 0.25 : 0.1 });
@@ -274,7 +285,7 @@ export class ReverbPanel extends Widget {
private readonly bezel = new Graphics();
private readonly rules = new Graphics();
readonly scope = new ReverbScope();
readonly roomButtons: RoomButton[];
readonly roomButtons: PickButton<ReverbRoom>[];
readonly soundButton: Dropdown;
readonly knobs = {} as Record<ReverbKey, Knob>;
private readonly captions = {} as Record<ReverbKey, { ink: Text; edge: Text }>;
@@ -301,7 +312,9 @@ export class ReverbPanel extends Widget {
this.addChild(this.screenName, this.highs);
this.groupNames = REVERB_LAYOUT.groups.map((group) => engrave(this, group.label, 9));
this.roomButtons = REVERB_ROOMS.map((room) => {
const button = new RoomButton(room.value, room.label, (picked) => host.pickRoom(picked));
const button = new PickButton(room.value, room.label, LAVENDER_BUTTONS, (picked) =>
host.pickRoom(picked),
);
this.addChild(button);
return button;
});
@@ -350,7 +363,7 @@ export class ReverbPanel extends Widget {
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = knob.alpha;
}
for (const button of this.roomButtons) button.pick(button.room === v.room);
for (const button of this.roomButtons) button.pick(button.value === v.room);
this.soundButton.setText(shown.sound || 'No sound');
this.soundButton.alpha = shown.idle.has('impulse') ? IDLE_ALPHA : 1;
this.soundCaption.ink.alpha = this.soundButton.alpha;
+94
View File
@@ -0,0 +1,94 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { SATURATION_AUTO_GAIN, SATURATION_TYPE, SoundFxSaturation } from '../../core/soundfx/nodes';
import type { SaturationType } from '../../core/soundfx/saturation';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { NodeParamDrag } from './nodeParamDrag';
import { type SaturationHost, SaturationPanel } from './SaturationPanel';
import {
heatOf,
idleSaturationKeys,
type SaturationKey,
saturationTexts,
saturationValues,
waveTrace,
} from './saturationView';
/**
* A Saturation node drawn as its device (#396): the wave a sine comes out as, reckoned from its
* settings, and a fire under it that burns as long as there is drive to be heard and the node is in
* the picture. It listens to nothing.
*/
export class SaturationFace implements NodeFace, SaturationHost {
readonly widget: SaturationPanel;
private readonly drag: NodeParamDrag;
constructor(
private readonly state: EditorState,
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new SaturationPanel(this);
this.drag = new NodeParamDrag(state, 'saturation-face');
}
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === SoundFxSaturation.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void {
const node = this.node();
if (!node) return;
const values = saturationValues(node.params);
const trace = waveTrace(values);
this.widget.sync({
values,
wired: this.wired,
idle: idleSaturationKeys(values),
trace,
texts: saturationTexts(values, trace),
heat: heatOf(values),
});
}
/** The fire flickers by the page's clock; without heat there is nothing to move. */
tick(): boolean {
if (!this.widget.burning) return false;
this.widget.step(performance.now() / 1000);
return true;
}
hide(): void {}
preview(key: SaturationKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: SaturationKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
pickType(type: SaturationType): void {
const node = this.node();
if (node && saturationValues(node.params).type !== type) this.set(SATURATION_TYPE.key, type);
}
switchAutoGain(on: boolean): void {
this.set(SATURATION_AUTO_GAIN.key, on);
}
private set(key: string, value: string | boolean): void {
if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, key, value));
}
}
+395
View File
@@ -0,0 +1,395 @@
/**
* The Saturation as a device (#396), what its node shows in the graph: a plate of copper, a screen
* that draws the wave a sine comes out as – over a glow that rises from below and embers that fly
* up, the hotter the harder it is driven – a lamp beside the name that glows with it, a button for
* each type, the auto-gain lever, the drive big and the tone, the output and the wet small.
*/
import { Graphics, type Text, type TextStyle } from 'pixi.js';
import { SATURATION_ROWS } from '../../core/soundfx/nodes';
import { SATURATION_TYPES, type SaturationType } from '../../core/soundfx/saturation';
import { crispText, Knob, textStyle, Widget } from '../../ui';
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel';
import { type Box, KNOB_CAPTION } from './hardwareView';
import { PickButton, type PickLook } from './ReverbPanel';
import {
embersAt,
flicker,
glowStrength,
heatColor,
heatWord,
SATURATION_KEYS,
SATURATION_KNOBS,
SATURATION_LAYOUT,
SATURATION_PANEL,
type SaturationKey,
type SaturationValues,
TEXT_ROW,
typeCaption,
WAVE_REACH,
type WaveTrace,
} from './saturationView';
/** What the panel asks of the node it sits in. */
export interface SaturationHost {
preview(key: SaturationKey, value: number): void;
commit(key: SaturationKey, value: number): void;
pickType(type: SaturationType): void;
switchAutoGain(on: boolean): void;
}
/** What the panel shows of a node. */
export interface SaturationShown {
values: SaturationValues;
wired: (key: SaturationKey) => boolean;
/** The settings that do nothing as things stand. */
idle: ReadonlySet<SaturationKey | 'type' | 'autoGain'>;
trace: WaveTrace;
texts: { auto: string; peak: string };
heat: number;
}
/** Brushed copper: warm to start with. */
const COPPER: PlateLook = {
stops: [
[0, 0xe8b08a],
[0.5, 0xd08f66],
[1, 0xa86a45],
],
rim: 0x6e3c22,
grain: 0x5a2c16,
};
const COPPER_BUTTONS: PickLook = { face: 0x4a3226, hover: 0x5c4032, picked: 0xc8461e, shadow: 0x24160f };
/** The arc of a chrome dial: its orange would vanish on copper. */
const ARC = 0x3a1206;
const BEZEL = 0x2e2420;
const BEZEL_TEXT = 0xe2c9b8;
/** Soot-black glass; the sine going in as a grey ghost behind what comes out. */
const SCOPE = { glass: 0x0d0907, grid: 0x2e2420, text: 0xffc98a, dim: 0x7a6650, ghost: 0x6b6460 };
const IDLE_ALPHA = 0.4;
/** The rows the glow is drawn in, and the share of the screen it rises to. */
const GLOW_ROW = 2;
const GLOW_RISE = 0.85;
function scopeStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
/**
* The screen: the sine going in as a grey ghost, the wave that comes out over it in the colour of
* the heat, a glow under them and embers rising – both moving by the page's clock, the wave not.
*/
class WaveScope extends Widget {
private readonly glass = new Graphics();
private readonly fire = new Graphics();
private readonly wave = new Graphics();
private readonly under: Record<'auto' | 'peak', Text>;
trace: WaveTrace | null = null;
heat = 0;
texts = { auto: '', peak: '' };
/** The embers drawn now, and how much the glow flickers (checks). */
embers = 0;
flare = 1;
/** Whether the wave goes past the screen's edge somewhere. */
over = false;
constructor() {
super();
this.addChild(this.glass, this.fire, this.wave);
this.under = { auto: this.made(0), peak: this.made(1) };
}
private made(ax: number): Text {
const t = crispText({ text: '', style: scopeStyle(9, SCOPE.text) });
t.anchor.set(ax, 0);
t.eventMode = 'none';
this.addChild(t);
return t;
}
show(trace: WaveTrace, heat: number, texts: WaveScope['texts']): void {
this.trace = trace;
this.heat = heat;
this.texts = texts;
this.invalidate();
}
private get glassH(): number {
return this.h - TEXT_ROW;
}
override redraw(): void {
const w = this.w;
const h = this.glassH;
const cy = h / 2;
const half = h / 2 - 10;
const g = this.glass.clear();
g.roundRect(0, 0, w, h, 4).fill(SCOPE.glass);
// Full scale in at -12 dBFS, and the middle.
g.moveTo(6, cy - half / 2)
.lineTo(w - 6, cy - half / 2)
.moveTo(6, cy + half / 2)
.lineTo(w - 6, cy + half / 2)
.stroke({ color: SCOPE.grid, width: 1 });
g.moveTo(6, cy)
.lineTo(w - 6, cy)
.stroke({ color: SCOPE.dim, width: 1, alpha: 0.5 });
const s = this.wave.clear();
this.over = false;
const trace = this.trace;
if (trace) {
const line = (values: number[]) => {
values.forEach((v, i) => {
if (Math.abs(v) > WAVE_REACH) this.over = true;
const x = 8 + (i / (values.length - 1)) * (w - 16);
const y = cy - (Math.max(-WAVE_REACH, Math.min(WAVE_REACH, v)) / WAVE_REACH) * half;
if (i === 0) s.moveTo(x, y);
else s.lineTo(x, y);
});
};
line(trace.dry);
s.stroke({ color: SCOPE.ghost, width: 1, alpha: 0.7 });
const color = heatColor(this.heat);
// A halo round the wave as it heats up, the wave itself on it.
if (this.heat > 0) {
line(trace.out);
s.stroke({ color, width: 2 + 6 * this.heat, alpha: 0.25 * this.heat, join: 'round' });
}
line(trace.out);
s.stroke({ color, width: 2, join: 'round' });
}
this.under.auto.text = this.texts.auto;
this.under.auto.position.set(0, h + 3);
this.under.peak.text = this.texts.peak;
this.under.peak.position.set(w, h + 3);
this.drawFire(performance.now() / 1000);
}
/** `seconds` on a clock of the page's: the glow flickers, the embers rise. */
step(seconds: number): void {
this.drawFire(seconds);
}
private drawFire(seconds: number): void {
const f = this.fire.clear();
const w = this.w;
const h = this.glassH;
this.embers = 0;
this.flare = 1;
if (w <= 0 || this.heat <= 0) return;
this.flare = flicker(seconds);
const strength = glowStrength(this.heat) * this.flare;
// A fire's colours lag behind its heat: yellow at the bottom of a white-hot one, red above it.
const color = heatColor(this.heat * 0.7);
const deep = heatColor(this.heat * 0.4);
// Rows from the bottom up, each fainter: a glow as a fire's under the glass.
const rows = Math.ceil((h * GLOW_RISE) / GLOW_ROW);
for (let i = 0; i < rows; i++) {
const share = 1 - i / rows;
f.rect(2, h - 2 - (i + 1) * GLOW_ROW, w - 4, GLOW_ROW).fill({
color: i < rows / 4 ? color : deep,
alpha: strength * share ** 2,
});
}
const embers = embersAt(this.heat, seconds);
for (const ember of embers)
f.circle(4 + ember.x * (w - 8), 4 + ember.y * (h - 8), ember.r).fill({
color: heatColor(Math.min(1, this.heat + 0.2)),
alpha: ember.alpha,
});
this.embers = embers.length;
}
}
export class SaturationPanel extends Widget {
private readonly plate = new Graphics();
private readonly bezel = new Graphics();
private readonly rules = new Graphics();
private readonly lampLight = new Graphics();
readonly scope = new WaveScope();
readonly typeButtons: PickButton<SaturationType>[];
readonly autoGain: Lever;
readonly knobs = {} as Record<SaturationKey, Knob>;
private readonly captions = {} as Record<SaturationKey, { ink: Text; edge: Text }>;
private readonly typeName: { ink: Text; edge: Text };
private readonly title: { ink: Text; edge: Text };
private readonly lampCaption: { ink: Text; edge: Text };
private readonly screenName: Text;
/** The heat in a word, above the screen on the right, in its colour. */
readonly heatText: Text;
/** The settings shown idle now (checks). */
idle: ReadonlySet<SaturationKey | 'type' | 'autoGain'> = new Set();
heat = 0;
constructor(host: SaturationHost) {
super();
this.minWidth = SATURATION_PANEL.w;
this.minHeight = SATURATION_PANEL.h;
this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight);
this.title = engrave(this, 'SATURATION', 14);
this.lampCaption = engrave(this, 'HEAT', 9);
this.screenName = crispText({ text: 'A SINE AT -12 dBFS', style: bezelStyle() });
this.screenName.eventMode = 'none';
this.heatText = crispText({ text: '', style: bezelStyle() });
this.heatText.anchor.set(1, 0);
this.heatText.eventMode = 'none';
this.addChild(this.screenName, this.heatText);
this.typeName = engrave(this, 'TYPE', 9);
this.typeButtons = SATURATION_TYPES.map((type) => {
const button = new PickButton(type.value, typeCaption(type.value), COPPER_BUTTONS, (picked) =>
host.pickType(picked),
);
this.addChild(button);
return button;
});
this.autoGain = new Lever('AUTO-GAIN', (on) => host.switchAutoGain(on));
this.addChild(this.autoGain);
for (const key of SATURATION_KEYS) {
const row = SATURATION_ROWS[key];
const look = SATURATION_KNOBS[key];
const knob = new Knob('', row.default, {
min: row.min ?? 0,
max: row.max ?? 1,
step: row.step ?? 0.01,
taper: look.taper,
bipolar: look.bipolar,
default: row.default,
decimals: look.decimals,
size: SATURATION_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
format: look.format,
ink: INK,
arc: ARC,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
}
override measure(): { w: number; h: number } {
return { ...SATURATION_PANEL };
}
sync(shown: SaturationShown): void {
const v = shown.values;
for (const key of SATURATION_KEYS) {
const knob = this.knobs[key];
knob.setValue(v[key]);
knob.setEnabled(!shown.wired(key));
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = knob.alpha;
}
for (const button of this.typeButtons) {
button.pick(button.value === v.type);
button.alpha = shown.idle.has('type') ? IDLE_ALPHA : 1;
}
this.typeName.ink.alpha = shown.idle.has('type') ? IDLE_ALPHA : 1;
this.autoGain.set(v.autoGain, v.autoGain ? 'on' : 'off');
this.autoGain.alpha = shown.idle.has('autoGain') ? IDLE_ALPHA : 1;
this.idle = shown.idle;
this.scope.show(shown.trace, shown.heat, shown.texts);
this.heatText.text = heatWord(shown.heat);
this.heatText.style.fill = shown.heat > 0.05 ? heatColor(shown.heat) : BEZEL_TEXT;
if (this.heat !== shown.heat) {
this.heat = shown.heat;
this.invalidate();
}
}
/** Whether anything moves: the glow and the embers, as long as there is heat. */
get burning(): boolean {
return this.heat > 0;
}
/** A frame on, `seconds` on the page's clock: the screen's fire and the lamp flicker with it. */
step(seconds: number): void {
this.scope.step(seconds);
this.drawLamp();
}
/** The lamp beside the name: dark when cold, glowing in the heat's colour, flickering as the screen does. */
private drawLamp(): void {
const { x, y, r } = SATURATION_LAYOUT.lamp;
const lit = Math.min(1, this.heat * 2) * this.scope.flare;
// Never quite white, so the lamp still reads as one at white heat.
const color = heatColor(0.2 + 0.6 * this.heat);
const g = this.lampLight.clear();
if (lit > 0) g.circle(x, y, r + 4).fill({ color, alpha: 0.3 * Math.min(1, lit) });
g.circle(x, y, r)
.fill(this.heat > 0 ? color : 0x4a2a1e)
.stroke({ color: 0x2a2c30, width: 1.5 });
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: this.heat > 0 ? 0.7 : 0.2 });
}
override layout(): void {
const L = SATURATION_LAYOUT;
const put = (widget: Widget, box: Box) => {
widget.position.set(Math.round(box.x), Math.round(box.y));
widget.resize(box.w, box.h);
};
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.heatText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
this.typeButtons.forEach((button, i) => put(button, L.types[i]!));
put(this.autoGain, L.autoGain);
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
placeEngraving(this.typeName, L.typeGroup.x + L.typeGroup.w / 2, L.typeGroup.y);
for (const key of SATURATION_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 + L.title.w / 2, L.title.y + 4);
}
override redraw(): void {
const L = SATURATION_LAYOUT;
drawPlate(this.plate.clear(), SATURATION_PANEL, COPPER, L.screws);
const b = L.bezel;
// The frame takes on the heat of the glass in it.
this.bezel
.clear()
.roundRect(b.x, b.y, b.w, b.h, 8)
.fill(BEZEL)
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
.stroke({ color: 0x1b120e, width: 1 });
if (this.heat > 0)
this.bezel
.roundRect(L.screen.x - 2, L.screen.y - 2, L.screen.w + 4, L.screen.h + 4, 5)
.stroke({ color: heatColor(this.heat), width: 1.5, alpha: 0.6 * this.heat });
// A rule each side of the types' name, engraved as the name is.
const group = L.typeGroup;
const mid = group.y + 6;
const centre = group.x + group.w / 2;
const gap = this.typeName.ink.width / 2 + 6;
this.rules
.clear()
.moveTo(group.x + 6, mid)
.lineTo(centre - gap, mid)
.moveTo(centre + gap, mid)
.lineTo(group.x + group.w - 6, mid)
.stroke({ color: INK, width: 1, alpha: 0.5 });
this.drawLamp();
}
}
+4 -1
View File
@@ -6,6 +6,7 @@ import {
SoundFxFilter,
SoundFxParametricEq,
SoundFxReverb,
SoundFxSaturation,
} from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
@@ -19,8 +20,9 @@ import { FilterPanel } from './FilterPanel';
import { KnobFace } from './KnobFace';
import { GAIN_DEVICE, MIX_DEVICE, PAN_DEVICE, PITCH_DEVICE } from './KnobPanel';
import { ReverbFace } from './ReverbFace';
import { SaturationFace } from './SaturationFace';
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(state, assetId, node.id);
@@ -34,5 +36,6 @@ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode
if (knobbed) return new KnobFace(state, assetId, node.id, knobbed);
if (node.type === SoundFxReverb.type) return new ReverbFace(state, assetId, node.id);
if (node.type === SoundFxDelay.type) return new DelayFace(state, assetId, node.id);
if (node.type === SoundFxSaturation.type) return new SaturationFace(state, assetId, node.id);
return null;
}
+284
View File
@@ -0,0 +1,284 @@
/**
* What a Saturation's node draws (#396), as numbers: where the parts of its device sit, how its
* knobs turn, the wave a sine at -12 dBFS comes out as – the screen – and the heat it glows with,
* the harder the sound is driven. Kept apart from the drawing so it is checked without a browser.
*/
import type { PropertyBag } from '../../core/model/Properties';
import { SATURATION_AUTO_GAIN, SATURATION_ROWS, saturationTypeOf, switchOn } from '../../core/soundfx/nodes';
import {
saturate,
SATURATION_REFERENCE,
SATURATION_TYPES,
saturationAutoGain,
type SaturationType,
toneCutoff,
} from '../../core/soundfx/saturation';
import { mixColor } from '../../core/util/color';
import { formatNumber } from '../../ui/math';
import { BIG_DIAL, type Box, type KnobSpot, screwsOf, SMALL_DIAL } from './hardwareView';
import { hzText, percentText } from './reverbView';
export type SaturationKey = keyof typeof SATURATION_ROWS;
export const SATURATION_KEYS = Object.keys(SATURATION_ROWS) as SaturationKey[];
export interface SaturationValues extends Record<SaturationKey, number> {
type: SaturationType;
autoGain: boolean;
}
const numberOf = (params: PropertyBag, key: SaturationKey): number => {
const value = params[key];
return typeof value === 'number' && Number.isFinite(value) ? value : SATURATION_ROWS[key].default;
};
/** The numbers a Saturation plays with, each its row's default where none is typed in. */
export function saturationValues(params: PropertyBag): SaturationValues {
return {
drive: numberOf(params, 'drive'),
tone: numberOf(params, 'tone'),
output: numberOf(params, 'output'),
mix: numberOf(params, 'mix'),
type: saturationTypeOf(params),
autoGain: switchOn(params, SATURATION_AUTO_GAIN),
};
}
/** At wet 0 the node lets the sound through as it is: nothing of how it bends it is heard. */
export function idleSaturationKeys(values: SaturationValues): Set<SaturationKey | 'type' | 'autoGain'> {
return values.mix > 0 ? new Set() : new Set(['drive', 'tone', 'output', 'type', 'autoGain']);
}
/** A type's name on its button: the first word of its own, `Hard clip` as HARD. */
export function typeCaption(type: SaturationType): string {
const label = SATURATION_TYPES.find((option) => option.value === type)?.label ?? type;
return label.split(' ')[0]!.toUpperCase();
}
export interface SaturationKnob {
label: string;
taper: number;
bipolar?: boolean;
decimals: number;
format: (value: number) => string;
}
const dbText = (db: number, signed: boolean): string =>
`${signed && db > 0 ? '+' : ''}${formatNumber(db, 1)} dB`;
/** How each knob turns: all evenly; the tone names the cutoff it opens to, the output turns round 0. */
export const SATURATION_KNOBS: Record<SaturationKey, SaturationKnob> = {
drive: { label: 'Drive', taper: 1, decimals: 1, format: (db) => dbText(db, false) },
tone: { label: 'Tone', taper: 1, decimals: 2, format: (tone) => hzText(toneCutoff(tone)) },
output: { label: 'Output', taper: 1, bipolar: true, decimals: 1, format: (db) => dbText(db, true) },
mix: { label: 'Wet', taper: 1, decimals: 2, format: percentText },
};
// --- The plate. ---
/** The plate: as the Delay's (#393), the panel never takes another size. */
export const SATURATION_PANEL = { w: 720, h: 280 };
/** The height of a group's name and its rule. */
const GROUP_LINE = 16;
/** A lever's box, as the Compressor's auto-gain (#379). */
const LEVER = { w: 118, h: 40 };
/** The extra the strip of text under the screen takes. */
export const TEXT_ROW = 14;
const BUTTON = { h: 22, gap: 6 };
export interface SaturationLayout {
title: Box;
/** The lamp that glows with the heat, beside the device's name; its own name after it. */
lamp: { x: number; y: number; r: number };
lampCaption: { x: number; y: number };
autoGain: Box;
bezel: Box;
screen: Box;
/** The name over the type buttons, and the buttons in the order of the types. */
typeGroup: Box;
types: Box[];
knobs: Record<SaturationKey, KnobSpot>;
screws: { x: number; y: number }[];
}
function saturationLayout(): SaturationLayout {
const { w } = SATURATION_PANEL;
const screen = { x: 34, y: 60, w: 240, h: 176 };
const bezel = { x: 24, y: 44, w: screen.w + 20, h: screen.h + 16 + TEXT_ROW + 6 };
// The drive big beside the screen, as the Compressor's threshold; the rest of the plate on the right.
const drive = { x: bezel.x + bezel.w + 24 + BIG_DIAL / 2, y: 160, size: BIG_DIAL };
const left = drive.x + BIG_DIAL / 2 + 24;
const right = w - 24;
const typeGroup = { x: left, y: 62, w: right - left, h: GROUP_LINE };
const perRow = Math.ceil(SATURATION_TYPES.length / 2);
const buttonW = (typeGroup.w - (perRow - 1) * BUTTON.gap) / perRow;
const types = SATURATION_TYPES.map((_, i) => ({
x: left + (i % perRow) * (buttonW + BUTTON.gap),
y: typeGroup.y + GROUP_LINE + 2 + Math.floor(i / perRow) * (BUTTON.h + BUTTON.gap),
w: buttonW,
h: BUTTON.h,
}));
const slot = (right - left) / 3;
const dialY = 186;
const small = (i: number): KnobSpot => ({ x: left + slot * (i + 0.5), y: dialY, size: SMALL_DIAL });
return {
title: { x: 34, y: 14, w: 126, h: 24 },
lamp: { x: 172, y: 26, r: 6 },
lampCaption: { x: 198, y: 21 },
autoGain: { x: right - LEVER.w, y: 10, ...LEVER },
bezel,
screen,
typeGroup,
types,
knobs: { drive, tone: small(0), output: small(1), mix: small(2) },
screws: screwsOf(SATURATION_PANEL),
};
}
export const SATURATION_LAYOUT: SaturationLayout = saturationLayout();
// --- The screen: a sine at -12 dBFS as it comes out. ---
/** How far up and down the screen goes, as a level: -6 dBFS, so the sine going in fills half of it. */
export const WAVE_REACH = 0.5;
/** The periods of the sine across the screen, and the points the wave is drawn with. */
const PERIODS = 2;
export const WAVE_POINTS = 241;
export interface WaveTrace {
/** The sine going in, at -12 dBFS. */
dry: number[];
/** What comes out: the bent sound with its DC taken out, its gain after it, blended by the wet. */
out: number[];
/** The loudest point that comes out. */
peak: number;
/** The gain after the curve in dB: auto-gain's and the output's. */
gain: number;
auto: number | null;
}
/**
* The wave a sine at -12 dBFS – where auto-gain is reckoned at – comes out as (#396): driven into
* the curve, its DC taken out as the node's high-pass does, the auto-gain and the output after it,
* blended with the sine by the wet. The tone's low-pass is left out: it would only round the edges.
*/
export function waveTrace(values: SaturationValues, points = WAVE_POINTS): WaveTrace {
const drive = 10 ** (values.drive / 20);
const dry = Array.from(
{ length: points },
(_, i) => SATURATION_REFERENCE * Math.sin((2 * Math.PI * PERIODS * i) / (points - 1)),
);
const bent = dry.map((x) => saturate(values.type, x * drive));
// The mean over whole periods: the last point is the first again, so it is left out.
const mean = bent.slice(0, -1).reduce((sum, v) => sum + v, 0) / (points - 1);
const auto = values.autoGain ? saturationAutoGain(values.type, values.drive) : null;
const gain = (auto ?? 0) + values.output;
const level = 10 ** (gain / 20);
const mix = Math.max(0, Math.min(1, values.mix));
const out = dry.map((x, i) => (1 - mix) * x + mix * (bent[i]! - mean) * level);
const peak = out.reduce((most, v) => Math.max(most, Math.abs(v)), 0);
return { dry, out, peak, gain, auto };
}
/** What the screen writes: auto-gain's share under it on the left, the loudest point on the right. */
export function saturationTexts(values: SaturationValues, trace: WaveTrace): { auto: string; peak: string } {
const peakDb = trace.peak > 0 ? 20 * Math.log10(trace.peak) : -Infinity;
return {
auto:
values.mix <= 0
? 'DRY'
: trace.auto === null
? 'AUTO-GAIN OFF'
: `AUTO-GAIN ${dbText(trace.auto, true)}`,
peak: Number.isFinite(peakDb) ? `PEAK ${formatNumber(peakDb, 1)} dBFS` : 'SILENT',
};
}
// --- The heat. ---
/** How hot the device runs, 0 to 1: the drive of its 48 dB, as much as the wet lets it be heard. */
export function heatOf(values: SaturationValues): number {
const drive = SATURATION_ROWS.drive.max ?? 48;
return Math.max(0, Math.min(1, values.drive / drive)) * Math.max(0, Math.min(1, values.mix));
}
/** Cold steel to ember red, orange, yellow and white hot, as metal goes in a fire. */
const HEAT_STOPS: readonly [heat: number, color: number][] = [
[0, 0x9aa6b8],
[0.2, 0xd9542a],
[0.45, 0xff8a24],
[0.7, 0xffc94a],
[1, 0xfff4dc],
];
export function heatColor(heat: number): number {
const h = Math.max(0, Math.min(1, heat));
for (let i = 1; i < HEAT_STOPS.length; i++) {
const [to, color] = HEAT_STOPS[i]!;
const [from, before] = HEAT_STOPS[i - 1]!;
if (h <= to) return mixColor(before, color, (h - from) / (to - from));
}
return HEAT_STOPS.at(-1)![1];
}
/** The word above the screen for a heat. */
export function heatWord(heat: number): string {
if (heat < 0.05) return 'COLD';
if (heat <= 0.25) return 'WARM';
if (heat <= 0.5) return 'HOT';
if (heat <= 0.75) return 'GLOWING';
return 'WHITE HOT';
}
/** How strong the glow rising from the bottom of the screen is at a heat, 0 to 1. */
export const glowStrength = (heat: number): number => 0.65 * Math.max(0, Math.min(1, heat)) ** 1.3;
/** The most embers that rise at white heat; under `EMBERS_FROM` none do. */
export const MOST_EMBERS = 24;
export const EMBERS_FROM = 0.15;
export const emberCount = (heat: number): number =>
Math.round((Math.max(0, heat - EMBERS_FROM) / (1 - EMBERS_FROM)) * MOST_EMBERS);
/** Four numbers from 0 to 1 for an ember, the same every time. */
function emberSeed(i: number): [number, number, number, number] {
let state = (i * 2654435761 + 396) >>> 0;
const next = () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 2 ** 32;
};
return [next(), next(), next(), next()];
}
export interface Ember {
/** Across and up the screen, 0 to 1, y 0 at the top. */
x: number;
y: number;
r: number;
alpha: number;
}
/**
* The embers that rise from the bottom of the screen at a heat, `seconds` on the page's clock: more
* and faster the hotter it runs, each wandering a little and fading as it rises, round again.
*/
export function embersAt(heat: number, seconds: number): Ember[] {
return Array.from({ length: emberCount(heat) }, (_, i) => {
const [across, speed, start, sway] = emberSeed(i);
const rise = (0.25 + 0.35 * speed) * (0.6 + 0.8 * heat);
const risen = (((seconds * rise + start) % 1) + 1) % 1;
return {
x: Math.max(0, Math.min(1, across + 0.03 * Math.sin(seconds * (1 + 2 * sway) + 6.28 * sway))),
y: 1 - risen,
r: 0.8 + 1.4 * sway,
alpha: Math.min(1, risen * 8) * (1 - risen) ** 1.5,
};
});
}
/** How the glow flickers, `seconds` on the page's clock: within 8 % either way, never the same twice in a row. */
export function flicker(seconds: number): number {
return (
1 + (0.08 * (Math.sin(13.1 * seconds) + Math.sin(7.3 * seconds + 1) + Math.sin(23.7 * seconds + 2))) / 3
);
}