Show a Bitcrusher node as a device that draws its pixels (#399)
game-maker / gate (push) Successful in 4m11s
game-maker / suite (push) Failing after 15m21s

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>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 22:05:47 +02:00
1 parent b145e1f288
commit a6c00f7b8b
9 files changed
+942 -3

No files matched your search

+1 -1
View File
@@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
The browser suite and the three example games need a machine with a GPU:
```bash
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1974 checks (GM_STEPS=20 for one step)
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1975 checks (GM_STEPS=20 for one step)
# every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+2 -1
View File
@@ -396,10 +396,11 @@ const ROW = 340;
/**
* Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide,
* a Chip's device (#386) wider, an Envelope's (#388) a little less, a Pitch's and a Mix's (#390) two,
* a Delay's (#393), a Phaser's and a Flanger's (#398) four.
* a Delay's (#393), a Phaser's, a Flanger's (#398) and a Bitcrusher's (#399) four.
*/
const COLUMNS_OF: Readonly<Record<string, number>> = {
[SoundFxFilter.type]: 4,
[SoundFxBitcrusher.type]: 4,
[SoundFxDelay.type]: 4,
[SoundFxPhaser.type]: 4,
[SoundFxFlanger.type]: 4,
+46
View File
@@ -6,6 +6,7 @@ import { currentNodePalette } from '../graph/NodePalette';
import { ChipPanel } from '../soundfx/ChipPanel';
import { EnvelopePanel } from '../soundfx/EnvelopePanel';
import { CompressorPanel } from '../soundfx/CompressorPanel';
import { CrusherPanel } from '../soundfx/CrusherPanel';
import { KnobPanel } from '../soundfx/KnobPanel';
import { DelayPanel } from '../soundfx/DelayPanel';
import { LimiterPanel } from '../soundfx/LimiterPanel';
@@ -627,6 +628,51 @@ export function graphHooks({ state, editor }: HookContext) {
};
},
},
/**
* The panel a Bitcrusher node shows (#399), in the sound FX graph or the instrument graph: whether
* it is shown and where, its knobs, which settings are idle, how crushed it is and how lit each
* bit's lamp is, what the screen draws and says, and the node's ports.
*/
bitcrusher: {
panel: (nodeId: string) => {
const canvas = [editor.soundFxEditor.canvas, editor.instrumentEditor.canvas].find(
(c) => c.faceOf(nodeId)?.widget instanceof CrusherPanel,
);
const panel = canvas?.faceOf(nodeId)?.widget;
if (!canvas || !(panel instanceof CrusherPanel)) return null;
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
const trace = panel.scope.trace;
return {
shown: shownOnScreen(panel),
rect: screenRect(panel),
size: { w: panel.w, h: panel.h },
node: node?.rect ?? null,
inputs: ports(node?.inputs ?? [], false),
outputs: ports(node?.outputs ?? [], true),
knobs: Object.fromEntries(
Object.entries(panel.knobs).map(([key, knob]) => [
key,
{ value: knob.value, number: knob.shown, enabled: knob.enabled, dial: knob.dialGlobal() },
]),
),
idle: [...panel.idle],
crush: { share: panel.crush, word: panel.crushText.text },
/** The bits' lamps, the highest first: 1 lit, 0 dark, a fraction for part of a bit. */
bits: panel.bitsLit,
scope: {
says: panel.scope.texts,
/** The step between two levels, the seconds across, and the holds the sine was cut into. */
step: trace?.step ?? null,
span: trace?.span ?? null,
holds: trace?.holds.length ?? 0,
/** The cells drawn, their shape, the grid lines, and whether the line that comes out is over them. */
drawn: panel.scope.drawn,
},
};
},
},
tremolo: { panel: modulationPanel },
vibrato: { panel: modulationPanel },
phaser: { panel: modulationPanel },
+68
View File
@@ -0,0 +1,68 @@
import type { GraphNode } from '../../core/graph';
import { findGraphAsset } from '../../core/model/Project';
import { SoundFxBitcrusher } from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { type CrusherHost, CrusherPanel } from './CrusherPanel';
import { crushOf, type CrushKey, crushTexts, crushTrace, crushValues, idleCrushKeys } from './crusherView';
import { NodeParamDrag } from './nodeParamDrag';
/**
* A Bitcrusher node drawn as its device (#399): what a sine comes out as, worked out from its
* settings by the node's own crush. Nothing on it moves by itself, and it listens to nothing.
*/
export class CrusherFace implements NodeFace, CrusherHost {
readonly widget: CrusherPanel;
private readonly drag: NodeParamDrag;
constructor(
private readonly state: EditorState,
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new CrusherPanel(this);
this.drag = new NodeParamDrag(state, 'crusher-face');
}
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === SoundFxBitcrusher.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void {
const node = this.node();
if (!node) return;
const values = crushValues(node.params);
const trace = crushTrace(values);
this.widget.sync({
values,
wired: this.wired,
idle: idleCrushKeys(values),
trace,
texts: crushTexts(values, trace),
crush: crushOf(values),
});
}
tick(): boolean {
return false;
}
hide(): void {}
preview(key: CrushKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: CrushKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
}
+359
View File
@@ -0,0 +1,359 @@
/**
* The Bitcrusher as a device (#399), what its node shows in the graph: a plate in bubblegum pink,
* a screen that draws what a sine comes out as in pixels – each as tall as a step the bits leave
* and as wide as a hold of the rate, so the coarser the crush, the coarser the picture – a lamp for
* each bit kept, one beside the name that lights the more it crushes, and the bits, the rate and
* the wet as knobs.
*/
import { Graphics, type Text, type TextStyle } from 'pixi.js';
import { CRUSH_ROWS } from '../../core/soundfx/nodes';
import { mixColor } from '../../core/util/color';
import { crispText, Knob, textStyle, Widget } from '../../ui';
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
import {
BIT_LAMPS,
bitLampsLit,
cellOf,
CRUSH_KEYS,
CRUSH_KNOBS,
type CrushKey,
type CrushTrace,
type CrushValues,
CRUSHER_LAYOUT,
CRUSHER_PANEL,
crushWord,
GRID_LEAST,
SCREEN_REACH,
SINE_HZ,
TEXT_ROW,
} from './crusherView';
import { type Box, KNOB_CAPTION } from './hardwareView';
/** What the panel asks of the node it sits in. */
export interface CrusherHost {
preview(key: CrushKey, value: number): void;
commit(key: CrushKey, value: number): void;
}
/** What the panel shows of a node. */
export interface CrusherShown {
values: CrushValues;
wired: (key: CrushKey) => boolean;
/** The settings that do nothing as things stand. */
idle: ReadonlySet<CrushKey>;
trace: CrushTrace;
texts: { noise: string; fold: string; span: string };
crush: number;
}
/** Bubblegum pink, an arcade's. */
const BUBBLEGUM: PlateLook = {
stops: [
[0, 0xf6c0d8],
[0.5, 0xeaa3c4],
[1, 0xd283aa],
],
rim: 0x94506f,
grain: 0x7a3a5a,
};
/** The arc of a chrome dial: its orange would vanish on pink. */
const ARC = 0x5a0f36;
const BEZEL = 0x2a2236;
const BEZEL_TEXT = 0xf2d4e4;
/** Night-blue glass with scan lines; the pixels cyan, the sine going in as a grey ghost behind them. */
const SCOPE = {
glass: 0x0b0a16,
scan: 0x000000,
grid: 0x262842,
text: 0x9ff7ff,
dim: 0x5f6a8a,
ghost: 0x6a6680,
pixel: 0x6ff3ff,
out: 0xf2fbff,
};
const LAMP = { off: 0x4a2a3a, lit: 0xff3d9a, bitOff: 0x3a2a3a };
const IDLE_ALPHA = 0.4;
/** The pixels between two scan lines. */
const SCAN = 3;
function scopeStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
/**
* The screen: the sine going in as a grey ghost and what the crush makes of it in cells over it,
* one per hold, on a grid of its steps and holds where they are wide enough to tell apart. Where
* the wet lets some of the sine through, the line that comes out is drawn over the cells.
*/
class PixelScope extends Widget {
private readonly glass = new Graphics();
private readonly cells = new Graphics();
private readonly under: Record<'noise' | 'fold', Text>;
private readonly spanText: Text;
trace: CrushTrace | null = null;
wet = 1;
texts = { noise: '', fold: '', span: '' };
/** What was drawn last (checks): the cells, how each is shaped, and the grid lines. */
drawn = { cells: 0, cell: { gap: 0, h: 0 }, wide: 0, levels: 0, holds: 0, line: false };
constructor() {
super();
this.addChild(this.glass, this.cells);
this.under = { noise: this.made(0), fold: this.made(1) };
this.spanText = crispText({ text: '', style: scopeStyle(8, SCOPE.dim) });
this.spanText.eventMode = 'none';
this.addChild(this.spanText);
}
private made(ax: number): Text {
const t = crispText({ text: '', style: scopeStyle(9, SCOPE.text) });
t.anchor.set(ax, 0);
t.eventMode = 'none';
this.addChild(t);
return t;
}
show(trace: CrushTrace, wet: number, texts: PixelScope['texts']): void {
this.trace = trace;
this.wet = Math.max(0, Math.min(1, wet));
this.texts = texts;
this.invalidate();
}
override redraw(): void {
const w = this.w;
const h = this.h - TEXT_ROW;
const cy = h / 2;
const half = h / 2 - 8;
const y = (v: number) => cy - (Math.max(-SCREEN_REACH, Math.min(SCREEN_REACH, v)) / SCREEN_REACH) * half;
const g = this.glass.clear();
g.roundRect(0, 0, w, h, 4).fill(SCOPE.glass);
const c = this.cells.clear();
const trace = this.trace;
this.drawn = { cells: 0, cell: { gap: 0, h: 0 }, wide: 0, levels: 0, holds: 0, line: false };
if (trace) {
const n = trace.dry.length;
const x = (i: number) => 6 + (i / n) * (w - 12);
const tall = (trace.step / SCREEN_REACH) * half;
const first = trace.holds[0]!;
const wide = trace.holds.length > 1 ? x(first.to) - x(first.from) : w - 12;
// The grid the sound is snapped to: a line at every level and at every hold, where they are apart enough.
if (tall >= GRID_LEAST) {
for (let k = -Math.floor(SCREEN_REACH / trace.step); k * trace.step <= SCREEN_REACH; k++) {
g.moveTo(6, y(k * trace.step)).lineTo(w - 6, y(k * trace.step));
this.drawn.levels++;
}
g.stroke({ color: SCOPE.grid, width: 1 });
}
if (wide >= GRID_LEAST) {
for (const hold of trace.holds) g.moveTo(x(hold.from), 4).lineTo(x(hold.from), h - 4);
this.drawn.holds = trace.holds.length;
g.stroke({ color: SCOPE.grid, width: 1 });
}
// Full scale either way, and the middle.
g.moveTo(6, y(1))
.lineTo(w - 6, y(1))
.moveTo(6, y(-1))
.lineTo(w - 6, y(-1))
.stroke({ color: SCOPE.dim, width: 1, alpha: 0.4 });
g.moveTo(6, cy)
.lineTo(w - 6, cy)
.stroke({ color: SCOPE.dim, width: 1, alpha: 0.6 });
const line = (values: Float32Array) => {
values.forEach((v, i) => {
if (i === 0) c.moveTo(x(i), y(v));
else c.lineTo(x(i), y(v));
});
};
line(trace.dry);
c.stroke({ color: SCOPE.ghost, width: 1, alpha: 0.7 });
// The risers between two holds, faint, so the cells still read as one staircase.
for (let i = 1; i < trace.holds.length; i++) {
const hold = trace.holds[i]!;
c.moveTo(x(hold.from), y(trace.holds[i - 1]!.level)).lineTo(x(hold.from), y(hold.level));
}
c.stroke({ color: SCOPE.pixel, width: 1, alpha: 0.3 });
const cell = cellOf(wide, tall);
for (const hold of trace.holds) {
const left = x(hold.from) + cell.gap;
const right = x(hold.to) - cell.gap;
// Kept on the glass: a level past the reach is drawn at its edge.
const top = Math.max(2, Math.min(h - 2 - cell.h, y(hold.level) - cell.h / 2));
c.rect(left, top, Math.max(1, right - left), cell.h);
}
c.fill({ color: SCOPE.pixel, alpha: 0.35 + 0.65 * this.wet });
this.drawn = { ...this.drawn, cells: trace.holds.length, cell, wide };
// Some of the sine comes through: the sound as it is heard over the cells.
if (this.wet < 1) {
line(trace.out);
c.stroke({ color: SCOPE.out, width: 1.5, join: 'round' });
this.drawn.line = true;
}
}
// Scan lines over it all, as on an arcade's tube.
for (let row = 1; row < h; row += SCAN) c.rect(2, row, w - 4, 1);
c.fill({ color: SCOPE.scan, alpha: 0.18 });
this.spanText.text = this.texts.span;
this.spanText.position.set(8, h - 14);
this.under.noise.text = this.texts.noise;
this.under.noise.position.set(0, h + 3);
this.under.fold.text = this.texts.fold;
this.under.fold.position.set(w, h + 3);
}
}
export class CrusherPanel extends Widget {
private readonly plate = new Graphics();
private readonly bezel = new Graphics();
private readonly lamps = new Graphics();
readonly scope = new PixelScope();
readonly knobs = {} as Record<CrushKey, Knob>;
private readonly captions = {} as Record<CrushKey, { ink: Text; edge: Text }>;
private readonly title: { ink: Text; edge: Text };
private readonly lampCaption: { ink: Text; edge: Text };
private readonly bitCaption: { ink: Text; edge: Text };
private readonly screenName: Text;
/** How crushed in a word, above the screen on the right. */
readonly crushText: Text;
/** The settings shown idle now, how crushed it is and how lit each bit's lamp is (checks). */
idle: ReadonlySet<CrushKey> = new Set();
crush = 0;
bitsLit: number[] = bitLampsLit(BIT_LAMPS);
constructor(host: CrusherHost) {
super();
this.minWidth = CRUSHER_PANEL.w;
this.minHeight = CRUSHER_PANEL.h;
this.addChild(this.plate, this.bezel, this.scope, this.lamps);
this.title = engrave(this, 'BITCRUSHER', 14);
this.lampCaption = engrave(this, 'CRUSH', 9);
this.bitCaption = engrave(this, 'BITS KEPT', 9);
this.screenName = crispText({ text: `A ${SINE_HZ} Hz SINE AT 0 dBFS`, style: bezelStyle() });
this.screenName.eventMode = 'none';
this.crushText = crispText({ text: '', style: bezelStyle() });
this.crushText.anchor.set(1, 0);
this.crushText.eventMode = 'none';
this.addChild(this.screenName, this.crushText);
for (const key of CRUSH_KEYS) {
const row = CRUSH_ROWS[key];
const look = CRUSH_KNOBS[key];
const knob = new Knob('', row.default, {
min: row.min ?? 0,
max: row.max ?? 1,
step: row.step ?? 0.01,
taper: look.taper,
default: row.default,
decimals: look.decimals,
size: CRUSHER_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
format: look.format,
ink: INK,
arc: ARC,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
}
override measure(): { w: number; h: number } {
return { ...CRUSHER_PANEL };
}
sync(shown: CrusherShown): void {
const v = shown.values;
for (const key of CRUSH_KEYS) {
const knob = this.knobs[key];
knob.setValue(v[key]);
knob.setEnabled(!shown.wired(key));
knob.alpha = shown.idle.has(key) ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = knob.alpha;
}
this.scope.show(shown.trace, v.mix, shown.texts);
this.crushText.text = crushWord(shown.crush);
const lit = bitLampsLit(v.bits);
const dimmed = shown.idle.has('bits') !== this.idle.has('bits');
this.idle = shown.idle;
if (dimmed || this.crush !== shown.crush || lit.some((share, i) => share !== this.bitsLit[i])) {
this.crush = shown.crush;
this.bitsLit = lit;
this.invalidate();
}
}
override layout(): void {
const L = CRUSHER_LAYOUT;
this.scope.position.set(L.screen.x, L.screen.y);
this.scope.resize(L.screen.w, L.screen.h + TEXT_ROW);
this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.crushText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
placeEngraving(this.bitCaption, L.bitCaption.x, L.bitCaption.y);
for (const key of CRUSH_KEYS) {
const spot = L.knobs[key];
const knob = this.knobs[key];
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[key], spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
}
override redraw(): void {
const L = CRUSHER_LAYOUT;
drawPlate(this.plate.clear(), CRUSHER_PANEL, BUBBLEGUM, L.screws);
const b = L.bezel;
this.bezel
.clear()
.roundRect(b.x, b.y, b.w, b.h, 8)
.fill(BEZEL)
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
.stroke({ color: 0x15101c, width: 1 });
this.drawLamps();
}
/** The lamp beside the name, the brighter the more it crushes, and one for each bit, dark where it is crushed away. */
private drawLamps(): void {
const L = CRUSHER_LAYOUT;
const g = this.lamps.clear();
const { x, y, r } = L.lamp;
const lit = this.crush > 0;
const color = mixColor(LAMP.off, LAMP.lit, 0.4 + 0.6 * this.crush);
if (lit) g.circle(x, y, r + 4).fill({ color: LAMP.lit, alpha: 0.3 * this.crush });
g.circle(x, y, r)
.fill(lit ? color : LAMP.off)
.stroke({ color: 0x2a2c30, width: 1.5 });
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: lit ? 0.7 : 0.2 });
const dim = this.idle.has('bits') ? IDLE_ALPHA : 1;
L.bitLamps.forEach((box: Box, i) => {
const share = this.bitsLit[i] ?? 0;
if (share > 0)
g.roundRect(box.x - 2, box.y - 2, box.w + 4, box.h + 4, 4).fill({
color: SCOPE.pixel,
alpha: 0.25 * share * dim,
});
g.roundRect(box.x, box.y, box.w, box.h, 2)
.fill({ color: share > 0 ? mixColor(LAMP.bitOff, SCOPE.pixel, share) : LAMP.bitOff, alpha: dim })
.stroke({ color: 0x2a2c30, width: 1, alpha: dim });
});
this.bitCaption.ink.alpha = dim;
}
}
+241
View File
@@ -0,0 +1,241 @@
/**
* What a Bitcrusher's node draws (#399), as numbers: where the parts of its device sit, how its
* knobs turn, the bits it keeps, and what a sine comes out as – the screen – worked out by the
* node's own `Crusher`, so the steps and the holds drawn are the ones heard. Kept apart from the
* drawing so it is checked without a browser.
*/
import type { PropertyBag } from '../../core/model/Properties';
import { Crusher, crushSteps } from '../../core/soundfx/crusher';
import { CRUSH_ROWS } from '../../core/soundfx/nodes';
import { formatNumber } from '../../ui/math';
import { BIG_DIAL, type Box, type KnobSpot, screwsOf, SMALL_DIAL } from './hardwareView';
import { hzText, percentText } from './reverbView';
export type CrushKey = keyof typeof CRUSH_ROWS;
export const CRUSH_KEYS = Object.keys(CRUSH_ROWS) as CrushKey[];
export type CrushValues = Record<CrushKey, number>;
const numberOf = (params: PropertyBag, key: CrushKey): number => {
const value = params[key];
return typeof value === 'number' && Number.isFinite(value) ? value : CRUSH_ROWS[key].default;
};
/** The numbers a Bitcrusher plays with, each its row's default where none is typed in. */
export function crushValues(params: PropertyBag): CrushValues {
return { bits: numberOf(params, 'bits'), rate: numberOf(params, 'rate'), mix: numberOf(params, 'mix') };
}
/** At wet 0 the node lets the sound through as it is: neither its bits nor its rate is heard. */
export function idleCrushKeys(values: CrushValues): Set<CrushKey> {
return values.mix > 0 ? new Set() : new Set(['bits', 'rate']);
}
export interface CrushKnob {
label: string;
taper: number;
decimals: number;
format: (value: number) => string;
}
export const bitsText = (bits: number): string => `${formatNumber(bits, 1)} bit`;
/** How each knob turns: the bits evenly, the rate giving its low end most of the turn – 8 kHz past the middle. */
export const CRUSH_KNOBS: Record<CrushKey, CrushKnob> = {
bits: { label: 'Bits', taper: 1, decimals: 1, format: bitsText },
rate: { label: 'Rate', taper: 3, decimals: 0, format: hzText },
mix: { label: 'Wet', taper: 1, decimals: 2, format: percentText },
};
// --- The plate. ---
/** The plate: as the Flanger's (#398), the panel never takes another size. */
export const CRUSHER_PANEL = { w: 680, h: 280 };
/** The extra the strip of text under the screen takes. */
export const TEXT_ROW = 14;
/** The bits there are, one lamp each, the highest on the left. */
export const BIT_LAMPS = 16;
const LAMP_GAP = 6;
export interface CrusherLayout {
title: Box;
/** The lamp that lights the more the sound is crushed, beside the device's name; its own name after it. */
lamp: { x: number; y: number; r: number };
lampCaption: { x: number; y: number };
bezel: Box;
screen: Box;
/** A lamp for each bit kept, and their name to the left of them. */
bitLamps: Box[];
bitCaption: { x: number; y: number };
knobs: Record<CrushKey, KnobSpot>;
screws: { x: number; y: number }[];
}
function crusherLayout(): CrusherLayout {
const { w } = CRUSHER_PANEL;
const screen = { x: 34, y: 60, w: 280, h: 176 };
const bezel = { x: 24, y: 44, w: screen.w + 20, h: screen.h + 16 + TEXT_ROW + 6 };
// The bits and the rate big, the two edges of a pixel on the screen; the wet small after them.
const dialY = 160;
const bits = { x: bezel.x + bezel.w + 20 + BIG_DIAL / 2, y: dialY, size: BIG_DIAL };
const rate = { x: bits.x + BIG_DIAL + 20, y: dialY, size: BIG_DIAL };
const mix = { x: rate.x + BIG_DIAL / 2 + 20 + 30, y: dialY, size: SMALL_DIAL };
const left = bits.x - BIG_DIAL / 2;
const right = w - 24;
const pitch = (right - left + LAMP_GAP) / BIT_LAMPS;
const bitLamps = Array.from({ length: BIT_LAMPS }, (_, i) => ({
x: left + i * pitch,
y: 56,
w: pitch - LAMP_GAP,
h: 12,
}));
return {
title: { x: 34, y: 14, w: 126, h: 24 },
lamp: { x: 172, y: 26, r: 6 },
lampCaption: { x: 204, y: 21 },
bezel,
screen,
bitLamps,
bitCaption: { x: left + (right - left) / 2, y: 40 },
knobs: { bits, rate, mix },
screws: screwsOf(CRUSHER_PANEL),
};
}
export const CRUSHER_LAYOUT: CrusherLayout = crusherLayout();
/** How lit each bit's lamp is, the highest first: the whole bits kept, the part of one a fraction keeps. */
export function bitLampsLit(bits: number): number[] {
const kept = Math.max(1, Math.min(BIT_LAMPS, Number.isFinite(bits) ? bits : BIT_LAMPS));
return Array.from({ length: BIT_LAMPS }, (_, i) => Math.max(0, Math.min(1, kept - i)));
}
// --- The screen: a sine at full scale as it comes out. ---
/** The sine the screen sends through, and the rate it is worked out at, a context's. */
export const SINE_HZ = 440;
export const SCREEN_RATE = 48000;
/** Two periods across the screen, or more where the rate is so low that fewer holds would show. */
const PERIODS = 2;
export const LEAST_HOLDS = 6;
/** How far up and down the screen goes: past full scale, as a few bits round a sine beyond it. */
export const SCREEN_REACH = 1.25;
/** The seconds across the screen at a rate. */
export function screenSpan(rate: number): number {
return Math.max(PERIODS / SINE_HZ, LEAST_HOLDS / Math.max(1, rate));
}
/** One sample held: from the sample it was taken at to the one the next is, and what it was rounded to. */
export interface Hold {
from: number;
to: number;
level: number;
}
export interface CrushTrace {
span: number;
/** The sine going in, one point a sample. */
dry: Float32Array;
/** What the crush makes of it, and what comes out once the wet blends the two. */
crushed: Float32Array;
out: Float32Array;
holds: Hold[];
/** The step between two levels, as a share of full scale. */
step: number;
}
/**
* What a sine at full scale comes out of the node as (#399), sample by sample at a context's rate:
* crushed by the node's own `Crusher`, then blended as its worklet blends. Where each hold starts
* comes from the `Crusher` too: a ramp through it at 16 bits takes a new value at every hold.
*/
export function crushTrace(values: CrushValues, rate = SCREEN_RATE): CrushTrace {
const span = screenSpan(values.rate);
const length = Math.max(2, Math.round(span * rate));
const dry = new Float32Array(length);
for (let i = 0; i < length; i++) dry[i] = Math.sin((2 * Math.PI * SINE_HZ * i) / rate);
const run = (input: Float32Array, bits: number): Float32Array => {
const output = new Float32Array(length);
new Crusher(rate).render(
[input],
[output],
Float32Array.of(bits),
Float32Array.of(values.rate),
Float32Array.of(1),
);
return output;
};
const crushed = run(dry, values.bits);
// A step of the ramp is far wider than one of 16 bits, so every hold rounds to a value of its own.
const ramp = Float32Array.from({ length }, (_, i) => i / length);
const taken = run(ramp, 16);
const holds: Hold[] = [];
for (let i = 0; i < length; i++)
if (i === 0 || taken[i] !== taken[i - 1]) holds.push({ from: i, to: i + 1, level: crushed[i]! });
else holds.at(-1)!.to = i + 1;
const wet = Math.max(0, Math.min(1, values.mix));
const out = dry.map((x, i) => x + (crushed[i]! - x) * wet);
return { span, dry, crushed, out, holds, step: 1 / crushSteps(values.bits) };
}
/** Under this many pixels a step or a hold is too fine for a grid line or a gap. */
export const GRID_LEAST = 5;
/** The least a cell is let be tall where its steps are coarse. */
const CELL_LEAST = 6;
/**
* How a hold is drawn, `wide` and `tall` the pixels of its hold and of one step: a cell of its
* own with a gap round it where both are big enough to tell apart, a bar 2 high where the steps are
* too fine, as wide as the hold either way. A cell is never more than twice as tall as it is wide,
* so the few levels of a bit or two stay bars on their level rather than walls across the screen.
*/
export function cellOf(wide: number, tall: number): { gap: number; h: number } {
const gap = wide >= GRID_LEAST && tall >= GRID_LEAST ? 1 : 0;
const most = Math.max(CELL_LEAST, 2 * wide);
return { gap, h: tall >= GRID_LEAST ? Math.min(tall - 2 * gap, most) : 2 };
}
/** The noise a sine at full scale has over it at so many bits, in dB under it: 6 dB a bit. */
export const noiseDb = (bits: number): number => 6.02 * Math.max(1, Math.min(16, bits)) + 1.76;
/** What the screen writes under it: the noise the bits leave, where the rate folds the highs back down. */
export function crushTexts(
values: CrushValues,
trace: CrushTrace,
): { noise: string; fold: string; span: string } {
const span = `${formatNumber(trace.span * 1000, 1)} ms`;
if (values.mix <= 0) return { noise: 'DRY', fold: '', span };
return {
noise: `NOISE -${formatNumber(noiseDb(values.bits), 0)} dB`,
fold: `FOLDS AT ${hzText(values.rate / 2)}`,
span,
};
}
// --- How crushed it is. ---
const BITS_MOST = CRUSH_ROWS.bits.max ?? 16;
const BITS_LEAST = CRUSH_ROWS.bits.min ?? 1;
const RATE_MOST = CRUSH_ROWS.rate.max ?? 48000;
const RATE_LEAST = CRUSH_ROWS.rate.min ?? 100;
/**
* How crushed the sound is, 0 to 1: whichever of the bits or the rate takes more of it – the rate
* by octaves, as it is heard – as much as the wet lets it be heard.
*/
export function crushOf(values: CrushValues): number {
const bits = (BITS_MOST - values.bits) / (BITS_MOST - BITS_LEAST);
const rate = Math.log(RATE_MOST / Math.max(RATE_LEAST, values.rate)) / Math.log(RATE_MOST / RATE_LEAST);
const share = Math.max(0, Math.min(1, Math.max(bits, rate)));
return share * Math.max(0, Math.min(1, values.mix));
}
/** The word above the screen for how crushed it is. */
export function crushWord(crush: number): string {
if (crush < 0.05) return 'CLEAN';
if (crush <= 0.3) return 'GRITTY';
if (crush <= 0.55) return 'CRUNCHY';
if (crush <= 0.8) return 'CRUSHED';
return 'BROKEN';
}
+4 -1
View File
@@ -1,6 +1,7 @@
import type { GraphNode } from '../../core/graph';
import { InstrumentChip, InstrumentEnvelope } from '../../core/instrument/nodes';
import {
SoundFxBitcrusher,
SoundFxCompressor,
SoundFxDelay,
SoundFxFilter,
@@ -13,6 +14,7 @@ import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { ChipFace } from './ChipFace';
import { CompressorFace } from './CompressorFace';
import { CrusherFace } from './CrusherFace';
import { CurveFace } from './CurveFace';
import { DelayFace } from './DelayFace';
import { EnvelopeFace } from './EnvelopeFace';
@@ -26,7 +28,7 @@ import { MOD_DEVICES } from './modulationView';
import { ReverbFace } from './ReverbFace';
import { SaturationFace } from './SaturationFace';
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396–#398), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396–#399), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(state, assetId, node.id);
@@ -42,6 +44,7 @@ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode
if (node.type === SoundFxDelay.type) return new DelayFace(state, assetId, node.id);
if (node.type === SoundFxSaturation.type) return new SaturationFace(state, assetId, node.id);
if (node.type === SoundFxLimiter.type) return new LimiterFace(state, assetId, node.id);
if (node.type === SoundFxBitcrusher.type) return new CrusherFace(state, assetId, node.id);
const swinging = MOD_DEVICES.find((device) => device.type === node.type);
if (swinging) return new ModulationFace(state, assetId, node.id, swinging);
return null;
+171
View File
@@ -0,0 +1,171 @@
/**
* 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');
});
});
+50
View File
@@ -1637,6 +1637,56 @@ export async function sound(t) {
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== ${JSON.stringify(id)});`);
}
// #399: a Bitcrusher is a device of its own size whose screen draws what a sine comes out as in
// cells as tall as a step and as wide as a hold, with a lamp for each bit kept. The bits
// and the rate knob are one undo step each, and the screen and the lamps follow them.
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes.push(${JSON.stringify(node('fx-bc', 'SoundFxBitcrusher', 0, 400, { bits: 4 }))});`);
await evaluate('__gm.soundfx.fit()');
await sleep(100);
const bcPanel = () => json("__gm.bitcrusher.panel('fx-bc')");
const bcLit = (p) => p.bits.filter((share) => share > 0).length;
const bcShown = await bcPanel();
const bcBitsDial = bcShown.knobs.bits.dial;
await drag(bcBitsDial.x, bcBitsDial.y, bcBitsDial.x, bcBitsDial.y - 40);
const bcBits = { params: await knobParams('fx-bc'), panel: await bcPanel() };
const bcRateDial = bcShown.knobs.rate.dial;
await drag(bcRateDial.x, bcRateDial.y, bcRateDial.x, bcRateDial.y + 40);
const bcRate = { params: await knobParams('fx-bc'), panel: await bcPanel() };
for (let i = 0; i < 2; i++) await press('z', { ctrl: true });
const bcUndone = { params: await knobParams('fx-bc'), panel: await bcPanel() };
check(
'a Bitcrusher node is a device whose screen draws a sine in the cells of its bits and its rate; both knobs are one undo step each',
bcShown.size.w === 680 &&
bcShown.size.h === 280 &&
Object.values(bcShown.inputs).length === 4 &&
Object.values(bcShown.inputs).every((p) => p.x < bcShown.rect.x) &&
Object.values(bcShown.outputs).every((p) => p.x > bcShown.rect.x + bcShown.rect.w) &&
bcShown.knobs.bits.number === '4 bit' &&
bcShown.knobs.rate.number === '8 kHz' &&
bcLit(bcShown) === 4 &&
bcShown.scope.step === 0.125 &&
bcShown.scope.holds === 37 &&
bcShown.scope.drawn.cells === 37 &&
bcShown.scope.drawn.cell.gap === 1 &&
bcShown.scope.says.noise === 'NOISE -26 dB' &&
bcShown.scope.says.fold === 'FOLDS AT 4 kHz' &&
bcShown.crush.word !== 'CLEAN' &&
bcBits.params.bits > 4 &&
bcLit(bcBits.panel) > 4 &&
bcBits.panel.scope.step < 0.125 &&
bcRate.params.rate < 8000 &&
bcRate.panel.scope.holds < 37 &&
bcUndone.params.bits === 4 &&
bcUndone.params.rate === undefined &&
bcLit(bcUndone.panel) === 4 &&
bcUndone.panel.scope.holds === 37,
JSON.stringify({ bcShown, bcBits, bcRate, bcUndone }),
);
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph;
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-bc');`);
await showTab('scene');
await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;