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

+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;