Show a Saturation node as a device that glows with its drive (#396)
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:
1 parent
8c53ce0cbe
commit
80b25488ce
11 files changed
+1092
-21
No files matched your search
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user