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

+2
View File
@@ -320,6 +320,8 @@ function bindStage(stage: FxStagePlan, bind: (amount: FxAmount) => FxAmount): Fx
if (stage.kind === 'pass')
return { ...stage, cutoff: bind(stage.cutoff), resonance: bind(stage.resonance) };
if (stage.kind === 'envelope') return stage;
if (stage.kind === 'crush')
return { ...stage, bits: bind(stage.bits), rate: bind(stage.rate), mix: bind(stage.mix) };
return {
...stage,
time: bind(stage.time),
+55
View File
@@ -0,0 +1,55 @@
/**
* The Bitcrusher (#322) sample by sample, as its worklet runs it: the sound is held for a while
* ("rate", sample-and-hold) and rounded to fewer steps ("bits"), then blended with what came in
* ("mix"). Bits need not be whole, so a ramp on them never jumps. Each number may be one value for
* the whole block or one per sample, as an AudioParam hands it over.
*/
export class Crusher {
/** Where the next sample is taken, in samples of the rate; at 1 or past it one is taken now. */
private phase = 1;
private held: number[] = [];
constructor(private readonly sampleRate: number) {}
render(
input: readonly Float32Array[],
output: readonly Float32Array[],
bits: Float32Array,
rate: Float32Array,
mix: Float32Array,
): void {
// Nothing coming in: what is held would stay on as a click of DC.
if (input.length === 0) {
this.held = [];
this.phase = 1;
for (const channel of output) channel.fill(0);
return;
}
const length = output[0]?.length ?? 0;
for (let i = 0; i < length; i++) {
const take = this.phase >= 1;
if (take) this.phase -= Math.floor(this.phase);
this.phase += crushRate(at(rate, i), this.sampleRate) / this.sampleRate;
const steps = crushSteps(at(bits, i));
const wet = Math.max(0, Math.min(1, at(mix, i)));
output.forEach((channel, c) => {
const dry = input[c]?.[i] ?? input[0]?.[i] ?? 0;
if (take || this.held[c] === undefined) this.held[c] = dry;
const crushed = Math.round(this.held[c] * steps) / steps;
channel[i] = dry + (crushed - dry) * wet;
});
}
}
}
/** Steps between 0 and full scale at that many bits: 2^(bits-1), so 1 bit leaves -1, 0 and 1. */
export function crushSteps(bits: number): number {
return 2 ** (Math.max(1, Math.min(16, Number.isFinite(bits) ? bits : 16)) - 1);
}
/** How often a sample is taken, never more often than the context makes them. */
function crushRate(rate: number, sampleRate: number): number {
return Math.max(1, Math.min(sampleRate, Number.isFinite(rate) ? rate : sampleRate));
}
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
+53 -2
View File
@@ -23,7 +23,19 @@ export type FxStage =
| { kind: 'filter'; filter: FxFilter; frequency: number; gain: number; q: number; mod?: FxMod }
| FxPass
| { kind: 'envelope'; envelope: FxEnvelope }
| FxDelay;
| FxDelay
| FxCrush;
/** A Bitcrusher (#322): bits, the rate in Hz it holds the sound at, and how much of it is heard. */
export interface FxCrush {
kind: 'crush';
bits: number;
rate: number;
mix: number;
bitsMod?: FxMod;
rateMod?: FxMod;
mixMod?: FxMod;
}
/** A filter as Web Audio takes it: `q` in dB for a low- or high-pass, as a factor for a band-pass. */
export interface FxPass {
@@ -144,7 +156,8 @@ export type FxStagePlan =
damping: FxAmount;
mix: FxAmount;
pingPong: boolean;
};
}
| { kind: 'crush'; bits: FxAmount; rate: FxAmount; mix: FxAmount };
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
export type FxPlan =
@@ -477,6 +490,28 @@ export const SoundFxDelay: SoundFxNodeDef = {
},
};
const CRUSH_BITS = { ...numberRow('bits', 'Bits', 1, 16, 0.1), default: 8 };
const CRUSH_RATE = { ...numberRow('rate', 'Rate (Hz)', 100, 48000, 1), default: 8000 };
const CRUSH_MIX = { ...numberRow('mix', 'Mix', 0, 1, 0.01), default: 1 };
export const SoundFxBitcrusher: SoundFxNodeDef = {
type: 'SoundFxBitcrusher',
label: 'Bitcrusher',
category: 'sound',
description:
'Makes the sound rough, like an old game console. "bits" rounds it to fewer steps: 16 is hardly heard, 8 crackles, 4 and below hisses and buzzes. Bits need not be whole, 3.5 lies between 3 and 4. "rate" takes a sample only so many times a second and holds it until the next: 48000 leaves the sound as it is, 8000 is dull and metallic, a few hundred Hz is a buzz of its own. "mix" is how much of the crushed sound is heard instead of the sound itself. All three can change while the sound plays, and in an instrument an Envelope can move them.',
inputs: [audioIn, ...[CRUSH_BITS, CRUSH_RATE, CRUSH_MIX].map(followingIn)],
outputs: [audioOut],
params: [],
emit: (sounds, amounts) => {
const mix = held(amounts.mix, CRUSH_MIX);
if (isNone(mix)) return arriving(sounds);
return withStages(arriving(sounds), [
{ kind: 'crush', bits: held(amounts.bits, CRUSH_BITS), rate: held(amounts.rate, CRUSH_RATE), mix },
]);
},
};
/** Where damping 0 leaves the echoes and damping 1 takes them: evenly in octaves between. */
const UNDAMPED_HZ = 20000;
const DAMPED_HZ = 500;
@@ -494,6 +529,7 @@ export const SOUND_FX_NODES: readonly SoundFxNodeDef[] = [
SoundFxFilter,
SoundFxMix,
SoundFxDelay,
SoundFxBitcrusher,
];
/** An amount with the value handed in for its parameter, held to its row; a 0 there does nothing. */
@@ -570,6 +606,21 @@ function stagesOf(plan: FxPlan, params: FxParams, tempo: number): FxStage[] {
});
continue;
}
if (stage.kind === 'crush') {
const bitsMod = modOf(stage.bits, params);
const rateMod = modOf(stage.rate, params);
const mixMod = modOf(stage.mix, params);
stages.push({
kind: 'crush',
bits: amountOf(stage.bits, params),
rate: amountOf(stage.rate, params),
mix: amountOf(stage.mix, params),
...(bitsMod && { bitsMod }),
...(rateMod && { rateMod }),
...(mixMod && { mixMod }),
});
continue;
}
const gain = amountOf(stage.gain, params);
const mod = modOf(stage.gain, params);
if (gain !== 0 || stage.gain.param !== undefined || mod)