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>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
/**
|
||
* What a Saturation's node draws (#396), as numbers: where the parts of its device sit, how its
|
||
* knobs turn, the wave a sine at -12 dBFS comes out as – the screen – and the heat it glows with,
|
||
* the harder the sound is driven. Kept apart from the drawing so it is checked without a browser.
|
||
*/
|
||
import type { PropertyBag } from '../../core/model/Properties';
|
||
import { SATURATION_AUTO_GAIN, SATURATION_ROWS, saturationTypeOf, switchOn } from '../../core/soundfx/nodes';
|
||
import {
|
||
saturate,
|
||
SATURATION_REFERENCE,
|
||
SATURATION_TYPES,
|
||
saturationAutoGain,
|
||
type SaturationType,
|
||
toneCutoff,
|
||
} from '../../core/soundfx/saturation';
|
||
import { mixColor } from '../../core/util/color';
|
||
import { formatNumber } from '../../ui/math';
|
||
import { BIG_DIAL, type Box, type KnobSpot, screwsOf, SMALL_DIAL } from './hardwareView';
|
||
import { hzText, percentText } from './reverbView';
|
||
|
||
export type SaturationKey = keyof typeof SATURATION_ROWS;
|
||
export const SATURATION_KEYS = Object.keys(SATURATION_ROWS) as SaturationKey[];
|
||
|
||
export interface SaturationValues extends Record<SaturationKey, number> {
|
||
type: SaturationType;
|
||
autoGain: boolean;
|
||
}
|
||
|
||
const numberOf = (params: PropertyBag, key: SaturationKey): number => {
|
||
const value = params[key];
|
||
return typeof value === 'number' && Number.isFinite(value) ? value : SATURATION_ROWS[key].default;
|
||
};
|
||
|
||
/** The numbers a Saturation plays with, each its row's default where none is typed in. */
|
||
export function saturationValues(params: PropertyBag): SaturationValues {
|
||
return {
|
||
drive: numberOf(params, 'drive'),
|
||
tone: numberOf(params, 'tone'),
|
||
output: numberOf(params, 'output'),
|
||
mix: numberOf(params, 'mix'),
|
||
type: saturationTypeOf(params),
|
||
autoGain: switchOn(params, SATURATION_AUTO_GAIN),
|
||
};
|
||
}
|
||
|
||
/** At wet 0 the node lets the sound through as it is: nothing of how it bends it is heard. */
|
||
export function idleSaturationKeys(values: SaturationValues): Set<SaturationKey | 'type' | 'autoGain'> {
|
||
return values.mix > 0 ? new Set() : new Set(['drive', 'tone', 'output', 'type', 'autoGain']);
|
||
}
|
||
|
||
/** A type's name on its button: the first word of its own, `Hard clip` as HARD. */
|
||
export function typeCaption(type: SaturationType): string {
|
||
const label = SATURATION_TYPES.find((option) => option.value === type)?.label ?? type;
|
||
return label.split(' ')[0]!.toUpperCase();
|
||
}
|
||
|
||
export interface SaturationKnob {
|
||
label: string;
|
||
taper: number;
|
||
bipolar?: boolean;
|
||
decimals: number;
|
||
format: (value: number) => string;
|
||
}
|
||
|
||
const dbText = (db: number, signed: boolean): string =>
|
||
`${signed && db > 0 ? '+' : ''}${formatNumber(db, 1)} dB`;
|
||
|
||
/** How each knob turns: all evenly; the tone names the cutoff it opens to, the output turns round 0. */
|
||
export const SATURATION_KNOBS: Record<SaturationKey, SaturationKnob> = {
|
||
drive: { label: 'Drive', taper: 1, decimals: 1, format: (db) => dbText(db, false) },
|
||
tone: { label: 'Tone', taper: 1, decimals: 2, format: (tone) => hzText(toneCutoff(tone)) },
|
||
output: { label: 'Output', taper: 1, bipolar: true, decimals: 1, format: (db) => dbText(db, true) },
|
||
mix: { label: 'Wet', taper: 1, decimals: 2, format: percentText },
|
||
};
|
||
|
||
// --- The plate. ---
|
||
|
||
/** The plate: as the Delay's (#393), the panel never takes another size. */
|
||
export const SATURATION_PANEL = { w: 720, h: 280 };
|
||
|
||
/** The height of a group's name and its rule. */
|
||
const GROUP_LINE = 16;
|
||
/** A lever's box, as the Compressor's auto-gain (#379). */
|
||
const LEVER = { w: 118, h: 40 };
|
||
/** The extra the strip of text under the screen takes. */
|
||
export const TEXT_ROW = 14;
|
||
const BUTTON = { h: 22, gap: 6 };
|
||
|
||
export interface SaturationLayout {
|
||
title: Box;
|
||
/** The lamp that glows with the heat, beside the device's name; its own name after it. */
|
||
lamp: { x: number; y: number; r: number };
|
||
lampCaption: { x: number; y: number };
|
||
autoGain: Box;
|
||
bezel: Box;
|
||
screen: Box;
|
||
/** The name over the type buttons, and the buttons in the order of the types. */
|
||
typeGroup: Box;
|
||
types: Box[];
|
||
knobs: Record<SaturationKey, KnobSpot>;
|
||
screws: { x: number; y: number }[];
|
||
}
|
||
|
||
function saturationLayout(): SaturationLayout {
|
||
const { w } = SATURATION_PANEL;
|
||
const screen = { x: 34, y: 60, w: 240, h: 176 };
|
||
const bezel = { x: 24, y: 44, w: screen.w + 20, h: screen.h + 16 + TEXT_ROW + 6 };
|
||
// The drive big beside the screen, as the Compressor's threshold; the rest of the plate on the right.
|
||
const drive = { x: bezel.x + bezel.w + 24 + BIG_DIAL / 2, y: 160, size: BIG_DIAL };
|
||
const left = drive.x + BIG_DIAL / 2 + 24;
|
||
const right = w - 24;
|
||
const typeGroup = { x: left, y: 62, w: right - left, h: GROUP_LINE };
|
||
const perRow = Math.ceil(SATURATION_TYPES.length / 2);
|
||
const buttonW = (typeGroup.w - (perRow - 1) * BUTTON.gap) / perRow;
|
||
const types = SATURATION_TYPES.map((_, i) => ({
|
||
x: left + (i % perRow) * (buttonW + BUTTON.gap),
|
||
y: typeGroup.y + GROUP_LINE + 2 + Math.floor(i / perRow) * (BUTTON.h + BUTTON.gap),
|
||
w: buttonW,
|
||
h: BUTTON.h,
|
||
}));
|
||
const slot = (right - left) / 3;
|
||
const dialY = 186;
|
||
const small = (i: number): KnobSpot => ({ x: left + slot * (i + 0.5), y: dialY, size: SMALL_DIAL });
|
||
return {
|
||
title: { x: 34, y: 14, w: 126, h: 24 },
|
||
lamp: { x: 172, y: 26, r: 6 },
|
||
lampCaption: { x: 198, y: 21 },
|
||
autoGain: { x: right - LEVER.w, y: 10, ...LEVER },
|
||
bezel,
|
||
screen,
|
||
typeGroup,
|
||
types,
|
||
knobs: { drive, tone: small(0), output: small(1), mix: small(2) },
|
||
screws: screwsOf(SATURATION_PANEL),
|
||
};
|
||
}
|
||
|
||
export const SATURATION_LAYOUT: SaturationLayout = saturationLayout();
|
||
|
||
// --- The screen: a sine at -12 dBFS as it comes out. ---
|
||
|
||
/** How far up and down the screen goes, as a level: -6 dBFS, so the sine going in fills half of it. */
|
||
export const WAVE_REACH = 0.5;
|
||
/** The periods of the sine across the screen, and the points the wave is drawn with. */
|
||
const PERIODS = 2;
|
||
const WAVE_POINTS = 241;
|
||
|
||
export interface WaveTrace {
|
||
/** The sine going in, at -12 dBFS. */
|
||
dry: number[];
|
||
/** What comes out: the bent sound with its DC taken out, its gain after it, blended by the wet. */
|
||
out: number[];
|
||
/** The loudest point that comes out. */
|
||
peak: number;
|
||
/** The gain after the curve in dB: auto-gain's and the output's. */
|
||
gain: number;
|
||
auto: number | null;
|
||
}
|
||
|
||
/**
|
||
* The wave a sine at -12 dBFS – where auto-gain is reckoned at – comes out as (#396): driven into
|
||
* the curve, its DC taken out as the node's high-pass does, the auto-gain and the output after it,
|
||
* blended with the sine by the wet. The tone's low-pass is left out: it would only round the edges.
|
||
*/
|
||
export function waveTrace(values: SaturationValues, points = WAVE_POINTS): WaveTrace {
|
||
const drive = 10 ** (values.drive / 20);
|
||
const dry = Array.from(
|
||
{ length: points },
|
||
(_, i) => SATURATION_REFERENCE * Math.sin((2 * Math.PI * PERIODS * i) / (points - 1)),
|
||
);
|
||
const bent = dry.map((x) => saturate(values.type, x * drive));
|
||
// The mean over whole periods: the last point is the first again, so it is left out.
|
||
const mean = bent.slice(0, -1).reduce((sum, v) => sum + v, 0) / (points - 1);
|
||
const auto = values.autoGain ? saturationAutoGain(values.type, values.drive) : null;
|
||
const gain = (auto ?? 0) + values.output;
|
||
const level = 10 ** (gain / 20);
|
||
const mix = Math.max(0, Math.min(1, values.mix));
|
||
const out = dry.map((x, i) => (1 - mix) * x + mix * (bent[i]! - mean) * level);
|
||
const peak = out.reduce((most, v) => Math.max(most, Math.abs(v)), 0);
|
||
return { dry, out, peak, gain, auto };
|
||
}
|
||
|
||
/** What the screen writes: auto-gain's share under it on the left, the loudest point on the right. */
|
||
export function saturationTexts(values: SaturationValues, trace: WaveTrace): { auto: string; peak: string } {
|
||
const peakDb = trace.peak > 0 ? 20 * Math.log10(trace.peak) : -Infinity;
|
||
return {
|
||
auto:
|
||
values.mix <= 0
|
||
? 'DRY'
|
||
: trace.auto === null
|
||
? 'AUTO-GAIN OFF'
|
||
: `AUTO-GAIN ${dbText(trace.auto, true)}`,
|
||
peak: Number.isFinite(peakDb) ? `PEAK ${formatNumber(peakDb, 1)} dBFS` : 'SILENT',
|
||
};
|
||
}
|
||
|
||
// --- The heat. ---
|
||
|
||
/** How hot the device runs, 0 to 1: the drive of its 48 dB, as much as the wet lets it be heard. */
|
||
export function heatOf(values: SaturationValues): number {
|
||
const drive = SATURATION_ROWS.drive.max ?? 48;
|
||
return Math.max(0, Math.min(1, values.drive / drive)) * Math.max(0, Math.min(1, values.mix));
|
||
}
|
||
|
||
/** Cold steel to ember red, orange, yellow and white hot, as metal goes in a fire. */
|
||
const HEAT_STOPS: readonly [heat: number, color: number][] = [
|
||
[0, 0x9aa6b8],
|
||
[0.2, 0xd9542a],
|
||
[0.45, 0xff8a24],
|
||
[0.7, 0xffc94a],
|
||
[1, 0xfff4dc],
|
||
];
|
||
|
||
export function heatColor(heat: number): number {
|
||
const h = Math.max(0, Math.min(1, heat));
|
||
for (let i = 1; i < HEAT_STOPS.length; i++) {
|
||
const [to, color] = HEAT_STOPS[i]!;
|
||
const [from, before] = HEAT_STOPS[i - 1]!;
|
||
if (h <= to) return mixColor(before, color, (h - from) / (to - from));
|
||
}
|
||
return HEAT_STOPS.at(-1)![1];
|
||
}
|
||
|
||
/** The word above the screen for a heat. */
|
||
export function heatWord(heat: number): string {
|
||
if (heat < 0.05) return 'COLD';
|
||
if (heat <= 0.25) return 'WARM';
|
||
if (heat <= 0.5) return 'HOT';
|
||
if (heat <= 0.75) return 'GLOWING';
|
||
return 'WHITE HOT';
|
||
}
|
||
|
||
/** How strong the glow rising from the bottom of the screen is at a heat, 0 to 1. */
|
||
export const glowStrength = (heat: number): number => 0.65 * Math.max(0, Math.min(1, heat)) ** 1.3;
|
||
|
||
/** The most embers that rise at white heat; under `EMBERS_FROM` none do. */
|
||
export const MOST_EMBERS = 24;
|
||
const EMBERS_FROM = 0.15;
|
||
|
||
export const emberCount = (heat: number): number =>
|
||
Math.round((Math.max(0, heat - EMBERS_FROM) / (1 - EMBERS_FROM)) * MOST_EMBERS);
|
||
|
||
/** Four numbers from 0 to 1 for an ember, the same every time. */
|
||
function emberSeed(i: number): [number, number, number, number] {
|
||
let state = (i * 2654435761 + 396) >>> 0;
|
||
const next = () => {
|
||
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
|
||
return state / 2 ** 32;
|
||
};
|
||
return [next(), next(), next(), next()];
|
||
}
|
||
|
||
export interface Ember {
|
||
/** Across and up the screen, 0 to 1, y 0 at the top. */
|
||
x: number;
|
||
y: number;
|
||
r: number;
|
||
alpha: number;
|
||
}
|
||
|
||
/**
|
||
* The embers that rise from the bottom of the screen at a heat, `seconds` on the page's clock: more
|
||
* and faster the hotter it runs, each wandering a little and fading as it rises, round again.
|
||
*/
|
||
export function embersAt(heat: number, seconds: number): Ember[] {
|
||
return Array.from({ length: emberCount(heat) }, (_, i) => {
|
||
const [across, speed, start, sway] = emberSeed(i);
|
||
const rise = (0.25 + 0.35 * speed) * (0.6 + 0.8 * heat);
|
||
const risen = (((seconds * rise + start) % 1) + 1) % 1;
|
||
return {
|
||
x: Math.max(0, Math.min(1, across + 0.03 * Math.sin(seconds * (1 + 2 * sway) + 6.28 * sway))),
|
||
y: 1 - risen,
|
||
r: 0.8 + 1.4 * sway,
|
||
alpha: Math.min(1, risen * 8) * (1 - risen) ** 1.5,
|
||
};
|
||
});
|
||
}
|
||
|
||
/** How the glow flickers, `seconds` on the page's clock: within 8 % either way, never the same twice in a row. */
|
||
export function flicker(seconds: number): number {
|
||
return (
|
||
1 + (0.08 * (Math.sin(13.1 * seconds) + Math.sin(7.3 * seconds + 1) + Math.sin(23.7 * seconds + 2))) / 3
|
||
);
|
||
}
|