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

+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),
};