Files
feuerwerk/src/editor/soundfx/SaturationPanel.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

336 lines
11 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.
/**
* 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();
}
}