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 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 21:02:50 +02:00
1 parent 28909f0adf
commit b6749cc43d
12 files changed
+1860 -11

No files matched your search

+1 -1
View File
@@ -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: The browser suite and the three example games need a machine with a GPU:
```bash ```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 # every tool signs in first – see the Accounts page
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
+3 -1
View File
@@ -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, * 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. * a Delay's (#393), a Phaser's and a Flanger's (#398) four.
*/ */
const COLUMNS_OF: Readonly<Record<string, number>> = { const COLUMNS_OF: Readonly<Record<string, number>> = {
[SoundFxFilter.type]: 4, [SoundFxFilter.type]: 4,
[SoundFxDelay.type]: 4, [SoundFxDelay.type]: 4,
[SoundFxPhaser.type]: 4,
[SoundFxFlanger.type]: 4,
[SoundFxPitch.type]: 2, [SoundFxPitch.type]: 2,
[SoundFxMix.type]: 2, [SoundFxMix.type]: 2,
[INSTRUMENT_CHIP]: 5, [INSTRUMENT_CHIP]: 5,
+3 -3
View File
@@ -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. */ /** 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. */ /** The longest a Vibrato's delay may get, and so what its line holds. */
const VIBRATO_MAX_DELAY = 0.5; 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 { export function vibratoDelay(shape: LfoShape, rate: number, depth: number): number {
if (!(rate > 0)) return 0; 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]!); 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)); while (this.lines.length < outputs.length) this.lines.push(new Float32Array(this.size));
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
const rate = Math.max(1e-3, at(numbers.rate, 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 delay = Math.max(0, Math.min(VIBRATO_MAX_DELAY, this.center + swing)) * this.sampleRate;
const read = this.written - delay; const read = this.written - delay;
const from = Math.floor(read); const from = Math.floor(read);
+6 -4
View File
@@ -871,7 +871,7 @@ export const PHASER_ROWS = {
feedback: PHASER_FEEDBACK, feedback: PHASER_FEEDBACK,
mix: PHASER_MIX, mix: PHASER_MIX,
}; };
const PHASER_STAGE_ROW = { export const PHASER_STAGE_ROW = {
key: 'stages', key: 'stages',
label: 'Stages', label: 'Stages',
kind: 'enum' as const, kind: 'enum' as const,
@@ -879,7 +879,7 @@ const PHASER_STAGE_ROW = {
options: PHASER_STAGES.map((n) => ({ value: String(n), label: String(n) })), 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); const stages = Number(settings[PHASER_STAGE_ROW.key] ?? PHASER_STAGE_ROW.default);
return PHASER_STAGES.find((n) => n === stages) ?? 4; return PHASER_STAGES.find((n) => n === stages) ?? 4;
} }
@@ -1146,7 +1146,7 @@ export const SoundFxSaturation: SoundFxNodeDef = effect(
'own', 'own',
); );
const LFO_SHAPE = { export const LFO_SHAPE = {
key: 'shape', key: 'shape',
label: 'Shape', label: 'Shape',
kind: 'enum' as const, kind: 'enum' as const,
@@ -1154,7 +1154,7 @@ const LFO_SHAPE = {
options: LFO_SHAPES, 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'; 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 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_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 }; 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( export const SoundFxTremolo: SoundFxNodeDef = effect(
{ {
+1 -1
View File
@@ -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. */ /** 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 * The flanger, which sfxr calls its phaser: the sound and a copy `offset` ms late. `sweep` moves
+55
View File
@@ -9,6 +9,7 @@ import { CompressorPanel } from '../soundfx/CompressorPanel';
import { KnobPanel } from '../soundfx/KnobPanel'; import { KnobPanel } from '../soundfx/KnobPanel';
import { DelayPanel } from '../soundfx/DelayPanel'; import { DelayPanel } from '../soundfx/DelayPanel';
import { LimiterPanel } from '../soundfx/LimiterPanel'; import { LimiterPanel } from '../soundfx/LimiterPanel';
import { ModulationPanel } from '../soundfx/ModulationPanel';
import { SaturationPanel } from '../soundfx/SaturationPanel'; import { SaturationPanel } from '../soundfx/SaturationPanel';
import { ReverbPanel } from '../soundfx/ReverbPanel'; import { ReverbPanel } from '../soundfx/ReverbPanel';
import { CurvePanel } from '../soundfx/CurvePanel'; import { CurvePanel } from '../soundfx/CurvePanel';
@@ -46,6 +47,56 @@ export function graphHooks({ state, editor }: HookContext) {
: null, : 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 { return {
graph: { graph: {
current: () => graphEditor.currentAssetId, 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: { instrument: {
current: () => editor.instrumentEditor.currentAssetId, current: () => editor.instrumentEditor.currentAssetId,
info: () => editor.instrumentEditor.shownInfo, info: () => editor.instrumentEditor.shownInfo,
+80
View File
@@ -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));
}
}
+694
View File
@@ -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<string>;
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<ModKind, ModLook> = {
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<string>[];
readonly knobs: Record<string, Knob> = {};
private readonly captions: Record<string, { ink: Text; edge: Text }> = {};
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<string> = 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();
}
}
+651
View File
@@ -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<string, number>;
/** The option its buttons have picked, '' without buttons. */
picked: string;
}
export function modValues(device: ModDevice, params: PropertyBag): ModValues {
const numbers: Record<string, number> = {};
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<string> {
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<ModKind, { w: number; h: number }> = {
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<string, KnobSpot>;
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<string, KnobSpot> = {};
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<ModKind, ModLayout> = {
tremolo: modLayout(TREMOLO_DEVICE),
vibrato: modLayout(VIBRATO_DEVICE),
phaser: modLayout(PHASER_DEVICE),
flanger: modLayout(FLANGER_DEVICE),
};
+5 -1
View File
@@ -21,10 +21,12 @@ import { FilterPanel } from './FilterPanel';
import { KnobFace } from './KnobFace'; import { KnobFace } from './KnobFace';
import { GAIN_DEVICE, MIX_DEVICE, PAN_DEVICE, PITCH_DEVICE } from './KnobPanel'; import { GAIN_DEVICE, MIX_DEVICE, PAN_DEVICE, PITCH_DEVICE } from './KnobPanel';
import { LimiterFace } from './LimiterFace'; import { LimiterFace } from './LimiterFace';
import { ModulationFace } from './ModulationFace';
import { MOD_DEVICES } from './modulationView';
import { ReverbFace } from './ReverbFace'; import { ReverbFace } from './ReverbFace';
import { SaturationFace } from './SaturationFace'; 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 { 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 === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
if (node.type === InstrumentEnvelope.type) return new EnvelopeFace(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 === SoundFxDelay.type) return new DelayFace(state, assetId, node.id);
if (node.type === SoundFxSaturation.type) return new SaturationFace(state, assetId, node.id); if (node.type === SoundFxSaturation.type) return new SaturationFace(state, assetId, node.id);
if (node.type === SoundFxLimiter.type) return new LimiterFace(state, assetId, node.id); if (node.type === 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; return null;
} }
+278
View File
@@ -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<string, number | string>) =>
[...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<string, number>) =>
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');
});
});
+83
View File
@@ -1554,6 +1554,89 @@ export async function sound(t) {
into.to = { node: 'fx-out', port: 'in' }; into.to = { node: 'fx-out', port: 'in' };
graph.edges = graph.edges.filter((e) => e.id !== 'fx-e-lm'); graph.edges = graph.edges.filter((e) => e.id !== 'fx-e-lm');
graph.nodes = graph.nodes.filter((n) => n.id !== 'fx-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 showTab('scene');
await mutate(` await mutate(`
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph; const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;