High up the Flanger's comb flickered and looked pixelated. Each column
of its screen took six readings, and its teeth pass there faster than
frames are drawn, up to 0.4 of a tooth a frame at 15 kHz by default.
Each frame caught other teeth, and each column was drawn as a sharp
rectangle.
- flangerColumns reckons each column exactly. The response only hangs
on the phase, and between two multiples of pi it runs one way. So a
column holds its lowest and highest at its ends or on the peaks and
notches inside it.
- Each frame is blurred over the delays of the last 1/20 s
(RESPONSE_BLUR), about what an eye blends. Where teeth pass faster
than that, they become a calm band from the deepest notch to the
highest peak. Further down, where they can be followed, they stay
sharp and move.
- The response is drawn as a band between two smooth lines through
the columns, no longer as one rectangle per column.
- responseColumnsOf picks the Phaser's readings or the Flanger's exact
columns. responseDb had no caller left and is gone.
Measured before choosing the blur, as the mean change of a column from
one frame to the next on the default Flanger (2-5 / 5-10 / 10-20 kHz):
readings as before 5.6 / 10.6 / 14.3 dB
exact, no blur 5.5 / 10.2 / 12.9 dB
exact, 1/60 s 5.0 / 7.6 / 4.0 dB
exact, 1/20 s 3.9 / 2.6 / 0.04 dB
Under 5 kHz most of what is left is the teeth really moving.
Tests:
Unit modulation-view (stein2): 24 tests, 2 new. The exact columns
hold what 4000 readings a column find there, and a fast swing
leaves the same band over 16 kHz in three frames in a row.
Counter-check: without the peaks and notches inside a column, both
new tests fail.
e2e 34 (atx): ALL PASSED, 142 checks.
Gate (atx): CHECK EXIT 0, 291 files, 4469 tests.
Pictures of the default Flanger and of one sweeping to 20 ms, near
and far, two of each 40 ms apart: smooth edges, the band the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
698 lines
26 KiB
TypeScript
698 lines
26 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
|
||
/** 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) };
|
||
});
|
||
}
|
||
|
||
/**
|
||
* How long a frame of the Flanger's response is blurred over, about what an eye blends: the delays
|
||
* its light passed in that time. High up its teeth pass faster than frames are drawn, and sharp
|
||
* frames of them flicker. Over 10 kHz this calms a column from 14 dB a frame to 0.04, under 5 kHz,
|
||
* where the teeth can be followed, it hardly changes them.
|
||
*/
|
||
export const RESPONSE_BLUR = 1 / 20;
|
||
|
||
/** A Phaser's or a Flanger's response over `columns` columns of the screen, `t` seconds after the sound. */
|
||
export function responseColumnsOf(
|
||
device: ModDevice,
|
||
values: ModValues,
|
||
t: number,
|
||
columns: number,
|
||
): { low: number; high: number }[] {
|
||
if (device.kind === 'phaser')
|
||
return responseColumns((hz) => phaserDb(values, phaserTurnAt(values, t), hz), columns);
|
||
const delays = [t - RESPONSE_BLUR, t - RESPONSE_BLUR / 2, t].map((at) =>
|
||
flangerDelayAt(values, Math.max(0, at)),
|
||
);
|
||
return flangerColumns(values, Math.min(...delays), Math.max(...delays), 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. */
|
||
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),
|
||
};
|