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:
1 parent
28909f0adf
commit
b6749cc43d
12 files changed
+1860
-11
No files matched your search
@@ -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),
|
||||
};
|
||||
Reference in new issue
Block a user