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

+77 -9
View File
@@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { FxEnvelopePlan, FxPlan, FxStage, FxStagePlan, FxVoice } from '../../src/core/soundfx/nodes';
import type { SequencePlay } from '../../src/core/music/playbacks';
import type { ScheduledNote } from '../../src/core/music/Scheduler';
import type { PitchOptions } from '../../src/viewport/pitchWorklet';
import type { PitchOptions } from '../../src/viewport/fxWorklet';
import { SoundLibrary } from '../../src/viewport/SoundLibrary';
/** What a test wants to hear: a chain whose pitch, where it is not 0, is a Pitch typed in. */
@@ -71,9 +71,16 @@ interface FakeSource {
stop: () => void;
}
/** The pitch worklet of a way (#317): what it was made with, its pitch and what was posted to it. */
/**
* The pitch worklet of a way (#317) or a Bitcrusher (#322): what it was made with, its pitch and
* what was posted to it.
*/
interface FakeWorklet {
options: { outputChannelCount: number[]; parameterData: { pitch: number }; processorOptions: PitchOptions };
options: {
outputChannelCount: number[];
parameterData: Record<string, number>;
processorOptions: PitchOptions;
};
pitch: FakeParam;
parameters: Map<string, FakeParam>;
posted: unknown[];
@@ -87,6 +94,8 @@ const worklets: FakeWorklet[] = [];
/** Every module loaded into a context, and what the next load does. */
const modules: string[] = [];
let loadModule: () => Promise<void> = () => Promise.resolve();
/** Whether a module has loaded: before that a worklet node cannot be made, as in a browser. */
let moduleLoaded = false;
const sources: FakeSource[] = [];
/** Every `connect` made, as "from > to" by the kind of node – the order a voice is wired in. */
@@ -173,7 +182,9 @@ function fakeContext() {
audioWorklet: {
addModule: (url: string) => {
modules.push(url);
return loadModule();
return loadModule().then(() => {
moduleLoaded = true;
});
},
},
destination: { kind: 'destination' },
@@ -265,6 +276,7 @@ beforeEach(() => {
worklets.length = 0;
modules.length = 0;
loadModule = () => Promise.resolve();
moduleLoaded = false;
wires.length = 0;
filters.length = 0;
gains.length = 0;
@@ -286,12 +298,15 @@ beforeEach(() => {
vi.stubGlobal(
'AudioWorkletNode',
class {
constructor(_context: unknown, _name: string, options: FakeWorklet['options']) {
const pitch = param(options.parameterData.pitch);
const node: FakeWorklet = wired('pitch', {
constructor(_context: unknown, name: string, options: FakeWorklet['options']) {
if (!moduleLoaded) throw new DOMException('no such processor', 'InvalidStateError');
const parameters = new Map(
Object.entries(options.parameterData).map(([key, value]) => [key, param(value)] as const),
);
const node: FakeWorklet = wired(name === 'bit-crusher' ? 'crush' : 'pitch', {
options,
pitch,
parameters: new Map([['pitch', pitch]]),
pitch: parameters.get('pitch') ?? param(0),
parameters,
posted: [] as unknown[],
port: {
postMessage: (message: unknown) => node.posted.push(message),
@@ -321,6 +336,13 @@ function planOf(chain: Chain): FxPlan {
const stages = chain.stages.map((stage): FxStagePlan => {
if (stage.kind === 'gain') return { kind: 'gain', db: { value: 20 * Math.log10(stage.gain) } };
if (stage.kind === 'filter') return { ...stage, gain: { value: stage.gain } };
if (stage.kind === 'crush')
return {
kind: 'crush',
bits: { value: stage.bits },
rate: { value: stage.rate },
mix: { value: stage.mix },
};
if (stage.kind !== 'delay') throw new Error(`no plan for a ${stage.kind} here`);
return {
...stage,
@@ -495,6 +517,52 @@ describe('the sound library', () => {
expect(library.stats().last).toMatchObject({ pitches: [7], duration: 1.5 });
});
it('sends a voice through a Bitcrusher on the worklet, which it loads first, and stops it with the voice (#322)', async () => {
const library = new SoundLibrary();
const crush: Chain = { pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 0.5 }] };
const voice = await play(library, FILE, {}, crush);
expect(modules).toHaveLength(1);
expect(wires).toEqual(['source > crush', 'crush > gain', 'gain > destination']);
const node = worklets[0]!;
expect(node.options.parameterData).toEqual({ bits: 4, rate: 8000, mix: 0.5 });
expect(library.levels(FILE)).toEqual([[4, 8000, 0.5]]);
library.stopVoice(voice);
clockTo();
expect(node.posted).toEqual(['stop']);
expect(node.disconnected).toBe(true);
});
it('moves the numbers of a Bitcrusher while it plays, its rate in octaves (#322)', async () => {
const library = new SoundLibrary();
const plan: FxPlan = {
pitch: [],
stages: [
{
kind: 'crush',
bits: { value: 8, param: 'b' },
rate: { value: 8000, param: 'r' },
mix: { value: 1 },
},
],
};
const voice = await play(library, FILE, {}, { plan, params: {} });
expect(library.tune(voice, { b: 3, r: 2000 }, 0.5)).toBe(true);
const node = worklets[0]!;
expect(node.parameters.get('bits')!.ramps).toEqual([['linear', 3, 0.5]]);
expect(node.parameters.get('rate')!.ramps).toEqual([['exponential', 2000, 0.5]]);
expect(node.parameters.get('mix')!.ramps).toEqual([]);
});
it('without the worklet a Bitcrusher lets the sound through as it is (#322)', async () => {
loadModule = () => Promise.reject(new Error('no worklets here'));
vi.spyOn(console, 'error').mockImplementation(() => {});
const library = new SoundLibrary();
await play(library, FILE, {}, { pitch: 0, stages: [{ kind: 'crush', bits: 4, rate: 8000, mix: 1 }] });
expect(worklets).toHaveLength(0);
expect(wires).toEqual(['source > gain', 'gain > gain', 'gain > destination']);
expect(library.stats()).toMatchObject({ started: 1, worklet: false });
});
it('a play that needs the worklet waits for it to load, as it would for its bytes', async () => {
let loaded = () => {};
loadModule = () => new Promise((resolve) => (loaded = resolve));