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:
1 parent
3443368855
commit
d844f2734a
18 files changed
+1615
-15
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user