diff --git a/README.md b/README.md index 2a0cfe7a..0e343cdc 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, 1967 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1968 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 c186cb93..a27ab99b 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -395,10 +395,12 @@ const COLUMN = 240; 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 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) four. */ const COLUMNS_OF: Readonly> = { [SoundFxFilter.type]: 4, + [SoundFxDelay.type]: 4, [SoundFxPitch.type]: 2, [SoundFxMix.type]: 2, [INSTRUMENT_CHIP]: 5, diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index db02937c..af7dd2b7 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -803,7 +803,7 @@ export const SoundFxMix: SoundFxNodeDef = { export const DELAY_MAX = 2; /** Up to two bars of 4/4 in beats; whatever it comes to is held to `DELAY_MAX` seconds. */ const DELAY_TIME = { ...numberRow('time', 'Time', 0.01, 8, 0.01), default: 0.25 }; -const DELAY_SYNC = { +export const DELAY_SYNC = { key: 'sync', label: 'Sync', kind: 'enum' as const, @@ -818,13 +818,30 @@ const DELAY_FEEDBACK = { ...numberRow('feedback', 'Feedback', 0, 0.95, 0.01), de const DELAY_DAMPING = { ...numberRow('damping', 'Damping', 0, 1, 0.01), default: 0.3 }; /** Wet of a Delay (#354): 0 the sound alone, 0.5 the sound and its echoes in full, 1 the echoes alone. */ const DELAY_MIX = { ...numberRow('mix', 'Wet', 0, 1, 0.01), default: 0.5 }; -const PING_PONG = { key: 'pingPong', label: 'Ping-pong', kind: 'boolean' as const, default: false }; +export const PING_PONG = { key: 'pingPong', label: 'Ping-pong', kind: 'boolean' as const, default: false }; +/** A Delay's numbers, for its device in the graph (#393). */ +export const DELAY_ROWS = { + time: DELAY_TIME, + feedback: DELAY_FEEDBACK, + damping: DELAY_DAMPING, + mix: DELAY_MIX, +}; /** How loud a Delay at `wet` lets the sound itself through, and how loud its echoes (#354). */ export function delayShares(wet: number): { dry: number; echo: number } { return { dry: Math.min(1, 2 * (1 - wet)), echo: Math.min(1, 2 * wet) }; } +/** + * How many echoes of a Delay are heard (#324): the echo `k` is `echo * feedback^(k-1)` as loud as + * the sound, and the last one counted is still above silence. Its tail, and its device's screen (#393). + */ +export function delayEchoes(feedback: number, mix: number): number { + const { echo } = delayShares(mix); + if (!(echo >= SILENT)) return 0; + return feedback > 0 ? 1 + Math.floor(Math.log(SILENT / echo) / Math.log(feedback)) : 1; +} + export const SoundFxDelay: SoundFxNodeDef = effect( { type: 'SoundFxDelay', @@ -1675,8 +1692,5 @@ export function fxLatencyOf(chain: FxChain, sampleRate: number): number { /** The echo `k` is `echo * feedback^(k-1)` as loud as the sound; the last one still heard sets the tail. */ function delayTail({ time, feedback, mix }: FxDelay): number { - const { echo } = delayShares(mix); - if (!(echo >= SILENT) || !(time > 0)) return 0; - const echoes = feedback > 0 ? 1 + Math.floor(Math.log(SILENT / echo) / Math.log(feedback)) : 1; - return echoes * time; + return time > 0 ? delayEchoes(feedback, mix) * time : 0; } diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index 44a73ff2..3b533169 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -7,6 +7,7 @@ import { ChipPanel } from '../soundfx/ChipPanel'; import { EnvelopePanel } from '../soundfx/EnvelopePanel'; import { CompressorPanel } from '../soundfx/CompressorPanel'; import { KnobPanel } from '../soundfx/KnobPanel'; +import { DelayPanel } from '../soundfx/DelayPanel'; import { ReverbPanel } from '../soundfx/ReverbPanel'; import { CurvePanel } from '../soundfx/CurvePanel'; import type { EqEditor } from '../soundfx/EqEditor'; @@ -417,6 +418,58 @@ export function graphHooks({ state, editor }: HookContext) { }; }, }, + /** + * The panel a Delay node shows (#393), in the sound FX graph or the instrument graph: whether it + * is shown and where, its knobs and levers, which settings are idle, the echoes the screen draws, + * what it says, where the running light is, and the node's ports. + */ + delay: { + panel: (nodeId: string) => { + const canvas = [editor.soundFxEditor.canvas, editor.instrumentEditor.canvas].find( + (c) => c.faceOf(nodeId)?.widget instanceof DelayPanel, + ); + const panel = canvas?.faceOf(nodeId)?.widget; + if (!canvas || !(panel instanceof DelayPanel)) 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 lever = (l: DelayPanel['syncLever']) => ({ + lit: l.lit, + says: l.says.text, + at: screenCentre(l), + }); + const train = panel.scope.train; + 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], + sync: lever(panel.syncLever), + pingPong: lever(panel.pingPongLever), + highs: panel.highs.text, + scope: { + every: train?.every ?? null, + heard: train?.heard ?? 0, + /** The echoes drawn: when, how loud, on which side, how bright. */ + echoes: train?.echoes ?? [], + says: panel.scope.texts, + /** The running light, in seconds after the sound; how lit each bar is; the lamp. */ + at: panel.scope.at, + glow: panel.scope.glow, + lamp: panel.scope.lamp, + }, + }; + }, + }, instrument: { current: () => editor.instrumentEditor.currentAssetId, info: () => editor.instrumentEditor.shownInfo, diff --git a/src/editor/soundfx/CompressorPanel.ts b/src/editor/soundfx/CompressorPanel.ts index f417b22f..a7428638 100644 --- a/src/editor/soundfx/CompressorPanel.ts +++ b/src/editor/soundfx/CompressorPanel.ts @@ -93,8 +93,8 @@ export interface PlateLook { grain: number; } -/** Brushed aluminium, the Compressor's. */ -const ALUMINIUM: PlateLook = { +/** Brushed aluminium, the Compressor's and the Delay's (#393). */ +export const ALUMINIUM: PlateLook = { stops: [ [0, 0xe2e4e7], [0.5, 0xcfd2d6], diff --git a/src/editor/soundfx/DelayFace.ts b/src/editor/soundfx/DelayFace.ts new file mode 100644 index 00000000..37ecef7b --- /dev/null +++ b/src/editor/soundfx/DelayFace.ts @@ -0,0 +1,83 @@ +import { SetNodeParamCommand } from '../../core/commands/graphCommands'; +import type { GraphNode } from '../../core/graph'; +import { findGraphAsset } from '../../core/model/Project'; +import { DELAY_SYNC, PING_PONG, SoundFxDelay } from '../../core/soundfx/nodes'; +import type { NodeFace } from '../graph/nodeFace'; +import type { EditorState } from '../state'; +import { compressorWired } from './CompressorEditor'; +import { type DelayHost, DelayPanel } from './DelayPanel'; +import { type DelayKey, delayTexts, delayValues, echoTrain, idleDelayKeys } from './delayView'; +import { NodeParamDrag } from './nodeParamDrag'; + +/** + * A Delay node drawn as its device (#393): its echoes drawn from its settings – at 120 BPM where + * they count beats, as the voice's own tempo is not known here – and a light that runs over them + * at the beat they come at for as long as the node is in the picture. It listens to nothing. + */ +export class DelayFace implements NodeFace, DelayHost { + readonly widget: DelayPanel; + private readonly drag: NodeParamDrag; + + constructor( + private readonly state: EditorState, + private readonly assetId: string, + private readonly nodeId: string, + ) { + this.widget = new DelayPanel(this); + this.drag = new NodeParamDrag(state, 'delay-face'); + } + + private node(): GraphNode | null { + const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find( + (n) => n.id === this.nodeId, + ); + return node?.type === SoundFxDelay.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 = delayValues(node.params); + const train = echoTrain(values); + this.widget.sync({ + values, + wired: this.wired, + idle: idleDelayKeys(values), + train, + texts: delayTexts(values, train), + }); + } + + /** The light runs on by the page's clock, so it keeps the beat however the frames come. */ + tick(): boolean { + this.widget.step(performance.now() / 1000); + return true; + } + + hide(): void {} + + preview(key: DelayKey, value: number): void { + const node = this.node(); + if (node) this.drag.preview(node, key, value); + } + + commit(key: DelayKey, value: number): void { + const node = this.node(); + if (node) this.drag.commit(this.assetId, node, key, value); + } + + switchSync(beats: boolean): void { + this.set(DELAY_SYNC.key, beats ? 'beats' : 'seconds'); + } + + switchPingPong(on: boolean): void { + this.set(PING_PONG.key, on); + } + + private set(key: string, value: string | boolean): void { + if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, key, value)); + } +} diff --git a/src/editor/soundfx/DelayPanel.ts b/src/editor/soundfx/DelayPanel.ts new file mode 100644 index 00000000..2d138124 --- /dev/null +++ b/src/editor/soundfx/DelayPanel.ts @@ -0,0 +1,386 @@ +/** + * The Delay as a device (#393), what its node shows in the graph: the Compressor's brushed + * aluminium (#379), a screen that draws the echoes as bars over time – as tall as they are loud, + * left and right taking turns with ping-pong, warm while they keep their highs – a light that runs + * over them at the beat they come at, a lamp that blinks with it, two levers and four knobs. + */ +import { Graphics, type Text, type TextStyle } from 'pixi.js'; +import { DELAY_ROWS } from '../../core/soundfx/nodes'; +import { mixColor } from '../../core/util/color'; +import { crispText, Knob, textStyle, Widget } from '../../ui'; +import { ALUMINIUM, drawPlate, engrave, INK, Lever, placeEngraving } from './CompressorPanel'; +import { + barHeight, + DELAY_KEYS, + DELAY_KNOBS, + DELAY_LAYOUT, + DELAY_PANEL, + type DelayKey, + type DelayTexts, + type DelayValues, + type EchoTrain, + sweepAt, + TEXT_ROW, + timeText, +} from './delayView'; +import { type Box, KNOB_CAPTION } from './hardwareView'; +import { percentText } from './reverbView'; + +/** What the panel asks of the node it sits in. */ +export interface DelayHost { + preview(key: DelayKey, value: number): void; + commit(key: DelayKey, value: number): void; + switchSync(beats: boolean): void; + switchPingPong(on: boolean): void; +} + +/** What the panel shows of a node. */ +export interface DelayShown { + values: DelayValues; + wired: (key: DelayKey) => boolean; + /** The settings that do nothing as things stand. */ + idle: ReadonlySet; + train: EchoTrain; + texts: DelayTexts; +} + +const BEZEL = 0x2a2c30; +const BEZEL_TEXT = 0xc9ccd2; +/** Smoked glass; an echo with its highs is warm white, one that lost them rust, as tape goes. */ +const SCOPE = { + glass: 0x0e0c0a, + grid: 0x2c2722, + text: 0xffc98a, + dim: 0x7a6650, + bright: 0xfff1d0, + dull: 0xb8442a, + sweep: 0xffffff, +}; +const LAMP = 0x4dff5a; +const IDLE_ALPHA = 0.4; +/** The sound's bar and the echoes', and the room left round the bars. */ +const BAR = 4; +const PAD = { left: 18, right: 22, top: 12 }; + +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; +} + +/** A bar as drawn: where it stands and how far up and down from the middle it goes. */ +interface DrawnBar { + x: number; + top: number; + bottom: number; +} + +/** + * The screen: the sound as a white bar at the left, its echoes after it as bars on a line of time, + * a faint line at every step, and a light that runs over them. Under it the step and the end. + */ +class DelayScope extends Widget { + private readonly g = new Graphics(); + private readonly sweep = new Graphics(); + private readonly sides: Record<'left' | 'right', Text>; + private readonly under: Record<'every' | 'ends', Text>; + private readonly moreText: Text; + private bars: DrawnBar[] = []; + train: EchoTrain | null = null; + texts = { every: '', ends: '', highs: '', more: '' }; + pingPong = false; + /** Where the light is, in seconds after the sound, and how lit each bar is (checks). */ + at = 0; + glow: number[] = []; + lamp = false; + + constructor() { + super(); + this.addChild(this.g, this.sweep); + this.sides = { left: this.made('L', 9, SCOPE.dim, 0, 0), right: this.made('R', 9, SCOPE.dim, 0, 1) }; + this.under = { every: this.made('', 9, SCOPE.text, 0, 0), ends: this.made('', 9, SCOPE.text, 1, 0) }; + this.moreText = this.made('', 9, SCOPE.dim, 1, 0.5); + } + + private made(text: string, size: number, fill: number, ax: number, ay: number): Text { + const t = crispText({ text, style: scopeStyle(size, fill) }); + t.anchor.set(ax, ay); + t.eventMode = 'none'; + this.addChild(t); + return t; + } + + show(train: EchoTrain, texts: DelayScope['texts'], pingPong: boolean): void { + this.train = train; + this.texts = texts; + this.pingPong = pingPong; + this.invalidate(); + } + + /** Where a time sits across the screen. */ + private xOf(seconds: number): number { + const span = this.train?.span ?? 1; + return PAD.left + (seconds / span) * (this.w - PAD.left - PAD.right); + } + + private get middle(): { y: number; half: number } { + const y = (this.h - TEXT_ROW) / 2; + return { y, half: y - PAD.top }; + } + + override redraw(): void { + const g = this.g.clear(); + const { y: cy, half } = this.middle; + g.roundRect(0, 0, this.w, this.h - TEXT_ROW, 4).fill(SCOPE.glass); + const train = this.train; + this.bars = []; + if (train) { + // A faint line at every step, as far as the screen goes, and the levels 20 and 40 dB down. + for (let t = train.every; t < train.span - 1e-9 && train.every > 0; t += train.every) + g.moveTo(this.xOf(t), PAD.top - 4).lineTo(this.xOf(t), cy + half + 4); + for (const share of [1 / 3, 2 / 3]) + g.moveTo(PAD.left - 6, cy - share * half) + .lineTo(this.w - 8, cy - share * half) + .moveTo(PAD.left - 6, cy + share * half) + .lineTo(this.w - 8, cy + share * half); + g.stroke({ color: SCOPE.grid, width: 1 }); + g.moveTo(PAD.left - 6, cy) + .lineTo(this.w - 8, cy) + .stroke({ color: SCOPE.dim, width: 1, alpha: 0.6 }); + + const bar = (x: number, h: number, side: 'both' | 'left' | 'right', color: number) => { + const top = side === 'right' ? cy + 1 : cy - h * half; + const bottom = side === 'left' ? cy - 1 : cy + h * half; + if (bottom - top >= 1) g.roundRect(x - BAR / 2, top, BAR, bottom - top, 1.5).fill(color); + this.bars.push({ x, top, bottom }); + }; + bar(this.xOf(0), barHeight(train.dry), 'both', 0xffffff); + for (const echo of train.echoes) + bar( + this.xOf(echo.t), + barHeight(echo.level), + echo.side, + mixColor(SCOPE.dull, SCOPE.bright, echo.bright), + ); + } + for (const [side, t] of Object.entries(this.sides)) { + t.visible = this.pingPong; + t.position.set(4, side === 'left' ? 3 : this.h - TEXT_ROW - 3); + } + this.moreText.text = this.texts.more; + this.moreText.position.set(this.w - 3, cy - 8); + const y = this.h - TEXT_ROW + 3; + this.under.every.text = this.texts.every; + this.under.every.position.set(0, y); + this.under.ends.text = this.texts.ends; + this.under.ends.position.set(this.w, y); + this.drawSweep(); + } + + /** `seconds` on a clock of the page's: the light where it is, the bars it passed glowing. */ + step(seconds: number): void { + if (!this.train) return; + const sweep = sweepAt(this.train, seconds); + this.at = sweep.at; + this.glow = sweep.glow; + this.lamp = sweep.lamp; + this.drawSweep(); + } + + private drawSweep(): void { + const s = this.sweep.clear(); + if (!this.train || this.w <= 0) return; + const { y: cy, half } = this.middle; + const x = this.xOf(this.at); + s.rect(x - 0.5, cy - half - 6, 1, 2 * half + 12).fill({ color: SCOPE.sweep, alpha: 0.35 }); + this.bars.forEach((bar, i) => { + const glow = this.glow[i] ?? 0; + if (glow <= 0) return; + s.roundRect( + bar.x - BAR, + Math.min(bar.top, cy - 2) - 2, + BAR * 2, + Math.max(bar.bottom, cy + 2) - Math.min(bar.top, cy - 2) + 4, + 3, + ).fill({ + color: SCOPE.sweep, + alpha: 0.55 * glow, + }); + }); + } +} + +export class DelayPanel extends Widget { + private readonly plate = new Graphics(); + private readonly bezel = new Graphics(); + private readonly rules = new Graphics(); + private readonly lampLight = new Graphics(); + readonly scope = new DelayScope(); + readonly syncLever: Lever; + readonly pingPongLever: Lever; + readonly knobs = {} as Record; + private readonly captions = {} as Record; + private readonly groupNames: { ink: Text; edge: Text }[]; + private readonly title: { ink: Text; edge: Text }; + private readonly lampCaption: { ink: Text; edge: Text }; + private readonly screenName: Text; + /** Where the echoes' highs end, above the screen on the right. */ + readonly highs: Text; + /** Whether the time knob counts beats now: its number reads as a note then. */ + private beats = false; + /** The settings shown idle now (checks). */ + idle: ReadonlySet = new Set(); + private lampLit: boolean | null = null; + + constructor(host: DelayHost) { + super(); + this.minWidth = DELAY_PANEL.w; + this.minHeight = DELAY_PANEL.h; + this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight); + this.title = engrave(this, 'DELAY', 14); + this.lampCaption = engrave(this, 'ECHO', 9); + this.screenName = crispText({ text: 'ECHOES OVER TIME', style: bezelStyle() }); + this.screenName.eventMode = 'none'; + this.highs = crispText({ text: '', style: bezelStyle() }); + this.highs.anchor.set(1, 0); + this.highs.eventMode = 'none'; + this.addChild(this.screenName, this.highs); + this.groupNames = DELAY_LAYOUT.groups.map((group) => engrave(this, group.label, 9)); + this.syncLever = new Lever('SYNC', (on) => host.switchSync(on)); + this.pingPongLever = new Lever('PING-PONG', (on) => host.switchPingPong(on)); + this.addChild(this.syncLever, this.pingPongLever); + for (const key of DELAY_KEYS) { + const row = DELAY_ROWS[key]; + const look = DELAY_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: DELAY_LAYOUT.knobs[key].size, + look: 'chrome', + typed: false, + format: key === 'time' ? (value) => timeText(value, this.beats) : percentText, + ink: INK, + 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 { ...DELAY_PANEL }; + } + + sync(shown: DelayShown): void { + const v = shown.values; + if (this.beats !== v.beats) { + this.beats = v.beats; + // The number under the time knob reads otherwise now, though its value stays. + this.knobs.time.reformat(); + } + for (const key of DELAY_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.syncLever.set(v.beats, v.beats ? 'beats' : 'seconds'); + this.syncLever.alpha = shown.idle.has('sync') ? IDLE_ALPHA : 1; + this.pingPongLever.set(v.pingPong, v.pingPong ? 'L · R' : 'off'); + this.pingPongLever.alpha = shown.idle.has('pingPong') ? IDLE_ALPHA : 1; + this.idle = shown.idle; + this.scope.show(shown.train, shown.texts, v.pingPong); + this.highs.text = shown.texts.highs; + } + + /** A frame on, `seconds` on the page's clock: the light runs on, the lamp blinks with the echoes. */ + step(seconds: number): void { + this.scope.step(seconds); + if (this.scope.lamp !== this.lampLit) { + this.lampLit = this.scope.lamp; + this.drawLamp(); + } + } + + private drawLamp(): void { + const { x, y, r } = DELAY_LAYOUT.lamp; + const lit = this.lampLit === true; + const g = this.lampLight.clear(); + if (lit) g.circle(x, y, r + 4).fill({ color: LAMP, alpha: 0.25 }); + g.circle(x, y, r) + .fill(lit ? LAMP : 0x1f4a22) + .stroke({ color: 0x2a2c30, width: 1.5 }); + g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: lit ? 0.7 : 0.2 }); + } + + override layout(): void { + const L = DELAY_LAYOUT; + const put = (widget: Widget, box: Box) => { + widget.position.set(Math.round(box.x), Math.round(box.y)); + widget.resize(box.w, box.h); + }; + put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW }); + this.screenName.position.set(L.screen.x, L.bezel.y + 3); + this.highs.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); + put(this.syncLever, L.sync); + put(this.pingPongLever, L.pingPong); + placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); + for (const key of DELAY_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); + } + L.groups.forEach((group, i) => + placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y), + ); + placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4); + } + + override redraw(): void { + const L = DELAY_LAYOUT; + drawPlate(this.plate.clear(), DELAY_PANEL, ALUMINIUM, 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: 0x1b1c20, width: 1 }); + // A rule each side of a group's name, engraved as the names are. + const r = this.rules.clear(); + L.groups.forEach((group, i) => { + const name = this.groupNames[i]!.ink; + const mid = group.box.y + 6; + const left = group.box.x + 6; + const right = group.box.x + group.box.w - 6; + const gap = name.width / 2 + 6; + const centre = group.box.x + group.box.w / 2; + if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid); + if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid); + }); + r.stroke({ color: INK, width: 1, alpha: 0.5 }); + this.drawLamp(); + } +} diff --git a/src/editor/soundfx/delayView.ts b/src/editor/soundfx/delayView.ts new file mode 100644 index 00000000..63b1ef7f --- /dev/null +++ b/src/editor/soundfx/delayView.ts @@ -0,0 +1,288 @@ +/** + * What a Delay's node draws (#393), as numbers: where the parts of its device sit, how its knobs + * turn, its echoes as bars over time – the screen – and the beat a light runs over them at. Kept + * apart from the drawing so it is checked without a browser. + */ +import type { PropertyBag } from '../../core/model/Properties'; +import { DEFAULT_TEMPO, secondsOfQuarters } from '../../core/music/time'; +import { + DELAY_MAX, + DELAY_ROWS, + DELAY_SYNC, + dampingCutoff, + delayEchoes, + delayShares, + PING_PONG, +} from '../../core/soundfx/nodes'; +import { formatNumber } from '../../ui/math'; +import { secondsText } from './chipView'; +import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView'; +import { brightness, hzText } from './reverbView'; + +export type DelayKey = keyof typeof DELAY_ROWS; +export const DELAY_KEYS = Object.keys(DELAY_ROWS) as DelayKey[]; + +export interface DelayValues extends Record { + /** Whether the time counts quarter notes at the tempo of the voice. */ + beats: boolean; + pingPong: boolean; +} + +const numberOf = (params: PropertyBag, key: DelayKey): number => { + const value = params[key]; + return typeof value === 'number' && Number.isFinite(value) ? value : DELAY_ROWS[key].default; +}; + +/** The numbers a Delay plays with, each its row's default where none is typed in. */ +export function delayValues(params: PropertyBag): DelayValues { + return { + time: numberOf(params, 'time'), + feedback: numberOf(params, 'feedback'), + damping: numberOf(params, 'damping'), + mix: numberOf(params, 'mix'), + beats: (params[DELAY_SYNC.key] ?? DELAY_SYNC.default) === 'beats', + pingPong: (params[PING_PONG.key] ?? PING_PONG.default) === true, + }; +} + +/** + * The settings that do nothing as things stand, greyed as the Reverb's are: at wet 0 there is no + * echo, so neither its time, its feedback, its damping nor its sides are heard. + */ +export function idleDelayKeys(values: DelayValues): Set { + return values.mix > 0 ? new Set() : new Set(['time', 'feedback', 'damping', 'sync', 'pingPong']); +} + +/** Seconds between the echoes: the time, or its quarter notes at `tempo`, never past the line. */ +export function echoSeconds(values: DelayValues, tempo = DEFAULT_TEMPO): number { + const seconds = values.beats ? secondsOfQuarters(values.time, tempo) : values.time; + return Math.max(0, Math.min(DELAY_MAX, seconds)); +} + +/** Note values a time in quarters is named by: whole, dotted and triplet ones. */ +const NOTE_NAMES: readonly [quarters: number, name: string][] = [ + [8, '2/1'], + [6, '1/1.'], + [4, '1/1'], + [3, '1/2.'], + [8 / 3, '1/1T'], + [2, '1/2'], + [1.5, '1/4.'], + [4 / 3, '1/2T'], + [1, '1/4'], + [0.75, '1/8.'], + [2 / 3, '1/4T'], + [0.5, '1/8'], + [0.375, '1/16.'], + [1 / 3, '1/8T'], + [0.25, '1/16'], + [1 / 6, '1/16T'], + [0.125, '1/32'], +]; + +/** + * A time in quarter notes as a musician names it – `1/8.` a dotted eighth, `1/8T` an eighth + * triplet – within half a step of the knob; else its number of beats. + */ +export function beatText(quarters: number): string { + const named = NOTE_NAMES.find(([q]) => Math.abs(q - quarters) <= 0.006); + if (named) return named[1]; + return `${formatNumber(quarters, 2)} beats`; +} + +/** The time knob's number: a note value on beats, else seconds – past the line no longer than it. */ +export function timeText(time: number, beats: boolean): string { + if (beats) return beatText(time); + return time >= DELAY_MAX ? `${secondsText(DELAY_MAX)} max` : secondsText(time); +} + +export interface DelayKnob { + label: string; + taper: number; + decimals: number; +} + +/** + * How each knob turns: the time gives its short end most of the turn – a quarter note, or a + * second, in the middle, a quarter second at a third – so slap-backs are as easy to set as bars. + */ +export const DELAY_KNOBS: Record = { + time: { label: 'Time', taper: 3, decimals: 2 }, + feedback: { label: 'Feedback', taper: 1, decimals: 2 }, + damping: { label: 'Damping', taper: 1, decimals: 2 }, + mix: { label: 'Wet', taper: 1, decimals: 2 }, +}; + +// --- The plate. --- + +/** The plate: as the Compressor's (#379), the panel never takes another size. */ +export const DELAY_PANEL = { w: 680, h: 280 }; + +const KNOB_SLOT = 64; +const GROUP_GAP = 16; +/** The height of a group's name and its rule. */ +const GROUP_LINE = 16; +/** A lever's box, as the Compressor's auto-gain (#379). */ +const LEVER = { w: 118, h: 40 }; +/** The extra the strip of text under the screen takes. */ +export const TEXT_ROW = 14; + +export interface DelayLayout { + title: Box; + /** The dark frame round the screen, and the screen in it. */ + bezel: Box; + screen: Box; + sync: Box; + pingPong: Box; + /** The light that blinks with every echo, beside the device's name and away from the levers' lamps; its own name after it. */ + lamp: { x: number; y: number; r: number }; + lampCaption: { x: number; y: number }; + groups: { label: string; box: Box }[]; + knobs: Record; + screws: { x: number; y: number }[]; +} + +function delayLayout(): DelayLayout { + const { w } = DELAY_PANEL; + const screen = { x: 34, y: 60, w: 300, h: 176 }; + const bezel = { x: 24, y: 44, w: screen.w + 20, h: screen.h + 16 + TEXT_ROW + 6 }; + // The right of the plate: the two levers on top, the knobs below. + const left = bezel.x + bezel.w + 24; + const right = w - 24; + const sync = { x: left, y: 52, ...LEVER }; + const pingPong = { x: right - LEVER.w, y: 52, ...LEVER }; + const groupTop = 130; + const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; + const groups: { label: string; box: Box }[] = []; + const knobs = {} as Record; + let x = left; + for (const [label, keys] of [ + ['TIME', ['time']], + ['ECHOES', ['feedback', 'damping']], + ['MIX', ['mix']], + ] as const) { + groups.push({ label, box: { x, y: groupTop, w: keys.length * KNOB_SLOT, h: GROUP_LINE } }); + for (const key of keys) { + knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL }; + x += KNOB_SLOT; + } + x += GROUP_GAP; + } + return { + title: { x: 34, y: 14, w: 80, h: 24 }, + bezel, + screen, + sync, + pingPong, + lamp: { x: 128, y: 26, r: 6 }, + lampCaption: { x: 156, y: 21 }, + groups, + knobs, + screws: screwsOf(DELAY_PANEL), + }; +} + +export const DELAY_LAYOUT: DelayLayout = delayLayout(); + +// --- The screen: the echoes as bars over time. --- + +/** The most echoes the screen draws; past them it says how many more there are. */ +export const SHOWN_ECHOES = 24; + +/** One bar: when it comes, how loud, on which side, and how much of the highs is left. */ +export interface Echo { + /** Seconds after the sound. */ + t: number; + /** As loud as the sound, 1 the same. */ + level: number; + side: 'both' | 'left' | 'right'; + /** 1 all the highs to 0 down to 500 Hz. */ + bright: number; +} + +export interface EchoTrain { + /** Seconds between the echoes. */ + every: number; + /** How loud the sound itself goes through. */ + dry: number; + /** The echoes drawn, at most `SHOWN_ECHOES`. */ + echoes: Echo[]; + /** All the echoes heard, drawn or not. */ + heard: number; + /** The seconds the screen spans: the bars drawn and one step after the last. */ + span: number; +} + +/** + * Where the highs of the echo `k` end: every round goes through the damping's low-pass (a + * Butterworth, as the Delay's is) once more, and `k` of them fall by 3 dB at `fc·(2^(1/k)−1)^(1/4)`. + */ +export function echoCutoff(damping: number, k: number): number { + return dampingCutoff(damping) * (2 ** (1 / Math.max(1, k)) - 1) ** 0.25; +} + +/** + * A Delay's echoes as they come (#393): the echo `k` after `k` steps, `echo · feedback^(k-1)` as + * loud as the sound, duller with every round – and with ping-pong the first on the left, the next + * on the right, as the line takes the sound in on its left and swaps the sides each round. + */ +export function echoTrain(values: DelayValues, tempo = DEFAULT_TEMPO): EchoTrain { + const every = echoSeconds(values, tempo); + const { dry, echo } = delayShares(values.mix); + const heard = every > 0 ? delayEchoes(values.feedback, values.mix) : 0; + const echoes: Echo[] = []; + for (let k = 1; k <= Math.min(heard, SHOWN_ECHOES); k++) + echoes.push({ + t: k * every, + level: echo * values.feedback ** (k - 1), + side: values.pingPong ? (k % 2 === 1 ? 'left' : 'right') : 'both', + bright: brightness(echoCutoff(values.damping, k)), + }); + // Without an echo the screen still spans a few steps, so the sound sits where it always does. + const span = Math.max(echoes.length + 1, 4) * Math.max(every, 0.01); + return { every, dry, echoes, heard, span }; +} + +/** How tall a bar stands, 1 as loud as the sound to 0 at 60 dB down – evenly in dB, as the Reverb's (#389). */ +export function barHeight(level: number): number { + return level > 0 ? Math.max(0, Math.min(1, 1 + (20 * Math.log10(level)) / 60)) : 0; +} + +/** How long a bar glows after the light has run over it, and the lamp stays lit. */ +export const GLOW_SECONDS = 0.18; +const LAMP_SECONDS = 0.08; + +/** + * The light that runs over the screen (#393): it goes from the sound to the end of the span in + * real time and round again, so it passes the bars at the very beat the echoes come at. `glow` is + * how much each bar is still lit by it, the sound first; the lamp blinks with each echo. + */ +export function sweepAt(train: EchoTrain, seconds: number): { at: number; glow: number[]; lamp: boolean } { + const at = ((seconds % train.span) + train.span) % train.span; + const times = [0, ...train.echoes.map((e) => e.t)]; + const since = times.map((t) => at - t); + const glow = since.map((s) => (s >= 0 && s < GLOW_SECONDS ? 1 - s / GLOW_SECONDS : 0)); + const lampFor = Math.min(LAMP_SECONDS, train.every / 2); + return { at, glow, lamp: since.slice(1).some((s) => s >= 0 && s < lampFor) }; +} + +/** What the screen writes; `more` the echoes heard past the bars drawn. */ +export interface DelayTexts { + every: string; + ends: string; + highs: string; + more: string; +} + +/** What the screen writes: the step between the echoes, when the last has faded, where the highs end. */ +export function delayTexts(values: DelayValues, train: EchoTrain, tempo = DEFAULT_TEMPO): DelayTexts { + const step = secondsText(train.every); + return { + every: values.beats + ? `EVERY ${beatText(values.time)} · ${step} AT ${formatNumber(tempo, 0)} BPM` + : `EVERY ${step}`, + ends: train.heard > 0 ? `ENDS ${secondsText(train.heard * train.every)}` : 'NO ECHOES', + highs: train.heard > 0 ? `HIGHS ${hzText(dampingCutoff(values.damping))}` : '', + more: train.heard > train.echoes.length ? `+${train.heard - train.echoes.length}` : '', + }; +} diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index 12730092..6d49cda6 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -2,6 +2,7 @@ import type { GraphNode } from '../../core/graph'; import { InstrumentChip, InstrumentEnvelope } from '../../core/instrument/nodes'; import { SoundFxCompressor, + SoundFxDelay, SoundFxFilter, SoundFxParametricEq, SoundFxReverb, @@ -11,6 +12,7 @@ import type { EditorState } from '../state'; import { ChipFace } from './ChipFace'; import { CompressorFace } from './CompressorFace'; import { CurveFace } from './CurveFace'; +import { DelayFace } from './DelayFace'; import { EnvelopeFace } from './EnvelopeFace'; import { EqPanel } from './EqPanel'; import { FilterPanel } from './FilterPanel'; @@ -18,7 +20,7 @@ import { KnobFace } from './KnobFace'; import { GAIN_DEVICE, MIX_DEVICE, PAN_DEVICE, PITCH_DEVICE } from './KnobPanel'; import { ReverbFace } from './ReverbFace'; -/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392), by type. */ +/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393), 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); @@ -31,5 +33,6 @@ export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode ); if (knobbed) return new KnobFace(state, assetId, node.id, knobbed); if (node.type === SoundFxReverb.type) return new ReverbFace(state, assetId, node.id); + if (node.type === SoundFxDelay.type) return new DelayFace(state, assetId, node.id); return null; } diff --git a/src/ui/widgets/Knob.ts b/src/ui/widgets/Knob.ts index 5559a281..1acf96e9 100644 --- a/src/ui/widgets/Knob.ts +++ b/src/ui/widgets/Knob.ts @@ -257,6 +257,11 @@ export class Knob extends VStack { this.show(value); } + /** Writes the number below anew, for a panel whose `format` reads otherwise now (#393). */ + reformat(): void { + if (!(this.field instanceof NumberField)) this.field.setText(this.text(this.current)); + } + preview(value: number): void { this.show(value); this.options.onInput?.(value); diff --git a/tests/editor/delay-view.test.ts b/tests/editor/delay-view.test.ts new file mode 100644 index 00000000..454404ce --- /dev/null +++ b/tests/editor/delay-view.test.ts @@ -0,0 +1,175 @@ +/** + * A Delay's node as a device (#393): every part on the plate and none over another, the time knob + * giving its short end the turn and naming notes on beats, the echoes on the screen as the line + * plays them, and the light that runs over them at their beat. + */ +import { describe, expect, it } from 'vitest'; +import { DELAY_MAX, DELAY_ROWS, dampingCutoff, delayEchoes } from '../../src/core/soundfx/nodes'; +import { + barHeight, + beatText, + DELAY_KEYS, + DELAY_KNOBS, + DELAY_LAYOUT, + DELAY_PANEL, + type DelayValues, + delayTexts, + delayValues, + echoCutoff, + echoSeconds, + echoTrain, + GLOW_SECONDS, + idleDelayKeys, + SHOWN_ECHOES, + sweepAt, + timeText, +} from '../../src/editor/soundfx/delayView'; +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 <= DELAY_PANEL.w && a.y + a.h <= DELAY_PANEL.h; + +const values = (over: Partial = {}): DelayValues => ({ ...delayValues({}), ...over }); + +describe('the Delay plate', () => { + const L = DELAY_LAYOUT; + const screws = L.screws.map((s) => ({ x: s.x - 7, y: s.y - 7, w: 14, h: 14 })); + + it('keeps the screen, the levers and the knobs on the plate, clear of each other and the screws', () => { + const parts: Box[] = [L.bezel, L.sync, L.pingPong, ...DELAY_KEYS.map((key) => knobBox(L.knobs[key]))]; + 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('sets the echo lamp beside the name, away from the lamps of the levers', () => { + 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 }; + expect(lamp.y + lamp.h).toBeLessThan(L.bezel.y); + expect(lamp.x + lamp.w).toBeLessThan(L.sync.x); + expect(overlap(lamp, L.title)).toBe(false); + }); +}); + +describe('the Delay knobs', () => { + const turn = (value: number) => { + const row = DELAY_ROWS.time; + return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...DELAY_KNOBS.time }); + }; + + it('puts a quarter note or a second in the middle of the time and a quarter second at a third', () => { + expect(turn(1)).toBeCloseTo(0.5, 2); + expect(turn(0.25)).toBeGreaterThan(0.28); + expect(turn(0.25)).toBeLessThan(0.34); + }); + + it('names a time on beats as a note, dotted and triplet ones too, within the knob’s step', () => { + expect(beatText(1)).toBe('1/4'); + expect(beatText(0.75)).toBe('1/8.'); + expect(beatText(0.33)).toBe('1/8T'); + expect(beatText(0.38)).toBe('1/16.'); + expect(beatText(0.6)).toBe('0.6 beats'); + expect(timeText(0.5, true)).toBe('1/8'); + }); + + it('holds a time in seconds to the line, and says so', () => { + expect(timeText(0.25, false)).toBe('250 ms'); + expect(timeText(5, false)).toBe('2 s max'); + expect(echoSeconds(values({ time: 5 }))).toBe(DELAY_MAX); + }); + + it('counts beats at the tempo, never past the line', () => { + expect(echoSeconds(values({ time: 0.75, beats: true }))).toBeCloseTo(0.375, 9); + expect(echoSeconds(values({ time: 1, beats: true }), 60)).toBeCloseTo(1, 9); + expect(echoSeconds(values({ time: 8, beats: true }))).toBe(DELAY_MAX); + }); + + it('greys all but the wet while the wet leaves no echo', () => { + expect([...idleDelayKeys(values({ mix: 0 }))].sort()).toEqual( + ['damping', 'feedback', 'pingPong', 'sync', 'time'].sort(), + ); + expect(idleDelayKeys(values({ mix: 0.01 })).size).toBe(0); + }); +}); + +describe('the echoes on the screen', () => { + it('draws an echo every step, each feedback times as loud, as many as are heard', () => { + const train = echoTrain(values({ time: 0.3, feedback: 0.5, mix: 0.5 })); + expect(train.heard).toBe(delayEchoes(0.5, 0.5)); + expect(train.echoes).toHaveLength(train.heard); + train.echoes.forEach((echo, i) => { + expect(echo.t).toBeCloseTo(0.3 * (i + 1), 9); + expect(echo.level).toBeCloseTo(0.5 ** i, 9); + }); + expect(train.dry).toBe(1); + }); + + it('draws at most so many and says how many more there are', () => { + const v = values({ time: 0.05, feedback: 0.92, mix: 0.8 }); + const train = echoTrain(v); + expect(train.echoes).toHaveLength(SHOWN_ECHOES); + expect(delayTexts(v, train).more).toBe(`+${train.heard - SHOWN_ECHOES}`); + expect(delayTexts(v, train).ends).toBe(`ENDS ${(train.heard * 0.05).toFixed(2)} s`); + }); + + it('takes turns left and right with ping-pong, the first on the left', () => { + const sides = (pingPong: boolean) => + echoTrain(values({ pingPong })) + .echoes.slice(0, 4) + .map((e) => e.side); + expect(sides(true)).toEqual(['left', 'right', 'left', 'right']); + expect(sides(false)).toEqual(['both', 'both', 'both', 'both']); + }); + + it('makes every echo duller than the last, the first as dull as the damping', () => { + expect(echoCutoff(0.4, 1)).toBeCloseTo(dampingCutoff(0.4), 6); + const bright = echoTrain(values({ damping: 0.4, feedback: 0.8 })).echoes.map((e) => e.bright); + for (let i = 1; i < bright.length; i++) expect(bright[i]!).toBeLessThan(bright[i - 1]!); + }); + + it('stands a bar as tall as its level evenly in dB, 60 dB down at the floor', () => { + expect(barHeight(1)).toBe(1); + expect(barHeight(0.1)).toBeCloseTo(2 / 3, 9); + expect(barHeight(0.001)).toBeCloseTo(0, 9); + expect(barHeight(0)).toBe(0); + }); + + it('draws the sound alone, and says so, where the wet is 0', () => { + const v = values({ mix: 0 }); + const train = echoTrain(v); + expect(train.echoes).toHaveLength(0); + expect(delayTexts(v, train)).toMatchObject({ ends: 'NO ECHOES', highs: '', more: '' }); + }); + + it('writes the note and the tempo it counts at on beats', () => { + const v = values({ time: 0.75, beats: true }); + expect(delayTexts(v, echoTrain(v)).every).toBe('EVERY 1/8. · 375 ms AT 120 BPM'); + expect(delayTexts(values(), echoTrain(values())).every).toBe('EVERY 250 ms'); + }); +}); + +describe('the running light', () => { + const train = echoTrain(values({ time: 0.3, feedback: 0.5, mix: 0.5 })); + + it('lights a bar the moment the light reaches it, at the beat of the echoes, and lets it fade', () => { + const at = sweepAt(train, 0.6); + expect(at.glow[2]).toBeCloseTo(1, 9); + expect(at.glow[1]).toBe(0); + expect(at.lamp).toBe(true); + expect(sweepAt(train, 0.6 + GLOW_SECONDS / 2).glow[2]).toBeCloseTo(0.5, 9); + expect(sweepAt(train, 0.45).lamp).toBe(false); + }); + + it('runs round the span again and again', () => { + expect(sweepAt(train, train.span + 0.6).at).toBeCloseTo(0.6, 9); + expect(sweepAt(train, -0.1).at).toBeCloseTo(train.span - 0.1, 9); + }); + + it('blinks the lamp with the echoes only, not with the sound', () => { + expect(sweepAt(train, 0).lamp).toBe(false); + expect(sweepAt(echoTrain(values({ mix: 0 })), 0).lamp).toBe(false); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index e0776ad3..85ee1e8d 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -500,7 +500,8 @@ export async function sound(t) { cut.stopped === beforeLetRing.stopped + 2, JSON.stringify({ before: beforeLetRing.stopped, letRing, cut }), ); - // #354: the Delay bypassed is drawn faded and says so, and the voice plays as if it were not there. + // #354: the Delay bypassed is drawn faded, and the voice plays as if it were not there. Since + // #393 the Delay is a device, which writes no setting rows, so "Bypass: true" is not read. await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; graph.nodes.find((n) => n.id === 'fx-delay').params.bypass = true;`); @@ -512,11 +513,8 @@ export async function sound(t) { const heardBypass = await waitFor(stats, (s) => s.started > beforeBypass.started); await evaluate('__gm.sound.stopAll()'); check( - 'a bypassed Delay is drawn faded with "Bypass: true", and the voice passes only the gain', - bypassed.alpha < 0.5 && - bypassed.gain === 1 && - bypassed.settings?.includes('Bypass: true') && - heardBypass.last?.stages === 1, + 'a bypassed Delay is drawn faded, and the voice passes only the gain', + bypassed.alpha < 0.5 && bypassed.gain === 1 && heardBypass.last?.stages === 1, JSON.stringify({ bypassed, last: heardBypass.last }), ); await mutate(` @@ -1387,6 +1385,64 @@ export async function sound(t) { 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-pn');`); + // #393: a Delay is a device of its own size with its echoes on a screen and a light running over + // them at their beat. Ping-pong puts them left and right by turns, Sync names the time as a + // note, the time knob turns it; each is one undo step. + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; + graph.nodes.push(${JSON.stringify(node('fx-dl', 'SoundFxDelay', 0, 400, { time: 0.5, feedback: 0.5, mix: 0.5 }))});`); + await evaluate('__gm.soundfx.fit()'); + await sleep(100); + const dlShown = await json("__gm.delay.panel('fx-dl')"); + await sleep(150); + const dlLater = (await json("__gm.delay.panel('fx-dl')")).scope.at; + await click(dlShown.pingPong.at.x, dlShown.pingPong.at.y); + const dlPingPong = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") }; + await click(dlShown.sync.at.x, dlShown.sync.at.y); + const dlBeats = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") }; + const dlTime = dlShown.knobs.time.dial; + await drag(dlTime.x, dlTime.y, dlTime.x, dlTime.y - 20); + const dlTurned = (await knobParams('fx-dl')).time; + for (let i = 0; i < 3; i++) await press('z', { ctrl: true }); + const dlUndone = { params: await knobParams('fx-dl'), panel: await json("__gm.delay.panel('fx-dl')") }; + const dlSides = (panel) => + panel.scope.echoes + .slice(0, 3) + .map((e) => e.side) + .join(' '); + check( + 'a Delay node is a device with its echoes on a screen and a running light; ping-pong, sync and the time knob are one undo step each', + dlShown.size.w === 680 && + dlShown.size.h === 280 && + Object.values(dlShown.inputs).length === 5 && + Object.values(dlShown.inputs).every((p) => p.x < dlShown.rect.x) && + Object.values(dlShown.outputs).every((p) => p.x > dlShown.rect.x + dlShown.rect.w) && + dlShown.knobs.time.number === '500 ms' && + dlShown.scope.heard === 10 && + dlShown.scope.echoes.length === 10 && + dlShown.scope.says.every === 'EVERY 500 ms' && + dlShown.scope.says.ends === 'ENDS 5 s' && + dlSides(dlShown) === 'both both both' && + // The light runs on by itself. + dlLater !== dlShown.scope.at && + dlPingPong.params.pingPong === true && + dlPingPong.panel.pingPong.lit && + dlSides(dlPingPong.panel) === 'left right left' && + dlBeats.params.sync === 'beats' && + dlBeats.panel.sync.says === 'beats' && + dlBeats.panel.knobs.time.number === '1/8' && + dlBeats.panel.scope.says.every === 'EVERY 1/8 · 250 ms AT 120 BPM' && + dlTurned > 0.5 && + dlUndone.params.time === 0.5 && + dlUndone.params.sync !== 'beats' && + dlUndone.params.pingPong !== true && + dlUndone.panel.knobs.time.number === '500 ms' && + dlSides(dlUndone.panel) === 'both both both', + JSON.stringify({ dlShown, dlLater, dlPingPong, dlBeats, dlTurned, dlUndone }), + ); + 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-dl');`); await showTab('scene'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;