Crush a sound to fewer bits and a lower rate with a Bitcrusher (#322)
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:
1 parent
af9b202100
commit
165f022fc0
11 files changed
+464
-59
No files matched your search
+66
-2
@@ -628,7 +628,7 @@ export async function sound(t) {
|
||||
const RATE = 48000;
|
||||
const render = async (voices, seconds, channels, shape) => {
|
||||
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
|
||||
await context.audioWorklet.addModule(__gm.sound.pitchWorkletUrl());
|
||||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||||
const tone = new Float32Array(RATE * seconds);
|
||||
for (let i = 0; i < tone.length; i++) tone[i] = Math.sin((2 * Math.PI * 440 * i) / RATE) * 0.5;
|
||||
for (let v = 0; v < voices; v++) {
|
||||
@@ -755,6 +755,70 @@ export async function sound(t) {
|
||||
[echo.late, echo.latePlain].every((late) => late.every((n, k) => n === 0 || n === k * 128)),
|
||||
JSON.stringify(echo),
|
||||
);
|
||||
// #322: the Bitcrusher on the worklet, rendered offline. A 440 Hz sine at 0.8 through 2 bits at
|
||||
// 4 kHz holds every value for 12 samples and leaves only steps of 0.5. 16 bits at 48 kHz
|
||||
// leave the sound as it is, a mix of 0.5 is half of each. Then 64 stereo voices for 2 s,
|
||||
// timed, for how many this machine crushes at once.
|
||||
const crushed = await evaluate(`
|
||||
(async () => {
|
||||
const RATE = 48000;
|
||||
const render = async (voices, seconds, channels, stage) => {
|
||||
const context = new OfflineAudioContext(channels, RATE * seconds, RATE);
|
||||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||||
const tone = context.createBuffer(channels, RATE * seconds, RATE);
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = tone.getChannelData(c);
|
||||
for (let i = 0; i < data.length; i++) data[i] = Math.sin((2 * Math.PI * 440 * i) / RATE) * 0.8;
|
||||
}
|
||||
for (let v = 0; v < voices; v++) {
|
||||
const source = context.createBufferSource();
|
||||
source.buffer = tone;
|
||||
const nodes = __gm.sound.stageNodes(context, { kind: 'crush', ...stage });
|
||||
source.connect(nodes.input);
|
||||
nodes.output.connect(context.destination);
|
||||
source.start();
|
||||
}
|
||||
const started = performance.now();
|
||||
const out = (await context.startRendering()).getChannelData(0);
|
||||
return { out, tone: tone.getChannelData(0), ms: performance.now() - started };
|
||||
};
|
||||
const crush = await render(1, 0.5, 1, { bits: 2, rate: 4000, mix: 1 });
|
||||
let offStep = 0;
|
||||
let unheld = 0;
|
||||
for (let i = 0; i < crush.out.length; i++) {
|
||||
offStep = Math.max(offStep, Math.abs(crush.out[i] * 2 - Math.round(crush.out[i] * 2)));
|
||||
if (crush.out[i] !== crush.out[i - (i % 12)]) unheld++;
|
||||
}
|
||||
const levels = [...new Set(crush.out)].sort((a, b) => a - b);
|
||||
const clean = await render(1, 0.5, 1, { bits: 16, rate: 48000, mix: 1 });
|
||||
const half = await render(1, 0.5, 1, { bits: 1, rate: 48000, mix: 0.5 });
|
||||
let cleanOff = 0;
|
||||
let halfOff = 0;
|
||||
for (let i = 0; i < clean.out.length; i++) {
|
||||
cleanOff = Math.max(cleanOff, Math.abs(clean.out[i] - clean.tone[i]));
|
||||
halfOff = Math.max(halfOff, Math.abs(half.out[i] - (half.tone[i] + Math.round(half.tone[i])) / 2));
|
||||
}
|
||||
const load = await render(64, 2, 2, { bits: 6, rate: 11025, mix: 0.8 });
|
||||
return {
|
||||
levels,
|
||||
offStep,
|
||||
unheld,
|
||||
cleanOff,
|
||||
halfOff,
|
||||
voices: 64,
|
||||
renderMs: Math.round(load.ms),
|
||||
realTimeVoices: Math.round((64 * 2000) / load.ms),
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'the Bitcrusher holds the sound at its rate and rounds it to its bits, and 16 bits at 48 kHz leave it as it is',
|
||||
JSON.stringify(crushed.levels) === JSON.stringify([-1, -0.5, 0, 0.5, 1]) &&
|
||||
crushed.offStep < 1e-6 &&
|
||||
crushed.unheld === 0 &&
|
||||
crushed.cleanOff < 2 ** -15 &&
|
||||
crushed.halfOff < 1e-6,
|
||||
JSON.stringify(crushed),
|
||||
);
|
||||
// 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(`
|
||||
@@ -1031,7 +1095,7 @@ export async function sound(t) {
|
||||
const RATE = 48000;
|
||||
const onset = async (startAt) => {
|
||||
const context = new OfflineAudioContext(1, RATE / 2, RATE);
|
||||
await context.audioWorklet.addModule(__gm.sound.pitchWorkletUrl());
|
||||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||||
const node = new AudioWorkletNode(context, 'pitch-player', {
|
||||
numberOfInputs: 0,
|
||||
outputChannelCount: [1],
|
||||
|
||||
@@ -581,6 +581,8 @@ export async function pattern(t) {
|
||||
project.assets = project.assets.filter((a) => !gone.has(a.id));
|
||||
__gm.state.select(null);`);
|
||||
await showTab('scene');
|
||||
// Only its own editor run: in CI a step before this one leaves one of its own running.
|
||||
const editorRun = await json('__gm.editorRun()');
|
||||
check(
|
||||
'the step leaves nothing of its own behind',
|
||||
(await json(
|
||||
@@ -590,7 +592,7 @@ export async function pattern(t) {
|
||||
left.assets === 0 &&
|
||||
idleLeft.objects === 0 &&
|
||||
idleLeft.assets === 0 &&
|
||||
!(await json('__gm.editorRun().running')),
|
||||
JSON.stringify({ left, idleLeft }),
|
||||
!editorRun.behaviors.some((id) => id.startsWith('pt64')),
|
||||
JSON.stringify({ left, idleLeft, editorRun }),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user