Files
feuerwerk/src/editor/soundfx/modulationView.ts
T
adminandClaude Opus 5.5 0701fe3856
feuerwerk / gate (push) Successful in 5m5s
feuerwerk / suite (push) Failing after 18m39s
Share the parts the sound devices and timelines repeated (#85)
Round 12 of the clean-up. Duplicate code had grown from 33 clones
(249 lines) after round 11 to 140 clones (1263 lines), almost all of
it in the panels and faces of the sound FX devices (#379 to #401).
It is down to 57 clones (416 lines) now, and src is 816 lines
shorter.

- deviceParts.ts holds what the device panels built by hand: the
  head, rows of knobs, the screen style and the row layout for
  groups of knobs. Twelve panels use it. panel.knobs stays because
  the debug hooks read it.
- DeviceFace is the base of the twelve faces. It finds the node and
  does wired, preview, commit, set, hide, reload and audition.
  compressorWired lives there as paramWired.
- TextPool replaces ten copies of "make texts as needed, hide the
  rest" in the wave and video tabs, the rulers, the piano roll and
  the song and composition timelines. clipStrip.ts is the clip label
  strip the wave and video tabs share.
- core/util/bytes.ts has the concat that ogg, matroska and avcColour
  each wrote out.
- Dead code is gone: clear on the video players (never called since
  #402), posterFileOf and videoTimeAt (only tests called them) and
  easeOut (the same as glide(t, 3)). Values only used in their own
  file lose their export.
- Comments over 125 columns are wrapped, and comments that told the
  history ("as before", "used to") say what is, in src and in the
  e2e modules.
- Left alone on purpose: AnimationEditor and CompositionEditor share
  only the play button, and the curve panels are built differently.
- Chip now places its boxes through put, which rounds. They were
  whole numbers already. The three row layouts are byte-identical
  before and after.
- README lists the video and composition assets and the Videos and
  Compositions pages.

Tests:
- Gate (melektaus-atx): CHECK EXIT 0, 313 files, 4835 tests.
- e2e 25, 34, 64, 65, 66, 69 (melektaus-atx): ALL PASSED, 330 checks.
- TextPool without keeping its texts and framesToRoom without making
  room both go red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:40:05 +02:00

763 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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. */
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<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),
};