Crush a sound to fewer bits and a lower rate with a Bitcrusher (#322)
game-maker / gate (push) Successful in 3m32s
game-maker / suite (push) Successful in 18m49s

A new effect for sound FX and instruments makes a sound rough like an
old console. It rounds the sound to fewer steps and holds it at a lower
sample rate, and all three of its numbers move while the sound plays.

- Bitcrusher node with bits (1 to 16, not only whole), rate (100 Hz to
  48 kHz, sample-and-hold) and mix. A mix typed in at 0 leaves it out.
- It runs on the audio thread as a second processor next to the pitch
  player. The module is now fxWorklet.ts and the test hook is called
  fxWorkletUrl.
- A play that needs the worklet waits for it, for a Pitch or a
  Bitcrusher alike. Without the worklet the sound goes through as is.
- Bits and mix move in what they hold, the rate in octaves. An
  Envelope can move all three in an instrument.
- A stage that runs on the worklet is stopped with its voice, like the
  drivers of an Envelope or a Mix.
- The math is a plain class, Crusher, so it is tested in Node.

The cleanup check of e2e 64 from af9b202 turned suite 553 red. It
asked that no editor run was left at all. In CI a step before 64 still
leaves one of its own running. The check now asks only about its own
behavior and prints what is still running, so the next suite names it.

Tests:
- Unit crusher (new) 4, soundfx +2, sound-library +3. All of tests/core,
  tests/viewport and tests/editor/graph: 176 files, 2934 green.
- Counter-check: without the rounding 2 of 4 crusher tests fail.
- e2e 34 (melektaus-atx): ALL PASSED, 58 checks (1 new). Offline in
  Chrome 2 bits at 4 kHz leave five levels held for 12 samples each.
  64 stereo voices for 2 s render in 1.9 s, about 68 at once.
- e2e 64 (melektaus-atx): ALL PASSED, 30 checks.
- Gate (melektaus-atx): CHECK EXIT 0, 254 files, 3946 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:22:54 +02:00
1 parent af9b202100
commit 165f022fc0
11 files changed
+464 -59

No files matched your search

+91 -38
View File
@@ -4,6 +4,7 @@ import {
FLAT_Q,
fxChainOf,
type FxChain,
type FxCrush,
type FxDelay,
type FxEnvelope,
type FxMod,
@@ -23,8 +24,8 @@ 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 './pitchWorklet';
import pitchWorklet from './pitchWorklet.ts?worker&url';
import type { PitchOptions } from './fxWorklet';
import fxWorkletUrl from './fxWorklet.ts?worker&url';
import { wake } from './wake';
/** A voice is no louder than a sound item of a song turned all the way up. */
@@ -76,6 +77,12 @@ interface Source {
leave: (() => void) | null;
}
/** Something of a voice that is started with it and stopped when it goes. */
interface Driver {
start(): void;
stop(): void;
}
/** Whether a note's end leaves a source as it is: a one-shot, which plays to the end of its sound. */
const playsThrough = (source: Source) => !source.gate && !source.leave;
@@ -111,10 +118,10 @@ interface Voice {
/** Sources already set to end, by a release or a stop. */
ending: Set<Source>;
/**
* What carries a number of it on the audio thread (a Mix "Both at center", an Envelope): stopped
* when it goes.
* What carries a number of it on the audio thread (a Mix "Both at center", an Envelope) or runs
* there until told otherwise (a Bitcrusher): stopped when it goes.
*/
drivers: ConstantSourceNode[];
drivers: Driver[];
envelopes: EnvelopeRun[];
/** Calls the end of one source in, once however it comes: by itself or by a release. */
finish: ((source: Source) => void) | null;
@@ -157,7 +164,7 @@ interface Wiring {
offset: number;
clip: boolean;
sources: Source[];
drivers: ConstantSourceNode[];
drivers: Driver[];
envelopes: Map<string, EnvelopeRun>;
pitches: number[];
knobs: Knob[];
@@ -231,8 +238,8 @@ export class SoundLibrary implements AudioSink {
*/
private readonly cuts = new Map<string, AudioBuffer>();
/** The pitch worklet being loaded, and whether it is there once it is known; null before that. */
private pitching: Promise<boolean> | null = null;
private canPitch: boolean | null = null;
private workletLoading: Promise<boolean> | null = null;
private hasWorklet: boolean | null = null;
/** The files whose samples the worklet holds already, so the next voice of one is sent without. */
private readonly sent = new Set<string>();
private readonly loading = new Map<string, Promise<AudioBuffer | null>>();
@@ -547,8 +554,8 @@ export class SoundLibrary implements AudioSink {
const id = this.nextId++;
const asked: Start = { ...start, id };
const loaded = start.files.every((file) => this.buffer(file) !== null);
const pitching = start.fx.layers.some(pitches) ? this.pitchWorklet() : null;
if (loaded && (!pitching || this.canPitch !== null)) {
const worklet = start.fx.layers.some(needsWorklet) ? this.fxWorklet() : null;
if (loaded && (!worklet || this.hasWorklet !== null)) {
this.start(asked);
return id;
}
@@ -562,18 +569,16 @@ export class SoundLibrary implements AudioSink {
released: false,
};
this.pending.push(waiting);
void Promise.all([Promise.all(start.files.map((file) => this.load(file))), pitching]).then(
([buffers]) => {
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;
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);
if (waiting.released) this.release(id);
},
);
void Promise.all([Promise.all(start.files.map((file) => this.load(file))), worklet]).then(([buffers]) => {
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;
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);
if (waiting.released) this.release(id);
});
return id;
}
@@ -689,7 +694,7 @@ export class SoundLibrary implements AudioSink {
tunedAt: this.tunedAt,
decoded: this.buffers.size,
failed: this.failed.size,
worklet: this.canPitch,
worklet: this.hasWorklet,
last: this.last,
};
}
@@ -748,13 +753,13 @@ export class SoundLibrary implements AudioSink {
...[...new Set([...layers.flatMap(filesOf), ...files])]
.filter((file) => file !== '')
.map((file) => this.load(file)),
layers.some(pitches) ? this.pitchWorklet() : null,
layers.some(needsWorklet) ? this.fxWorklet() : null,
]);
}
/** Where the pitch worklet is served from, for a check that runs it in an offline context (test hook). */
pitchWorkletUrl(): string {
return pitchWorklet;
/** Where the FX worklet is served from, for a check that runs it in an offline context (test hook). */
fxWorkletUrl(): string {
return fxWorkletUrl;
}
/**
@@ -1076,6 +1081,7 @@ export class SoundLibrary implements AudioSink {
tail.connect(made.input);
tail = made.output;
knobs.push(...made.knobs);
if (made.stop) wiring.drivers.push({ start: () => {}, stop: made.stop });
}
wiring.stages += chain.stages.length;
return tail;
@@ -1121,7 +1127,7 @@ export class SoundLibrary implements AudioSink {
// A file that could not be loaded leaves its way silent rather than the whole note.
const buffer = wiring.buffers.get(file) ?? context.createBuffer(1, 1, context.sampleRate);
wiring.pitches.push(pitch);
if (shifted && this.canPitch) {
if (shifted && this.hasWorklet) {
const options: PitchOptions = {
file,
step: buffer.sampleRate / context.sampleRate,
@@ -1259,15 +1265,15 @@ export class SoundLibrary implements AudioSink {
return this.failed.has(parseCutName(name).file);
}
/** Loads the pitch worklet into the context once; says whether it is there. */
private pitchWorklet(): Promise<boolean> {
return (this.pitching ??= (async () => {
/** Loads the FX worklet (Pitch, Bitcrusher) into the context once; says whether it is there. */
private fxWorklet(): Promise<boolean> {
return (this.workletLoading ??= (async () => {
try {
await this.audio().audioWorklet.addModule(pitchWorklet);
return (this.canPitch = true);
await this.audio().audioWorklet.addModule(fxWorkletUrl);
return (this.hasWorklet = true);
} catch (error: unknown) {
console.error('[sounds] cannot load the pitch worklet, a Pitch is not heard', error);
return (this.canPitch = false);
console.error('[sounds] cannot load the FX worklet, a Pitch or Bitcrusher is not heard', error);
return (this.hasWorklet = false);
}
})());
}
@@ -1314,9 +1320,10 @@ interface LastVoice {
envelopes: number;
}
/** Whether any way of a plan has a Pitch, and so needs the worklet. */
function pitches(plan: FxPlan): boolean {
return 'mix' in plan ? pitches(plan.a) || pitches(plan.b) : plan.pitch.length > 0;
/** Whether a plan has a Pitch or a Bitcrusher 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;
}
/** The files the ways of a plan read with a source of their own (#331). */
@@ -1348,6 +1355,8 @@ interface StageNodes {
input: AudioNode;
output: AudioNode;
knobs: Knob[];
/** Ends a stage that runs on the audio thread until it is told to. */
stop?: () => void;
}
/**
@@ -1392,6 +1401,7 @@ export function stageNodes(context: BaseAudioContext, stage: FxStage, follow?: F
}
if (stage.kind === 'pass') return passNodes(context, stage, follow);
if (stage.kind === 'delay') return delayNodes(context, stage);
if (stage.kind === 'crush') return crushNodes(context, stage, follow);
const filter = context.createBiquadFilter();
filter.type = stage.filter;
filter.frequency.value = stage.frequency;
@@ -1520,6 +1530,48 @@ function delayNodes(context: BaseAudioContext, stage: FxDelay): StageNodes {
};
}
/**
* A Bitcrusher (#322) on the worklet. Bits and mix move in what they hold, the rate in octaves.
* Where the worklet is not there the sound goes through as it is.
*/
function crushNodes(context: BaseAudioContext, stage: FxCrush, follow?: Follow): StageNodes {
let node: AudioWorkletNode;
try {
node = new AudioWorkletNode(context, 'bit-crusher', {
parameterData: {
bits: stage.bitsMod ? 0 : stage.bits,
rate: stage.rateMod ? 0 : stage.rate,
mix: stage.mixMod ? 0 : stage.mix,
},
});
} catch {
const through = context.createGain();
const still = (target: number): Knob => ({ param: null, target, even: 'value' });
return {
input: through,
output: through,
knobs: [still(stage.bits), still(stage.rate), still(stage.mix)],
};
}
const param = (name: string) => node.parameters.get(name)!;
if (stage.bitsMod) follow?.(stage.bitsMod, param('bits'), false);
if (stage.rateMod) follow?.(stage.rateMod, param('rate'), true);
if (stage.mixMod) follow?.(stage.mixMod, param('mix'), false);
return {
input: node,
output: node,
knobs: [
{ param: stage.bitsMod ? null : param('bits'), target: stage.bits, even: 'value' },
{ param: stage.rateMod ? null : param('rate'), target: stage.rate, even: 'ratio' },
{ param: stage.mixMod ? null : param('mix'), target: stage.mix, even: 'value' },
],
stop: () => {
node.port.postMessage('stop');
node.disconnect();
},
};
}
/** The shares of a and b over `mix` at 0, 0.5 and 1: a WaveShaper reads -1…1, so its curve starts below 0. */
const SHARE_A = new Float32Array([1, 1, 1, 1, 0]);
const SHARE_B = new Float32Array([0, 0, 0, 1, 1]);
@@ -1622,6 +1674,7 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
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);
else if (stage.kind === 'crush') into.push(stage.bits, stage.rate, stage.mix);
else if (stage.kind !== 'envelope') into.push(stage.gain);
return into;
}