/** * A Bitcrusher's node as a device (#399): every part on the plate and none over another, the bits * it keeps, and the screen – what a sine comes out as, worked out by the node's own crush, so * the steps and holds drawn are the ones heard. */ import { describe, expect, it } from 'vitest'; import { Crusher, crushSteps } from '../../src/core/soundfx/crusher'; import { CRUSH_ROWS } from '../../src/core/soundfx/nodes'; import { bitLampsLit, cellOf, CRUSH_KEYS, CRUSH_KNOBS, type CrushValues, CRUSHER_LAYOUT, CRUSHER_PANEL, crushOf, crushTexts, crushTrace, crushValues, crushWord, idleCrushKeys, LEAST_HOLDS, SCREEN_RATE, screenSpan, } from '../../src/editor/soundfx/crusherView'; import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView'; 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 <= CRUSHER_PANEL.w && a.y + a.h <= CRUSHER_PANEL.h; const values = (over: Partial = {}): CrushValues => ({ ...crushValues({}), ...over }); describe('the Bitcrusher plate', () => { const L = CRUSHER_LAYOUT; const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 })); 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 }; const lamps = L.bitLamps.reduce((all, b) => ({ ...all, w: b.x + b.w - all.x }), { ...L.bitLamps[0]! }); it('keeps the screen, the lamps and the knobs on the plate, clear of each other and the screws', () => { const parts: Box[] = [L.bezel, lamps, ...CRUSH_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 lamp for every bit, none over another', () => { expect(L.bitLamps).toHaveLength(CRUSH_ROWS.bits.max!); for (let i = 1; i < L.bitLamps.length; i++) expect(L.bitLamps[i]!.x).toBeGreaterThan(L.bitLamps[i - 1]!.x + L.bitLamps[i - 1]!.w); }); it('lights the bits kept, the highest first, part of one for a fraction', () => { expect(bitLampsLit(16).every((share) => share === 1)).toBe(true); expect(bitLampsLit(8)).toEqual([...Array(8).fill(1), ...Array(8).fill(0)]); expect(bitLampsLit(3.5).slice(0, 5)).toEqual([1, 1, 1, 0.5, 0]); expect(bitLampsLit(1).filter((share) => share > 0)).toEqual([1]); }); }); describe('the Bitcrusher knobs', () => { const position = (key: 'bits' | 'rate', value: number) => { const row = CRUSH_ROWS[key]; return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...CRUSH_KNOBS[key] }); }; it('turns the bits evenly and puts 8 kHz just past the middle of the rate', () => { expect(position('bits', 8.5)).toBeCloseTo(0.5); expect(position('rate', 8000)).toBeGreaterThan(0.5); expect(position('rate', 8000)).toBeLessThan(0.6); }); it('names the bits and the rate as they are heard', () => { expect(CRUSH_KNOBS.bits.format(8)).toBe('8 bit'); expect(CRUSH_KNOBS.bits.format(3.5)).toBe('3.5 bit'); expect(CRUSH_KNOBS.rate.format(8000)).toBe('8 kHz'); expect(CRUSH_KNOBS.rate.format(300)).toBe('300 Hz'); }); it('greys the bits and the rate at wet 0, nothing else', () => { expect([...idleCrushKeys(values({ mix: 0 }))].sort()).toEqual(['bits', 'rate']); expect(idleCrushKeys(values()).size).toBe(0); }); }); describe('the Bitcrusher screen', () => { it('comes out as the node’s own worklet makes it, the wet blended in', () => { for (const over of [{}, { bits: 3, rate: 4000, mix: 0.4 }, { bits: 1.5, rate: 300 }]) { const v = values(over); const trace = crushTrace(v); const heard = new Float32Array(trace.dry.length); new Crusher(SCREEN_RATE).render( [trace.dry], [heard], Float32Array.of(v.bits), Float32Array.of(v.rate), Float32Array.of(v.mix), ); trace.out.forEach((x, i) => expect(x).toBeCloseTo(heard[i]!, 6)); } }); it('cuts the sine into the holds of the rate, each one level the bits round it to', () => { const v = values({ bits: 4, rate: 8000 }); const trace = crushTrace(v); const steps = crushSteps(4); // 8 kHz at 48 kHz: a hold every 6 samples. The first is one longer, as in the worklet: six // sixths add up to just under 1 there. const widths = trace.holds.map((h) => h.to - h.from); expect(widths[0]).toBe(7); expect(widths.slice(1, -1).every((w) => w === 6)).toBe(true); for (const hold of trace.holds) { expect(hold.level).toBeCloseTo(Math.round(trace.dry[hold.from]! * steps) / steps, 6); for (let i = hold.from; i < hold.to; i++) expect(trace.crushed[i]).toBe(hold.level); } expect(trace.step).toBe(1 / steps); }); it('holds nothing at the rate of the context, and shows enough holds where the rate is low', () => { const fine = crushTrace(values({ rate: 48000 })); expect(fine.holds).toHaveLength(fine.dry.length); expect(screenSpan(8000)).toBeCloseTo(2 / 440); expect(screenSpan(300)).toBeCloseTo(LEAST_HOLDS / 300); expect(crushTrace(values({ rate: 300 })).holds).toHaveLength(LEAST_HOLDS); }); it('draws a hold as a cell where its pixels allow, as a bar where the steps are fine, never a wall', () => { expect(cellOf(15, 16)).toEqual({ gap: 1, h: 14 }); expect(cellOf(9, 0.6)).toEqual({ gap: 0, h: 2 }); // One bit's levels are a screen apart: a bar on each. expect(cellOf(3.7, 64)).toEqual({ gap: 0, h: 7.4 }); expect(cellOf(45, 64)).toEqual({ gap: 1, h: 62 }); }); it('says the noise the bits leave and where the rate folds back, nothing of it when dry', () => { const v = values(); expect(crushTexts(v, crushTrace(v))).toEqual({ noise: 'NOISE -50 dB', fold: 'FOLDS AT 4 kHz', span: '4.5 ms', }); const dry = values({ mix: 0 }); expect(crushTexts(dry, crushTrace(dry))).toMatchObject({ noise: 'DRY', fold: '' }); }); }); describe('how crushed a Bitcrusher is', () => { it('takes whichever of the bits and the rate crushes more, as far as the wet is heard', () => { expect(crushOf(values({ bits: 16, rate: 48000 }))).toBe(0); expect(crushOf(values({ bits: 1 }))).toBe(1); expect(crushOf(values({ bits: 16, rate: 100 }))).toBeCloseTo(1); expect(crushOf(values({ bits: 1, mix: 0.5 }))).toBeCloseTo(0.5); expect(crushOf(values({ mix: 0 }))).toBe(0); }); it('names it from clean to broken', () => { expect([0, 0.2, 0.5, 0.7, 0.9].map(crushWord)).toEqual([ 'CLEAN', 'GRITTY', 'CRUNCHY', 'CRUSHED', 'BROKEN', ]); expect(crushWord(crushOf(values()))).toBe('CRUNCHY'); }); });