/** * The Acid of an instrument (#421): nodeseq's acid bass rendered sample by sample as its worklet * does it – the oscillator, the resonant low-pass, the ADSR on volume and filter alike, the glide * and the cut – and the node that makes it. */ import { describe, expect, it } from 'vitest'; import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile'; import { ACID_ROWS, 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 { type AcidKnobs, type AcidNote, acidQ, acidResponseDb, AcidSynth, acidTopHz, SUSTAIN_SWEEP, } 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; type Knobs = Partial>; const note = (frequency: number, more: Partial = {}): AcidNote => ({ wave: 'saw', frequency, from: null, glide: 0, attack: 0.003, decay: 0.16, sustain: 1, release: 0.08, velocity: 1, ...more, }); /** * `seconds` of a synth playing `played`, in blocks: the note ends at `endAt` and is cut at `cutAt`, * each a second or never. Says what it played, its state after, and the second it stopped at. */ function render( set: Knobs, played: AcidNote, seconds: number, { endAt = Infinity, cutAt = Infinity }: { endAt?: number; cutAt?: number } = {}, ) { const synth = new AcidSynth(played, RATE); const out = new Float32Array(Math.round(seconds * RATE)); const one = (value: number) => new Float32Array([value]); let stopped = Infinity; for (let done = 0; done < out.length; done += BLOCK) { const block = out.subarray(done, done + BLOCK); // Per sample, as an AudioParam hands over one that is set on the clock. const switched = (at: number, before: number) => Float32Array.from(block, (_, i) => ((done + i) / RATE < at ? before : 1 - before)); const knobs: AcidKnobs = { cutoff: one(set.cutoff ?? 500), resonance: one(set.resonance ?? 0), envMod: one(set.envMod ?? 0), pitch: one(set.pitch ?? 0), gate: switched(endAt, 1), cut: switched(cutAt, 0), }; if (!synth.render(block, knobs)) { let last = block.length - 1; while (last > 0 && block[last] === 0) last -= 1; stopped = (done + last + 1) / RATE; break; } } return { out, synth, stopped }; } /** 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; }; const db = (value: number) => 20 * Math.log10(value); 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 above 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('turns the resonance into a Q in dB as nodeseq does, 0.5 to 17.5', () => { expect(db(acidQ(0))).toBeCloseTo(0.5, 6); expect(db(acidQ(0.78))).toBeCloseTo(13.76, 6); expect(db(acidQ(1))).toBeCloseTo(17.5, 6); // A two-pole low-pass passes its cutoff Q times as loud. expect(acidResponseDb(1000, 1000, 1)).toBeCloseTo(17.5, 6); expect(acidResponseDb(50, 1000, 0)).toBeCloseTo(0, 1); expect(acidResponseDb(10000, 1000, 0)).toBeCloseTo(-40, 0); }); it('rings at its cutoff as high as the filter it draws', () => { // The 18th harmonic of 55 Hz sits right at the cutoff; a saw's harmonics fall as 1/n. for (const resonance of [0, 0.78, 1]) { const { out } = render({ cutoff: 990, resonance }, note(55), 0.8); const heard = db(energyAt(out, 990, 14400) / energyAt(out, 55, 14400)); const drawn = acidResponseDb(990, 990, resonance) - db(18) - acidResponseDb(55, 990, resonance); expect(heard).toBeGreaterThan(drawn - 1); expect(heard).toBeLessThan(drawn + 1); } }); it('shapes its volume as nodeseq automates it: a straight attack, then setTargetAtTime', () => { const played = note(110, { attack: 0.01, decay: 0.3, sustain: 0.25, velocity: 0.5 }); // nodeseq's level, with Chrome's sawtooth 0.85 of a plain one, at velocity 0.5. const top = 0.32 * 0.85 * 0.5; const ampAt = (seconds: number) => render({ cutoff: 8000 }, played, seconds).synth.state.amp; expect(ampAt(0.005)).toBeCloseTo(top / 2, 3); // A decay's time constant is a third of it: after the whole decay e^-3 of the way is left. const held = top * 0.25; expect(ampAt(0.01 + 0.3)).toBeCloseTo(held + (top - held) * Math.exp(-3), 3); expect(ampAt(2)).toBeCloseTo(held, 4); }); it('opens the filter in the attack, lets it fall to where the sustain holds it, and closes it in the release', () => { const played = note(110, { attack: 0.005, decay: 0.2, sustain: 0.5, release: 0.2 }); const envAt = (seconds: number, endAt = Infinity) => render({ cutoff: 200, envMod: 0.6 }, played, seconds, { endAt }).synth.state.env; expect(envAt(0.005)).toBeCloseTo(1, 2); expect(envAt(1.5)).toBeCloseTo(0.5 * SUSTAIN_SWEEP, 3); // A release's time constant is a quarter of it. expect(envAt(1.5 + 0.05, 1.5)).toBeCloseTo(0.5 * SUSTAIN_SWEEP * Math.exp(-1), 2); expect(acidTopHz(200, 0.6)).toBeCloseTo(200 * 2 ** 3, 6); expect(acidTopHz(4000, 1)).toBe(18000); // And it is heard: the highs are there at the top of the sweep and gone once it fell. const { out } = render({ cutoff: 200, envMod: 1 }, note(110, { sustain: 0 }), 0.6); expect(energyAt(out, 1100, 0, 1440)).toBeGreaterThan(energyAt(out, 1100, 21600, 24000) * 10); }); it('plays out its release when the note ends and then stops by itself', () => { const { out, synth, stopped } = render({ cutoff: 2000 }, note(110, { release: 0.1 }), 1, { endAt: 0.2 }); expect(synth.state.released).toBe(true); expect(peak(out, 0.2 * RATE, 0.21 * RATE)).toBeGreaterThan(0.05); // As nodeseq stops its oscillator: so many releases and 50 ms after the end. expect(stopped).toBeCloseTo(0.2 + 0.1 * 1.6 + 0.05, 2); expect(peak(out, Math.ceil(stopped * RATE))).toBe(0); }); it('glides from the note before, evenly in octaves, and strikes anew', () => { const gliding = note(220, { from: 110, glide: 0.05 }); const hzAt = (seconds: number, played = gliding) => render({}, played, seconds).synth.state.hz; expect(hzAt(0.025)).toBeCloseTo(110 * Math.SQRT2, 0); expect(hzAt(0.06)).toBeCloseTo(220, 6); expect(hzAt(0.001, note(220))).toBeCloseTo(220, 6); expect(hzAt(0.001, note(220, { from: 110, glide: 0 }))).toBeCloseTo(220, 6); // The sweep starts at the top again: a glide is not a slide that runs on. expect(render({ envMod: 1 }, gliding, 0.003).synth.state.env).toBeCloseTo(1, 1); }); it('falls silent within 60 ms when the next note cuts it short', () => { const { out, synth, stopped } = render({ cutoff: 2000 }, note(110), 0.5, { cutAt: 0.1 }); expect(synth.state.cut).toBe(true); expect(stopped).toBeCloseTo(0.16, 2); expect(peak(out, 0.15 * RATE, 0.16 * RATE)).toBeLessThan(peak(out, 0.09 * RATE, 0.1 * RATE) / 100); }); it('stays finite at full resonance and sweep, high and low', () => { for (const hz of [30, 55, 880, 4000]) { const { out } = render({ cutoff: 18000, resonance: 1, envMod: 1 }, note(hz, { sustain: 0.5 }), 0.5); expect(out.every(Number.isFinite)).toBe(true); expect(peak(out)).toBeLessThan(4); } }); }); 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 with nodeseq's acid by default, as loud as its velocity", () => { const sources = instrumentSourcesOf(makeProject().project); const build = compileInstrument(graph({ wave: 'square', cutoff: 900 }), 60, sources); expect(build.reason).toBe(''); const [layer] = build.layers; const chain = fxChainOf(layer!, { '@velocity': 0.7 }); if ('mix' in chain) throw new Error('one way'); expect(chain.acid).toMatchObject({ id: 'acid', wave: 'square', cutoff: 900, resonance: 0.78, envMod: 0.62, attack: 0.003, decay: 0.16, sustain: 0.15, release: 0.08, glide: 0.05, velocity: 0.7, }); expect(chain.acid!.frequency).toBeCloseTo(noteFrequency(60), 6); // What moves as the note plays: the cutoff, the resonance and the env mod. expect(levelsOf(chain)).toEqual([900, 0.78, 0.62]); }); it('holds its knobs to their rows', () => { const sources = instrumentSourcesOf(makeProject().project); const [layer] = compileInstrument( graph({ cutoff: 99999, resonance: 3, attack: 0, sustain: 2, glide: -1 }), 60, sources, ).layers; const chain = fxChainOf(layer!, {}); if ('mix' in chain) throw new Error('one way'); expect(chain.acid).toMatchObject({ cutoff: ACID_ROWS.cutoff.max, resonance: 1, attack: ACID_ROWS.attack.min, sustain: 1, glide: 0, }); }); });