Add an Acid instrument node for TB-303 style bass lines (#421)

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-13 11:08:57 +02:00
1 parent 3443368855
commit d844f2734a
18 files changed
+1615 -15

No files matched your search

+89
View File
@@ -2920,6 +2920,95 @@ export async function sound(t) {
JSON.stringify({ envShown, envTurned, envScrubbed, envUndone, laser: laser.envelope }),
);
// #421: Preset → Acid Bass makes Notes → Acid → Delay → Output, the Acid drawn as a device between
// its ports: a button per wave and a knob per number, each change one undo step, its screen
// drawn from it. The worklet hands the synth to a note that slides in; of three notes the
// second, struck where the first ends, slides, the third after a gap does not. An undo
// brings the Laser back.
await clickHook('presetAt');
await clickText('Acid Bass');
await sleep(300);
await clickText('Fit');
await sleep(300);
const acidGraph = () =>
json(`(() => {
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
const acid = graph.nodes.find((n) => n.type === 'InstrumentAcid');
return { types: graph.nodes.map((n) => n.type), id: acid?.id ?? null, params: acid?.params ?? null };
})()`);
const acidBuilt = await acidGraph();
const acidPanel = () => json(`__gm.acid.panel('${acidBuilt.id}')`);
const acidShown = await acidPanel();
const acidRect = acidShown?.rect ?? { x: 0, y: 0, w: 0, h: 0 };
const acidBeside = (p) => p.y > acidRect.y && p.y < acidRect.y + acidRect.h;
await click(acidShown.waves.square.x, acidShown.waves.square.y);
const acidSquared = { params: (await acidGraph()).params, panel: await acidPanel() };
const cutoffDial = acidShown.knobs.cutoff.dial;
await drag(cutoffDial.x, cutoffDial.y, cutoffDial.x, cutoffDial.y - 20);
const acidTurned = { params: (await acidGraph()).params, panel: await acidPanel() };
for (let i = 0; i < 2; i++) await press('z', { ctrl: true });
const acidUndone = (await acidGraph()).params;
check(
'Preset → Acid Bass makes an Acid shown as its device; a wave button and a knob each change it as one undo step, and its screen follows',
acidBuilt.types.join() === 'InstrumentNotes,InstrumentAcid,SoundFxDelay,InstrumentOutput' &&
acidShown.shown &&
acidShown.size.w === 520 &&
acidShown.size.h === 280 &&
Object.values(acidShown.inputs).length === 8 &&
Object.values(acidShown.inputs).every((p) => p.x < acidRect.x && acidBeside(p)) &&
Object.values(acidShown.outputs).every((p) => p.x > acidRect.x + acidRect.w && acidBeside(p)) &&
acidShown.wave === 'saw' &&
acidShown.knobs.cutoff.value === 300 &&
acidShown.screen.rest === 'REST 300 Hz' &&
acidShown.trace.peakDb > 6 &&
acidSquared.params.wave === 'square' &&
acidSquared.panel.wave === 'square' &&
acidTurned.params.cutoff > 300 &&
acidTurned.panel.screen.rest !== acidShown.screen.rest &&
acidUndone.wave === 'saw' &&
acidUndone.cutoff === 300,
JSON.stringify({ acidBuilt, acidShown, acidSquared, acidTurned, acidUndone }),
);
const acidRender = JSON.parse(await evaluate('__gm.acid.render(0.1, 0.3).then(JSON.stringify)'));
check(
'the worklet hands the synth of an Acid to the note that slides in, from its first sample on and without a jump',
acidRender.a.first >= 0 &&
acidRender.a.first < 4 &&
acidRender.a.last < acidRender.handover &&
acidRender.b.first === acidRender.handover &&
acidRender.b.last === 0.3 * 48000 - 1 &&
acidRender.jump > 0 &&
acidRender.jump <= acidRender.step,
JSON.stringify(acidRender),
);
await evaluate('__gm.sound.stopAll()');
const beforeLine = await stats();
await evaluate(
`__gm.music.play(${JSON.stringify({
notes: [
{ tick: 0, length: 480, note: 48, velocity: 0.8, instrument: insId },
{ tick: 480, length: 480, note: 55, velocity: 0.8, instrument: insId },
{ tick: 1440, length: 480, note: 48, velocity: 1, instrument: insId },
],
})}, { tempo: 120 })`,
);
const acidLine = await trace(stats, (s) => s.started >= beforeLine.started + 3 && s.playing === 0);
await evaluate('__gm.music.stop()');
const slidOf = (n) => acidLine.find((s) => s.started === beforeLine.started + n)?.last?.slid ?? null;
await press('z', { ctrl: true });
const acidGone = await acidGraph();
check(
'of three Acid notes the one struck where the last ends slides, the one after a gap does not; an undo brings the Laser back',
slidOf(1) === 0 && slidOf(2) === 1 && slidOf(3) === 0 && acidGone.types.join() === laser.types.join(),
JSON.stringify({
slid: [1, 2, 3].map(slidOf),
started: acidLine.map((s) => s.started),
acidGone: acidGone.types,
}),
);
const beforeRandom = await stats();
await clickHook('randomizeAt');
const randomized = await waitFor(stats, (s) => s.started > beforeRandom.started);