A Bitcrusher only said what it does in rows of numbers. How coarse its steps and holds are could not be seen. Now the node is a device of its own size. Its screen sends a sine through the node's own crush and draws what comes out in cells, as tall as a step and as wide as a hold, so the coarser the crush, the coarser the picture. - CrusherPanel draws a 680x280 plate in bubblegum pink. Bits and rate are large knobs, wet is small. Rate gives its low end more of the turn, 8 kHz sits just past the middle. - crushTrace runs a 440 Hz sine at 0 dBFS through Crusher at 48 kHz and blends it as the worklet does. Where each hold starts comes from Crusher too: a ramp through it at 16 bits takes a new value per hold. - cellOf shapes a hold. From 5 px a step or hold gets a grid line and a gap. Finer steps give a 2 px bar. A cell is never more than twice as tall as wide, so 1 bit gives bars at -1, 0 and 1. - The screen shows two periods, or 6 holds where the rate is low. The sine going in is a grey ghost. Below wet 1 the heard line lies over the cells. - Above the screen a word for how crushed it is, CLEAN to BROKEN. Under it the noise the bits leave and where the rate folds back. - 16 lamps show the bits kept, the highest on the left. A CRUSH lamp beside the name glows the more it crushes. - COLUMNS_OF gives the Bitcrusher 4 columns in the presets. - Hook __gm.bitcrusher.panel(id) for the sound FX and instrument graphs. Decided myself: - The screen is still. Nothing on it moves, so tick() says false. - How crushed counts whichever of bits or rate takes more, the rate in octaves, times wet. Found: at 8 kHz the first hold is 7 samples long, the rest 6. Six sixths add up to just under 1 in Crusher. The worklet does the same, so the screen shows what is heard. Every sfxr effect preset ends in a Bitcrusher and gets 3 columns wider. The Laser preset still fits whole on Fit. Tests: - Unit: crusher-view 13 tests (new). The screen matches Crusher with its wet on three settings. - Counter-test: without the wet in the blend the worklet test fails. - e2e 34: ALL PASSED, 143 checks (1 new). - Gate: CHECK EXIT 0, 292 files, 4484 tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
6.9 KiB
TypeScript
172 lines
6.9 KiB
TypeScript
/**
|
||
* A Bitcrusher's node as a device (#399): every part on the plate and none over another, the bits
|
||
* it keeps, and the screen – what a sine comes out as, worked out by the node's own crush, so
|
||
* the steps and holds drawn are the ones heard.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import { Crusher, crushSteps } from '../../src/core/soundfx/crusher';
|
||
import { CRUSH_ROWS } from '../../src/core/soundfx/nodes';
|
||
import {
|
||
bitLampsLit,
|
||
cellOf,
|
||
CRUSH_KEYS,
|
||
CRUSH_KNOBS,
|
||
type CrushValues,
|
||
CRUSHER_LAYOUT,
|
||
CRUSHER_PANEL,
|
||
crushOf,
|
||
crushTexts,
|
||
crushTrace,
|
||
crushValues,
|
||
crushWord,
|
||
idleCrushKeys,
|
||
LEAST_HOLDS,
|
||
SCREEN_RATE,
|
||
screenSpan,
|
||
} from '../../src/editor/soundfx/crusherView';
|
||
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
|
||
import { knobPosition } from '../../src/ui/knob';
|
||
|
||
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||
const inside = (a: Box) =>
|
||
a.x >= 0 && a.y >= 0 && a.x + a.w <= CRUSHER_PANEL.w && a.y + a.h <= CRUSHER_PANEL.h;
|
||
|
||
const values = (over: Partial<CrushValues> = {}): CrushValues => ({ ...crushValues({}), ...over });
|
||
|
||
describe('the Bitcrusher plate', () => {
|
||
const L = CRUSHER_LAYOUT;
|
||
const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 }));
|
||
const lamp = { x: L.lamp.x - L.lamp.r, y: L.lamp.y - L.lamp.r, w: 2 * L.lamp.r, h: 2 * L.lamp.r };
|
||
const lamps = L.bitLamps.reduce((all, b) => ({ ...all, w: b.x + b.w - all.x }), { ...L.bitLamps[0]! });
|
||
|
||
it('keeps the screen, the lamps and the knobs on the plate, clear of each other and the screws', () => {
|
||
const parts: Box[] = [L.bezel, lamps, ...CRUSH_KEYS.map((key) => knobBox(L.knobs[key])), lamp, L.title];
|
||
for (const part of parts) {
|
||
expect(inside(part)).toBe(true);
|
||
for (const screw of screws) expect(overlap(part, screw)).toBe(false);
|
||
}
|
||
for (let i = 0; i < parts.length; i++)
|
||
for (let j = i + 1; j < parts.length; j++) expect(overlap(parts[i]!, parts[j]!)).toBe(false);
|
||
});
|
||
|
||
it('has a lamp for every bit, none over another', () => {
|
||
expect(L.bitLamps).toHaveLength(CRUSH_ROWS.bits.max!);
|
||
for (let i = 1; i < L.bitLamps.length; i++)
|
||
expect(L.bitLamps[i]!.x).toBeGreaterThan(L.bitLamps[i - 1]!.x + L.bitLamps[i - 1]!.w);
|
||
});
|
||
|
||
it('lights the bits kept, the highest first, part of one for a fraction', () => {
|
||
expect(bitLampsLit(16).every((share) => share === 1)).toBe(true);
|
||
expect(bitLampsLit(8)).toEqual([...Array<number>(8).fill(1), ...Array<number>(8).fill(0)]);
|
||
expect(bitLampsLit(3.5).slice(0, 5)).toEqual([1, 1, 1, 0.5, 0]);
|
||
expect(bitLampsLit(1).filter((share) => share > 0)).toEqual([1]);
|
||
});
|
||
});
|
||
|
||
describe('the Bitcrusher knobs', () => {
|
||
const position = (key: 'bits' | 'rate', value: number) => {
|
||
const row = CRUSH_ROWS[key];
|
||
return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...CRUSH_KNOBS[key] });
|
||
};
|
||
|
||
it('turns the bits evenly and puts 8 kHz just past the middle of the rate', () => {
|
||
expect(position('bits', 8.5)).toBeCloseTo(0.5);
|
||
expect(position('rate', 8000)).toBeGreaterThan(0.5);
|
||
expect(position('rate', 8000)).toBeLessThan(0.6);
|
||
});
|
||
|
||
it('names the bits and the rate as they are heard', () => {
|
||
expect(CRUSH_KNOBS.bits.format(8)).toBe('8 bit');
|
||
expect(CRUSH_KNOBS.bits.format(3.5)).toBe('3.5 bit');
|
||
expect(CRUSH_KNOBS.rate.format(8000)).toBe('8 kHz');
|
||
expect(CRUSH_KNOBS.rate.format(300)).toBe('300 Hz');
|
||
});
|
||
|
||
it('greys the bits and the rate at wet 0, nothing else', () => {
|
||
expect([...idleCrushKeys(values({ mix: 0 }))].sort()).toEqual(['bits', 'rate']);
|
||
expect(idleCrushKeys(values()).size).toBe(0);
|
||
});
|
||
});
|
||
|
||
describe('the Bitcrusher screen', () => {
|
||
it('comes out as the node’s own worklet makes it, the wet blended in', () => {
|
||
for (const over of [{}, { bits: 3, rate: 4000, mix: 0.4 }, { bits: 1.5, rate: 300 }]) {
|
||
const v = values(over);
|
||
const trace = crushTrace(v);
|
||
const heard = new Float32Array(trace.dry.length);
|
||
new Crusher(SCREEN_RATE).render(
|
||
[trace.dry],
|
||
[heard],
|
||
Float32Array.of(v.bits),
|
||
Float32Array.of(v.rate),
|
||
Float32Array.of(v.mix),
|
||
);
|
||
trace.out.forEach((x, i) => expect(x).toBeCloseTo(heard[i]!, 6));
|
||
}
|
||
});
|
||
|
||
it('cuts the sine into the holds of the rate, each one level the bits round it to', () => {
|
||
const v = values({ bits: 4, rate: 8000 });
|
||
const trace = crushTrace(v);
|
||
const steps = crushSteps(4);
|
||
// 8 kHz at 48 kHz: a hold every 6 samples. The first is one longer, as in the worklet: six
|
||
// sixths add up to just under 1 there.
|
||
const widths = trace.holds.map((h) => h.to - h.from);
|
||
expect(widths[0]).toBe(7);
|
||
expect(widths.slice(1, -1).every((w) => w === 6)).toBe(true);
|
||
for (const hold of trace.holds) {
|
||
expect(hold.level).toBeCloseTo(Math.round(trace.dry[hold.from]! * steps) / steps, 6);
|
||
for (let i = hold.from; i < hold.to; i++) expect(trace.crushed[i]).toBe(hold.level);
|
||
}
|
||
expect(trace.step).toBe(1 / steps);
|
||
});
|
||
|
||
it('holds nothing at the rate of the context, and shows enough holds where the rate is low', () => {
|
||
const fine = crushTrace(values({ rate: 48000 }));
|
||
expect(fine.holds).toHaveLength(fine.dry.length);
|
||
expect(screenSpan(8000)).toBeCloseTo(2 / 440);
|
||
expect(screenSpan(300)).toBeCloseTo(LEAST_HOLDS / 300);
|
||
expect(crushTrace(values({ rate: 300 })).holds).toHaveLength(LEAST_HOLDS);
|
||
});
|
||
|
||
it('draws a hold as a cell where its pixels allow, as a bar where the steps are fine, never a wall', () => {
|
||
expect(cellOf(15, 16)).toEqual({ gap: 1, h: 14 });
|
||
expect(cellOf(9, 0.6)).toEqual({ gap: 0, h: 2 });
|
||
// One bit's levels are a screen apart: a bar on each.
|
||
expect(cellOf(3.7, 64)).toEqual({ gap: 0, h: 7.4 });
|
||
expect(cellOf(45, 64)).toEqual({ gap: 1, h: 62 });
|
||
});
|
||
|
||
it('says the noise the bits leave and where the rate folds back, nothing of it when dry', () => {
|
||
const v = values();
|
||
expect(crushTexts(v, crushTrace(v))).toEqual({
|
||
noise: 'NOISE -50 dB',
|
||
fold: 'FOLDS AT 4 kHz',
|
||
span: '4.5 ms',
|
||
});
|
||
const dry = values({ mix: 0 });
|
||
expect(crushTexts(dry, crushTrace(dry))).toMatchObject({ noise: 'DRY', fold: '' });
|
||
});
|
||
});
|
||
|
||
describe('how crushed a Bitcrusher is', () => {
|
||
it('takes whichever of the bits and the rate crushes more, as far as the wet is heard', () => {
|
||
expect(crushOf(values({ bits: 16, rate: 48000 }))).toBe(0);
|
||
expect(crushOf(values({ bits: 1 }))).toBe(1);
|
||
expect(crushOf(values({ bits: 16, rate: 100 }))).toBeCloseTo(1);
|
||
expect(crushOf(values({ bits: 1, mix: 0.5 }))).toBeCloseTo(0.5);
|
||
expect(crushOf(values({ mix: 0 }))).toBe(0);
|
||
});
|
||
|
||
it('names it from clean to broken', () => {
|
||
expect([0, 0.2, 0.5, 0.7, 0.9].map(crushWord)).toEqual([
|
||
'CLEAN',
|
||
'GRITTY',
|
||
'CRUNCHY',
|
||
'CRUSHED',
|
||
'BROKEN',
|
||
]);
|
||
expect(crushWord(crushOf(values()))).toBe('CRUNCHY');
|
||
});
|
||
});
|