diff --git a/README.md b/README.md index 0e343cdc..afb45c1d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index 39a7a79f..455c30d9 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -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'; } diff --git a/src/core/soundfx/saturation.ts b/src/core/soundfx/saturation.ts index 81baa6b0..50cb5918 100644 --- a/src/core/soundfx/saturation.ts +++ b/src/core/soundfx/saturation.ts @@ -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)); } diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 3b533169..9e10b049 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -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, diff --git a/src/editor/soundfx/ReverbPanel.ts b/src/editor/soundfx/ReverbPanel.ts index 73efceb9..cec80577 100644 --- a/src/editor/soundfx/ReverbPanel.ts +++ b/src/editor/soundfx/ReverbPanel.ts @@ -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 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[]; readonly soundButton: Dropdown; readonly knobs = {} as Record; private readonly captions = {} as Record; @@ -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; diff --git a/src/editor/soundfx/SaturationFace.ts b/src/editor/soundfx/SaturationFace.ts new file mode 100644 index 00000000..ccec0d5b --- /dev/null +++ b/src/editor/soundfx/SaturationFace.ts @@ -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)); + } +} diff --git a/src/editor/soundfx/SaturationPanel.ts b/src/editor/soundfx/SaturationPanel.ts new file mode 100644 index 00000000..80e40732 --- /dev/null +++ b/src/editor/soundfx/SaturationPanel.ts @@ -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; + 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[]; + readonly autoGain: Lever; + readonly knobs = {} as Record; + private readonly captions = {} as Record; + 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 = 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(); + } +} diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index 6d49cda6..5207eb05 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -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; } diff --git a/src/editor/soundfx/saturationView.ts b/src/editor/soundfx/saturationView.ts new file mode 100644 index 00000000..334af100 --- /dev/null +++ b/src/editor/soundfx/saturationView.ts @@ -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 { + 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 { + 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 = { + 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; + 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 + ); +} diff --git a/tests/editor/saturation-view.test.ts b/tests/editor/saturation-view.test.ts new file mode 100644 index 00000000..24c6d1e4 --- /dev/null +++ b/tests/editor/saturation-view.test.ts @@ -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({}), + ...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)); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 2cfff47a..42770148 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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;