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>
351 lines
12 KiB
TypeScript
351 lines
12 KiB
TypeScript
/**
|
||
* The Reverb as a device (#389), what its node shows in the graph: the Compressor's plate (#379) in
|
||
* pale lavender, a round screen that draws the tail as rings out from the sound – as far out as it
|
||
* rings, as bright as it is loud, as blue as its highs are still there – a button for each type, the
|
||
* sound a type of Sound takes, and a chrome knob for each number.
|
||
*/
|
||
import { type FederatedPointerEvent, Graphics, type Text } from 'pixi.js';
|
||
import { REVERB_ROWS } from '../../core/soundfx/nodes';
|
||
import { REVERB_ROOMS, type ReverbRoom } from '../../core/soundfx/reverb';
|
||
import { mixColor } from '../../core/util/color';
|
||
import { Dropdown, type Knob, Widget } from '../../ui';
|
||
import {
|
||
bezelText,
|
||
DeviceKnobs,
|
||
drawBezel,
|
||
drawPlate,
|
||
drawRules,
|
||
type Engraving,
|
||
engrave,
|
||
engraveGroups,
|
||
IDLE_ALPHA,
|
||
PickButton,
|
||
type PickLook,
|
||
placeEngraving,
|
||
placeGroups,
|
||
type PlateLook,
|
||
put,
|
||
screenText,
|
||
} from './deviceParts';
|
||
import { apartLabels } from './eqView';
|
||
import {
|
||
markText,
|
||
REVERB_KEYS,
|
||
REVERB_KNOBS,
|
||
REVERB_LAYOUT,
|
||
REVERB_PANEL,
|
||
type ReverbKey,
|
||
type ReverbTail,
|
||
type ReverbValues,
|
||
driftAt,
|
||
glowOf,
|
||
type Pulse,
|
||
pulseAt,
|
||
type Reflection,
|
||
reflections,
|
||
SCOPE_GRID,
|
||
SCOPE_MARKS,
|
||
scopeRadius,
|
||
scopeTexts,
|
||
tailRings,
|
||
tailSeconds,
|
||
wetShares,
|
||
} from './reverbView';
|
||
|
||
/** What the panel asks of the node it sits in. */
|
||
export interface ReverbHost {
|
||
preview(key: ReverbKey, value: number): void;
|
||
commit(key: ReverbKey, value: number): void;
|
||
pickRoom(room: ReverbRoom): void;
|
||
/** Opens the sounds to pick from below the button. */
|
||
pickSound(button: Widget): void;
|
||
}
|
||
|
||
/** What the panel shows of a node. */
|
||
export interface ReverbShown {
|
||
values: ReverbValues;
|
||
wired: (key: ReverbKey) => boolean;
|
||
/** The settings that do nothing as things stand. */
|
||
idle: ReadonlySet<ReverbKey | 'impulse'>;
|
||
/** The tail to draw; null while there is none to know – no sound picked, or not loaded yet. */
|
||
tail: ReverbTail | null;
|
||
/** The picked sound's name, '' for none. */
|
||
sound: string;
|
||
/** What the screen says where it has no tail. */
|
||
missing: string;
|
||
}
|
||
|
||
/** Pale anodised lavender. */
|
||
const LAVENDER: PlateLook = {
|
||
stops: [
|
||
[0, 0xe2ddf2],
|
||
[0.5, 0xcdc6e8],
|
||
[1, 0xb0a7d6],
|
||
],
|
||
rim: 0x7a70a6,
|
||
grain: 0x544a80,
|
||
};
|
||
const BEZEL = 0x2c2a3c;
|
||
const BEZEL_TEXT = 0xc4bfe0;
|
||
/** Night glass; a tail with its highs is ice, one that lost them violet. */
|
||
const SCOPE = {
|
||
glass: 0x0b0d1a,
|
||
grid: 0x232a48,
|
||
text: 0x9fb0ff,
|
||
dim: 0x5b6590,
|
||
bright: 0xd8f6ff,
|
||
dull: 0x6c4fd0,
|
||
};
|
||
/** A sound's tail, whose highs the screen cannot tell. */
|
||
const UNKNOWN_TINT = 0xb9c2ff;
|
||
const PICKED = 0x5b45b8;
|
||
/** The ring a pixel of the tail is drawn with, and the strip of text under the screen. */
|
||
const RING_STEP = 1.5;
|
||
const TEXT_ROW = 14;
|
||
|
||
const tint = (bright: number | null): number =>
|
||
bright === null ? UNKNOWN_TINT : mixColor(SCOPE.dull, SCOPE.bright, bright);
|
||
|
||
const LAVENDER_BUTTONS: PickLook = { face: 0x45435a, hover: 0x55536a, picked: PICKED, shadow: 0x24222f };
|
||
|
||
/**
|
||
* The round screen: the sound as a dot in the middle, the tail as rings out from it on a scale of
|
||
* seconds, scattered reflections over it, and under it the pre-delay and the end. The sound rings
|
||
* out again and again as a front running over the rings, and the reflections drift.
|
||
*/
|
||
class ReverbScope extends Widget {
|
||
private readonly g = new Graphics();
|
||
private readonly motion = new Graphics();
|
||
private readonly marks: Text[];
|
||
private readonly under: Record<'pre' | 'ends', Text>;
|
||
private dots: Reflection[] = [];
|
||
tail: ReverbTail | null = null;
|
||
mix = 0;
|
||
/** What the texts under the screen say now, and the highs above it on the bezel (checks). */
|
||
says = { pre: '', highs: '', ends: '' };
|
||
/** How far out the tail reaches and the rings drawn (checks). */
|
||
reach = 0;
|
||
rings = 0;
|
||
/** The page's clock the motion is drawn at, and where the sound is ringing out then (checks). */
|
||
seconds = 0;
|
||
pulse: Pulse | null = null;
|
||
|
||
constructor() {
|
||
super();
|
||
this.addChild(this.g, this.motion);
|
||
this.marks = SCOPE_MARKS.map((seconds) => screenText(this, 8, SCOPE.dim, 0, 1, markText(seconds)));
|
||
this.under = { pre: screenText(this, 9, SCOPE.text, 0), ends: screenText(this, 9, SCOPE.text, 1) };
|
||
}
|
||
|
||
show(tail: ReverbTail | null, mix: number, missing: string): void {
|
||
this.tail = tail;
|
||
this.mix = mix;
|
||
this.says = scopeTexts(tail, missing);
|
||
this.invalidate();
|
||
}
|
||
|
||
/** The round part: a square above the strip of text. */
|
||
get disc(): { x: number; y: number; r: number } {
|
||
const side = Math.min(this.w, this.h - TEXT_ROW);
|
||
return { x: this.w / 2, y: side / 2, r: side / 2 - 10 };
|
||
}
|
||
|
||
override redraw(): void {
|
||
const { x: cx, y: cy, r: R } = this.disc;
|
||
const g = this.g.clear();
|
||
g.roundRect(0, 0, this.w, this.h - TEXT_ROW, 4).fill(SCOPE.glass);
|
||
for (const seconds of SCOPE_GRID) g.circle(cx, cy, scopeRadius(seconds) * R);
|
||
g.moveTo(cx - R, cy)
|
||
.lineTo(cx + R, cy)
|
||
.moveTo(cx, cy - R)
|
||
.lineTo(cx, cy + R);
|
||
g.stroke({ color: SCOPE.grid, width: 1 });
|
||
|
||
const tail = this.tail;
|
||
const shares = wetShares(this.mix);
|
||
this.reach = 0;
|
||
this.rings = 0;
|
||
this.dots = tail ? reflections(tail, R) : [];
|
||
if (tail) {
|
||
const rings = tailRings(tail, R, RING_STEP);
|
||
for (const ring of rings)
|
||
g.circle(cx, cy, ring.r).stroke({
|
||
color: tint(ring.bright),
|
||
width: RING_STEP,
|
||
alpha: ring.level * shares.tail,
|
||
});
|
||
// Where it has fallen by 60 dB: a dashed ring, as there is nothing left of it to see.
|
||
this.reach = scopeRadius(tail.predelay + tailSeconds(tail));
|
||
const end = this.reach * R;
|
||
const dashes = 48;
|
||
for (let i = 0; i < dashes; i += 2) {
|
||
const a = (i / dashes) * Math.PI * 2;
|
||
const b = ((i + 1) / dashes) * Math.PI * 2;
|
||
g.moveTo(cx + Math.cos(a) * end, cy + Math.sin(a) * end).lineTo(
|
||
cx + Math.cos(b) * end,
|
||
cy + Math.sin(b) * end,
|
||
);
|
||
}
|
||
g.stroke({ color: SCOPE.text, width: 1, alpha: 0.6 * Math.max(0.3, shares.tail) });
|
||
this.rings = rings.length;
|
||
}
|
||
|
||
// The scale up the middle, every other mark on the other side of the line, outer ones first:
|
||
// one that would still run into the last on its side is left out.
|
||
for (const side of [0, 1]) {
|
||
const order = [...this.marks.keys()].reverse().filter((_, k) => k % 2 === side);
|
||
const placed = order.map((i) => {
|
||
const t = this.marks[i]!;
|
||
t.anchor.set(side, 1);
|
||
t.position.set(
|
||
Math.round(cx + (side ? -3 : 3)),
|
||
Math.round(cy - scopeRadius(SCOPE_MARKS[i]!) * R - 1),
|
||
);
|
||
return { from: t.y - t.height, to: t.y };
|
||
});
|
||
const shown = apartLabels(placed, 1);
|
||
order.forEach((i, k) => (this.marks[i]!.visible = shown[k]!));
|
||
}
|
||
|
||
const y = this.h - TEXT_ROW + 3;
|
||
this.under.pre.text = this.says.pre;
|
||
this.under.pre.position.set(0, y);
|
||
this.under.ends.text = this.says.ends;
|
||
this.under.ends.position.set(this.w, y);
|
||
this.drawMotion();
|
||
}
|
||
|
||
/** `seconds` on a clock of the page's; whether anything moves, which it does not without a tail to see. */
|
||
step(seconds: number): boolean {
|
||
if (!this.tail || wetShares(this.mix).tail <= 0) return false;
|
||
this.seconds = seconds;
|
||
this.drawMotion();
|
||
return true;
|
||
}
|
||
|
||
/** The reflections where they have drifted to, the front running out and the sound flashing. */
|
||
private drawMotion(): void {
|
||
const m = this.motion.clear();
|
||
if (this.w <= 0) return;
|
||
const { x: cx, y: cy, r: R } = this.disc;
|
||
const shares = wetShares(this.mix);
|
||
const pulse = this.tail ? pulseAt(this.tail, this.seconds) : null;
|
||
this.pulse = pulse;
|
||
if (pulse?.front != null && shares.tail > 0) {
|
||
const color = tint(pulse.bright);
|
||
const alpha = (0.25 + 0.75 * pulse.level) * shares.tail;
|
||
m.circle(cx, cy, pulse.front * R).stroke({ color, width: 6, alpha: 0.12 * alpha });
|
||
m.circle(cx, cy, pulse.front * R).stroke({ color, width: 1.5, alpha: 0.7 * alpha });
|
||
}
|
||
for (const dot of this.dots) {
|
||
const at = driftAt(dot, this.seconds);
|
||
const glow = pulse ? glowOf(dot, pulse) : 0;
|
||
m.circle(cx + Math.cos(at.angle) * at.r, cy + Math.sin(at.angle) * at.r, 1.2 + 0.5 * glow).fill({
|
||
color: glow > 0 ? mixColor(tint(dot.bright), 0xffffff, glow * 0.6) : tint(dot.bright),
|
||
alpha: Math.min(1, (dot.level * at.shine + glow * 0.5) * shares.tail * 1.4),
|
||
});
|
||
}
|
||
const flash = pulse && shares.tail > 0 ? pulse.flash : 0;
|
||
if (flash > 0) m.circle(cx, cy, 3.5 + 5 * flash).fill({ color: 0xffffff, alpha: 0.35 * flash });
|
||
m.circle(cx, cy, 3.5).fill({ color: 0xffffff, alpha: Math.min(1, shares.dry + flash * 0.5) });
|
||
}
|
||
}
|
||
|
||
export class ReverbPanel extends Widget {
|
||
private readonly plate = new Graphics();
|
||
private readonly bezel = new Graphics();
|
||
private readonly rules = new Graphics();
|
||
readonly scope = new ReverbScope();
|
||
readonly roomButtons: PickButton<ReverbRoom>[];
|
||
readonly soundButton: Dropdown;
|
||
readonly knobs: Record<ReverbKey, Knob>;
|
||
private readonly dials: DeviceKnobs<ReverbKey>;
|
||
private readonly soundCaption: Engraving;
|
||
private readonly groupNames: Engraving[];
|
||
private readonly title: Engraving;
|
||
private readonly screenName: Text;
|
||
/** Where the highs end up, above the screen on the right: there was no room for it below. */
|
||
readonly highs: Text;
|
||
/** The settings shown idle now (checks). */
|
||
idle: ReadonlySet<ReverbKey | 'impulse'> = new Set();
|
||
|
||
constructor(host: ReverbHost) {
|
||
super();
|
||
this.minWidth = REVERB_PANEL.w;
|
||
this.minHeight = REVERB_PANEL.h;
|
||
this.addChild(this.plate, this.rules, this.bezel, this.scope);
|
||
this.title = engrave(this, 'REVERB', 14);
|
||
this.screenName = bezelText(this, 'TAIL OVER TIME', BEZEL_TEXT);
|
||
this.highs = bezelText(this, '', BEZEL_TEXT, true);
|
||
this.groupNames = engraveGroups(this, REVERB_LAYOUT.groups);
|
||
this.roomButtons = REVERB_ROOMS.map((room) => {
|
||
const button = new PickButton(room.value, room.label, LAVENDER_BUTTONS, (picked) =>
|
||
host.pickRoom(picked),
|
||
);
|
||
this.addChild(button);
|
||
return button;
|
||
});
|
||
this.soundCaption = engrave(this, 'SOUND', 9);
|
||
this.soundButton = new Dropdown('No sound', {
|
||
width: REVERB_LAYOUT.sound.w,
|
||
onClick: () => host.pickSound(this.soundButton),
|
||
});
|
||
// A press on it is not one on the node.
|
||
this.soundButton.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
|
||
this.addChild(this.soundButton);
|
||
this.dials = new DeviceKnobs(
|
||
this,
|
||
REVERB_KEYS,
|
||
(key) => ({ row: REVERB_ROWS[key], look: REVERB_KNOBS[key], size: REVERB_LAYOUT.knobs[key].size }),
|
||
host,
|
||
);
|
||
this.knobs = this.dials.knobs;
|
||
}
|
||
|
||
override measure(): { w: number; h: number } {
|
||
return { ...REVERB_PANEL };
|
||
}
|
||
|
||
sync(shown: ReverbShown): void {
|
||
const v = shown.values;
|
||
this.dials.sync((key) => v[key], shown.wired, shown.idle);
|
||
for (const button of this.roomButtons) button.pick(button.value === v.room);
|
||
this.soundButton.setText(shown.sound || 'No sound');
|
||
this.soundButton.alpha = shown.idle.has('impulse') ? IDLE_ALPHA : 1;
|
||
this.soundCaption.ink.alpha = this.soundButton.alpha;
|
||
this.idle = shown.idle;
|
||
this.scope.show(shown.tail, v.mix, shown.missing);
|
||
this.highs.text = this.scope.says.highs;
|
||
}
|
||
|
||
/** Moves the screen on to `seconds` on the page's clock; whether anything on it moves. */
|
||
step(seconds: number): boolean {
|
||
return this.scope.step(seconds);
|
||
}
|
||
|
||
override layout(): void {
|
||
const L = REVERB_LAYOUT;
|
||
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
|
||
this.screenName.position.set(L.screen.x, L.bezel.y + 3);
|
||
this.highs.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
|
||
this.roomButtons.forEach((button, i) => put(button, L.rooms[i]!));
|
||
const sound = this.soundButton.measure();
|
||
put(this.soundButton, { ...L.sound, y: L.sound.y + (L.sound.h - sound.h) / 2, h: sound.h });
|
||
placeEngraving(this.soundCaption, L.soundCaption.x, L.soundCaption.y);
|
||
this.dials.place((key) => L.knobs[key]);
|
||
placeGroups(this.groupNames, L.groups);
|
||
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
|
||
}
|
||
|
||
override redraw(): void {
|
||
const L = REVERB_LAYOUT;
|
||
drawPlate(this.plate.clear(), REVERB_PANEL, LAVENDER, L.screws);
|
||
const b = L.bezel;
|
||
drawBezel(this.bezel.clear(), b, BEZEL, 0x1b1a24);
|
||
drawRules(
|
||
this.rules.clear(),
|
||
L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink] as const),
|
||
);
|
||
}
|
||
}
|