Files
feuerwerk/tests/editor/saturation-view.test.ts
T
adminandClaude Opus 5.5 80b25488ce
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Successful in 16m2s
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>
2026-10-10 20:05:11 +02:00

179 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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));
});
});