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>
336 lines
11 KiB
TypeScript
336 lines
11 KiB
TypeScript
/**
|
||
* The Saturation as a device (#396), what its node shows in the graph: a plate of copper, a screen
|
||
* that draws the wave a sine comes out as – over a glow that rises from below and embers that fly
|
||
* up, the hotter the harder it is driven – a lamp beside the name that glows with it, a button for
|
||
* each type, the auto-gain lever, the drive big and the tone, the output and the wet small.
|
||
*/
|
||
import { Graphics, type Text } from 'pixi.js';
|
||
import { SATURATION_ROWS } from '../../core/soundfx/nodes';
|
||
import { SATURATION_TYPES, type SaturationType } from '../../core/soundfx/saturation';
|
||
import { type Knob, Widget } from '../../ui';
|
||
import {
|
||
bezelText,
|
||
DeviceKnobs,
|
||
drawBezel,
|
||
drawLamp,
|
||
drawPlate,
|
||
drawRules,
|
||
type Engraving,
|
||
engrave,
|
||
IDLE_ALPHA,
|
||
Lever,
|
||
PickButton,
|
||
type PickLook,
|
||
placeEngraving,
|
||
type PlateLook,
|
||
put,
|
||
screenText,
|
||
} from './deviceParts';
|
||
import {
|
||
embersAt,
|
||
flicker,
|
||
glowStrength,
|
||
heatColor,
|
||
heatWord,
|
||
SATURATION_KEYS,
|
||
SATURATION_KNOBS,
|
||
SATURATION_LAYOUT,
|
||
SATURATION_PANEL,
|
||
type SaturationKey,
|
||
type SaturationValues,
|
||
TEXT_ROW,
|
||
typeCaption,
|
||
WAVE_REACH,
|
||
type WaveTrace,
|
||
} from './saturationView';
|
||
|
||
/** What the panel asks of the node it sits in. */
|
||
export interface SaturationHost {
|
||
preview(key: SaturationKey, value: number): void;
|
||
commit(key: SaturationKey, value: number): void;
|
||
pickType(type: SaturationType): void;
|
||
switchAutoGain(on: boolean): void;
|
||
}
|
||
|
||
/** What the panel shows of a node. */
|
||
export interface SaturationShown {
|
||
values: SaturationValues;
|
||
wired: (key: SaturationKey) => boolean;
|
||
/** The settings that do nothing as things stand. */
|
||
idle: ReadonlySet<SaturationKey | 'type' | 'autoGain'>;
|
||
trace: WaveTrace;
|
||
texts: { auto: string; peak: string };
|
||
heat: number;
|
||
}
|
||
|
||
/** Brushed copper: warm to start with. */
|
||
const COPPER: PlateLook = {
|
||
stops: [
|
||
[0, 0xe8b08a],
|
||
[0.5, 0xd08f66],
|
||
[1, 0xa86a45],
|
||
],
|
||
rim: 0x6e3c22,
|
||
grain: 0x5a2c16,
|
||
};
|
||
const COPPER_BUTTONS: PickLook = { face: 0x4a3226, hover: 0x5c4032, picked: 0xc8461e, shadow: 0x24160f };
|
||
/** The arc of a chrome dial: its orange would vanish on copper. */
|
||
const ARC = 0x3a1206;
|
||
const BEZEL = 0x2e2420;
|
||
const BEZEL_TEXT = 0xe2c9b8;
|
||
/** Soot-black glass; the sine going in as a grey ghost behind what comes out. */
|
||
const SCOPE = { glass: 0x0d0907, grid: 0x2e2420, text: 0xffc98a, dim: 0x7a6650, ghost: 0x6b6460 };
|
||
/** The rows the glow is drawn in, and the share of the screen it rises to. */
|
||
const GLOW_ROW = 2;
|
||
const GLOW_RISE = 0.85;
|
||
|
||
/**
|
||
* The screen: the sine going in as a grey ghost, the wave that comes out over it in the colour of
|
||
* the heat, a glow under them and embers rising – both moving by the page's clock, the wave not.
|
||
*/
|
||
class WaveScope extends Widget {
|
||
private readonly glass = new Graphics();
|
||
private readonly fire = new Graphics();
|
||
private readonly wave = new Graphics();
|
||
private readonly under: Record<'auto' | 'peak', Text>;
|
||
trace: WaveTrace | null = null;
|
||
heat = 0;
|
||
texts = { auto: '', peak: '' };
|
||
/** The embers drawn now, and how much the glow flickers (checks). */
|
||
embers = 0;
|
||
flare = 1;
|
||
/** Whether the wave goes past the screen's edge somewhere. */
|
||
over = false;
|
||
|
||
constructor() {
|
||
super();
|
||
this.addChild(this.glass, this.fire, this.wave);
|
||
this.under = { auto: screenText(this, 9, SCOPE.text, 0), peak: screenText(this, 9, SCOPE.text, 1) };
|
||
}
|
||
|
||
show(trace: WaveTrace, heat: number, texts: WaveScope['texts']): void {
|
||
this.trace = trace;
|
||
this.heat = heat;
|
||
this.texts = texts;
|
||
this.invalidate();
|
||
}
|
||
|
||
private get glassH(): number {
|
||
return this.h - TEXT_ROW;
|
||
}
|
||
|
||
override redraw(): void {
|
||
const w = this.w;
|
||
const h = this.glassH;
|
||
const cy = h / 2;
|
||
const half = h / 2 - 10;
|
||
const g = this.glass.clear();
|
||
g.roundRect(0, 0, w, h, 4).fill(SCOPE.glass);
|
||
// Full scale in at -12 dBFS, and the middle.
|
||
g.moveTo(6, cy - half / 2)
|
||
.lineTo(w - 6, cy - half / 2)
|
||
.moveTo(6, cy + half / 2)
|
||
.lineTo(w - 6, cy + half / 2)
|
||
.stroke({ color: SCOPE.grid, width: 1 });
|
||
g.moveTo(6, cy)
|
||
.lineTo(w - 6, cy)
|
||
.stroke({ color: SCOPE.dim, width: 1, alpha: 0.5 });
|
||
|
||
const s = this.wave.clear();
|
||
this.over = false;
|
||
const trace = this.trace;
|
||
if (trace) {
|
||
const line = (values: number[]) => {
|
||
values.forEach((v, i) => {
|
||
if (Math.abs(v) > WAVE_REACH) this.over = true;
|
||
const x = 8 + (i / (values.length - 1)) * (w - 16);
|
||
const y = cy - (Math.max(-WAVE_REACH, Math.min(WAVE_REACH, v)) / WAVE_REACH) * half;
|
||
if (i === 0) s.moveTo(x, y);
|
||
else s.lineTo(x, y);
|
||
});
|
||
};
|
||
line(trace.dry);
|
||
s.stroke({ color: SCOPE.ghost, width: 1, alpha: 0.7 });
|
||
const color = heatColor(this.heat);
|
||
// A halo round the wave as it heats up, the wave itself on it.
|
||
if (this.heat > 0) {
|
||
line(trace.out);
|
||
s.stroke({ color, width: 2 + 6 * this.heat, alpha: 0.25 * this.heat, join: 'round' });
|
||
}
|
||
line(trace.out);
|
||
s.stroke({ color, width: 2, join: 'round' });
|
||
}
|
||
this.under.auto.text = this.texts.auto;
|
||
this.under.auto.position.set(0, h + 3);
|
||
this.under.peak.text = this.texts.peak;
|
||
this.under.peak.position.set(w, h + 3);
|
||
this.drawFire(performance.now() / 1000);
|
||
}
|
||
|
||
/** `seconds` on a clock of the page's: the glow flickers, the embers rise. */
|
||
step(seconds: number): void {
|
||
this.drawFire(seconds);
|
||
}
|
||
|
||
private drawFire(seconds: number): void {
|
||
const f = this.fire.clear();
|
||
const w = this.w;
|
||
const h = this.glassH;
|
||
this.embers = 0;
|
||
this.flare = 1;
|
||
if (w <= 0 || this.heat <= 0) return;
|
||
this.flare = flicker(seconds);
|
||
const strength = glowStrength(this.heat) * this.flare;
|
||
// A fire's colours lag behind its heat: yellow at the bottom of a white-hot one, red above it.
|
||
const color = heatColor(this.heat * 0.7);
|
||
const deep = heatColor(this.heat * 0.4);
|
||
// Rows from the bottom up, each fainter: a glow as a fire's under the glass.
|
||
const rows = Math.ceil((h * GLOW_RISE) / GLOW_ROW);
|
||
for (let i = 0; i < rows; i++) {
|
||
const share = 1 - i / rows;
|
||
f.rect(2, h - 2 - (i + 1) * GLOW_ROW, w - 4, GLOW_ROW).fill({
|
||
color: i < rows / 4 ? color : deep,
|
||
alpha: strength * share ** 2,
|
||
});
|
||
}
|
||
const embers = embersAt(this.heat, seconds);
|
||
for (const ember of embers)
|
||
f.circle(4 + ember.x * (w - 8), 4 + ember.y * (h - 8), ember.r).fill({
|
||
color: heatColor(Math.min(1, this.heat + 0.2)),
|
||
alpha: ember.alpha,
|
||
});
|
||
this.embers = embers.length;
|
||
}
|
||
}
|
||
|
||
export class SaturationPanel extends Widget {
|
||
private readonly plate = new Graphics();
|
||
private readonly bezel = new Graphics();
|
||
private readonly rules = new Graphics();
|
||
private readonly lampLight = new Graphics();
|
||
readonly scope = new WaveScope();
|
||
readonly typeButtons: PickButton<SaturationType>[];
|
||
readonly autoGain: Lever;
|
||
readonly knobs: Record<SaturationKey, Knob>;
|
||
private readonly dials: DeviceKnobs<SaturationKey>;
|
||
private readonly typeName: Engraving;
|
||
private readonly title: Engraving;
|
||
private readonly lampCaption: Engraving;
|
||
private readonly screenName: Text;
|
||
/** The heat in a word, above the screen on the right, in its colour. */
|
||
readonly heatText: Text;
|
||
/** The settings shown idle now (checks). */
|
||
idle: ReadonlySet<SaturationKey | 'type' | 'autoGain'> = new Set();
|
||
heat = 0;
|
||
|
||
constructor(host: SaturationHost) {
|
||
super();
|
||
this.minWidth = SATURATION_PANEL.w;
|
||
this.minHeight = SATURATION_PANEL.h;
|
||
this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight);
|
||
this.title = engrave(this, 'SATURATION', 14);
|
||
this.lampCaption = engrave(this, 'HEAT', 9);
|
||
this.screenName = bezelText(this, 'A SINE AT -12 dBFS', BEZEL_TEXT);
|
||
this.heatText = bezelText(this, '', BEZEL_TEXT, true);
|
||
this.typeName = engrave(this, 'TYPE', 9);
|
||
this.typeButtons = SATURATION_TYPES.map((type) => {
|
||
const button = new PickButton(type.value, typeCaption(type.value), COPPER_BUTTONS, (picked) =>
|
||
host.pickType(picked),
|
||
);
|
||
this.addChild(button);
|
||
return button;
|
||
});
|
||
this.autoGain = new Lever('AUTO-GAIN', (on) => host.switchAutoGain(on));
|
||
this.addChild(this.autoGain);
|
||
this.dials = new DeviceKnobs(
|
||
this,
|
||
SATURATION_KEYS,
|
||
(key) => ({
|
||
row: SATURATION_ROWS[key],
|
||
look: SATURATION_KNOBS[key],
|
||
size: SATURATION_LAYOUT.knobs[key].size,
|
||
}),
|
||
host,
|
||
ARC,
|
||
);
|
||
this.knobs = this.dials.knobs;
|
||
}
|
||
|
||
override measure(): { w: number; h: number } {
|
||
return { ...SATURATION_PANEL };
|
||
}
|
||
|
||
sync(shown: SaturationShown): void {
|
||
const v = shown.values;
|
||
this.dials.sync((key) => v[key], shown.wired, shown.idle);
|
||
for (const button of this.typeButtons) {
|
||
button.pick(button.value === v.type);
|
||
button.alpha = shown.idle.has('type') ? IDLE_ALPHA : 1;
|
||
}
|
||
this.typeName.ink.alpha = shown.idle.has('type') ? IDLE_ALPHA : 1;
|
||
this.autoGain.set(v.autoGain, v.autoGain ? 'on' : 'off');
|
||
this.autoGain.alpha = shown.idle.has('autoGain') ? IDLE_ALPHA : 1;
|
||
this.idle = shown.idle;
|
||
this.scope.show(shown.trace, shown.heat, shown.texts);
|
||
this.heatText.text = heatWord(shown.heat);
|
||
this.heatText.style.fill = shown.heat > 0.05 ? heatColor(shown.heat) : BEZEL_TEXT;
|
||
if (this.heat !== shown.heat) {
|
||
this.heat = shown.heat;
|
||
this.invalidate();
|
||
}
|
||
}
|
||
|
||
/** Whether anything moves: the glow and the embers, as long as there is heat. */
|
||
get burning(): boolean {
|
||
return this.heat > 0;
|
||
}
|
||
|
||
/** A frame on, `seconds` on the page's clock: the screen's fire and the lamp flicker with it. */
|
||
step(seconds: number): void {
|
||
this.scope.step(seconds);
|
||
this.drawLamp();
|
||
}
|
||
|
||
/** The lamp beside the name: dark when cold, glowing in the heat's colour, flickering as the screen does. */
|
||
private drawLamp(): void {
|
||
const { x, y, r } = SATURATION_LAYOUT.lamp;
|
||
const lit = Math.min(1, this.heat * 2) * this.scope.flare;
|
||
// Never quite white, so the lamp still reads as one at white heat.
|
||
const color = heatColor(0.2 + 0.6 * this.heat);
|
||
drawLamp(
|
||
this.lampLight.clear(),
|
||
{ x, y, r },
|
||
this.heat > 0 ? color : 0x4a2a1e,
|
||
lit > 0 ? { color, alpha: 0.3 * Math.min(1, lit) } : null,
|
||
this.heat > 0 ? 0.7 : 0.2,
|
||
);
|
||
}
|
||
|
||
override layout(): void {
|
||
const L = SATURATION_LAYOUT;
|
||
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
|
||
this.screenName.position.set(L.screen.x, L.bezel.y + 3);
|
||
this.heatText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
|
||
this.typeButtons.forEach((button, i) => put(button, L.types[i]!));
|
||
put(this.autoGain, L.autoGain);
|
||
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
|
||
placeEngraving(this.typeName, L.typeGroup.x + L.typeGroup.w / 2, L.typeGroup.y);
|
||
this.dials.place((key) => L.knobs[key]);
|
||
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
|
||
}
|
||
|
||
override redraw(): void {
|
||
const L = SATURATION_LAYOUT;
|
||
drawPlate(this.plate.clear(), SATURATION_PANEL, COPPER, L.screws);
|
||
const b = L.bezel;
|
||
// The frame takes on the heat of the glass in it.
|
||
drawBezel(this.bezel.clear(), b, BEZEL, 0x1b120e);
|
||
if (this.heat > 0)
|
||
this.bezel
|
||
.roundRect(L.screen.x - 2, L.screen.y - 2, L.screen.w + 4, L.screen.h + 4, 5)
|
||
.stroke({ color: heatColor(this.heat), width: 1.5, alpha: 0.6 * this.heat });
|
||
drawRules(this.rules.clear(), [[L.typeGroup, this.typeName.ink]]);
|
||
this.drawLamp();
|
||
}
|
||
}
|