diff --git a/README.md b/README.md index f14883ef..d8f373e8 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/core/instrument/chipPresets.ts b/src/core/instrument/chipPresets.ts index 4716f6f5..9a7f3cb0 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -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> = { [SoundFxFilter.type]: 4, + [SoundFxBitcrusher.type]: 4, [SoundFxDelay.type]: 4, [SoundFxPhaser.type]: 4, [SoundFxFlanger.type]: 4, diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index d57be144..181b647c 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -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 }, diff --git a/src/editor/soundfx/CrusherFace.ts b/src/editor/soundfx/CrusherFace.ts new file mode 100644 index 00000000..477bce5e --- /dev/null +++ b/src/editor/soundfx/CrusherFace.ts @@ -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); + } +} diff --git a/src/editor/soundfx/CrusherPanel.ts b/src/editor/soundfx/CrusherPanel.ts new file mode 100644 index 00000000..3cfa03da --- /dev/null +++ b/src/editor/soundfx/CrusherPanel.ts @@ -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; + 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; + private readonly captions = {} as Record; + 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 = 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; + } +} diff --git a/src/editor/soundfx/crusherView.ts b/src/editor/soundfx/crusherView.ts new file mode 100644 index 00000000..d07d81c5 --- /dev/null +++ b/src/editor/soundfx/crusherView.ts @@ -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; + +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 { + 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 = { + 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; + 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'; +} diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index 21437181..aff129e2 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -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; diff --git a/tests/editor/crusher-view.test.ts b/tests/editor/crusher-view.test.ts new file mode 100644 index 00000000..2bc31bb4 --- /dev/null +++ b/tests/editor/crusher-view.test.ts @@ -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({}), ...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(8).fill(1), ...Array(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'); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 21f27e2e..6ccd6fae 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -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;