The instruments could only come near a 303 with a Chip, a Filter and two envelopes. The biquad filter does not saturate, every note strikes anew, and there was no slide and no accent. - Acid is a new instrument node that plays a saw or a square through a four-pole ladder low-pass with tanh saturation, oversampled twice. Knobs: cutoff, resonance, env mod, decay, accent, tuning and slide time. - An accent comes from the velocity, from 0.9 on. It is louder, opens the filter further with a shorter decay, and accents close behind each other build up. - Two notes that touch or overlap slide. Each note stays a voice of its own, so the scheduler is unchanged. The notes of a line share one synth in the worklet, and the new voice takes it over from its first sample on. - The node sets its own volume, so it needs no envelope behind it. - The graph shows it as a device with the filter curve at rest and at the top of the sweep. - A preset Acid Bass puts an Acid in front of a short echo. Tests: - tsc, eslint, Prettier clean - Unit: 14 new tests in acid.test.ts for pitch, low-pass, resonance, sweep, accent, slide and the handover between two voices. - e2e 34 on melektaus-atx: ALL PASSED, 148 checks (3 new). The handover sits on sample 4800 and jumps by 0.0012, of three notes only the second slides. - Without the handover, or with a slide that strikes anew, the unit tests are red. - Gate on melektaus-atx: tsc, eslint, Prettier clean, 4753 of 4754 tests green. The red one was sound-library.test.ts, which did not know the new slid count in the stats. Fixed, and the file alone is green again with 128 tests. No second full gate. - Not checked: the sound by ear, slide at the keyboard, a bake with Acid, the CPU load of many notes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
276 lines
11 KiB
TypeScript
276 lines
11 KiB
TypeScript
/**
|
|
* 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<Record<keyof AcidKnobs, number>> = {}): 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> = {}): 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<Record<keyof AcidKnobs, number>>,
|
|
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 });
|
|
});
|
|
});
|