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

+87 -10
View File
@@ -4,6 +4,7 @@ import {
FLAT_Q,
fxChainOf,
type FxChain,
type FxChip,
type FxCrush,
type FxDelay,
type FxEnvelope,
@@ -24,7 +25,7 @@ import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
import { DEFAULT_TEMPO } from '../core/music/time';
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
import type { PitchOptions } from './fxWorklet';
import type { ChipOptions, PitchOptions } from './fxWorklet';
import fxWorkletUrl from './fxWorklet.ts?worker&url';
import { wake } from './wake';
@@ -325,7 +326,7 @@ export class SoundLibrary implements AudioSink {
const files = [...new Set(layers.flatMap(filesOf))].filter(
(file) => file !== '' && !this.hasFailed(file),
);
if (files.length === 0) return 0;
if (files.length === 0 && !layers.some(hasChip)) return 0;
const fx = { layers, params, tempo: timing.tempo ?? DEFAULT_TEMPO };
const at = timing.at ?? 0;
const bus = timing.bus ?? '';
@@ -573,7 +574,8 @@ export class SoundLibrary implements AudioSink {
this.pending = this.pending.filter((other) => other !== waiting);
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the
// bytes were in flight has to win, or a sound the run already ended would start afterwards.
if (buffers.every((buffer) => !buffer) || waiting.stopped) return;
const silent = buffers.every((buffer) => !buffer) && !start.fx.layers.some(hasChip);
if (silent || waiting.stopped) return;
if (this.cancelsFor(start.file, start.owner) !== askedAt) return;
this.start({ ...asked, fx: waiting.fx });
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
@@ -1035,6 +1037,10 @@ export class SoundLibrary implements AudioSink {
this.started += 1;
for (const driver of wiring.drivers) driver.start();
for (const source of sources) source.start(when, wiring.offset);
// Silent once its decay is over, however long the note is held (#329).
for (const run of voice.envelopes)
if (run.envelope.sustain <= 0)
this.endAt(voice, run.sources, when + run.envelope.attack + run.envelope.decay);
if (duration > 0) this.release(id, when + duration);
if (clip) this.endAt(voice, sources, clip.end);
}
@@ -1111,9 +1117,9 @@ export class SoundLibrary implements AudioSink {
* its volume, and can fade out by itself when its note ends.
*/
private source(wiring: Wiring, chain: Way): AudioNode {
const sound = this.sound(wiring, chain);
const sound = chain.chip ? this.chip(wiring, chain, chain.chip) : this.sound(wiring, chain);
const own = chain.source;
if (!wiring.rings && !own && !wiring.clip) return sound;
if (!wiring.rings && !own && !chain.chip && !wiring.clip) return sound;
const fade = gainNode(wiring.context, own?.volume ?? 1);
wiring.sources.at(-1)!.fade = fade;
return sound.connect(fade);
@@ -1198,6 +1204,69 @@ export class SoundLibrary implements AudioSink {
return source;
}
/**
* A Chip (#329): a worklet of its own that ends with the note. A Pitch on its way moves its
* frequency itself. Without the worklet the way is silent and ends at once.
*/
private chip(wiring: Wiring, { pitch, shifted, pitchMods }: Way, chip: FxChip): AudioNode {
const { context, knobs } = wiring;
wiring.pitches.push(pitch);
const { frequency, duty, frequencyMod, dutyMod, ...shape } = chip;
if (!this.hasWorklet) {
const silent = context.createBufferSource();
silent.buffer = context.createBuffer(1, 1, context.sampleRate);
wiring.sources.push({
start: (at) => silent.start(at),
stop: () => silent.stop(),
ended: (then) => {
silent.onended = then;
},
fade: null,
gate: true,
leave: null,
});
if (shifted) knobs.push({ param: null, target: pitch, even: 'value' });
knobs.push(
{ param: null, target: frequency, even: 'ratio' },
{ param: null, target: duty, even: 'value' },
);
return silent;
}
const options: ChipOptions = { ...shape, startAt: wiring.at };
const node = new AudioWorkletNode(context, 'chip-player', {
numberOfInputs: 0,
outputChannelCount: [1],
parameterData: { frequency: frequencyMod ? 0 : frequency, duty: dutyMod ? 0 : duty, pitch },
processorOptions: options,
});
wiring.sources.push({
start: () => {},
stop: () => {
node.port.postMessage('stop');
node.disconnect();
},
ended: (then) => {
node.port.onmessage = (event: MessageEvent) => {
if (event.data === 'ended') then();
};
},
fade: null,
gate: true,
leave: null,
});
const param = (name: string) => node.parameters.get(name)!;
if (shifted) knobs.push({ param: param('pitch'), target: pitch, even: 'value' });
knobs.push(
{ param: frequencyMod ? null : param('frequency'), target: frequency, even: 'ratio' },
{ param: dutyMod ? null : param('duty'), target: duty, even: 'value' },
);
const follow = this.follower(wiring);
if (frequencyMod) follow(frequencyMod, param('frequency'), true);
if (dutyMod) follow(dutyMod, param('duty'), false);
for (const mod of pitchMods ?? []) follow(mod, param('pitch'), false);
return node;
}
/**
* Sends every knob of a voice towards what its values say now, in `time` seconds of the audio
* clock along `ease`, or else in a quick glide. Says whether any of them had somewhere to go.
@@ -1265,14 +1334,14 @@ export class SoundLibrary implements AudioSink {
return this.failed.has(parseCutName(name).file);
}
/** Loads the FX worklet (Pitch, Bitcrusher) into the context once; says whether it is there. */
/** Loads the FX worklet (Pitch, Bitcrusher, Chip) into the context once; says whether it is there. */
private fxWorklet(): Promise<boolean> {
return (this.workletLoading ??= (async () => {
try {
await this.audio().audioWorklet.addModule(fxWorkletUrl);
return (this.hasWorklet = true);
} catch (error: unknown) {
console.error('[sounds] cannot load the FX worklet, a Pitch or Bitcrusher is not heard', error);
console.error('[sounds] cannot load the FX worklet, a Pitch, Bitcrusher or Chip is not heard', error);
return (this.hasWorklet = false);
}
})());
@@ -1320,10 +1389,15 @@ interface LastVoice {
envelopes: number;
}
/** Whether a plan has a Pitch or a Bitcrusher anywhere, and so needs the worklet. */
/** Whether a plan has a Pitch, a Bitcrusher or a Chip anywhere, and so needs the worklet. */
function needsWorklet(plan: FxPlan): boolean {
if (plan.stages.some((stage) => stage.kind === 'crush')) return true;
return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0;
return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0 || !!plan.chip;
}
/** Whether a way of a plan plays a Chip (#329), which needs no file. */
function hasChip(plan: FxPlan): boolean {
return 'mix' in plan ? hasChip(plan.a) || hasChip(plan.b) : plan.chip !== undefined;
}
/** The files the ways of a plan read with a source of their own (#331). */
@@ -1670,7 +1744,10 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
into.push(1 - chain.mix);
levelsOf(chain.b, into);
into.push(chain.mix);
} else if (chain.shifted) into.push(chain.pitch);
} else {
if (chain.shifted) into.push(chain.pitch);
if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty);
}
for (const stage of chain.stages)
if (stage.kind === 'delay') into.push(stage.time, stage.feedback, stage.damping, stage.mix);
else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q);