/** * 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)); }); });