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

+1 -1
View File
@@ -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, 1968 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1969 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
+4 -4
View File
@@ -1095,15 +1095,15 @@ export const SoundFxLimiter: SoundFxNodeDef = effect(
const DRIVE = { ...numberRow('drive', 'Drive (dB)', 0, 48, 0.5), default: 12 };
const TONE = { ...numberRow('tone', 'Tone', 0, 1, 0.01), default: 1 };
const OUTPUT = numberRow('output', 'Output (dB)', -24, 24, 0.5);
const SATURATION_AUTO_GAIN = { ...COMPRESS_AUTO_GAIN, default: true };
export const SATURATION_AUTO_GAIN = { ...COMPRESS_AUTO_GAIN, default: true };
const SATURATION_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 1 };
const SATURATION_ROWS = {
export const SATURATION_ROWS = {
drive: DRIVE,
tone: TONE,
output: OUTPUT,
mix: SATURATION_MIX,
};
const SATURATION_TYPE = {
export const SATURATION_TYPE = {
key: 'curve',
label: 'Type',
kind: 'enum' as const,
@@ -1111,7 +1111,7 @@ const SATURATION_TYPE = {
options: SATURATION_TYPES,
};
function saturationTypeOf(settings: PropertyBag): SaturationType {
export function saturationTypeOf(settings: PropertyBag): SaturationType {
return SATURATION_TYPES.find((option) => option.value === settings[SATURATION_TYPE.key])?.value ?? 'soft';
}
+3 -3
View File
@@ -71,7 +71,7 @@ export function saturationCurve(type: SaturationType): Float32Array {
}
/** The level a reference sine is at: -12 dBFS, about where a sound is loud but not at its peak. */
const REFERENCE = 0.25;
export const SATURATION_REFERENCE = 0.25;
const STEPS = 256;
/**
@@ -82,11 +82,11 @@ const STEPS = 256;
export function saturationAutoGain(type: SaturationType, driveDb: number): number {
const drive = 10 ** (driveDb / 20);
const out = Array.from({ length: STEPS }, (_, i) =>
saturate(type, REFERENCE * Math.sin((2 * Math.PI * i) / STEPS) * drive),
saturate(type, SATURATION_REFERENCE * Math.sin((2 * Math.PI * i) / STEPS) * drive),
);
const mean = out.reduce((sum, v) => sum + v, 0) / STEPS;
const rms = Math.sqrt(out.reduce((sum, v) => sum + (v - mean) ** 2, 0) / STEPS);
const gain = rms > 0 ? 20 * Math.log10(REFERENCE / Math.SQRT2 / rms) : 48;
const gain = rms > 0 ? 20 * Math.log10(SATURATION_REFERENCE / Math.SQRT2 / rms) : 48;
return Math.max(-48, Math.min(48, gain));
}
+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
);
}
+178
View File
@@ -0,0 +1,178 @@
/**
* A Saturation's node as a device (#396): every part on the plate and none over another, the wave
* a sine at -12 dBFS comes out as, and the heat it glows with – hotter the harder it is driven.
*/
import { describe, expect, it } from 'vitest';
import { SATURATION_ROWS } from '../../src/core/soundfx/nodes';
import { SATURATION_TYPES } from '../../src/core/soundfx/saturation';
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
import {
embersAt,
emberCount,
flicker,
heatColor,
heatOf,
heatWord,
idleSaturationKeys,
MOST_EMBERS,
SATURATION_KEYS,
SATURATION_KNOBS,
SATURATION_LAYOUT,
SATURATION_PANEL,
type SaturationValues,
saturationTexts,
saturationValues,
typeCaption,
waveTrace,
} from '../../src/editor/soundfx/saturationView';
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 inside = (a: Box) =>
a.x >= 0 && a.y >= 0 && a.x + a.w <= SATURATION_PANEL.w && a.y + a.h <= SATURATION_PANEL.h;
const values = (over: Partial<SaturationValues> = {}): SaturationValues => ({
...saturationValues({}),
...over,
});
describe('the Saturation plate', () => {
const L = SATURATION_LAYOUT;
const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 }));
// A lever's ball hangs 8 below its box when it is off.
const lever = { ...L.autoGain, h: L.autoGain.h + 8 };
const lamp = { x: L.lamp.x - L.lamp.r, y: L.lamp.y - L.lamp.r, w: 2 * L.lamp.r, h: 2 * L.lamp.r };
it('keeps the screen, the lever, the buttons and the knobs on the plate, clear of each other and the screws', () => {
const parts: Box[] = [
L.bezel,
lever,
L.typeGroup,
...L.types,
...SATURATION_KEYS.map((key) => knobBox(L.knobs[key])),
lamp,
L.title,
];
for (const part of parts) {
expect(inside(part)).toBe(true);
for (const screw of screws) expect(overlap(part, screw)).toBe(false);
}
for (let i = 0; i < parts.length; i++)
for (let j = i + 1; j < parts.length; j++) expect(overlap(parts[i]!, parts[j]!)).toBe(false);
});
it('has a button for every type, named by a word of its own', () => {
expect(L.types).toHaveLength(SATURATION_TYPES.length);
const names = SATURATION_TYPES.map((t) => typeCaption(t.value));
expect(new Set(names).size).toBe(names.length);
expect(typeCaption('hard')).toBe('HARD');
expect(typeCaption('soft')).toBe('SOFT');
});
});
describe('the Saturation knobs', () => {
const position = (key: 'output' | 'drive', value: number) => {
const row = SATURATION_ROWS[key];
return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...SATURATION_KNOBS[key] });
};
it('turns the output round 0 straight up, the drive from its start', () => {
expect(position('output', 0)).toBeCloseTo(0.5);
expect(position('drive', 0)).toBe(0);
expect(position('drive', 24)).toBeCloseTo(0.5);
});
it('names the tone by the cutoff it opens to and the output with its sign', () => {
expect(SATURATION_KNOBS.tone.format(1)).toBe('20 kHz');
expect(SATURATION_KNOBS.tone.format(0)).toBe('1 kHz');
expect(SATURATION_KNOBS.output.format(3.5)).toBe('+3.5 dB');
expect(SATURATION_KNOBS.output.format(-6)).toBe('-6 dB');
});
it('greys everything but the wet at wet 0, nothing else', () => {
expect([...idleSaturationKeys(values({ mix: 0 }))].sort()).toEqual(
['autoGain', 'drive', 'output', 'tone', 'type'].sort(),
);
expect(idleSaturationKeys(values({ autoGain: false })).size).toBe(0);
});
});
describe('the Saturation screen', () => {
it('lets the sine through as it is at wet 0', () => {
const trace = waveTrace(values({ drive: 48, mix: 0 }));
trace.out.forEach((v, i) => expect(v).toBeCloseTo(trace.dry[i]!, 9));
expect(saturationTexts(values({ mix: 0 }), trace)).toEqual({ auto: 'DRY', peak: 'PEAK -12 dBFS' });
});
it('bends a sine into a square at full drive, kept as loud by auto-gain', () => {
const trace = waveTrace(values({ type: 'hard', drive: 48 }));
const flat = trace.out.filter((v) => Math.abs(Math.abs(v) - trace.peak) < 1e-6).length;
expect(flat / trace.out.length).toBeGreaterThan(0.9);
// As loud by its RMS as the sine going in.
const rms = (xs: number[]) => Math.sqrt(xs.reduce((s, v) => s + v * v, 0) / xs.length);
expect(20 * Math.log10(rms(trace.out) / rms(trace.dry))).toBeCloseTo(0, 0);
});
it('takes out the DC a bent half leaves, as the node does', () => {
const trace = waveTrace(values({ type: 'rectify', drive: 24 }));
const mean = trace.out.slice(0, -1).reduce((s, v) => s + v, 0) / (trace.out.length - 1);
expect(Math.abs(mean)).toBeLessThan(1e-9);
});
it('puts the output alone after the curve without auto-gain', () => {
const trace = waveTrace(values({ autoGain: false, output: -6 }));
expect(trace.gain).toBe(-6);
expect(trace.auto).toBeNull();
expect(saturationTexts(values({ autoGain: false }), trace).auto).toBe('AUTO-GAIN OFF');
});
});
describe('the Saturation heat', () => {
it('runs hotter with the drive and only as far as the wet is heard', () => {
expect(heatOf(values({ drive: 0 }))).toBe(0);
expect(heatOf(values({ drive: 12 }))).toBeCloseTo(0.25);
expect(heatOf(values({ drive: 48 }))).toBe(1);
expect(heatOf(values({ drive: 48, mix: 0.5 }))).toBeCloseTo(0.5);
expect(heatOf(values({ drive: 48, mix: 0 }))).toBe(0);
});
it('goes from cold steel through ember red to white', () => {
const red = (c: number) => (c >> 16) & 0xff;
const blue = (c: number) => c & 0xff;
expect(blue(heatColor(0))).toBeGreaterThan(red(heatColor(0)));
expect(red(heatColor(0.3))).toBeGreaterThan(blue(heatColor(0.3)) + 100);
expect(heatColor(1)).toBe(0xfff4dc);
expect([0, 0.2, 0.4, 0.6, 0.9].map(heatWord)).toEqual(['COLD', 'WARM', 'HOT', 'GLOWING', 'WHITE HOT']);
});
it('sends up more embers the hotter it runs, none while it is only warm', () => {
expect(emberCount(0.1)).toBe(0);
expect(emberCount(0.5)).toBeGreaterThan(emberCount(0.3));
expect(emberCount(1)).toBe(MOST_EMBERS);
});
it('lets the embers rise by the clock, on the screen and the same every time', () => {
const now = embersAt(0.8, 3);
const later = embersAt(0.8, 3.1);
expect(embersAt(0.8, 3)).toEqual(now);
for (const e of [...now, ...later]) {
expect(e.x).toBeGreaterThanOrEqual(0);
expect(e.x).toBeLessThanOrEqual(1);
expect(e.y).toBeGreaterThan(0);
expect(e.y).toBeLessThanOrEqual(1);
expect(e.alpha).toBeGreaterThanOrEqual(0);
expect(e.alpha).toBeLessThanOrEqual(1);
}
// Most go up between two frames; one that went round starts at the bottom again.
const rose = now.filter((e, i) => later[i]!.y < e.y).length;
expect(rose).toBeGreaterThan(now.length * 0.8);
});
it('flickers within 8 % either way', () => {
for (let t = 0; t < 10; t += 0.037) {
expect(flicker(t)).toBeGreaterThanOrEqual(0.92);
expect(flicker(t)).toBeLessThanOrEqual(1.08);
}
expect(flicker(1)).not.toBe(flicker(1.05));
});
});
+52
View File
@@ -1443,6 +1443,58 @@ export async function sound(t) {
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-dl');`);
// #396: a Saturation is a device of its own size with the wave a sine comes out as on a screen,
// glowing hotter the more it is driven. A type button, the auto-gain lever and the drive
// knob are one undo step each.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-st', 'SoundFxSaturation', 0, 400, { drive: 12 }))});`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const stShown = await json("__gm.saturation.panel('fx-st')");
await sleep(150);
const stLater = (await json("__gm.saturation.panel('fx-st')")).scope.flare;
await click(stShown.types.hard.x, stShown.types.hard.y);
const stHard = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
await click(stShown.autoGain.at.x, stShown.autoGain.at.y);
const stOff = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
const stDrive = stShown.knobs.drive.dial;
await drag(stDrive.x, stDrive.y, stDrive.x, stDrive.y - 40);
const stHot = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
for (let i = 0; i < 3; i++) await press('z', { ctrl: true });
const stUndone = { params: await knobParams('fx-st'), panel: await json("__gm.saturation.panel('fx-st')") };
check(
'a Saturation node is a device whose screen glows hotter with the drive; a type, auto-gain and the drive knob are one undo step each',
stShown.size.w === 720 &&
stShown.size.h === 280 &&
Object.values(stShown.inputs).length === 5 &&
Object.values(stShown.inputs).every((p) => p.x < stShown.rect.x) &&
Object.values(stShown.outputs).every((p) => p.x > stShown.rect.x + stShown.rect.w) &&
stShown.type === 'soft' &&
stShown.knobs.drive.number === '12 dB' &&
stShown.heat.word === 'WARM' &&
stShown.heat.burning &&
stShown.autoGain.lit &&
stShown.scope.says.auto.startsWith('AUTO-GAIN ') &&
// The glow flickers by itself.
stLater !== stShown.scope.flare &&
stHard.params.curve === 'hard' &&
stHard.panel.type === 'hard' &&
stOff.params.autoGain === false &&
stOff.panel.scope.says.auto === 'AUTO-GAIN OFF' &&
stHot.params.drive > 12 &&
stHot.panel.heat.share > stShown.heat.share &&
stHot.panel.scope.embers > 0 &&
stUndone.params.drive === 12 &&
stUndone.params.curve !== 'hard' &&
stUndone.params.autoGain !== false &&
stUndone.panel.type === 'soft' &&
stUndone.panel.heat.word === 'WARM',
JSON.stringify({ stShown, stLater, stHard, stOff, stHot, stUndone }),
);
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-st');`);
await showTab('scene');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;