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>
179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
/**
|
||
* 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));
|
||
});
|
||
});
|