From b6749cc43db10d4041bf881862732f01cdcedaad Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sat, 10 Oct 2026 21:02:50 +0200 Subject: [PATCH] Show Tremolo, Vibrato, Phaser and Flanger as devices that swing (#398) The four effects that swing were still drawn as rows of numbers. Like the Delay, the Saturation and the Limiter, each node now shows a device of a fixed size. Its screen draws what it does over the first seconds of a sound, and a light plays that in real time, over and over. - One device for all four (ModulationPanel, ModulationFace and modulationView), as KnobPanel is for Gain, Pitch, Mix and Pan. A short description gives each its title, plate, buttons and knobs. - Tremolo, 640x280 in cream: a wave its volume shapes, the volume as its outline. It says DOWN TO 50%, EVERY 200 ms and DIPS -6 dB. - Vibrato, 640x280 in plum: the pitch in semitones, cool below and warm above. Under a wet below 1 the dry pitch stands beside it. It says how late the voice is (LATE 0.93 ms). - Phaser, 760x280 in mustard, and Flanger, 680x280 in steel blue: the response from 20 Hz to 20 kHz, and under it a lane of the turn or the delay over time. The response is drawn where the light is, so the notches swirl and the comb jets. A comb finer than a column fills it. - The light loops over 1, 2, 4 or 8 s, two LFO swings at least, for a Flanger as long as its sweep takes to reach 20 ms. The face only keeps the editor drawing while something swings and is heard. - A Rate lamp beside each name goes from dark to lit with the swing. - Nothing is listened to. The pictures use the worklets' own numbers: lfoAt, vibratoSpeedUp (now exported from lfo.ts), the allpass chain with its feedback, and the comb, reckoned at 48 kHz. - Phaser and Flanger take 4 preset columns. Phased Pad was looked at in a picture, nothing overlaps. - Exported from nodes.ts: LFO_SHAPE, lfoShapeOf, TREMOLO_ROWS, VIBRATO_ROWS, PHASER_STAGE_ROW and phaserStagesOf. FLANGER_MAX_MS is exported from phaser.ts. Decided here, as the request left the look open: - A notch counts where the response falls below -3 dB, not some dB under the loudest point. Feedback rings peaks far above 0 dB, and the first rule counted 7 notches on a Phaser of 12 stages. - Rate and Depth of Tremolo and Vibrato are big knobs, Wet is small. Tests: Unit modulation-view (stein2): 22 tests, new. Phaser and Flanger give the dB of their worklets on a sine within 0.005 dB, the Tremolo its samples, the Vibrato the pitch it reads at. Counter-check: with the sign of the Phaser's feedback flipped in one place, exactly the worklet test fails. e2e 34 (atx): ALL PASSED, 142 checks, 4 new. Each device has its size, its ports outside, a light that runs by itself, and a button and a knob as one undo step each. Picking 8 stages on the Phaser draws 4 notches. Gate (atx): CHECK EXIT 0, 291 files, 4467 tests. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- src/core/instrument/chipPresets.ts | 4 +- src/core/soundfx/lfo.ts | 6 +- src/core/soundfx/nodes.ts | 10 +- src/core/soundfx/phaser.ts | 2 +- src/editor/debug/graphHooks.ts | 55 ++ src/editor/soundfx/ModulationFace.ts | 80 +++ src/editor/soundfx/ModulationPanel.ts | 694 ++++++++++++++++++++++++++ src/editor/soundfx/modulationView.ts | 651 ++++++++++++++++++++++++ src/editor/soundfx/nodeFaces.ts | 6 +- tests/editor/modulation-view.test.ts | 278 +++++++++++ tools/e2e/34-sound.mjs | 83 +++ 12 files changed, 1860 insertions(+), 11 deletions(-) create mode 100644 src/editor/soundfx/ModulationFace.ts create mode 100644 src/editor/soundfx/ModulationPanel.ts create mode 100644 src/editor/soundfx/modulationView.ts create mode 100644 tests/editor/modulation-view.test.ts diff --git a/README.md b/README.md index 32fcf750..f14883ef 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, 1970 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1974 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 a27ab99b..4716f6f5 100644 --- a/src/core/instrument/chipPresets.ts +++ b/src/core/instrument/chipPresets.ts @@ -396,11 +396,13 @@ 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) four. + * a Delay's (#393), a Phaser's and a Flanger's (#398) four. */ const COLUMNS_OF: Readonly> = { [SoundFxFilter.type]: 4, [SoundFxDelay.type]: 4, + [SoundFxPhaser.type]: 4, + [SoundFxFlanger.type]: 4, [SoundFxPitch.type]: 2, [SoundFxMix.type]: 2, [INSTRUMENT_CHIP]: 5, diff --git a/src/core/soundfx/lfo.ts b/src/core/soundfx/lfo.ts index f716969c..6a356485 100644 --- a/src/core/soundfx/lfo.ts +++ b/src/core/soundfx/lfo.ts @@ -68,7 +68,7 @@ export function lfoSwing(shape: LfoShape): number { } /** How much faster the sound is read at the top of a Vibrato `depth` semitones deep. */ -const speedUp = (depth: number) => 2 ** (Math.max(0, depth) / 12) - 1; +export const vibratoSpeedUp = (depth: number) => 2 ** (Math.max(0, depth) / 12) - 1; /** The longest a Vibrato's delay may get, and so what its line holds. */ const VIBRATO_MAX_DELAY = 0.5; @@ -79,7 +79,7 @@ const VIBRATO_MAX_DELAY = 0.5; */ export function vibratoDelay(shape: LfoShape, rate: number, depth: number): number { if (!(rate > 0)) return 0; - return Math.min(VIBRATO_MAX_DELAY / 2, (speedUp(depth) * lfoSwing(shape)) / rate); + return Math.min(VIBRATO_MAX_DELAY / 2, (vibratoSpeedUp(depth) * lfoSwing(shape)) / rate); } const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!); @@ -134,7 +134,7 @@ export class Vibrato { while (this.lines.length < outputs.length) this.lines.push(new Float32Array(this.size)); for (let i = 0; i < length; i++) { const rate = Math.max(1e-3, at(numbers.rate, i)); - const swing = (speedUp(at(numbers.depth, i)) / rate) * swingAt(this.shape, this.phase); + const swing = (vibratoSpeedUp(at(numbers.depth, i)) / rate) * swingAt(this.shape, this.phase); const delay = Math.max(0, Math.min(VIBRATO_MAX_DELAY, this.center + swing)) * this.sampleRate; const read = this.written - delay; const from = Math.floor(read); diff --git a/src/core/soundfx/nodes.ts b/src/core/soundfx/nodes.ts index e8549ab5..1b60ac60 100644 --- a/src/core/soundfx/nodes.ts +++ b/src/core/soundfx/nodes.ts @@ -871,7 +871,7 @@ export const PHASER_ROWS = { feedback: PHASER_FEEDBACK, mix: PHASER_MIX, }; -const PHASER_STAGE_ROW = { +export const PHASER_STAGE_ROW = { key: 'stages', label: 'Stages', kind: 'enum' as const, @@ -879,7 +879,7 @@ const PHASER_STAGE_ROW = { options: PHASER_STAGES.map((n) => ({ value: String(n), label: String(n) })), }; -function phaserStagesOf(settings: PropertyBag): number { +export function phaserStagesOf(settings: PropertyBag): number { const stages = Number(settings[PHASER_STAGE_ROW.key] ?? PHASER_STAGE_ROW.default); return PHASER_STAGES.find((n) => n === stages) ?? 4; } @@ -1146,7 +1146,7 @@ export const SoundFxSaturation: SoundFxNodeDef = effect( 'own', ); -const LFO_SHAPE = { +export const LFO_SHAPE = { key: 'shape', label: 'Shape', kind: 'enum' as const, @@ -1154,7 +1154,7 @@ const LFO_SHAPE = { options: LFO_SHAPES, }; -function lfoShapeOf(settings: PropertyBag): LfoShape { +export function lfoShapeOf(settings: PropertyBag): LfoShape { return LFO_SHAPES.find((option) => option.value === settings[LFO_SHAPE.key])?.value ?? 'sine'; } @@ -1162,6 +1162,8 @@ const TREMOLO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0, 20, 0.01), default: const TREMOLO_DEPTH = { ...numberRow('depth', 'Depth (%)', 0, 100, 1), default: 50 }; const VIBRATO_RATE = { ...numberRow('rate', 'Rate (Hz)', 0.5, 20, 0.01), default: 5 }; const VIBRATO_DEPTH = { ...numberRow('depth', 'Depth (st)', 0, 4, 0.01), default: 0.5 }; +export const TREMOLO_ROWS = { rate: TREMOLO_RATE, depth: TREMOLO_DEPTH, wet: WET }; +export const VIBRATO_ROWS = { rate: VIBRATO_RATE, depth: VIBRATO_DEPTH, wet: WET }; export const SoundFxTremolo: SoundFxNodeDef = effect( { diff --git a/src/core/soundfx/phaser.ts b/src/core/soundfx/phaser.ts index cf332345..cf628ad3 100644 --- a/src/core/soundfx/phaser.ts +++ b/src/core/soundfx/phaser.ts @@ -72,7 +72,7 @@ export class Phaser { } /** The longest a Flanger delays its copy, in ms; a delay past it stays there, as sfxr's does at its own end. */ -const FLANGER_MAX_MS = 20; +export const FLANGER_MAX_MS = 20; /** * The flanger, which sfxr calls its phaser: the sound and a copy `offset` ms late. `sweep` moves diff --git a/src/editor/debug/graphHooks.ts b/src/editor/debug/graphHooks.ts index adac4223..d57be144 100644 --- a/src/editor/debug/graphHooks.ts +++ b/src/editor/debug/graphHooks.ts @@ -9,6 +9,7 @@ import { CompressorPanel } from '../soundfx/CompressorPanel'; import { KnobPanel } from '../soundfx/KnobPanel'; import { DelayPanel } from '../soundfx/DelayPanel'; import { LimiterPanel } from '../soundfx/LimiterPanel'; +import { ModulationPanel } from '../soundfx/ModulationPanel'; import { SaturationPanel } from '../soundfx/SaturationPanel'; import { ReverbPanel } from '../soundfx/ReverbPanel'; import { CurvePanel } from '../soundfx/CurvePanel'; @@ -46,6 +47,56 @@ export function graphHooks({ state, editor }: HookContext) { : null, }; }; + /** + * The panel a Tremolo, a Vibrato, a Phaser or a Flanger node shows (#398), in the sound FX graph + * or the instrument graph: whether it is shown and where, its knobs and buttons, which settings + * are idle, what the screen draws and says, where its light is, and the node's ports. + */ + const modulationPanel = (nodeId: string) => { + const canvas = [editor.soundFxEditor.canvas, editor.instrumentEditor.canvas].find( + (c) => c.faceOf(nodeId)?.widget instanceof ModulationPanel, + ); + const panel = canvas?.faceOf(nodeId)?.widget; + if (!canvas || !(panel instanceof ModulationPanel)) 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 scope = panel.scope; + const picture = scope.picture; + return { + kind: panel.device.kind, + 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], + picked: panel.pickButtons.find((b) => b.picked)?.value ?? null, + picks: Object.fromEntries(panel.pickButtons.map((b) => [b.value, screenCentre(b)])), + side: panel.sideText.text, + lamp: panel.lamp, + scope: { + says: scope.texts, + /** The seconds the screen spans, the lowest and highest the light reads, whether it moves. */ + span: picture?.span ?? null, + lowest: picture?.lowest ?? null, + highest: picture?.highest ?? null, + moving: picture?.moving ?? false, + /** Where the light is, in seconds after the sound, and what it reads there. */ + at: scope.at, + reading: scope.reading, + /** A Phaser's or a Flanger's notches drawn now, by Hz. */ + notches: 'notches' in scope ? scope.notches : null, + }, + }; + }; return { graph: { current: () => graphEditor.currentAssetId, @@ -576,6 +627,10 @@ export function graphHooks({ state, editor }: HookContext) { }; }, }, + tremolo: { panel: modulationPanel }, + vibrato: { panel: modulationPanel }, + phaser: { panel: modulationPanel }, + flanger: { panel: modulationPanel }, instrument: { current: () => editor.instrumentEditor.currentAssetId, info: () => editor.instrumentEditor.shownInfo, diff --git a/src/editor/soundfx/ModulationFace.ts b/src/editor/soundfx/ModulationFace.ts new file mode 100644 index 00000000..4ef7d12c --- /dev/null +++ b/src/editor/soundfx/ModulationFace.ts @@ -0,0 +1,80 @@ +import { SetNodeParamCommand } from '../../core/commands/graphCommands'; +import type { GraphNode } from '../../core/graph'; +import { findGraphAsset } from '../../core/model/Project'; +import type { NodeFace } from '../graph/nodeFace'; +import type { EditorState } from '../state'; +import { compressorWired } from './CompressorEditor'; +import { type ModHost, ModulationPanel } from './ModulationPanel'; +import { idleModKeys, loopAt, type ModDevice, modTexts, modValues, swingPicture } from './modulationView'; +import { NodeParamDrag } from './nodeParamDrag'; + +/** + * A Tremolo, a Vibrato, a Phaser or a Flanger node drawn as its device (#398): what it does over + * the first seconds of a sound, reckoned from its settings, and a light that plays it in real time + * for as long as something swings and the node is in the picture. It listens to nothing. + */ +export class ModulationFace implements NodeFace, ModHost { + readonly widget: ModulationPanel; + private readonly drag: NodeParamDrag; + + constructor( + private readonly state: EditorState, + private readonly assetId: string, + private readonly nodeId: string, + private readonly device: ModDevice, + ) { + this.widget = new ModulationPanel(device, this); + this.drag = new NodeParamDrag(state, `${device.kind}-face`); + } + + private node(): GraphNode | null { + const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find( + (n) => n.id === this.nodeId, + ); + return node?.type === this.device.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 = modValues(this.device, node.params); + const picture = swingPicture(this.device, values); + this.widget.sync({ + values, + wired: this.wired, + idle: idleModKeys(this.device, values), + picture, + texts: modTexts(this.device, values, picture), + }); + } + + /** The light runs by the page's clock; with nothing that swings there is nothing to move. */ + tick(): boolean { + const picture = this.widget.scope.picture; + if (!picture?.moving) return false; + this.widget.step(loopAt(picture.span, performance.now() / 1000)); + return true; + } + + hide(): void {} + + preview(key: string, value: number): void { + const node = this.node(); + if (node) this.drag.preview(node, key, value); + } + + commit(key: string, value: number): void { + const node = this.node(); + if (node) this.drag.commit(this.assetId, node, key, value); + } + + pick(value: string): void { + const node = this.node(); + const pick = this.device.pick; + if (node && pick && pick.of(node.params) !== value) + this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, pick.key, value)); + } +} diff --git a/src/editor/soundfx/ModulationPanel.ts b/src/editor/soundfx/ModulationPanel.ts new file mode 100644 index 00000000..e20485bc --- /dev/null +++ b/src/editor/soundfx/ModulationPanel.ts @@ -0,0 +1,694 @@ +/** + * The Tremolo, the Vibrato, the Phaser and the Flanger as devices (#398), what their nodes show in + * the graph: a plate of their own, a screen, a lamp beside the name that swings with them, the + * buttons of a shape or of stages, and their knobs. A Tremolo's screen draws a wave its volume + * shapes over the first seconds of a sound, a Vibrato's the pitch; a Phaser's and a Flanger's what + * they do to each frequency, and in a lane under it where that is going. A light plays it all in + * real time, over and over. + */ +import { Graphics, type Text, type TextStyle } from 'pixi.js'; +import { mixColor } from '../../core/util/color'; +import { crispText, Knob, textStyle, Widget } from '../../ui'; +import { secondsText } from './chipView'; +import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel'; +import { TEXT_ROW } from './delayView'; +import { type Box, KNOB_CAPTION } from './hardwareView'; +import { + heightOf, + lampOf, + MOD_LAYOUTS, + type ModDevice, + type ModKind, + type ModTexts, + type ModValues, + notchesOf, + RESPONSE_FLOOR, + RESPONSE_TOP, + responseColumns, + responseDb, + shareOfHz, + type SwingPicture, + swingValueAt, + wetKey, +} from './modulationView'; +import { PickButton, type PickLook } from './ReverbPanel'; + +/** What the panel asks of the node it sits in. */ +export interface ModHost { + preview(key: string, value: number): void; + commit(key: string, value: number): void; + pick(value: string): void; +} + +/** What the panel shows of a node. */ +export interface ModShown { + values: ModValues; + wired: (key: string) => boolean; + /** The settings that do nothing as things stand. */ + idle: ReadonlySet; + picture: SwingPicture; + texts: ModTexts; +} + +/** A device's colours: its plate, its buttons and knobs, its screen, and its lamp dark and lit. */ +interface ModLook { + plate: PlateLook; + buttons: PickLook; + arc: number; + glass: number; + grid: number; + text: number; + dim: number; + trace: number; + /** A Vibrato's pitch goes from this below to the trace above. */ + under: number; + lamp: [off: number, on: number]; +} + +/** + * A Tremolo in the cream and red of an old amp's front, a Vibrato in plum, a Phaser in mustard as + * the pedals of the 70s were, a Flanger in steel blue with a violet jet on its screen. + */ +const LOOKS: Record = { + tremolo: { + plate: { + stops: [ + [0, 0xf4ecd6], + [0.5, 0xe6d9b8], + [1, 0xcdbb92], + ], + rim: 0x7a6740, + grain: 0x5c4a28, + }, + buttons: { face: 0x4a3f2e, hover: 0x5c5040, picked: 0xc0392b, shadow: 0x241d14 }, + arc: 0x8a2416, + glass: 0x110d08, + grid: 0x2e2618, + text: 0xffd28a, + dim: 0x7a6a50, + trace: 0xffb347, + under: 0xffb347, + lamp: [0x5a1a14, 0xff3b2a], + }, + vibrato: { + plate: { + stops: [ + [0, 0xf2c6dc], + [0.5, 0xe0a6c4], + [1, 0xc283a6], + ], + rim: 0x6e2f50, + grain: 0x4f1d38, + }, + buttons: { face: 0x4a2a3c, hover: 0x5c3a4e, picked: 0x8e3fc8, shadow: 0x24121c }, + arc: 0x4a0f30, + glass: 0x0e0912, + grid: 0x2a1f30, + text: 0xf2c6ff, + dim: 0x7a6080, + trace: 0xffa8d8, + under: 0x8fb4ff, + lamp: [0x3e1f4a, 0xc56cff], + }, + phaser: { + plate: { + stops: [ + [0, 0xf6dc7a], + [0.5, 0xe9c552], + [1, 0xcfa632], + ], + rim: 0x7a5a10, + grain: 0x5c420a, + }, + buttons: { face: 0x3e3520, hover: 0x504530, picked: 0xd9531e, shadow: 0x1e190c }, + arc: 0x5a3a00, + glass: 0x07100f, + grid: 0x18302c, + text: 0x9ff5e8, + dim: 0x4f7a72, + trace: 0x5ef0d8, + under: 0x5ef0d8, + lamp: [0x5a2a10, 0xff6a2a], + }, + flanger: { + plate: { + stops: [ + [0, 0xc9d4e2], + [0.5, 0xaebdd0], + [1, 0x8c9fb8], + ], + rim: 0x3c4a60, + grain: 0x2a3648, + }, + buttons: { face: 0x2a3446, hover: 0x3a4456, picked: 0x6a4fd0, shadow: 0x141a24 }, + arc: 0x1c2a44, + glass: 0x0a0a14, + grid: 0x22223a, + text: 0xd8c8ff, + dim: 0x6a6a90, + trace: 0xb48cff, + under: 0xb48cff, + lamp: [0x1f3550, 0x5ab4ff], + }, +}; + +const BEZEL = 0x2a2624; +const BEZEL_TEXT = 0xe4dccf; +const IDLE_ALPHA = 0.4; +/** Cycles of the wave a Tremolo's volume shapes, across its screen. */ +const CARRIER = 48; +/** How finely a Vibrato's pitch is coloured: by chunks of so many points. */ +const PITCH_CHUNK = 20; + +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; +} + +/** What both screens share: the glass, what they write and where the light is. */ +abstract class ModScope extends Widget { + protected readonly g = new Graphics(); + protected readonly light = new Graphics(); + protected readonly under: Record<'left' | 'right', Text>; + picture: SwingPicture | null = null; + values: ModValues | null = null; + texts: ModTexts = { side: '', left: '', right: '' }; + /** Where the light is, in seconds after the sound, and what it reads there (checks). */ + at = 0; + reading = 0; + + constructor( + protected readonly device: ModDevice, + protected readonly look: ModLook, + ) { + super(); + this.addChild(this.g, this.light); + this.under = { left: this.made(9, look.text, 0, 0), right: this.made(9, look.text, 1, 0) }; + } + + protected made(size: number, fill: number, ax: number, ay: number, text = ''): Text { + const t = crispText({ text, style: scopeStyle(size, fill) }); + t.anchor.set(ax, ay); + t.eventMode = 'none'; + this.addChild(t); + return t; + } + + show(picture: SwingPicture, values: ModValues, texts: ModTexts): void { + this.picture = picture; + this.values = values; + this.texts = texts; + if (!picture.moving) this.at = 0; + this.reading = swingValueAt(this.device, values, this.at); + this.invalidate(); + } + + protected get glassH(): number { + return this.h - TEXT_ROW; + } + + /** The glass and what is written under it; the rest is each screen's own. */ + protected drawGlass(): void { + this.g.roundRect(0, 0, this.w, this.glassH, 4).fill(this.look.glass); + const y = this.glassH + 3; + this.under.left.text = this.texts.left; + this.under.left.position.set(0, y); + this.under.right.text = this.texts.right; + this.under.right.position.set(this.w, y); + } + + /** `t` seconds after the sound: the light moves there. */ + step(t: number): void { + if (!this.picture || !this.values) return; + this.at = t; + this.reading = swingValueAt(this.device, this.values, t); + this.drawLight(); + } + + /** A faint line where every swing of the LFO starts, as long as they are not too close. */ + protected drawSwings(x0: number, w: number, top: number, bottom: number): void { + const rate = this.values?.numbers.rate ?? 0; + const span = this.picture?.span ?? 1; + if (!(rate > 0) || span * rate > 40) return; + for (let k = 1; k / rate < span - 1e-9; k++) { + const x = x0 + (k / rate / span) * w; + this.g.moveTo(x, top).lineTo(x, bottom); + } + this.g.stroke({ color: this.look.grid, width: 1 }); + } + + protected abstract drawLight(): void; +} + +/** The Tremolo's and the Vibrato's screen: the volume, or the pitch, over the first seconds of a sound. */ +class SwingScope extends ModScope { + private readonly marks: Record<'top' | 'bottom' | 'span', Text>; + + constructor(device: ModDevice, look: ModLook) { + super(device, look); + this.marks = { + top: this.made(8, look.dim, 0, 0), + bottom: this.made(8, look.dim, 0, 1), + span: this.made(8, look.dim, 1, 1), + }; + } + + private get plot(): Box { + return { x: 8, y: 12, w: this.w - 16, h: this.glassH - 28 }; + } + + private xOf(t: number): number { + const p = this.plot; + return p.x + (t / (this.picture?.span ?? 1)) * p.w; + } + + /** Where a Vibrato's pitch sits up the screen. */ + private yOf(value: number): number { + const p = this.plot; + return p.y + (1 - (this.picture ? heightOf(this.picture, value) : 0.5)) * p.h; + } + + override redraw(): void { + const g = this.g.clear(); + this.drawGlass(); + const picture = this.picture; + const p = this.plot; + const mid = p.y + p.h / 2; + this.marks.top.text = ''; + this.marks.bottom.text = ''; + if (picture) { + this.drawSwings(p.x, p.w, p.y - 4, p.y + p.h + 4); + const trace = picture.trace; + const xAt = (i: number) => p.x + (i / (trace.length - 1)) * p.w; + if (this.device.kind === 'tremolo') { + // Full volume up and down, and the middle the wave swings round. + g.moveTo(p.x, p.y) + .lineTo(p.x + p.w, p.y) + .moveTo(p.x, p.y + p.h) + .lineTo(p.x + p.w, p.y + p.h) + .stroke({ color: this.look.grid, width: 1 }); + g.moveTo(p.x, mid) + .lineTo(p.x + p.w, mid) + .stroke({ color: this.look.dim, width: 1, alpha: 0.5 }); + // The sound as a wave its volume shapes, and the volume as the wave's outline. + trace.forEach((level, i) => { + const y = mid - level * (p.h / 2) * Math.sin((2 * Math.PI * CARRIER * i) / (trace.length - 1)); + if (i === 0) g.moveTo(xAt(i), y); + else g.lineTo(xAt(i), y); + }); + g.stroke({ color: this.look.trace, width: 1, alpha: 0.35 }); + for (const side of [-1, 1]) { + trace.forEach((level, i) => { + const y = mid + side * level * (p.h / 2); + if (i === 0) g.moveTo(xAt(i), y); + else g.lineTo(xAt(i), y); + }); + g.stroke({ color: this.look.trace, width: 2, join: 'round' }); + } + } else { + const reach = picture.high; + for (const share of [-1, -0.5, 0.5, 1]) + g.moveTo(p.x, this.yOf(share * reach)).lineTo(p.x + p.w, this.yOf(share * reach)); + g.stroke({ color: this.look.grid, width: 1 }); + this.marks.top.text = `+${reach} st`; + this.marks.top.position.set(p.x + 2, p.y - 10); + this.marks.bottom.text = `-${reach} st`; + this.marks.bottom.position.set(p.x + 2, p.y + p.h + 10); + // The pitch the sound came at; under a wet below 1 it is heard beside the swing. + const wet = Math.max(0, Math.min(1, this.values?.numbers[wetKey(this.device)] ?? 1)); + g.moveTo(p.x, mid) + .lineTo(p.x + p.w, mid) + .stroke({ color: this.look.dim, width: 1 + (1 - wet) * 2, alpha: 0.5 + 0.5 * (1 - wet) }); + // The pitch it swings to: cool below, warm above, in chunks so a few strokes do. + for (let from = 0; from < trace.length - 1; from += PITCH_CHUNK) { + const to = Math.min(trace.length - 1, from + PITCH_CHUNK); + const middle = trace[Math.round((from + to) / 2)]!; + g.moveTo(xAt(from), this.yOf(trace[from]!)); + for (let i = from + 1; i <= to; i++) g.lineTo(xAt(i), this.yOf(trace[i]!)); + g.stroke({ + color: mixColor(this.look.under, this.look.trace, heightOf(picture, middle)), + width: 2, + alpha: 0.35 + 0.65 * wet, + join: 'round', + }); + } + } + this.marks.span.text = secondsText(picture.span); + this.marks.span.position.set(p.x + p.w, this.glassH - 2); + } + this.drawLight(); + } + + protected drawLight(): void { + const s = this.light.clear(); + const picture = this.picture; + if (!picture?.moving || this.w <= 0) return; + const p = this.plot; + const x = this.xOf(this.at); + s.rect(x - 0.5, p.y - 6, 1, p.h + 12).fill({ color: 0xffffff, alpha: 0.35 }); + const dots = + this.device.kind === 'tremolo' + ? [-1, 1].map((side) => p.y + p.h / 2 + side * this.reading * (p.h / 2)) + : [this.yOf(this.reading)]; + for (const y of dots) { + s.circle(x, y, 6).fill({ color: this.look.trace, alpha: 0.3 }); + s.circle(x, y, 3).fill(0xffffff); + } + } +} + +/** The Hz written under a Phaser's or a Flanger's screen, and the faint ones between them. */ +const DECADES: readonly [hz: number, text: string][] = [ + [100, '100'], + [1000, '1k'], + [10000, '10k'], +]; +const BETWEEN = [50, 200, 500, 2000, 5000]; +/** The dB written left of the response, 0 first. */ +const DB_MARKS = [0, -12, -24]; + +/** + * The Phaser's and the Flanger's screen: what they do to each frequency where the light is – the + * notches they cut, and how they ring – the range they sweep shaded behind it, and under it a lane + * of where that goes over the first seconds of a sound. + */ +class ResponseScope extends ModScope { + private readonly curve = new Graphics(); + private readonly hzMarks: Text[]; + private readonly dbMarks: Text[]; + private readonly marks: Record<'lane' | 'span', Text>; + /** The notches drawn now, by Hz (checks). */ + notches: number[] = []; + + constructor(device: ModDevice, look: ModLook) { + super(device, look); + this.addChildAt(this.curve, 1); + this.hzMarks = DECADES.map(([, text]) => this.made(8, look.dim, 0.5, 0, text)); + this.dbMarks = DB_MARKS.map((db) => this.made(8, look.dim, 1, 0.5, String(db))); + this.marks = { lane: this.made(8, look.dim, 0, 0), span: this.made(8, look.dim, 1, 0) }; + } + + /** The response, with room on its left for its dB. */ + private get response(): Box { + return { x: 28, y: 10, w: this.w - 36, h: 100 }; + } + + private get lane(): Box { + return { x: 8, y: 130, w: this.w - 16, h: this.glassH - 136 }; + } + + private xOfHz(hz: number): number { + const r = this.response; + return r.x + Math.max(0, Math.min(1, shareOfHz(hz))) * r.w; + } + + private yOfDb(db: number): number { + const r = this.response; + const clamped = Math.max(RESPONSE_FLOOR, Math.min(RESPONSE_TOP, db)); + return r.y + ((RESPONSE_TOP - clamped) / (RESPONSE_TOP - RESPONSE_FLOOR)) * r.h; + } + + /** Where the light's reading is marked on the frequencies: the Phaser's turn, the Flanger's first notch. */ + private markHz(reading: number): number | null { + if (this.device.kind === 'phaser') return reading; + return reading > 0 ? 500 / reading : null; + } + + override redraw(): void { + const g = this.g.clear(); + this.drawGlass(); + const r = this.response; + const picture = this.picture; + for (const hz of BETWEEN) g.moveTo(this.xOfHz(hz), r.y).lineTo(this.xOfHz(hz), r.y + r.h); + g.stroke({ color: this.look.grid, width: 1, alpha: 0.5 }); + for (const [hz] of DECADES) g.moveTo(this.xOfHz(hz), r.y).lineTo(this.xOfHz(hz), r.y + r.h); + for (const db of DB_MARKS.slice(1)) g.moveTo(r.x, this.yOfDb(db)).lineTo(r.x + r.w, this.yOfDb(db)); + g.stroke({ color: this.look.grid, width: 1 }); + g.moveTo(r.x, this.yOfDb(0)) + .lineTo(r.x + r.w, this.yOfDb(0)) + .stroke({ color: this.look.dim, width: 1, alpha: 0.6 }); + this.hzMarks.forEach((t, i) => t.position.set(this.xOfHz(DECADES[i]![0]), r.y + r.h + 2)); + this.dbMarks.forEach((t, i) => t.position.set(r.x - 4, this.yOfDb(DB_MARKS[i]!))); + const lane = this.lane; + g.moveTo(lane.x, lane.y - 4) + .lineTo(lane.x + lane.w, lane.y - 4) + .stroke({ color: this.look.grid, width: 1 }); + this.marks.lane.position.set(lane.x + 2, lane.y); + this.marks.span.position.set(lane.x + lane.w, lane.y); + this.marks.lane.text = ''; + this.marks.span.text = ''; + if (picture) { + // The range the light sweeps the mark through, shaded behind the response. + const ends = [this.markHz(picture.lowest), this.markHz(picture.highest)].filter( + (hz): hz is number => hz !== null, + ); + if (ends.length === 2 && picture.moving) { + const a = this.xOfHz(Math.min(...ends)); + const b = this.xOfHz(Math.max(...ends)); + g.rect(a, r.y, Math.max(1, b - a), r.h).fill({ color: this.look.trace, alpha: 0.08 }); + } + this.drawSwings(lane.x, lane.w, lane.y, lane.y + lane.h); + const trace = picture.trace; + trace.forEach((value, i) => { + const x = lane.x + (i / (trace.length - 1)) * lane.w; + const y = lane.y + lane.h - heightOf(picture, value) * lane.h; + if (i === 0) g.moveTo(x, y); + else g.lineTo(x, y); + }); + g.stroke({ color: this.look.trace, width: 1.5, alpha: 0.8, join: 'round' }); + this.marks.lane.text = this.device.kind === 'phaser' ? 'TURN' : `DELAY UP TO ${picture.high} ms`; + this.marks.span.text = secondsText(picture.span); + } + this.drawLight(); + } + + protected drawLight(): void { + const c = this.curve.clear(); + const s = this.light.clear(); + const picture = this.picture; + const values = this.values; + if (!picture || !values || this.w <= 0) return; + const r = this.response; + const columns = responseColumns( + (hz) => responseDb(this.device, values, this.reading, hz), + Math.max(1, Math.round(r.w)), + ); + this.notches = notchesOf(columns); + const bottom = r.y + r.h; + // Under the response a faint fill, the response itself as thin as a line where it is smooth + // and as tall as a column holds where a comb runs too fine for them. + columns.forEach((column, i) => + c.rect(r.x + i, this.yOfDb(column.high), 1, bottom - this.yOfDb(column.high)), + ); + c.fill({ color: this.look.trace, alpha: 0.12 }); + columns.forEach((column, i) => { + const top = this.yOfDb(column.high); + c.rect(r.x + i, top - 0.75, 1, Math.max(1.5, this.yOfDb(column.low) - top + 0.75)); + }); + c.fill(this.look.trace); + const hz = this.markHz(this.reading); + if (hz !== null && shareOfHz(hz) >= 0 && shareOfHz(hz) <= 1) + s.rect(this.xOfHz(hz) - 0.5, r.y, 1, r.h).fill({ color: 0xffffff, alpha: 0.3 }); + if (!picture.moving) return; + const lane = this.lane; + const x = lane.x + (this.at / picture.span) * lane.w; + const y = lane.y + lane.h - heightOf(picture, this.reading) * lane.h; + s.rect(x - 0.5, lane.y - 2, 1, lane.h + 4).fill({ color: 0xffffff, alpha: 0.35 }); + s.circle(x, y, 5).fill({ color: this.look.trace, alpha: 0.3 }); + s.circle(x, y, 2.5).fill(0xffffff); + } +} + +export class ModulationPanel extends Widget { + private readonly plate = new Graphics(); + private readonly bezel = new Graphics(); + private readonly rules = new Graphics(); + private readonly lampLight = new Graphics(); + private readonly look: ModLook; + readonly scope: SwingScope | ResponseScope; + readonly pickButtons: PickButton[]; + readonly knobs: Record = {}; + private readonly captions: Record = {}; + private readonly groupNames: { ink: Text; edge: Text }[]; + private readonly pickName: { ink: Text; edge: Text } | null; + private readonly title: { ink: Text; edge: Text }; + private readonly lampCaption: { ink: Text; edge: Text }; + private readonly screenName: Text; + /** What the screen sums up, above it on the right. */ + readonly sideText: Text; + /** The settings shown idle now, and how lit the lamp is, 0 to 1 (checks). */ + idle: ReadonlySet = new Set(); + lamp = 0; + + constructor( + readonly device: ModDevice, + host: ModHost, + ) { + super(); + const L = MOD_LAYOUTS[device.kind]; + this.look = LOOKS[device.kind]; + this.minWidth = L.panel.w; + this.minHeight = L.panel.h; + this.scope = + device.kind === 'tremolo' || device.kind === 'vibrato' + ? new SwingScope(device, this.look) + : new ResponseScope(device, this.look); + this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight); + this.title = engrave(this, device.title, 14); + this.lampCaption = engrave(this, 'RATE', 9); + this.screenName = crispText({ text: device.screenName, style: bezelStyle() }); + this.screenName.eventMode = 'none'; + this.sideText = crispText({ text: '', style: bezelStyle() }); + this.sideText.anchor.set(1, 0); + this.sideText.eventMode = 'none'; + this.addChild(this.screenName, this.sideText); + this.pickName = device.pick ? engrave(this, device.pick.label, 9) : null; + this.pickButtons = (device.pick?.options ?? []).map((option) => { + const button = new PickButton(option.value, option.caption, this.look.buttons, (picked) => + host.pick(picked), + ); + this.addChild(button); + return button; + }); + this.groupNames = L.groups.map((group) => engrave(this, group.label, 9)); + for (const look of device.knobs) { + const row = look.row; + const knob = new Knob('', row.default, { + min: row.min ?? 0, + max: row.max ?? 1, + step: row.step ?? 0.01, + taper: look.taper, + bipolar: look.bipolar, + default: row.default, + decimals: look.decimals, + size: L.knobs[look.key]!.size, + look: 'chrome', + typed: false, + format: look.format, + ink: INK, + arc: this.look.arc, + onInput: (value) => host.preview(look.key, value), + onChange: (value) => host.commit(look.key, value), + }); + this.knobs[look.key] = knob; + this.captions[look.key] = engrave(this, look.label.toUpperCase(), 9); + this.addChild(knob); + } + } + + override measure(): { w: number; h: number } { + return { ...MOD_LAYOUTS[this.device.kind].panel }; + } + + sync(shown: ModShown): void { + for (const look of this.device.knobs) { + const knob = this.knobs[look.key]!; + knob.setValue(shown.values.numbers[look.key]!); + knob.setEnabled(!shown.wired(look.key)); + knob.alpha = shown.idle.has(look.key) ? IDLE_ALPHA : 1; + this.captions[look.key]!.ink.alpha = knob.alpha; + } + const pickIdle = this.device.pick !== null && shown.idle.has(this.device.pick.key); + for (const button of this.pickButtons) { + button.pick(button.value === shown.values.picked); + button.alpha = pickIdle ? IDLE_ALPHA : 1; + } + if (this.pickName) this.pickName.ink.alpha = pickIdle ? IDLE_ALPHA : 1; + this.idle = shown.idle; + this.scope.show(shown.picture, shown.values, shown.texts); + this.sideText.text = shown.texts.side; + this.lamp = lampOf(shown.picture, this.scope.reading); + this.drawLamp(); + } + + /** Whether anything moves: there is a swing, and it is heard. */ + get moving(): boolean { + return this.scope.picture?.moving === true; + } + + /** `t` seconds after the sound: the screen's light goes there, the lamp swings with it. */ + step(t: number): void { + this.scope.step(t); + if (this.scope.picture) this.lamp = lampOf(this.scope.picture, this.scope.reading); + this.drawLamp(); + } + + private drawLamp(): void { + const { x, y, r } = MOD_LAYOUTS[this.device.kind].lamp; + const [off, on] = this.look.lamp; + const lit = this.lamp; + const g = this.lampLight.clear(); + if (lit > 0) g.circle(x, y, r + 4).fill({ color: on, alpha: 0.3 * lit }); + g.circle(x, y, r) + .fill(mixColor(off, on, lit)) + .stroke({ color: 0x2a2c30, width: 1.5 }); + g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: 0.2 + 0.5 * lit }); + } + + override layout(): void { + const L = MOD_LAYOUTS[this.device.kind]; + 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.sideText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3); + this.pickButtons.forEach((button, i) => put(button, L.picks[i]!)); + if (this.pickName && L.pickGroup) + placeEngraving(this.pickName, L.pickGroup.x + L.pickGroup.w / 2, L.pickGroup.y); + placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y); + for (const look of this.device.knobs) { + const spot = L.knobs[look.key]!; + const knob = this.knobs[look.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[look.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 = MOD_LAYOUTS[this.device.kind]; + drawPlate(this.plate.clear(), L.panel, this.look.plate, 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: 0x161412, width: 1 }); + // A rule each side of a group's name, engraved as the names are. + const r = this.rules.clear(); + const named: [Box, Text][] = L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink]); + if (L.pickGroup && this.pickName) named.push([L.pickGroup, this.pickName.ink]); + for (const [box, name] of named) { + const mid = box.y + 6; + const left = box.x + 6; + const right = box.x + box.w - 6; + const gap = name.width / 2 + 6; + const centre = box.x + 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/modulationView.ts b/src/editor/soundfx/modulationView.ts new file mode 100644 index 00000000..54fe7a20 --- /dev/null +++ b/src/editor/soundfx/modulationView.ts @@ -0,0 +1,651 @@ +/** + * What the nodes of the four effects that swing draw (#398) – the Tremolo, the Vibrato, the Phaser + * and the Flanger – as numbers: where the parts of their devices sit, how their knobs turn, what + * their screens draw of the first seconds of a sound, and the light that plays it over and over. + * Kept apart from the drawing so it is checked without a browser. + */ +import type { PropertyBag } from '../../core/model/Properties'; +import type { FlatDef } from '../../core/properties/PropertySchema'; +import { LFO_SHAPES, lfoAt, type LfoShape, vibratoDelay, vibratoSpeedUp } from '../../core/soundfx/lfo'; +import { + FLANGER_ROWS, + LFO_SHAPE, + lfoShapeOf, + PHASER_ROWS, + PHASER_STAGE_ROW, + phaserStagesOf, + SoundFxFlanger, + SoundFxPhaser, + SoundFxTremolo, + SoundFxVibrato, + TREMOLO_ROWS, + VIBRATO_ROWS, +} from '../../core/soundfx/nodes'; +import { FLANGER_MAX_MS } from '../../core/soundfx/phaser'; +import { formatNumber } from '../../ui/math'; +import { secondsText } from './chipView'; +import { TEXT_ROW } from './delayView'; +import { BIG_DIAL, type Box, KNOB_CAPTION, type KnobSpot, screwsOf, SMALL_DIAL } from './hardwareView'; +import { hzText, percentText } from './reverbView'; + +export type ModKind = 'tremolo' | 'vibrato' | 'phaser' | 'flanger'; + +/** A knob: the row it sets, its name, how it turns and how its number reads. */ +export interface ModKnob { + key: string; + row: FlatDef<'number'>; + label: string; + taper: number; + bipolar?: boolean; + decimals: number; + format: (value: number) => string; +} + +/** The buttons that pick one of a setting's options: an LFO's shape, a Phaser's stages. */ +export interface ModPick { + key: string; + label: string; + options: { value: string; caption: string }[]; + of: (params: PropertyBag) => string; +} + +export interface ModDevice { + kind: ModKind; + /** The node it is drawn for. */ + type: string; + title: string; + /** What its screen is called, above it on the left. */ + screenName: string; + pick: ModPick | null; + knobs: ModKnob[]; +} + +const rateText = (hz: number): string => `${formatNumber(hz, hz < 1 ? 2 : 1)} Hz`; +const msText = (ms: number): string => `${formatNumber(ms, ms < 10 ? 2 : 1)} ms`; +const stText = (st: number): string => `${formatNumber(st, 2)} st`; +const sweepText = (msPerS: number): string => `${msPerS > 0 ? '+' : ''}${formatNumber(msPerS, 1)} ms/s`; + +const SHAPES: ModPick = { + key: LFO_SHAPE.key, + label: 'SHAPE', + options: LFO_SHAPES.map((option) => ({ value: option.value, caption: option.label.toUpperCase() })), + of: lfoShapeOf, +}; + +const wetKnob = (row: FlatDef<'number'>, key = row.key): ModKnob => ({ + key, + row, + label: 'Wet', + taper: 1, + decimals: 2, + format: percentText, +}); + +/** + * The four devices. Rates give their slow end more of the turn – 5 Hz sits at the middle of a + * Tremolo's – as do the Phaser's frequency, which is heard in octaves, the Flanger's milliseconds + * and its sweep either way of 0. + */ +export const TREMOLO_DEVICE: ModDevice = { + kind: 'tremolo', + type: SoundFxTremolo.type, + title: 'TREMOLO', + screenName: 'VOLUME OVER TIME', + pick: SHAPES, + knobs: [ + { key: 'rate', row: TREMOLO_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText }, + { + key: 'depth', + row: TREMOLO_ROWS.depth, + label: 'Depth', + taper: 1, + decimals: 0, + format: (pct) => `${formatNumber(pct, 0)}%`, + }, + wetKnob(TREMOLO_ROWS.wet), + ], +}; + +export const VIBRATO_DEVICE: ModDevice = { + kind: 'vibrato', + type: SoundFxVibrato.type, + title: 'VIBRATO', + screenName: 'PITCH OVER TIME', + pick: SHAPES, + knobs: [ + { key: 'rate', row: VIBRATO_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText }, + { key: 'depth', row: VIBRATO_ROWS.depth, label: 'Depth', taper: 2, decimals: 2, format: stText }, + wetKnob(VIBRATO_ROWS.wet), + ], +}; + +export const PHASER_DEVICE: ModDevice = { + kind: 'phaser', + type: SoundFxPhaser.type, + title: 'PHASER', + screenName: 'NOTCHES', + pick: { + key: PHASER_STAGE_ROW.key, + label: 'STAGES', + options: PHASER_STAGE_ROW.options.map((option) => ({ value: option.value, caption: option.label })), + of: (params) => String(phaserStagesOf(params)), + }, + knobs: [ + { key: 'frequency', row: PHASER_ROWS.frequency, label: 'Freq', taper: 4, decimals: 0, format: hzText }, + { + key: 'depth', + row: PHASER_ROWS.depth, + label: 'Depth', + taper: 1, + decimals: 1, + format: (oct) => `${formatNumber(oct, 1)} oct`, + }, + { key: 'rate', row: PHASER_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText }, + { + key: 'feedback', + row: PHASER_ROWS.feedback, + label: 'Feedback', + taper: 1, + decimals: 2, + format: percentText, + }, + wetKnob(PHASER_ROWS.mix), + ], +}; + +export const FLANGER_DEVICE: ModDevice = { + kind: 'flanger', + type: SoundFxFlanger.type, + title: 'FLANGER', + screenName: 'COMB', + pick: null, + knobs: [ + { key: 'offset', row: FLANGER_ROWS.offset, label: 'Offset', taper: 2, decimals: 2, format: msText }, + { + key: 'sweep', + row: FLANGER_ROWS.sweep, + label: 'Sweep', + taper: 3, + bipolar: true, + decimals: 1, + format: sweepText, + }, + { key: 'rate', row: FLANGER_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText }, + { key: 'depth', row: FLANGER_ROWS.depth, label: 'Depth', taper: 2, decimals: 2, format: msText }, + { + key: 'feedback', + row: FLANGER_ROWS.feedback, + label: 'Feedback', + taper: 1, + decimals: 2, + format: percentText, + }, + wetKnob(FLANGER_ROWS.mix), + ], +}; + +export const MOD_DEVICES: readonly ModDevice[] = [ + TREMOLO_DEVICE, + VIBRATO_DEVICE, + PHASER_DEVICE, + FLANGER_DEVICE, +]; + +/** The key of the knob that blends a device's swing with the sound as it came. */ +export const wetKey = (device: ModDevice): string => device.knobs.at(-1)!.key; + +export interface ModValues { + /** Every knob's number, its row's default where none is typed in. */ + numbers: Record; + /** The option its buttons have picked, '' without buttons. */ + picked: string; +} + +export function modValues(device: ModDevice, params: PropertyBag): ModValues { + const numbers: Record = {}; + for (const knob of device.knobs) { + const value = params[knob.key]; + numbers[knob.key] = typeof value === 'number' && Number.isFinite(value) ? value : knob.row.default; + } + return { numbers, picked: device.pick?.of(params) ?? '' }; +} + +const num = (values: ModValues, key: string): number => values.numbers[key] ?? 0; +const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)); + +/** + * The settings that do nothing as things stand, greyed as the Delay's are: none of the swing is + * heard at wet 0, nor at depth 0 of a Tremolo or a Vibrato, which leave the sound alone then. A + * Phaser or a Flanger without depth has an LFO that moves nothing. + */ +export function idleModKeys(device: ModDevice, values: ModValues): Set { + const keys = [...device.knobs.map((knob) => knob.key), ...(device.pick ? [device.pick.key] : [])]; + const wet = wetKey(device); + if (num(values, wet) <= 0) return new Set(keys.filter((key) => key !== wet)); + if (num(values, 'depth') > 0) return new Set(); + if (device.kind === 'tremolo' || device.kind === 'vibrato') + return new Set(keys.filter((key) => key !== 'depth')); + return new Set(['rate']); +} + +// --- What the light plays: the first seconds of a sound. --- + +/** The seconds a screen spans: the first of these that is long enough. */ +export const SPANS = [1, 2, 4, 8] as const; + +export const spanFor = (seconds: number): number => SPANS.find((s) => s >= seconds - 1e-9) ?? SPANS.at(-1)!; + +/** Two swings at least of an LFO at `rate` Hz; one that stands still is shown a second. */ +export const lfoSpan = (rate: number): number => (rate > 0 ? spanFor(2 / rate) : SPANS[0]); + +/** Where the light is, `seconds` on the page's clock: through the span in real time and round again. */ +export const loopAt = (span: number, seconds: number): number => ((seconds % span) + span) % span; + +/** A Phaser's allpasses turn where its LFO has them `t` seconds after the sound starts (#341). */ +export function phaserTurnAt(values: ModValues, t: number, rate = SHOWN_RATE): number { + const swing = Math.sin(2 * Math.PI * Math.max(0, num(values, 'rate')) * t); + const hz = num(values, 'frequency') * 2 ** ((num(values, 'depth') / 2) * swing); + return Math.max(10, Math.min(rate * 0.45, hz)); +} + +/** + * How late a Flanger's copy is `t` seconds after the sound starts, in ms (#341): the offset moved on + * by the sweep and mirrored at 0, the LFO's swing on top, never past its longest. + */ +export function flangerDelayAt(values: ModValues, t: number): number { + const swept = Math.max(-2 * FLANGER_MAX_MS, Math.min(2 * FLANGER_MAX_MS, num(values, 'sweep') * t)); + const swing = (1 - Math.cos(2 * Math.PI * Math.max(0, num(values, 'rate')) * t)) / 2; + return Math.min( + FLANGER_MAX_MS, + Math.abs(num(values, 'offset') + swept) + Math.max(0, num(values, 'depth')) * swing, + ); +} + +/** + * What a device's light reads `t` seconds after the sound starts: the volume a Tremolo lets + * through (1 in full, its wet blended in), the pitch a Vibrato bends to in semitones, where a + * Phaser's allpasses turn in Hz, how late a Flanger's copy is in ms. + */ +export function swingValueAt(device: ModDevice, values: ModValues, t: number): number { + switch (device.kind) { + case 'tremolo': { + const lfo = lfoAt(values.picked as LfoShape, Math.max(0, num(values, 'rate')) * t); + const depth = clamp01(num(values, 'depth') / 100) * clamp01(num(values, 'wet')); + return 1 - (depth * (1 - lfo)) / 2; + } + case 'vibrato': { + const lfo = lfoAt(values.picked as LfoShape, Math.max(0, num(values, 'rate')) * t); + return 12 * Math.log2(Math.max(1e-3, 1 + vibratoSpeedUp(num(values, 'depth')) * lfo)); + } + case 'phaser': + return phaserTurnAt(values, t); + case 'flanger': + return flangerDelayAt(values, t); + } +} + +/** How far a Vibrato's screen reaches up and down, in semitones: the first that holds its swing. */ +const PITCH_REACHES = [0.5, 1, 2, 4, 8] as const; +/** How far up a Flanger's lane of delay goes, in ms. */ +const DELAY_REACHES = [2.5, 5, 10, FLANGER_MAX_MS] as const; + +export const SWING_POINTS = 601; + +export interface SwingPicture { + /** The seconds the screen spans. */ + span: number; + /** The light's reading over the span, `SWING_POINTS` of them. */ + trace: number[]; + lowest: number; + highest: number; + /** What the screen's height stands for, bottom to top; `log` for Hz. */ + low: number; + high: number; + log: boolean; + /** Whether anything moves: there is a swing, and it is heard. */ + moving: boolean; +} + +/** What a device's screen draws over time, its light's readings over the first seconds of a sound. */ +export function swingPicture(device: ModDevice, values: ModValues, points = SWING_POINTS): SwingPicture { + const n = (key: string) => num(values, key); + const lfoMoves = n('rate') > 0 && n('depth') > 0; + let span: number = lfoSpan(lfoMoves ? n('rate') : 0); + if (device.kind === 'flanger' && n('sweep') !== 0) { + // Until the sweep rests the delay at its longest: up from the offset, or down through 0 and up again. + const resting = + n('sweep') > 0 + ? (FLANGER_MAX_MS - n('offset')) / n('sweep') + : (n('offset') + FLANGER_MAX_MS) / -n('sweep'); + span = Math.max(span, spanFor(resting)); + } + const trace = Array.from({ length: points }, (_, i) => + swingValueAt(device, values, (i / (points - 1)) * span), + ); + const lowest = Math.min(...trace); + const highest = Math.max(...trace); + const heard = n(wetKey(device)) > 0; + const moving = heard && highest - lowest > 1e-9; + switch (device.kind) { + case 'tremolo': + return { span, trace, lowest, highest, low: 0, high: 1, log: false, moving }; + case 'vibrato': { + const reach = Math.max(Math.abs(lowest), Math.abs(highest)); + const r = PITCH_REACHES.find((p) => p >= reach - 1e-9) ?? PITCH_REACHES.at(-1)!; + return { span, trace, lowest, highest, low: -r, high: r, log: false, moving }; + } + case 'phaser': { + // Half an octave of room either side, so a Phaser that stands still sits in the middle. + const room = 2 ** 0.5; + return { span, trace, lowest, highest, low: lowest / room, high: highest * room, log: true, moving }; + } + case 'flanger': { + const top = DELAY_REACHES.find((ms) => ms >= highest - 1e-9) ?? FLANGER_MAX_MS; + return { span, trace, lowest, highest, low: 0, high: top, log: false, moving }; + } + } +} + +/** Where a reading sits on the screen's height, 0 at the bottom to 1 at the top. */ +export function heightOf(picture: SwingPicture, value: number): number { + const share = picture.log + ? Math.log(value / picture.low) / Math.log(picture.high / picture.low) + : (value - picture.low) / (picture.high - picture.low); + return clamp01(share); +} + +/** How lit the lamp beside the name is at a reading: from dark at the low end of the swing to full at the top. */ +export function lampOf(picture: SwingPicture, value: number): number { + if (!picture.moving) return 0; + return clamp01((value - picture.lowest) / (picture.highest - picture.lowest)); +} + +// --- What a Phaser or a Flanger does to each frequency. --- + +/** The rate the screens reckon at: the Phaser's allpasses differ a little by it near the top. */ +export const SHOWN_RATE = 48000; +export const LOW_HZ = 20; +export const HIGH_HZ = 20000; +/** The screen's dB, top to bottom: a notch goes deeper than its floor. */ +export const RESPONSE_TOP = 12; +export const RESPONSE_FLOOR = -36; + +/** A frequency at a share of the screen's width, which is in octaves. */ +export const hzAt = (share: number): number => LOW_HZ * (HIGH_HZ / LOW_HZ) ** share; +export const shareOfHz = (hz: number): number => Math.log(hz / LOW_HZ) / Math.log(HIGH_HZ / LOW_HZ); + +/** `|1 - s + s·y|` in dB for the wet share `s` of a copy `y`, as Phaser and Flanger blend (#341). */ +function blendDb(share: number, yr: number, yi: number): number { + const re = 1 - share + share * yr; + const im = share * yi; + return 10 * Math.log10(Math.max(1e-12, re * re + im * im)); +} + +/** + * A Phaser's response in dB at `hz` with its allpasses turning at `turn`: its stages in a row, what + * leaves them fed back a sample later, blended with the sound by half its wet, as its worklet does. + */ +export function phaserDb(values: ModValues, turn: number, hz: number, rate = SHOWN_RATE): number { + const stages = Number(values.picked) || 4; + const feedback = Math.max(-0.95, Math.min(0.95, num(values, 'feedback'))); + const t = Math.tan((Math.PI * turn) / rate); + const a = (t - 1) / (t + 1); + const w = (2 * Math.PI * hz) / rate; + const zr = Math.cos(w); + const zi = -Math.sin(w); + // One allpass, (a + z⁻¹) / (1 + a·z⁻¹), then all of them. + const nr = a + zr; + const dr = 1 + a * zr; + const di = a * zi; + const d = dr * dr + di * di; + const ar = (nr * dr + zi * di) / d; + const ai = (zi * dr - nr * di) / d; + let pr = 1; + let pi = 0; + for (let s = 0; s < stages; s++) [pr, pi] = [pr * ar - pi * ai, pr * ai + pi * ar]; + // y = P / (1 − feedback·z⁻¹·P) + const fr = 1 - feedback * (zr * pr - zi * pi); + const fi = -feedback * (zr * pi + zi * pr); + const f = fr * fr + fi * fi; + return blendDb(clamp01(num(values, 'mix')) / 2, (pr * fr + pi * fi) / f, (pi * fr - pr * fi) / f); +} + +/** A Flanger's response in dB at `hz` with its copy `ms` late, fed back into its line, blended by half its wet. */ +export function flangerDb(values: ModValues, ms: number, hz: number): number { + const feedback = Math.max(0, Math.min(0.95, num(values, 'feedback'))); + const phase = (-2 * Math.PI * hz * ms) / 1000; + const zr = Math.cos(phase); + const zi = Math.sin(phase); + // y = z / (1 − feedback·z), z the delay + const fr = 1 - feedback * zr; + const fi = -feedback * zi; + const f = fr * fr + fi * fi; + return blendDb(clamp01(num(values, 'mix')) / 2, (zr * fr + zi * fi) / f, (zi * fr - zr * fi) / f); +} + +/** A Phaser's or a Flanger's response in dB at `hz`, at the light's reading `at` – where it turns, how late it is. */ +export function responseDb(device: ModDevice, values: ModValues, at: number, hz: number): number { + return device.kind === 'phaser' ? phaserDb(values, at, hz) : flangerDb(values, at, hz); +} + +/** Readings within each column of the screen: enough that a notch never slips between them. */ +const PER_COLUMN = 6; + +/** + * The response over `columns` columns of the screen, each the lowest and highest it gets inside + * it: a comb too fine for the columns fills the ones it runs through instead of aliasing. + */ +export function responseColumns( + db: (hz: number) => number, + columns: number, +): { low: number; high: number }[] { + return Array.from({ length: columns }, (_, c) => { + let low = Infinity; + let high = -Infinity; + for (let k = 0; k < PER_COLUMN; k++) { + const value = db(hzAt((c + (k + 0.5) / PER_COLUMN) / columns)); + low = Math.min(low, value); + high = Math.max(high, value); + } + return { low, high }; + }); +} + +/** + * How far under the sound as it came a column has to reach to count as in a notch: half its power. + * Below the loudest would not do, as feedback rings peaks far over 0 dB between the notches. + */ +const NOTCH_DB = -3; + +/** The notches the columns show: each run of columns under `NOTCH_DB`, by the Hz of its deepest. */ +export function notchesOf(columns: readonly { low: number; high: number }[]): number[] { + const notches: number[] = []; + let deepest = -1; + columns.forEach((column, c) => { + const inside = column.low < NOTCH_DB; + if (inside && (deepest < 0 || column.low < columns[deepest]!.low)) deepest = c; + if (!inside && deepest >= 0) { + notches.push(hzAt((deepest + 0.5) / columns.length)); + deepest = -1; + } + }); + if (deepest >= 0) notches.push(hzAt((deepest + 0.5) / columns.length)); + return notches; +} + +// --- What the screens write. --- + +export interface ModTexts { + /** Above the screen on the right. */ + side: string; + /** Under the screen, left and right. */ + left: string; + right: string; +} + +const pctText = (share: number): string => `${formatNumber(Math.round(share * 100), 0)}%`; +const everyText = (rate: number): string => `EVERY ${secondsText(1 / rate)}`; + +export function modTexts(device: ModDevice, values: ModValues, picture: SwingPicture): ModTexts { + const n = (key: string) => num(values, key); + const lfoMoves = n('rate') > 0 && n('depth') > 0; + if (n(wetKey(device)) <= 0) return { side: 'DRY', left: '', right: '' }; + switch (device.kind) { + case 'tremolo': { + if (!(picture.lowest < 1)) return { side: 'NO SWING', left: '', right: '' }; + const db = + picture.lowest > 0 + ? `DIPS ${formatNumber(20 * Math.log10(picture.lowest), 1)} dB` + : 'DIPS TO SILENCE'; + return { + side: `DOWN TO ${pctText(picture.lowest)}`, + left: n('rate') > 0 ? everyText(n('rate')) : 'STANDS STILL', + right: db, + }; + } + case 'vibrato': { + if (!(n('depth') > 0)) return { side: 'NO SWING', left: '', right: '' }; + const shape = values.picked as LfoShape; + return { + side: `UP ${stText(picture.highest)} DOWN ${stText(-picture.lowest)}`, + left: everyText(n('rate')), + right: `LATE ${msText(1000 * vibratoDelay(shape, n('rate'), n('depth')))}`, + }; + } + case 'phaser': + return { + side: `${Number(values.picked) / 2} NOTCHES`, + left: picture.moving + ? `TURNS ${hzText(picture.lowest)} TO ${hzText(picture.highest)}` + : `TURNS AT ${hzText(picture.lowest)}`, + right: lfoMoves ? everyText(n('rate')) : 'STANDS STILL', + }; + case 'flanger': + return { + side: picture.moving + ? `DELAY ${msText(picture.lowest)} TO ${msText(picture.highest)}` + : `DELAY ${msText(picture.lowest)}`, + left: lfoMoves ? `LFO ${everyText(n('rate'))}` : 'NO LFO', + right: n('sweep') !== 0 ? `SWEEP ${sweepText(n('sweep'))}` : 'NO SWEEP', + }; + } +} + +// --- The plates. --- + +/** The plates: as the Delay's (#393), a panel never takes another size. */ +export const MOD_PANELS: Record = { + tremolo: { w: 640, h: 280 }, + vibrato: { w: 640, h: 280 }, + phaser: { w: 760, h: 280 }, + flanger: { w: 680, h: 280 }, +}; + +/** The height of a group's name and its rule. */ +const GROUP_LINE = 16; +const GROUP_GAP = 16; +const BUTTON = { h: 22, gap: 6 }; +/** How wide a letter of a device's name is engraved, near enough to place the lamp after it. */ +const TITLE_LETTER = 13; + +export interface ModLayout { + panel: { w: number; h: number }; + title: Box; + /** The lamp that swings with the light, 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; + /** The name over the buttons, and the buttons in the order of the options; none for a Flanger. */ + pickGroup: Box | null; + picks: Box[]; + groups: { label: string; box: Box }[]; + knobs: Record; + screws: { x: number; y: number }[]; +} + +function modLayout(device: ModDevice): ModLayout { + const panel = MOD_PANELS[device.kind]; + const wide = device.kind === 'phaser' || device.kind === 'flanger'; + const screen = { x: 34, y: 60, w: wide ? 300 : 260, h: 176 }; + const bezel = { x: 24, y: 44, w: screen.w + 20, h: screen.h + 16 + TEXT_ROW + 6 }; + const left = bezel.x + bezel.w + 24; + const right = panel.w - 24; + const title = { x: 34, y: 14, w: device.title.length * TITLE_LETTER, h: 24 }; + const lamp = { x: title.x + title.w + 14, y: 26, r: 6 }; + + let pickGroup: Box | null = null; + let picks: Box[] = []; + if (device.pick) { + const options = device.pick.options; + pickGroup = { x: left, y: 50, w: right - left, h: GROUP_LINE }; + const w = (pickGroup.w - (options.length - 1) * BUTTON.gap) / options.length; + picks = options.map((_, i) => ({ x: left + i * (w + BUTTON.gap), y: 68, w, h: BUTTON.h })); + } + + const groups: { label: string; box: Box }[] = []; + const knobs: Record = {}; + if (device.kind === 'tremolo' || device.kind === 'vibrato') { + // Rate and depth big, the wet small, all on one line under the shapes. + const y = 180; + knobs.rate = { x: left + 54, y, size: BIG_DIAL }; + knobs.depth = { x: left + 166, y, size: BIG_DIAL }; + knobs.wet = { x: right - 34, y, size: SMALL_DIAL }; + } else if (device.kind === 'phaser') { + const sets = [ + ['SWEEP', ['frequency', 'depth', 'rate']], + ['RING', ['feedback']], + ['MIX', ['mix']], + ] as const; + const slot = (right - left - (sets.length - 1) * GROUP_GAP) / 5; + const top = 128; + const y = top + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; + let x = left; + for (const [label, keys] of sets) { + groups.push({ label, box: { x, y: top, w: keys.length * slot, h: GROUP_LINE } }); + for (const key of keys) { + knobs[key] = { x: x + slot / 2, y, size: SMALL_DIAL }; + x += slot; + } + x += GROUP_GAP; + } + } else { + // Three columns of two: the delay, its LFO, and how the comb rings and is heard. + const sets = [ + ['DELAY', ['offset', 'sweep']], + ['LFO', ['rate', 'depth']], + ['COMB', ['feedback', 'mix']], + ] as const; + const gap = 12; + const w = (right - left - (sets.length - 1) * gap) / sets.length; + const top = 50; + const first = top + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; + sets.forEach(([label, keys], i) => { + const x = left + i * (w + gap); + groups.push({ label, box: { x, y: top, w, h: GROUP_LINE } }); + keys.forEach((key, row) => { + knobs[key] = { x: x + w / 2, y: first + row * 92, size: SMALL_DIAL }; + }); + }); + } + return { + panel, + title, + lamp, + lampCaption: { x: lamp.x + 28, y: 21 }, + bezel, + screen, + pickGroup, + picks, + groups, + knobs, + screws: screwsOf(panel), + }; +} + +export const MOD_LAYOUTS: Record = { + tremolo: modLayout(TREMOLO_DEVICE), + vibrato: modLayout(VIBRATO_DEVICE), + phaser: modLayout(PHASER_DEVICE), + flanger: modLayout(FLANGER_DEVICE), +}; diff --git a/src/editor/soundfx/nodeFaces.ts b/src/editor/soundfx/nodeFaces.ts index 6fb4a36c..21437181 100644 --- a/src/editor/soundfx/nodeFaces.ts +++ b/src/editor/soundfx/nodeFaces.ts @@ -21,10 +21,12 @@ import { FilterPanel } from './FilterPanel'; import { KnobFace } from './KnobFace'; import { GAIN_DEVICE, MIX_DEVICE, PAN_DEVICE, PITCH_DEVICE } from './KnobPanel'; import { LimiterFace } from './LimiterFace'; +import { ModulationFace } from './ModulationFace'; +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, #397), by type. */ +/** The nodes drawn as a face of their own instead of rows (#380, #381, #384–#386, #388–#390, #392, #393, #396–#398), 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); @@ -40,5 +42,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); + 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/modulation-view.test.ts b/tests/editor/modulation-view.test.ts new file mode 100644 index 00000000..19025afc --- /dev/null +++ b/tests/editor/modulation-view.test.ts @@ -0,0 +1,278 @@ +/** + * The Tremolo, the Vibrato, the Phaser and the Flanger as devices (#398): every part on its plate + * and none over another, how their knobs turn, and that what their screens draw is what their + * worklets do – the volume, the pitch, and what is left of each frequency. + */ +import { describe, expect, it } from 'vitest'; +import { Tremolo, Vibrato, vibratoDelay } from '../../src/core/soundfx/lfo'; +import { Flanger, Phaser } from '../../src/core/soundfx/phaser'; +import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView'; +import { + FLANGER_DEVICE, + flangerDb, + flangerDelayAt, + idleModKeys, + lampOf, + lfoSpan, + loopAt, + MOD_DEVICES, + MOD_LAYOUTS, + type ModDevice, + modTexts, + modValues, + notchesOf, + PHASER_DEVICE, + phaserDb, + responseColumns, + SHOWN_RATE, + swingPicture, + swingValueAt, + TREMOLO_DEVICE, + VIBRATO_DEVICE, +} from '../../src/editor/soundfx/modulationView'; +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 knob = (device: ModDevice, key: string) => device.knobs.find((k) => k.key === key)!; + +const RATE = SHOWN_RATE; +const one = (value: number) => new Float32Array([value]); + +/** How loud a sine at `hz` comes out of `render` once it has settled, in dB against what went in. */ +function measuredDb(render: (input: Float32Array[], output: Float32Array[]) => void, hz: number): number { + const length = RATE; + const input = Float32Array.from({ length }, (_, i) => Math.sin((2 * Math.PI * hz * i) / RATE)); + const output = new Float32Array(length); + for (let at = 0; at < length; at += 128) + render([input.subarray(at, at + 128)], [output.subarray(at, at + 128)]); + const half = length / 2; + const rms = (a: Float32Array) => Math.sqrt(a.subarray(half).reduce((s, v) => s + v * v, 0) / half); + return 20 * Math.log10(rms(output) / rms(input)); +} + +describe('the plates', () => { + for (const device of MOD_DEVICES) { + const L = MOD_LAYOUTS[device.kind]; + it(`keeps the ${device.title.toLowerCase()}'s screen, buttons and knobs on the plate, clear of each other and the screws`, () => { + 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 parts: Box[] = [ + L.bezel, + ...(L.pickGroup ? [L.pickGroup] : []), + ...L.picks, + ...L.groups.map((g) => g.box), + ...device.knobs.map((k) => knobBox(L.knobs[k.key]!)), + lamp, + L.title, + ]; + for (const part of parts) { + expect(part.x >= 0 && part.y >= 0).toBe(true); + expect(part.x + part.w <= L.panel.w && part.y + part.h <= L.panel.h).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 button for every option it picks from', () => { + expect(MOD_LAYOUTS.tremolo.picks).toHaveLength(4); + expect(MOD_LAYOUTS.phaser.picks).toHaveLength(5); + expect(MOD_LAYOUTS.flanger.picks).toHaveLength(0); + expect(PHASER_DEVICE.pick!.options.map((o) => o.caption)).toEqual(['2', '4', '6', '8', '12']); + expect(TREMOLO_DEVICE.pick!.options.map((o) => o.caption)).toEqual(['SINE', 'TRIANGLE', 'SQUARE', 'SAW']); + }); +}); + +describe('the knobs', () => { + const position = (device: ModDevice, key: string, value: number) => { + const k = knob(device, key); + return knobPosition(value, { min: k.row.min!, max: k.row.max!, step: k.row.step!, ...k }); + }; + + it('gives the slow end of a rate and the low end of a frequency most of the turn, and turns a sweep round 0', () => { + expect(position(TREMOLO_DEVICE, 'rate', 5)).toBeCloseTo(0.5); + expect(position(PHASER_DEVICE, 'frequency', 800)).toBeCloseTo(0.44, 2); + expect(position(FLANGER_DEVICE, 'sweep', 0)).toBeCloseTo(0.5); + }); + + it('names what each number is', () => { + expect(knob(TREMOLO_DEVICE, 'rate').format(5)).toBe('5 Hz'); + expect(knob(FLANGER_DEVICE, 'rate').format(0.25)).toBe('0.25 Hz'); + expect(knob(TREMOLO_DEVICE, 'depth').format(50)).toBe('50%'); + expect(knob(VIBRATO_DEVICE, 'depth').format(0.5)).toBe('0.5 st'); + expect(knob(PHASER_DEVICE, 'frequency').format(1200)).toBe('1.2 kHz'); + expect(knob(PHASER_DEVICE, 'depth').format(2)).toBe('2 oct'); + expect(knob(FLANGER_DEVICE, 'sweep').format(5)).toBe('+5 ms/s'); + expect(knob(FLANGER_DEVICE, 'sweep').format(-2.5)).toBe('-2.5 ms/s'); + expect(knob(FLANGER_DEVICE, 'offset').format(1)).toBe('1 ms'); + }); + + it('greys all but the wet at wet 0, all but the depth of a Tremolo at depth 0, the rate of a Phaser without depth', () => { + const idle = (device: ModDevice, params: Record) => + [...idleModKeys(device, modValues(device, params))].sort(); + expect(idle(TREMOLO_DEVICE, {})).toEqual([]); + expect(idle(TREMOLO_DEVICE, { wet: 0 })).toEqual(['depth', 'rate', 'shape']); + expect(idle(TREMOLO_DEVICE, { depth: 0 })).toEqual(['rate', 'shape', 'wet']); + expect(idle(PHASER_DEVICE, { depth: 0 })).toEqual(['rate']); + expect(idle(FLANGER_DEVICE, { mix: 0 })).toEqual(['depth', 'feedback', 'offset', 'rate', 'sweep']); + }); +}); + +describe('the light', () => { + it('spans two swings at least, in whole seconds of 1, 2, 4 or 8', () => { + expect(lfoSpan(5)).toBe(1); + expect(lfoSpan(0.5)).toBe(4); + expect(lfoSpan(0.25)).toBe(8); + expect(lfoSpan(0.1)).toBe(8); + expect(lfoSpan(0)).toBe(1); + }); + + it('spans a Flanger until its sweep has the delay at its longest', () => { + const values = modValues(FLANGER_DEVICE, { offset: 1, sweep: 5, depth: 0 }); + expect(swingPicture(FLANGER_DEVICE, values).span).toBe(4); + expect(swingPicture(FLANGER_DEVICE, values).highest).toBe(20); + }); + + it('runs round the span in real time', () => { + expect(loopAt(4, 9)).toBe(1); + expect(loopAt(4, -1)).toBe(3); + }); + + it('lights the lamp from dark at the bottom of the swing to full at its top, not at all when nothing moves', () => { + const picture = swingPicture(TREMOLO_DEVICE, modValues(TREMOLO_DEVICE, {})); + expect(lampOf(picture, 0.5)).toBe(0); + expect(lampOf(picture, 1)).toBe(1); + expect(lampOf(swingPicture(TREMOLO_DEVICE, modValues(TREMOLO_DEVICE, { rate: 0 })), 1)).toBe(0); + }); +}); + +describe('the Tremolo screen', () => { + it('draws the volume its worklet lets through', () => { + const values = modValues(TREMOLO_DEVICE, { shape: 'triangle', rate: 3, depth: 80 }); + const tremolo = new Tremolo('triangle', RATE); + const input = new Float32Array(RATE).fill(1); + const output = new Float32Array(RATE); + tremolo.render([input], [output], { rate: one(3), depth: one(0.8) }); + for (const i of [0, 1000, 8000, 20000, 47000]) + expect(output[i]).toBeCloseTo(swingValueAt(TREMOLO_DEVICE, values, i / RATE), 4); + }); + + it('goes down by the depth as far as the wet lets it, and says so', () => { + const picture = (params: Record) => + swingPicture(TREMOLO_DEVICE, modValues(TREMOLO_DEVICE, params)); + expect(picture({}).lowest).toBeCloseTo(0.5); + expect(picture({ wet: 0.5 }).lowest).toBeCloseTo(0.75); + const values = modValues(TREMOLO_DEVICE, {}); + expect(modTexts(TREMOLO_DEVICE, values, picture({}))).toEqual({ + side: 'DOWN TO 50%', + left: 'EVERY 200 ms', + right: 'DIPS -6 dB', + }); + }); +}); + +describe('the Vibrato screen', () => { + it('draws the pitch its worklet reads the sound at', () => { + const params = { shape: 'sine', rate: 4, depth: 2 }; + const values = modValues(VIBRATO_DEVICE, params); + const vibrato = new Vibrato('sine', RATE, vibratoDelay('sine', 4, 2)); + // A ramp in comes out as where the line is read, so its steps are how fast: the pitch. + const input = Float32Array.from({ length: RATE }, (_, i) => i); + const output = new Float32Array(RATE); + vibrato.render([input], [output], { rate: one(4), depth: one(2) }); + for (const i of [6000, 9000, 15000, 30000]) { + const pitch = 12 * Math.log2(output[i + 1]! - output[i]!); + expect(pitch).toBeCloseTo(swingValueAt(VIBRATO_DEVICE, values, i / RATE), 1); + } + }); + + it('swings the depth up and a little more down, and says how late the voice is', () => { + const values = modValues(VIBRATO_DEVICE, {}); + const picture = swingPicture(VIBRATO_DEVICE, values); + expect(picture.highest).toBeCloseTo(0.5); + expect(picture.lowest).toBeCloseTo(12 * Math.log2(2 - 2 ** (0.5 / 12))); + expect(picture.high).toBe(1); + expect(modTexts(VIBRATO_DEVICE, values, picture)).toEqual({ + side: 'UP 0.5 st DOWN 0.51 st', + left: 'EVERY 200 ms', + right: 'LATE 0.93 ms', + }); + }); +}); + +describe('the Phaser screen', () => { + it('draws what its worklet leaves of a sine', () => { + const values = modValues(PHASER_DEVICE, { rate: 0, frequency: 800, feedback: 0.6 }); + const numbers = { + frequency: one(800), + rate: one(0), + depth: one(2), + feedback: one(0.6), + mix: one(1), + }; + for (const hz of [150, 500, 2000, 6000]) { + const phaser = new Phaser(RATE, 4); + const db = measuredDb((input, output) => phaser.render(input, output, numbers), hz); + expect(db).toBeCloseTo(phaserDb(values, 800, hz), 2); + } + }); + + it('cuts a notch for every two stages', () => { + for (const stages of ['2', '4', '6', '8']) { + const values = modValues(PHASER_DEVICE, { stages }); + const columns = responseColumns((hz) => phaserDb(values, 800, hz), 300); + expect(notchesOf(columns)).toHaveLength(Number(stages) / 2); + } + }); + + it('says how many notches, where they turn and how often', () => { + const values = modValues(PHASER_DEVICE, {}); + expect(modTexts(PHASER_DEVICE, values, swingPicture(PHASER_DEVICE, values))).toEqual({ + side: '2 NOTCHES', + left: 'TURNS 400 Hz TO 1.6 kHz', + right: 'EVERY 2 s', + }); + }); +}); + +describe('the Flanger screen', () => { + it('draws what its worklet leaves of a sine: a notch at 500 Hz for 1 ms, a peak at 1 kHz', () => { + const values = modValues(FLANGER_DEVICE, { offset: 1, depth: 0, feedback: 0.5 }); + const numbers = { + offset: one(1), + sweep: one(0), + rate: one(0), + depth: one(0), + feedback: one(0.5), + mix: one(1), + }; + for (const hz of [300, 500, 1000, 1700]) { + const flanger = new Flanger(RATE); + const db = measuredDb((input, output) => flanger.render(input, output, numbers), hz); + expect(db).toBeCloseTo(flangerDb(values, 1, hz), 2); + } + expect(flangerDb(values, 1, 1000)).toBeCloseTo(20 * Math.log10(1.5)); + expect(flangerDb(values, 1, 500)).toBeLessThan(-15); + }); + + it('sweeps the delay down through 0 and up again, the LFO on top', () => { + const values = modValues(FLANGER_DEVICE, { offset: 1, sweep: -2, depth: 0 }); + expect(flangerDelayAt(values, 0.5)).toBeCloseTo(0); + expect(flangerDelayAt(values, 1)).toBeCloseTo(1); + const swinging = modValues(FLANGER_DEVICE, { offset: 1, rate: 0.25, depth: 2 }); + expect(flangerDelayAt(swinging, 2)).toBeCloseTo(3); + }); + + it('says how late the copy gets, its LFO and its sweep, or that it is dry', () => { + const values = modValues(FLANGER_DEVICE, {}); + expect(modTexts(FLANGER_DEVICE, values, swingPicture(FLANGER_DEVICE, values))).toEqual({ + side: 'DELAY 1 ms TO 3 ms', + left: 'LFO EVERY 4 s', + right: 'NO SWEEP', + }); + const dry = modValues(FLANGER_DEVICE, { mix: 0 }); + expect(modTexts(FLANGER_DEVICE, dry, swingPicture(FLANGER_DEVICE, dry)).side).toBe('DRY'); + }); +}); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index 04f1f277..21f27e2e 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -1554,6 +1554,89 @@ export async function sound(t) { into.to = { node: 'fx-out', port: 'in' }; graph.edges = graph.edges.filter((e) => e.id !== 'fx-e-lm'); graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-lm');`); + // #398: a Tremolo, a Vibrato, a Phaser and a Flanger are devices of their own size whose screens + // draw what they do over the first seconds of a sound, a light playing it by itself. A + // button and a knob are one undo step each, and the screen follows them. + const swinging = [ + { + kind: 'tremolo', + type: 'SoundFxTremolo', + size: [640, 280], + inputs: 4, + pick: 'square', + knob: 'depth', + shown: (p) => p.knobs.rate.number === '5 Hz' && p.side === 'DOWN TO 50%' && p.picked === 'sine', + turned: (p) => p.side !== 'DOWN TO 50%' && p.picked === 'square', + }, + { + kind: 'vibrato', + type: 'SoundFxVibrato', + size: [640, 280], + inputs: 4, + pick: 'triangle', + knob: 'depth', + shown: (p) => p.side === 'UP 0.5 st DOWN 0.51 st' && p.scope.says.right === 'LATE 0.93 ms', + turned: (p) => p.scope.highest > 0.5 && p.picked === 'triangle', + }, + { + kind: 'phaser', + type: 'SoundFxPhaser', + size: [760, 280], + inputs: 6, + pick: '8', + knob: 'frequency', + shown: (p) => p.side === '2 NOTCHES' && p.scope.notches.length === 2, + turned: (p) => p.side === '4 NOTCHES' && p.scope.notches.length === 4 && p.scope.highest > 1600, + }, + { + kind: 'flanger', + type: 'SoundFxFlanger', + size: [680, 280], + inputs: 7, + pick: null, + knob: 'sweep', + shown: (p) => p.side === 'DELAY 1 ms TO 3 ms' && p.scope.says.right === 'NO SWEEP', + turned: (p) => p.scope.says.right.startsWith('SWEEP +'), + }, + ]; + for (const device of swinging) { + const id = `fx-${device.kind}`; + const panel = () => json(`__gm.${device.kind}.panel('${id}')`); + await mutate(` + const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(fxId)}).graph; + graph.nodes.push(${JSON.stringify(node(id, device.type, 0, 400, {}))});`); + await evaluate('__gm.soundfx.fit()'); + await sleep(100); + const shown = await panel(); + await sleep(150); + const later = (await panel()).scope.at; + if (device.pick) await click(shown.picks[device.pick].x, shown.picks[device.pick].y); + const dial = shown.knobs[device.knob].dial; + await drag(dial.x, dial.y, dial.x, dial.y - 20); + const turned = { params: await knobParams(id), panel: await panel() }; + for (let i = 0; i < (device.pick ? 2 : 1); i++) await press('z', { ctrl: true }); + const undone = { params: await knobParams(id), panel: await panel() }; + check( + `a ${device.kind} node is a device whose screen plays what it does; a button and a knob are one undo step each`, + shown.size.w === device.size[0] && + shown.size.h === device.size[1] && + Object.values(shown.inputs).length === device.inputs && + Object.values(shown.inputs).every((p) => p.x < shown.rect.x) && + Object.values(shown.outputs).every((p) => p.x > shown.rect.x + shown.rect.w) && + device.shown(shown) && + shown.scope.moving && + // The light runs on by itself. + later !== shown.scope.at && + turned.params[device.knob] !== undefined && + device.turned(turned.panel) && + Object.keys(undone.params).length === 0 && + device.shown(undone.panel), + JSON.stringify({ shown, later, turned, undone }), + ); + await mutate(` + 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)});`); + } await showTab('scene'); await mutate(` const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;