Make 8-bit sounds without a file with a Chip node in instruments (#329)
game-maker / suite (push) Failing after 36s
game-maker / gate (push) Successful in 3m33s

An instrument could only play sound files so far. The Chip makes the
sound of a note itself, the way old game consoles and sfxr did: a raw
wave and what its frequency does over the note. A laser, a jump or a
pickup is now an instrument and ends by itself.

- Chip node with wave (square, triangle, saw, sine, noise and the
  periodic noise of the NES), follow note, frequency, duty, duty
  sweep, slide, delta slide, min frequency, vibrato, arpeggio and
  repeat. It counts in Hz, semitones and seconds, not sfxr's 0 to 1.
- It runs as a third processor in the FX worklet. The math is a plain
  class, ChipPlayer, tested in Node. Every sample is worked out 8
  times and averaged, as sfxr does.
- Frequency and duty are AudioParams, so an Envelope can move them
  and a parameter can tune them while the note plays. A Pitch behind
  a Chip moves its frequency directly, without the phase vocoder.
- Below its min frequency a Chip ends and tells the page. Without the
  worklet it is silent and its note ends at once.
- An Envelope with sustain 0 now ends its note once the decay is over,
  for every source under it, Samples too. Before, a note with
  duration 0 ran on silently until a Stop sound.
- A note made of Chips alone needs no file. playNote and the wait for
  the bytes no longer drop it for having none.
- Inputs that do nothing as things stand are grey in the inspector.
  idleParams is told which inputs are connected, so a connected one
  always counts.

The credit names sfxr (Tomas Pettersson, MIT) and jsfxr, which turned
out to be public domain, not MIT as the plan in #329 said.

Tests:
- Unit chip (new) 12, sound-library +4. All of tests/core,
  tests/viewport, tests/editor/graph and tests/editor/inspector:
  178 files, 2953 green.
- Counter-checks: semitones read as /24 turn 4 chip tests red. Without
  the sustain rule and the file check 4 library tests fail.
- e2e 34 (melektaus-atx): ALL PASSED, 60 checks (2 new). Offline in
  Chrome a square at 220 Hz plus 12 semitones and duty 0.25 gave 329
  periods in 0.75 s, up 0.249. 64 noise voices for 2 s rendered in
  2.0 s, about 63 at once. Live a 0.2 s note with sustain 0 was gone
  after 0.26 s.
- Gate (melektaus-atx): CHECK EXIT 0, 255 files, 3962 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 22:54:44 +02:00
1 parent 165f022fc0
commit f46b2316f2
12 files changed
+870 -23

No files matched your search

+92
View File
@@ -819,6 +819,68 @@ export async function sound(t) {
crushed.halfOff < 1e-6,
JSON.stringify(crushed),
);
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of
// 110 Hz, it ends at 1 s and tells the page. Then 64 voices for 2 s, timed.
const chipped = await evaluate(`
(async () => {
const RATE = 48000;
const shape = { wave: 'square', dutySweep: 0, slide: 0, deltaSlide: 0, minFrequency: 0, vibratoDepth: 0,
vibratoSpeed: 6, arpeggio: 0, arpeggioTime: 0.1, repeat: 0, startAt: 0 };
const render = async (voices, seconds, params, options) => {
const context = new OfflineAudioContext(1, RATE * seconds, RATE);
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
const said = [];
for (let v = 0; v < voices; v++) {
const node = new AudioWorkletNode(context, 'chip-player', {
numberOfInputs: 0,
outputChannelCount: [1],
parameterData: params,
processorOptions: { ...shape, ...options },
});
node.port.onmessage = (event) => said.push(event.data);
node.connect(context.destination);
}
const started = performance.now();
const out = (await context.startRendering()).getChannelData(0);
const ms = performance.now() - started;
await new Promise((resolve) => setTimeout(resolve, 100));
return { out, ms, said };
};
const cycles = (out, from, to) => {
let n = 0;
for (let i = Math.round(from * RATE) + 1; i < Math.round(to * RATE); i++) if (out[i - 1] <= 0 && out[i] > 0) n++;
return n;
};
const tone = await render(1, 1, { frequency: 220, duty: 0.25, pitch: 12 }, { startAt: 0.25 });
const heard = tone.out.subarray(0.25 * RATE);
const firstHeard = tone.out.findIndex((v) => v !== 0);
const slid = await render(1, 1.5, { frequency: 440, duty: 0.5, pitch: 0 }, { slide: -24, minFrequency: 110 });
const lastHeard = slid.out.findLastIndex((v) => v !== 0);
const load = await render(64, 2, { frequency: 330, duty: 0.5, pitch: 0 }, { wave: 'noise', slide: -6, vibratoDepth: 0.5 });
return {
firstHeard,
periods: cycles(tone.out, 0.25, 1),
up: Math.round((heard.filter((v) => v > 0).length / heard.length) * 1000) / 1000,
peak: heard.reduce((top, v) => Math.max(top, Math.abs(v)), 0),
lastHeard: lastHeard / RATE,
said: slid.said,
voices: 64,
renderMs: Math.round(load.ms),
realTimeVoices: Math.round((64 * 2000) / load.ms),
};
})()`);
check(
'the Chip plays its wave at its frequency and pitch from its start, and ends by itself below its lowest frequency',
chipped.firstHeard === 0.25 * 48000 &&
Math.abs(chipped.periods - 330) <= 1 &&
Math.abs(chipped.up - 0.25) < 0.01 &&
Math.abs(chipped.peak - 0.5) < 0.01 &&
Math.abs(chipped.lastHeard - 1) < 0.01 &&
chipped.said.join() === 'ended',
JSON.stringify(chipped),
);
// A Mix "Both at center", rendered offline: a steady 1 on a and 0.5 on b in two channels, so each
// share reads off as it is. Then mix glides 0 → 1 over a second: at its middle both stay full.
const atCenter = await evaluate(`
@@ -1185,6 +1247,36 @@ export async function sound(t) {
JSON.stringify({ last: shaped.stats.last, shapedGone }),
);
// #329: the Sample swapped for a Chip, the Envelope's sustain at 0 and its decay 0.1 s. The note of
// 0.2 s is over once the decay is, on its own – held to its end and through the release of
// 1 s it would take 1.2 s.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
const notes = graph.nodes.find((n) => n.type === 'InstrumentNotes');
graph.nodes.push(${JSON.stringify(node('ins-chip', 'InstrumentChip', 120, 200, { wave: 'saw', slide: -12 }))});
graph.edges.find((e) => e.to.node === 'ins-env' && e.to.port === 'in').from = { node: 'ins-chip', port: 'out' };
graph.edges.push({ id: 'ins-e-chip', from: { node: notes.id, port: 'any' }, to: { node: 'ins-chip', port: 'trigger' } });
Object.assign(graph.nodes.find((n) => n.id === 'ins-env').params, { attack: 0.005, decay: 0.1, sustain: 0 });`);
const beforeChip = await stats();
await evaluate(
`__gm.music.play(${JSON.stringify({ notes: [{ tick: 0, length: 240, note: 60, velocity: 1, instrument: insId }] })}, { tempo: 150 })`,
);
const chipNote = await waitFor(noteNow, (s) => s.stats.started > beforeChip.started);
const chipOut = await trace(noteNow, (s) => s.stats.playing === 0);
await evaluate('__gm.music.stop()');
const chipGone = +(chipOut.at(-1).clock - (chipNote.stats.last?.at ?? 0)).toFixed(3);
// 0.105 s of attack and decay; up to the 150 ms the polls take on top.
check(
'a Chip plays a note without a sound file, and an Envelope with sustain 0 ends it after its decay',
chipNote.stats.worklet === true &&
chipNote.stats.last?.file === `instrument:${insId}` &&
chipNote.stats.last?.envelopes === 1 &&
chipNote.stats.last?.duration === 0 &&
chipOut.at(-1).stats.playing === 0 &&
chipGone < 0.5,
JSON.stringify({ last: chipNote.stats.last, chipGone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-note');