/** * A Chip's node as a device (#386): every part on the plate and none over another, the wave as * the Chip plays it, and the pitch over a note as slide, vibrato, arpeggio and repeat make it. */ import { describe, expect, it } from 'vitest'; import { CHIP_ROWS } from '../../src/core/instrument/nodes'; import { ChipPlayer, type ChipShape } from '../../src/core/soundfx/chip'; import { CHIP_GROUPS, CHIP_KEYS, CHIP_KNOBS, CHIP_LAYOUT, CHIP_PANEL, chipValues, type ChipValues, hzText, pitchTrace, semitoneText, wavePoints, } from '../../src/editor/soundfx/chipView'; 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 <= CHIP_PANEL.w && a.y + a.h <= CHIP_PANEL.h; const values = (changes: Partial = {}): ChipValues => ({ ...chipValues({}), ...changes }); describe('the Chip plate', () => { const L = CHIP_LAYOUT; const parts: [string, Box][] = [ ['title', L.title], ['follow', L.follow], ['bezel', L.bezel], ...L.waves.map((b, i): [string, Box] => [`wave ${i}`, b]), ...L.groups.map((g): [string, Box] => [g.label, g.box]), ...CHIP_KEYS.map((key): [string, Box] => [key, knobBox(L.knobs[key])]), ]; it('has every part on it, none over another, and clear of the screws', () => { for (const [name, box] of parts) expect(inside(box), name).toBe(true); for (let i = 0; i < parts.length; i++) for (let j = i + 1; j < parts.length; j++) expect(overlap(parts[i]![1], parts[j]![1]), `${parts[i]![0]} / ${parts[j]![0]}`).toBe(false); for (const screw of L.screws) { const head = { x: screw.x - 6, y: screw.y - 6, w: 12, h: 12 }; for (const [name, box] of parts) expect(overlap(head, box), `screw / ${name}`).toBe(false); } }); it('has both screens in the bezel side by side, and a knob for every number in a group', () => { for (const screen of [L.wave, L.pitch]) { expect(screen.x).toBeGreaterThan(L.bezel.x); expect(screen.x + screen.w).toBeLessThan(L.bezel.x + L.bezel.w); expect(screen.y + screen.h).toBeLessThan(L.bezel.y + L.bezel.h); } expect(L.wave.x + L.wave.w).toBeLessThan(L.pitch.x); expect(CHIP_GROUPS.flatMap((g) => g.keys).sort()).toEqual([...CHIP_KEYS].sort()); expect(CHIP_KEYS.sort()).toEqual(Object.keys(CHIP_ROWS).sort()); }); it('turns a frequency and a slide so the usual numbers get the turn', () => { const turn = (key: keyof typeof CHIP_KNOBS, value: number) => { const row = CHIP_ROWS[key]; return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...CHIP_KNOBS[key] }); }; // A4 sits a good way round, not at a fiftieth of it. expect(turn('frequency', 440)).toBeGreaterThan(0.35); expect(turn('slide', 0)).toBe(0.5); expect(turn('slide', -120)).toBeLessThan(0.4); expect(hzText(440)).toBe('440'); expect(hzText(3528)).toBe('3.53k'); expect(hzText(20000)).toBe('20.0k'); expect(CHIP_KNOBS.repeat.format!(0)).toBe('off'); expect(CHIP_KNOBS.minFrequency.format!(30)).toBe('30'); }); }); describe('the wave screen', () => { /** What the Chip itself plays over two periods of a wave at a frequency that makes them 400 samples. */ function played(wave: ChipShape['wave'], duty: number): number[] { const shape: ChipShape = { wave, dutySweep: 0, slide: 0, deltaSlide: 0, minFrequency: 0, vibratoDepth: 0, vibratoSpeed: 0, arpeggio: 0, arpeggioTime: 0, repeat: 0, }; const out = new Float32Array(400); new ChipPlayer(shape, 400 * 100).render( [out], new Float32Array([200]), new Float32Array([duty]), new Float32Array([0]), ); return [...out]; } /** The drawn line's height at `x`: the last point at or before it. */ const drawnAt = (points: { x: number; y: number }[], x: number) => points.filter((p) => p.x <= x + 1e-9).at(-1)!.y; it('draws a wave as the Chip plays it, a square at its duty and half as loud', () => { for (const [wave, duty] of [ ['square', 0.25], ['saw', 0.5], ['triangle', 0.5], ['sine', 0.5], ] as const) { const points = wavePoints(wave, duty, 400); const samples = played(wave, duty); // Away from the steps, where 8 samples averaged blur them. for (const i of [30, 70, 130, 230, 330]) expect(drawnAt(points, i / 400), `${wave} at ${i}`).toBeCloseTo(samples[i]!, 1); } const square = wavePoints('square', 0.25); expect(Math.max(...square.map((p) => p.y))).toBe(0.5); expect(square.find((p) => p.y === -0.5)!.x).toBeCloseTo(0.125, 9); }); it('stands the steps of a square and a saw upright, and starts and ends at the edges', () => { for (const wave of ['square', 'saw', 'noise', 'periodic'] as const) { const points = wavePoints(wave, 0.5); expect(points[0]!.x).toBe(0); expect(points.at(-1)!.x).toBe(1); for (let i = 1; i < points.length; i++) expect(points[i]!.x).toBeGreaterThanOrEqual(points[i - 1]!.x); } const saw = wavePoints('saw', 0.5); const middle = saw.filter((p) => p.x === 0.5).map((p) => p.y); expect(middle).toEqual([-1, 1]); // Two periods of noise, each of 32 values, the same every time. expect(wavePoints('noise', 0.5)).toEqual(wavePoints('noise', 0.5)); expect(wavePoints('noise', 0.5).length).toBe(128); }); }); describe('the pitch screen', () => { it('is a flat second from the note when nothing moves it', () => { const trace = pitchTrace(values(), true); expect(trace.span).toBe(1); expect(trace.end).toBeNull(); expect(trace.points.every((p) => p.st === 0)).toBe(true); expect(trace.from).toBe('A4 440 Hz'); expect([trace.low, trace.high]).toEqual([-2, 2]); }); it('ends a falling note where it passes "min frequency", and looks a quarter past it', () => { // The kick of the drum kit: 160 Hz down 120 st/s to 30 Hz, which is 29 semitones down. const trace = pitchTrace(values({ frequency: 160, slide: -120, minFrequency: 30 }), false); const reach = 12 * Math.log2(30 / 160); expect(trace.end).toBeCloseTo(-reach / 120, 2); expect(trace.span).toBeCloseTo(trace.end! * 1.25, 2); expect(trace.floor).toBeCloseTo(reach, 9); expect(trace.low).toBeLessThan(reach); expect(trace.from).toBe('160 Hz'); expect(Math.min(...trace.points.map((p) => p.st))).toBeGreaterThanOrEqual(reach); }); it('shows three rounds of a repeat, with the arpeggio jumping in each', () => { const trace = pitchTrace(values({ arpeggio: 12, arpeggioTime: 0.04, repeat: 0.08 }), true); expect(trace.span).toBeCloseTo(0.24, 9); expect(trace.restarts.length).toBe(2); const at = (t: number) => trace.points.find((p) => p.t >= t)!.st; expect(at(0.02)).toBe(0); expect(at(0.06)).toBe(12); expect(at(0.1)).toBe(0); expect(at(0.14)).toBe(12); expect(trace.high).toBeGreaterThan(12); }); it('wobbles by the vibrato either way of the slide', () => { const trace = pitchTrace(values({ vibratoDepth: 0.5, vibratoSpeed: 5, slide: 6 }), true); const quarter = trace.points.find((p) => p.t >= 0.05)!; expect(quarter.st).toBeCloseTo(6 * quarter.t + 0.5 * Math.sin(2 * Math.PI * 5 * quarter.t), 9); expect(semitoneText(trace.high)).toMatch(/^\+\d/); expect(semitoneText(-29.4)).toBe('-29 st'); expect(semitoneText(0)).toBe('0 st'); }); });