/** * The Chip of an instrument (#329): a raw wave whose frequency slides, wobbles, jumps and starts * over as in sfxr, rendered sample by sample as its worklet does it, and the node that makes it. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand } from '../../src/core/commands/assetCommands'; import { createEmptyGraph } from '../../src/core/graph/Graph'; import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile'; import { InstrumentChip } from '../../src/core/instrument/nodes'; import { noteFrequency } from '../../src/core/instrument/notes'; import type { BehaviorAsset, InstrumentAsset } from '../../src/core/model/Asset'; import { ChipPlayer, type ChipShape, PERIODIC_STEPS } from '../../src/core/soundfx/chip'; import { fxChainOf, type FxPlan } from '../../src/core/soundfx/nodes'; import { graphEditor, makeProject } from '../helpers'; const RATE = 48000; const BLOCK = 128; const FLAT: ChipShape = { wave: 'square', dutySweep: 0, slide: 0, deltaSlide: 0, minFrequency: 0, vibratoDepth: 0, vibratoSpeed: 6, arpeggio: 0, arpeggioTime: 0.1, repeat: 0, }; /** A seeded random, so noise is the same on every run. */ function seeded(seed = 1): () => number { let state = seed; return () => { state = (state * 1664525 + 1013904223) >>> 0; return state / 2 ** 32; }; } /** `seconds` of a Chip at `hz`, in blocks as the audio thread hands them over; `ended` once it stops. */ function render(shape: Partial, hz: number, seconds: number, { duty = 0.5, pitch = 0 } = {}) { const player = new ChipPlayer({ ...FLAT, ...shape }, RATE, seeded()); const out = new Float32Array(Math.round(seconds * RATE)); let ended = -1; for (let at = 0; at < out.length; at += BLOCK) { const block = out.subarray(at, Math.min(out.length, at + BLOCK)); const going = player.render( [block], new Float32Array([hz]), new Float32Array([duty]), new Float32Array([pitch]), ); if (!going && ended < 0) ended = at; } return { out, ended }; } /** Periods begun between two seconds: rises through 0. */ function cycles(out: Float32Array, from = 0, to = out.length / RATE): number { let count = 0; for (let i = Math.max(1, Math.round(from * RATE)); i < Math.round(to * RATE); i++) if (out[i - 1]! <= 0 && out[i]! > 0) count += 1; return count; } const share = (out: Float32Array, from: number, to: number) => { const part = out.subarray(Math.round(from * RATE), Math.round(to * RATE)); return part.filter((v) => v > 0).length / part.length; }; describe('the Chip, sample by sample (#329)', () => { it('plays each wave at its frequency, a square at half the level as in sfxr', () => { for (const wave of ['square', 'triangle', 'saw', 'sine'] as const) { const { out } = render({ wave }, 440, 1); expect(cycles(out), wave).toBeGreaterThanOrEqual(439); expect(cycles(out), wave).toBeLessThanOrEqual(441); const peak = out.reduce((top, v) => Math.max(top, Math.abs(v)), 0); expect(peak, wave).toBeCloseTo(wave === 'square' ? 0.5 : 1, 1); } }); it('keeps a square up for its duty, and sweeps the duty by so much a second', () => { expect(share(render({}, 480, 0.5, { duty: 0.25 }).out, 0, 0.5)).toBeCloseTo(0.25, 2); const swept = render({ dutySweep: 0.4 }, 480, 1, { duty: 0.1 }).out; expect(share(swept, 0, 0.1)).toBeCloseTo(0.12, 2); expect(share(swept, 0.9, 1)).toBeCloseTo(0.48, 2); }); it('a pitch moves the frequency itself, in semitones', () => { expect(cycles(render({}, 440, 1, { pitch: 12 }).out)).toBeCloseTo(880, -1); }); it('slides in semitones a second, and a delta slide speeds the slide up', () => { // An octave a second from 220: 220 / ln 2 periods in the first second. expect(cycles(render({ slide: 12 }, 220, 1).out)).toBeCloseTo(220 / Math.LN2, -1); // 24 st/s² is an octave by the end of the first second, along t². let integral = 0; for (let i = 0; i < 1000; i++) integral += 2 ** (((i + 0.5) / 1000) ** 2) / 1000; expect(cycles(render({ deltaSlide: 24 }, 220, 1).out)).toBeCloseTo(220 * integral, -1); }); it('ends a note that slides below its lowest frequency, silent after it', () => { // Two octaves down in a second: 110 Hz after 1 s. const { out, ended } = render({ slide: -24, minFrequency: 110 }, 440, 1.5); expect(ended / RATE).toBeGreaterThan(0.99); expect(ended / RATE).toBeLessThan(1.01); expect(out.subarray(Math.round(1.01 * RATE)).every((v) => v === 0)).toBe(true); expect(render({ slide: -24 }, 440, 1.5).ended).toBe(-1); }); it('jumps by the arpeggio at its time, and starts over every repeat', () => { const jumped = render({ arpeggio: 12, arpeggioTime: 0.5 }, 220, 1).out; expect(cycles(jumped, 0, 0.5)).toBeCloseTo(110, -1); expect(cycles(jumped, 0.5, 1)).toBeCloseTo(220, -1); const repeated = render({ slide: 48, repeat: 0.25 }, 220, 1).out; const quarters = [0, 1, 2, 3].map((q) => cycles(repeated, q * 0.25, (q + 1) * 0.25)); expect(Math.max(...quarters) - Math.min(...quarters)).toBeLessThanOrEqual(1); // The slide does not start over without it. expect(cycles(render({ slide: 48 }, 220, 1).out, 0.75, 1)).toBeGreaterThan(quarters[3]! * 4); }); it('wobbles by the vibrato: higher on the first half of its turn, lower on the second', () => { const { out } = render({ vibratoDepth: 2, vibratoSpeed: 1 }, 440, 1); const periods = (from: number) => { let sum = 0; for (let i = 0; i < 1000; i++) sum += (440 * 2 ** ((2 * Math.sin(2 * Math.PI * (from + (i + 0.5) / 2000))) / 12)) / 2000; return sum; }; expect(cycles(out, 0, 0.5)).toBeCloseTo(periods(0), -1); expect(cycles(out, 0.5, 1)).toBeCloseTo(periods(0.5), -1); expect(periods(0) - periods(0.5)).toBeGreaterThan(30); }); it('noise draws new values every period, the periodic one repeats the NES short noise once a period', () => { // 480 Hz at 48 kHz is a period of 100 samples. const noise = render({ wave: 'noise' }, 480, 0.1).out; expect(noise.every((v) => v >= -1 && v <= 1)).toBe(true); let same = 0; for (let i = 100; i < 200; i++) if (noise[i] === noise[i + 100]) same += 1; expect(same).toBeLessThan(10); const periodic = render({ wave: 'periodic' }, 480, 0.1).out; same = 0; for (let i = 100; i < 4000; i++) if (Math.abs(periodic[i]! - periodic[i + 100]!) < 1e-6) same += 1; expect(same / 3900).toBeGreaterThan(0.95); expect(PERIODIC_STEPS).toBe(93); }); }); function makeChip() { const f = makeProject(); const command = new CreateAssetCommand('instrument', 'Chip'); f.stack.execute(command); const instrument = command.asset as InstrumentAsset; instrument.graph = createEmptyGraph(); const edit = graphEditor(f, instrument as unknown as BehaviorAsset); const notes = edit.addNode('InstrumentNotes'); const chip = edit.addNode('InstrumentChip', { wave: 'saw', slide: -100 }); const output = edit.addNode('InstrumentOutput'); edit.connect(notes, 'any', chip, 'trigger'); return { ...f, instrument, edit, notes, chip, output }; } const compile = (f: ReturnType, note: number) => compileInstrument(f.instrument.graph, note, instrumentSourcesOf(f.project)); const chipOf = (plan: FxPlan | undefined) => (plan && 'pitch' in plan ? fxChainOf(plan) : null); describe('the Chip node', () => { it('plays at the pitch of the note, its numbers held to their rows', () => { const f = makeChip(); f.edit.connect(f.chip, 'out', f.output, 'in'); const build = compile(f, 69); expect(build.reason).toBe(''); const chain = chipOf(build.layers[0]); expect(chain && 'chip' in chain && chain.chip).toMatchObject({ wave: 'saw', frequency: 440, duty: 0.5, slide: -100, vibratoSpeed: 6, }); // Outside the Notes node's range it is silence. expect(compile(f, 20).layers).toEqual([]); }); it('plays the frequency typed in without "follow note", and what is connected before both', () => { const f = makeChip(); f.edit.connect(f.chip, 'out', f.output, 'in'); f.chip.params.followNote = false; f.chip.params.frequency = 1000; const typed = chipOf(compile(f, 60).layers[0]); expect(typed && 'chip' in typed && typed.chip?.frequency).toBe(1000); f.edit.connect(f.notes, 'frequency', f.chip, 'frequency'); const fed = chipOf(compile(f, 60).layers[0]); expect(fed && 'chip' in fed && fed.chip?.frequency).toBeCloseTo(noteFrequency(60), 6); }); it('takes a Pitch behind it on its own way, and an Envelope on its frequency', () => { const f = makeChip(); const pitch = f.edit.addNode('SoundFxPitch', { semitones: 7 }); const envelope = f.edit.addNode('InstrumentEnvelope', { from: 200, to: 2000 }); f.edit.connect(f.chip, 'out', pitch, 'in'); f.edit.connect(pitch, 'out', f.output, 'in'); f.edit.connect(envelope, 'value', f.chip, 'frequency'); const chain = chipOf(compile(f, 60).layers[0]); expect(chain).toMatchObject({ pitch: 7, shifted: true }); expect(chain && 'chip' in chain && chain.chip?.frequencyMod).toMatchObject({ from: 200, to: 2000 }); }); it('greys what does nothing as things stand', () => { const idle = (params: Record, fed: string[] = []) => InstrumentChip.idleParams!(params as never, (key) => fed.includes(key)); expect(idle({})).toEqual(['frequency', 'vibratoSpeed', 'arpeggioTime']); expect(idle({ wave: 'noise', followNote: false, vibratoDepth: 1, arpeggio: 12 })).toEqual([ 'duty', 'dutySweep', ]); // A connected input counts as moving, whatever was typed into it before. expect(idle({}, ['frequency', 'vibratoDepth', 'arpeggio'])).toEqual(['followNote']); }); });