Files
feuerwerk/tests/editor/crusher-view.test.ts
adminandClaude Opus 5.5 a6c00f7b8b
game-maker / gate (push) Successful in 4m11s
game-maker / suite (push) Failing after 15m21s
Show a Bitcrusher node as a device that draws its pixels (#399)
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>
2026-10-10 22:05:47 +02:00

172 lines
6.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
});
});