/** * The Acid of an instrument (#421): a TB-303 line rendered sample by sample as its worklet does it, * the line that lets a note slide out of the one before, and the node that makes it. */ import { describe, expect, it } from 'vitest'; import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile'; import { INSTRUMENT_ACID, INSTRUMENT_NOTES, INSTRUMENT_OUTPUT } from '../../src/core/instrument/nodes'; import { noteFrequency } from '../../src/core/instrument/notes'; import type { Graph } from '../../src/core/graph/Graph'; import type { PropertyBag } from '../../src/core/model/Properties'; import { ACCENT_VELOCITY, type AcidKnobs, AcidLine, type AcidNote, acidResponseDb, type AcidShape, AcidSynth, } from '../../src/core/soundfx/acid'; import { fxChainOf } from '../../src/core/soundfx/nodes'; import { levelsOf } from '../../src/viewport/fxNodes'; import { makeProject } from '../helpers'; const RATE = 48000; const BLOCK = 128; const SAW: AcidShape = { wave: 'saw', slideTime: 0.06 }; function knobs(set: Partial> = {}): AcidKnobs { const one = (value: number) => new Float32Array([value]); return { cutoff: one(set.cutoff ?? 500), resonance: one(set.resonance ?? 0), envMod: one(set.envMod ?? 0), decay: one(set.decay ?? 0.5), accent: one(set.accent ?? 0), pitch: one(set.pitch ?? 0), }; } const note = (frequency: number, more: Partial = {}): AcidNote => ({ frequency, accented: false, slide: false, ...more, }); /** `seconds` of a synth struck with `first`, in blocks; `then` strikes more notes at their second. */ function render( set: Partial>, first: AcidNote, seconds: number, then: { at: number; note: AcidNote }[] = [], shape: AcidShape = SAW, ) { const synth = new AcidSynth(shape, RATE); synth.strike(first); const out = new Float32Array(Math.round(seconds * RATE)); const k = knobs(set); const due = [...then]; let done = 0; while (done < out.length) { const next = due[0] ? Math.round(due[0].at * RATE) : Infinity; const to = Math.min(out.length, done + BLOCK, next); synth.render(out, done, to, k); done = to; if (done === next) synth.strike(due.shift()!.note); } return { out, synth }; } /** How loud `hz` is in a stretch of the sound: the Goertzel magnitude through a Hann window, per sample. */ function energyAt(out: Float32Array, hz: number, from = 0, to = out.length): number { const w = (2 * Math.PI * hz) / RATE; const c = 2 * Math.cos(w); let s1 = 0; let s2 = 0; const n = to - from; for (let i = from; i < to; i++) { const hann = 0.5 - 0.5 * Math.cos((2 * Math.PI * (i - from)) / (n - 1)); const s0 = out[i]! * hann + c * s1 - s2; s2 = s1; s1 = s0; } return Math.sqrt(s1 * s1 + s2 * s2 - c * s1 * s2) / (to - from); } const peak = (out: Float32Array, from = 0, to = out.length) => { let most = 0; for (let i = from; i < to; i++) most = Math.max(most, Math.abs(out[i]!)); return most; }; describe('the Acid synth (#421)', () => { it('plays a saw at the frequency of its note', () => { const { out } = render({ cutoff: 8000 }, note(110), 0.5); const at = energyAt(out, 110, 4800); expect(at).toBeGreaterThan(energyAt(out, 117, 4800) * 10); expect(energyAt(out, 220, 4800)).toBeGreaterThan(at * 0.2); expect(peak(out)).toBeLessThan(1); }); it('takes the highs away below its cutoff', () => { const open = render({ cutoff: 6000 }, note(110), 0.3).out; const shut = render({ cutoff: 200 }, note(110), 0.3).out; const high = (out: Float32Array) => energyAt(out, 1100, 4800); expect(high(shut)).toBeLessThan(high(open) / 30); }); it('rings at its cutoff the more, the higher the resonance', () => { // The 8th harmonic of 110 Hz sits right at the cutoff. const flat = render({ cutoff: 880, resonance: 0 }, note(110), 0.4).out; const ringing = render({ cutoff: 880, resonance: 0.9 }, note(110), 0.4).out; const ratio = (out: Float32Array) => energyAt(out, 880, 4800) / energyAt(out, 110, 4800); expect(ratio(ringing)).toBeGreaterThan(ratio(flat) * 4); }); it('stays finite and in range at full resonance, sweep and accent', () => { const { out } = render( { cutoff: 12000, resonance: 1, envMod: 1, accent: 1, decay: 2 }, note(55, { accented: true }), 3, [0.5, 1, 1.5, 2].map((at, i) => ({ at, note: note(55 * 2 ** (i / 3), { accented: true }) })), ); expect(out.every(Number.isFinite)).toBe(true); expect(peak(out)).toBeLessThan(1.5); }); it('sweeps its filter open on every note and lets it fall in the decay', () => { const { out } = render({ cutoff: 200, envMod: 1, decay: 0.4 }, note(110), 0.6); const early = energyAt(out, 1100, 480, 2880); const late = energyAt(out, 1100, 21600, 24000); expect(early).toBeGreaterThan(late * 10); }); it('makes an accented note louder and builds up on accents close behind each other', () => { const plain = render({ envMod: 0.5, accent: 1 }, note(110), 0.1).out; const accented = render({ envMod: 0.5, accent: 1 }, note(110, { accented: true }), 0.1).out; expect(peak(accented)).toBeGreaterThan(peak(plain) * 1.5); // A sixteenth at 120 BPM apart: the second accent starts with the first one's charge in it. const step = 0.125; const top = 0.04; const one = render({ accent: 1 }, note(110, { accented: true }), top).synth.state.charge; const two = render({ accent: 1 }, note(110, { accented: true }), step + top, [ { at: step, note: note(110, { accented: true }) }, ]).synth.state.charge; expect(two).toBeGreaterThan(one * 1.2); }); it('glides to a note it slides to, without striking anew', () => { const shape = { wave: 'saw' as const, slideTime: 0.06 }; const { synth } = render({}, note(110), 0.2, [{ at: 0.1, note: note(220, { slide: true }) }], shape); expect(synth.state.hz).toBeGreaterThan(215); const midway = render({}, note(110), 0.12, [{ at: 0.1, note: note(220, { slide: true }) }], shape); expect(midway.synth.state.hz).toBeGreaterThan(130); expect(midway.synth.state.hz).toBeLessThan(200); // The sweep runs on: it fell for 0.12 s and was not set back to the top. expect(midway.synth.state.sweep).toBeLessThan(0.5); const struck = render({}, note(110), 0.1005, [{ at: 0.1, note: note(220) }], shape); expect(struck.synth.state.hz).toBeCloseTo(220, 3); expect(struck.synth.state.sweep).toBeGreaterThan(0.9); }); it('never slides on its first note', () => { const { synth } = render({}, note(220, { slide: true }), 0.01); expect(synth.state.hz).toBeCloseTo(220, 3); expect(synth.state.sweep).toBeGreaterThan(0.9); }); it('draws a low-pass that loses 12 dB at its cutoff and peaks there with resonance', () => { expect(acidResponseDb(1000, 1000, 0)).toBeCloseTo(-12.04, 1); expect(acidResponseDb(50, 1000, 0)).toBeCloseTo(0, 1); expect(acidResponseDb(1000, 1000, 1)).toBeGreaterThan(acidResponseDb(50, 1000, 1) + 12); expect(acidResponseDb(8000, 1000, 0)).toBeLessThan(-60); }); }); describe('the line of an Acid (#421)', () => { /** Two notes on a line, the second sliding in at `slideAt`, played block by block in the order given. */ function played(slideAt: number, blocks: number, order: 'first' | 'second') { const line = new AcidLine<'a' | 'b'>(new AcidSynth(SAW, RATE)); line.strike('a', 0, note(110)); line.strike('b', slideAt, note(165, { slide: true })); const a = new Float32Array(blocks * BLOCK); const b = new Float32Array(blocks * BLOCK); const k = knobs({ cutoff: 2000 }); for (let n = 0; n < blocks; n++) { const frame = n * BLOCK; const into = (out: Float32Array) => out.subarray(frame, frame + BLOCK); if (order === 'first') { line.play('a', frame, into(a), k); line.play('b', frame, into(b), k); } else { line.play('b', frame, into(b), k); line.play('a', frame, into(a), k); } } return { a, b }; } it('hands the synth to the note that slides in, from its first sample on', () => { const { a, b } = played(200, 4, 'first'); expect(peak(a, 0, 200)).toBeGreaterThan(0.05); expect(peak(a, 200)).toBe(0); expect(peak(b, 0, 200)).toBe(0); expect(peak(b, 200)).toBeGreaterThan(0.05); // One synth: what both hear is what it plays with both notes struck in turn. const reference = render({ cutoff: 2000 }, note(110), (4 * BLOCK) / RATE, [ { at: 200 / RATE, note: note(165, { slide: true }) }, ]).out; const heard = a.map((value, i) => value + b[i]!); expect(Array.from(heard)).toEqual(Array.from(reference)); }); it('plays the same whichever note is run first in a block', () => { const one = played(300, 5, 'first'); const two = played(300, 5, 'second'); expect(Array.from(two.a)).toEqual(Array.from(one.a)); expect(Array.from(two.b)).toEqual(Array.from(one.b)); }); it('is silent before its first note and says when its last one has gone', () => { const line = new AcidLine<'a'>(new AcidSynth(SAW, RATE)); line.strike('a', 1000, note(110)); const out = new Float32Array(BLOCK); line.play('a', 0, out, knobs()); expect(peak(out)).toBe(0); expect(line.leave('a')).toBe(true); }); }); describe('the Acid node (#421)', () => { const graph = (params: PropertyBag = {}): Graph => ({ nodes: [ { id: 'notes', type: INSTRUMENT_NOTES, x: 0, y: 0, params: {} }, { id: 'acid', type: INSTRUMENT_ACID, x: 200, y: 0, params }, { id: 'out', type: INSTRUMENT_OUTPUT, x: 900, y: 0, params: {} }, ], edges: [ { id: 'e1', from: { node: 'notes', port: 'any' }, to: { node: 'acid', port: 'trigger' } }, { id: 'e2', from: { node: 'acid', port: 'out' }, to: { node: 'out', port: 'in' } }, ], }); it('plays the note, tuned, with its knobs, and is accented by a hard velocity', () => { const sources = instrumentSourcesOf(makeProject().project); const build = compileInstrument(graph({ wave: 'square', tuning: 12, cutoff: 900 }), 60, sources); expect(build.reason).toBe(''); const [layer] = build.layers; const soft = fxChainOf(layer!, { '@velocity': 0.8 }); const hard = fxChainOf(layer!, { '@velocity': ACCENT_VELOCITY }); if ('mix' in soft || 'mix' in hard) throw new Error('one way'); expect(soft.acid).toMatchObject({ id: 'acid', wave: 'square', cutoff: 900, resonance: 0.7, envMod: 0.5, decay: 0.5, accent: 0.6, slideTime: 0.06, accented: false, }); expect(soft.acid!.frequency).toBeCloseTo(noteFrequency(72), 6); expect(hard.acid!.accented).toBe(true); expect(levelsOf(soft)).toEqual([900, 0.7, 0.5, 0.5, 0.6]); }); it('holds its knobs to their rows', () => { const sources = instrumentSourcesOf(makeProject().project); const [layer] = compileInstrument(graph({ cutoff: 99999, resonance: 3, decay: 0 }), 60, sources).layers; const chain = fxChainOf(layer!, {}); if ('mix' in chain) throw new Error('one way'); expect(chain.acid).toMatchObject({ cutoff: 12000, resonance: 1, decay: 0.2 }); }); });