/** * What the nodes of the four effects that swing draw (#398) – the Tremolo, the Vibrato, the Phaser * and the Flanger – as numbers: where the parts of their devices sit, how their knobs turn, what * their screens draw of the first seconds of a sound, and the light that plays it over and over. * Kept apart from the drawing so it is checked without a browser. */ import type { PropertyBag } from '../../core/model/Properties'; import type { FlatDef } from '../../core/properties/PropertySchema'; import { LFO_SHAPES, lfoAt, type LfoShape, vibratoDelay, vibratoSpeedUp } from '../../core/soundfx/lfo'; import { FLANGER_ROWS, LFO_SHAPE, lfoShapeOf, PHASER_ROWS, PHASER_STAGE_ROW, phaserStagesOf, SoundFxFlanger, SoundFxPhaser, SoundFxTremolo, SoundFxVibrato, TREMOLO_ROWS, VIBRATO_ROWS, } from '../../core/soundfx/nodes'; import { FLANGER_MAX_MS } from '../../core/soundfx/phaser'; import { formatNumber } from '../../ui/math'; import { secondsText } from './chipView'; import { TEXT_ROW } from './delayView'; import { BIG_DIAL, type Box, KNOB_CAPTION, type KnobSpot, screwsOf, SMALL_DIAL } from './hardwareView'; import { hzText, percentText } from './reverbView'; export type ModKind = 'tremolo' | 'vibrato' | 'phaser' | 'flanger'; /** A knob: the row it sets, its name, how it turns and how its number reads. */ export interface ModKnob { key: string; row: FlatDef<'number'>; label: string; taper: number; bipolar?: boolean; decimals: number; format: (value: number) => string; } /** The buttons that pick one of a setting's options: an LFO's shape, a Phaser's stages. */ export interface ModPick { key: string; label: string; options: { value: string; caption: string }[]; of: (params: PropertyBag) => string; } export interface ModDevice { kind: ModKind; /** The node it is drawn for. */ type: string; title: string; /** What its screen is called, above it on the left. */ screenName: string; pick: ModPick | null; knobs: ModKnob[]; } const rateText = (hz: number): string => `${formatNumber(hz, hz < 1 ? 2 : 1)} Hz`; const msText = (ms: number): string => `${formatNumber(ms, ms < 10 ? 2 : 1)} ms`; const stText = (st: number): string => `${formatNumber(st, 2)} st`; const sweepText = (msPerS: number): string => `${msPerS > 0 ? '+' : ''}${formatNumber(msPerS, 1)} ms/s`; const SHAPES: ModPick = { key: LFO_SHAPE.key, label: 'SHAPE', options: LFO_SHAPES.map((option) => ({ value: option.value, caption: option.label.toUpperCase() })), of: lfoShapeOf, }; const wetKnob = (row: FlatDef<'number'>, key = row.key): ModKnob => ({ key, row, label: 'Wet', taper: 1, decimals: 2, format: percentText, }); /** * The four devices. Rates give their slow end more of the turn – 5 Hz sits at the middle of a * Tremolo's – as do the Phaser's frequency, which is heard in octaves, the Flanger's milliseconds * and its sweep either way of 0. */ export const TREMOLO_DEVICE: ModDevice = { kind: 'tremolo', type: SoundFxTremolo.type, title: 'TREMOLO', screenName: 'VOLUME OVER TIME', pick: SHAPES, knobs: [ { key: 'rate', row: TREMOLO_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText }, { key: 'depth', row: TREMOLO_ROWS.depth, label: 'Depth', taper: 1, decimals: 0, format: (pct) => `${formatNumber(pct, 0)}%`, }, wetKnob(TREMOLO_ROWS.wet), ], }; export const VIBRATO_DEVICE: ModDevice = { kind: 'vibrato', type: SoundFxVibrato.type, title: 'VIBRATO', screenName: 'PITCH OVER TIME', pick: SHAPES, knobs: [ { key: 'rate', row: VIBRATO_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText }, { key: 'depth', row: VIBRATO_ROWS.depth, label: 'Depth', taper: 2, decimals: 2, format: stText }, wetKnob(VIBRATO_ROWS.wet), ], }; export const PHASER_DEVICE: ModDevice = { kind: 'phaser', type: SoundFxPhaser.type, title: 'PHASER', screenName: 'NOTCHES', pick: { key: PHASER_STAGE_ROW.key, label: 'STAGES', options: PHASER_STAGE_ROW.options.map((option) => ({ value: option.value, caption: option.label })), of: (params) => String(phaserStagesOf(params)), }, knobs: [ { key: 'frequency', row: PHASER_ROWS.frequency, label: 'Freq', taper: 4, decimals: 0, format: hzText }, { key: 'depth', row: PHASER_ROWS.depth, label: 'Depth', taper: 1, decimals: 1, format: (oct) => `${formatNumber(oct, 1)} oct`, }, { key: 'rate', row: PHASER_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText }, { key: 'feedback', row: PHASER_ROWS.feedback, label: 'Feedback', taper: 1, decimals: 2, format: percentText, }, wetKnob(PHASER_ROWS.mix), ], }; export const FLANGER_DEVICE: ModDevice = { kind: 'flanger', type: SoundFxFlanger.type, title: 'FLANGER', screenName: 'COMB', pick: null, knobs: [ { key: 'offset', row: FLANGER_ROWS.offset, label: 'Offset', taper: 2, decimals: 2, format: msText }, { key: 'sweep', row: FLANGER_ROWS.sweep, label: 'Sweep', taper: 3, bipolar: true, decimals: 1, format: sweepText, }, { key: 'rate', row: FLANGER_ROWS.rate, label: 'Rate', taper: 2, decimals: 2, format: rateText }, { key: 'depth', row: FLANGER_ROWS.depth, label: 'Depth', taper: 2, decimals: 2, format: msText }, { key: 'feedback', row: FLANGER_ROWS.feedback, label: 'Feedback', taper: 1, decimals: 2, format: percentText, }, wetKnob(FLANGER_ROWS.mix), ], }; export const MOD_DEVICES: readonly ModDevice[] = [ TREMOLO_DEVICE, VIBRATO_DEVICE, PHASER_DEVICE, FLANGER_DEVICE, ]; /** The key of the knob that blends a device's swing with the sound as it came. */ export const wetKey = (device: ModDevice): string => device.knobs.at(-1)!.key; export interface ModValues { /** Every knob's number, its row's default where none is typed in. */ numbers: Record; /** The option its buttons have picked, '' without buttons. */ picked: string; } export function modValues(device: ModDevice, params: PropertyBag): ModValues { const numbers: Record = {}; for (const knob of device.knobs) { const value = params[knob.key]; numbers[knob.key] = typeof value === 'number' && Number.isFinite(value) ? value : knob.row.default; } return { numbers, picked: device.pick?.of(params) ?? '' }; } const num = (values: ModValues, key: string): number => values.numbers[key] ?? 0; const clamp01 = (value: number): number => Math.max(0, Math.min(1, value)); /** * The settings that do nothing as things stand, greyed as the Delay's are: none of the swing is * heard at wet 0, nor at depth 0 of a Tremolo or a Vibrato, which leave the sound alone then. A * Phaser or a Flanger without depth has an LFO that moves nothing. */ export function idleModKeys(device: ModDevice, values: ModValues): Set { const keys = [...device.knobs.map((knob) => knob.key), ...(device.pick ? [device.pick.key] : [])]; const wet = wetKey(device); if (num(values, wet) <= 0) return new Set(keys.filter((key) => key !== wet)); if (num(values, 'depth') > 0) return new Set(); if (device.kind === 'tremolo' || device.kind === 'vibrato') return new Set(keys.filter((key) => key !== 'depth')); return new Set(['rate']); } // --- What the light plays: the first seconds of a sound. --- /** The seconds a screen spans: the first of these that is long enough. */ const SPANS = [1, 2, 4, 8] as const; 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). */ 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; 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; const LOW_HZ = 20; 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); } /** 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, perColumn = PER_COLUMN, ): { low: number; high: number }[] { return Array.from({ length: columns }, (_, c) => { let low = Infinity; let high = -Infinity; for (let k = 0; k < perColumn; k++) { const value = db(hzAt((c + (k + 0.5) / perColumn) / columns)); low = Math.min(low, value); high = Math.max(high, value); } return { low, high }; }); } /** * A Flanger's response over `columns` columns while its copy is `fromMs` to `toMs` late, exactly: * it only hangs on the phase `2π·hz·ms`, and between two multiples of π it runs one way. So a * column holds its lowest and highest at the ends of the phases it spans or on the peaks and * notches between them. Readings would catch other teeth each frame and flicker where they are * finer than a column. */ export function flangerColumns( values: ModValues, fromMs: number, toMs: number, columns: number, ): { low: number; high: number }[] { // The phase in half turns: a peak at every even one, a notch at every odd one. const db = (halfTurns: number): number => flangerDb(values, 1, halfTurns * 500); const early = Math.max(0, Math.min(fromMs, toMs)) / 500; const late = Math.max(0, fromMs, toMs) / 500; return Array.from({ length: columns }, (_, c) => { const from = hzAt(c / columns) * early; const to = hzAt((c + 1) / columns) * late; const ends = [db(from), db(to)]; const first = Math.ceil(from); for (let k = first; k <= first + 1 && k <= to; k++) ends.push(db(k)); return { low: Math.min(...ends), high: Math.max(...ends) }; }); } /** A point of a response drawn as a line: how far along the screen, and its dB. */ export interface ResponsePoint { share: number; db: number; } /** * A Flanger's response as a line over `columns` columns while its copy is `ms` late, exactly: the * ends of each column and the peaks and notches between them, where they are. A column the comb * runs too fine for gets one peak and one notch, so the line zigzags over all it holds. */ export function flangerLine(values: ModValues, ms: number, columns: number): ResponsePoint[] { const db = (halfTurns: number): number => flangerDb(values, 1, halfTurns * 500); const halfTurnsAt = (share: number): number => (hzAt(share) * Math.max(0, ms)) / 500; const shareOf = (halfTurns: number): number => shareOfHz((halfTurns * 500) / Math.max(0, ms)); const points: ResponsePoint[] = []; for (let c = 0; c <= columns; c++) { const from = halfTurnsAt(c / columns); points.push({ share: c / columns, db: db(from) }); if (c === columns) break; const to = halfTurnsAt((c + 1) / columns); const first = Math.ceil(from); for (let k = first; k <= first + 1 && k < to; k++) if (k > from) points.push({ share: shareOf(k), db: db(k) }); } return points; } /** * How long the Flanger's trail lasts, about what an eye blends: the delays its light passed in that * time, drawn faintly behind the line. High up its teeth pass faster than frames are drawn, and a * sharp line of them flickers. There the trail is a calm band, over 10 kHz it changes 0.04 dB a frame. */ const RESPONSE_TRAIL = 1 / 20; /** * How clearly the line can be followed at `hz` while the delay moves `movedMs` within the trail: * fully while its teeth move up to a quarter of one, not at all from half of one on. There they * would flicker, and only the trail is left. */ export function clearAt(hz: number, movedMs: number): number { const halfTurns = (hz * Math.abs(movedMs)) / 500; return clamp01(2 - 2 * halfTurns); } /** A Phaser's or a Flanger's response as the screen draws it, `t` seconds after the sound. */ export interface ResponseFrame { /** Where the light is now, left to right. */ line: ResponsePoint[]; /** How clearly each point of the line can be followed, 0 to 1. */ clear: number[]; /** The lowest and highest of the line in each column: its notches are counted on them. */ columns: { low: number; high: number }[]; /** What it swept through just before, each band from longer ago to now, the longest first. */ trail: { low: number; high: number }[][]; } export function responseFrame( device: ModDevice, values: ModValues, t: number, columns: number, ): ResponseFrame { if (device.kind === 'phaser') { const db = (hz: number) => phaserDb(values, phaserTurnAt(values, t), hz); const line = Array.from({ length: columns + 1 }, (_, c) => ({ share: c / columns, db: db(hzAt(c / columns)), })); return { line, clear: line.map(() => 1), columns: responseColumns(db, columns), trail: [] }; } const delayAt = (at: number) => flangerDelayAt(values, Math.max(0, at)); const ms = delayAt(t); const line = flangerLine(values, ms, columns); const band = (age: number) => { const delays = [t - age, t - age / 2, t].map(delayAt); return { low: Math.min(...delays), high: Math.max(...delays) }; }; const trail = [band(RESPONSE_TRAIL), band(RESPONSE_TRAIL / 2)]; const moved = trail[0]!.high - trail[0]!.low; return { line, clear: line.map((point) => clearAt(hzAt(point.share), moved)), columns: flangerColumns(values, ms, ms, columns), trail: moved > 0 ? trail.map((d) => flangerColumns(values, d.low, d.high, columns)) : [], }; } /** * 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. */ const MOD_PANELS: Record = { tremolo: { w: 640, h: 280 }, vibrato: { w: 640, h: 280 }, phaser: { w: 760, h: 280 }, flanger: { w: 680, h: 280 }, }; /** The height of a group's name and its rule. */ const GROUP_LINE = 16; const GROUP_GAP = 16; const BUTTON = { h: 22, gap: 6 }; /** How wide a letter of a device's name is engraved, near enough to place the lamp after it. */ const TITLE_LETTER = 13; export interface ModLayout { panel: { w: number; h: number }; title: Box; /** The lamp that swings with the light, beside the device's name; its own name after it. */ lamp: { x: number; y: number; r: number }; lampCaption: { x: number; y: number }; bezel: Box; screen: Box; /** The name over the buttons, and the buttons in the order of the options; none for a Flanger. */ pickGroup: Box | null; picks: Box[]; groups: { label: string; box: Box }[]; knobs: Record; screws: { x: number; y: number }[]; } function modLayout(device: ModDevice): ModLayout { const panel = MOD_PANELS[device.kind]; const wide = device.kind === 'phaser' || device.kind === 'flanger'; const screen = { x: 34, y: 60, w: wide ? 300 : 260, h: 176 }; const bezel = { x: 24, y: 44, w: screen.w + 20, h: screen.h + 16 + TEXT_ROW + 6 }; const left = bezel.x + bezel.w + 24; const right = panel.w - 24; const title = { x: 34, y: 14, w: device.title.length * TITLE_LETTER, h: 24 }; const lamp = { x: title.x + title.w + 14, y: 26, r: 6 }; let pickGroup: Box | null = null; let picks: Box[] = []; if (device.pick) { const options = device.pick.options; pickGroup = { x: left, y: 50, w: right - left, h: GROUP_LINE }; const w = (pickGroup.w - (options.length - 1) * BUTTON.gap) / options.length; picks = options.map((_, i) => ({ x: left + i * (w + BUTTON.gap), y: 68, w, h: BUTTON.h })); } const groups: { label: string; box: Box }[] = []; const knobs: Record = {}; if (device.kind === 'tremolo' || device.kind === 'vibrato') { // Rate and depth big, the wet small, all on one line under the shapes. const y = 180; knobs.rate = { x: left + 54, y, size: BIG_DIAL }; knobs.depth = { x: left + 166, y, size: BIG_DIAL }; knobs.wet = { x: right - 34, y, size: SMALL_DIAL }; } else if (device.kind === 'phaser') { const sets = [ ['SWEEP', ['frequency', 'depth', 'rate']], ['RING', ['feedback']], ['MIX', ['mix']], ] as const; const slot = (right - left - (sets.length - 1) * GROUP_GAP) / 5; const top = 128; const y = top + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; let x = left; for (const [label, keys] of sets) { groups.push({ label, box: { x, y: top, w: keys.length * slot, h: GROUP_LINE } }); for (const key of keys) { knobs[key] = { x: x + slot / 2, y, size: SMALL_DIAL }; x += slot; } x += GROUP_GAP; } } else { // Three columns of two: the delay, its LFO, and how the comb rings and is heard. const sets = [ ['DELAY', ['offset', 'sweep']], ['LFO', ['rate', 'depth']], ['COMB', ['feedback', 'mix']], ] as const; const gap = 12; const w = (right - left - (sets.length - 1) * gap) / sets.length; const top = 50; const first = top + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2; sets.forEach(([label, keys], i) => { const x = left + i * (w + gap); groups.push({ label, box: { x, y: top, w, h: GROUP_LINE } }); keys.forEach((key, row) => { knobs[key] = { x: x + w / 2, y: first + row * 92, size: SMALL_DIAL }; }); }); } return { panel, title, lamp, lampCaption: { x: lamp.x + 28, y: 21 }, bezel, screen, pickGroup, picks, groups, knobs, screws: screwsOf(panel), }; } export const MOD_LAYOUTS: Record = { tremolo: modLayout(TREMOLO_DEVICE), vibrato: modLayout(VIBRATO_DEVICE), phaser: modLayout(PHASER_DEVICE), flanger: modLayout(FLANGER_DEVICE), };