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>
This commit is contained in:
1 parent
b145e1f288
commit
a6c00f7b8b
9 files changed
+942
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user