Files
feuerwerk/src/editor/soundfx/ModulationPanel.ts
T
adminandClaude Opus 5.5 b145e1f288
game-maker / gate (push) Successful in 4m8s
game-maker / suite (push) Failing after 16m2s
Draw the Flanger's comb as a thin line with a fading trail (#398)
The comb was still drawn too heavy. Each frame showed the whole band
the comb had swept over the last 1/20 s, edged by two lines 1.5 wide,
and the faster the delay moved, the thicker that band got.

- flangerLine draws the comb where the light is as one line, 1 wide.
  It runs through the ends of each column and the peaks and notches
  in it, where they are. A comb finer than a column zigzags over it.
- Behind the line a faint trail shows what the comb swept over the
  last 1/20 s, and over the last 1/40 s once more, so it fades with
  age. A Flanger that stands still has none.
- The line fades out where its teeth move more than a quarter of a
  tooth within the trail (clearAt), and is gone from half a tooth on.
  There only the calm band of the trail is left, so nothing flickers.
- responseFrame gives the screen the line, how clear each point is,
  the sharp columns for the notches and the trail. It replaces
  responseColumnsOf, and RESPONSE_BLUR is now RESPONSE_TRAIL.
- The notches are counted on the sharp comb again, not on the band.
- The Phaser's response is drawn as the same thin line, without a
  trail.

Tests:
  Unit modulation-view (stein2): 26 tests, 2 new. The line holds the
    comb's dB at each point and every notch. On a fast swing it fades
    out over 8 kHz, stays clear under 1 kHz, and the trail is the same
    band over 16 kHz in three frames. A still Flanger has no trail.
  Counter-check: with clearAt always 1, the fade test fails.
  e2e 34 (atx): ALL PASSED, 142 checks.
  Gate (atx): CHECK EXIT 0, 291 files, 4471 tests.
  Pictures of the default Flanger, a fast one (2 Hz, 5 ms) and one
    sweeping to 20 ms, and of the Phaser: a thin line, the trail
    behind it, a calm band high up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:30:03 +02:00

711 lines
26 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 Tremolo, the Vibrato, the Phaser and the Flanger as devices (#398), what their nodes show in
* the graph: a plate of their own, a screen, a lamp beside the name that swings with them, the
* buttons of a shape or of stages, and their knobs. A Tremolo's screen draws a wave its volume
* shapes over the first seconds of a sound, a Vibrato's the pitch; a Phaser's and a Flanger's what
* they do to each frequency, and in a lane under it where that is going. A light plays it all in
* real time, over and over.
*/
import { Graphics, type Text, type TextStyle } from 'pixi.js';
import { mixColor } from '../../core/util/color';
import { crispText, Knob, textStyle, Widget } from '../../ui';
import { secondsText } from './chipView';
import { drawPlate, engrave, INK, placeEngraving, type PlateLook } from './CompressorPanel';
import { TEXT_ROW } from './delayView';
import { type Box, KNOB_CAPTION } from './hardwareView';
import {
heightOf,
lampOf,
MOD_LAYOUTS,
type ModDevice,
type ModKind,
type ModTexts,
type ModValues,
notchesOf,
RESPONSE_FLOOR,
RESPONSE_TOP,
responseFrame,
shareOfHz,
type SwingPicture,
swingValueAt,
wetKey,
} from './modulationView';
import { PickButton, type PickLook } from './ReverbPanel';
/** What the panel asks of the node it sits in. */
export interface ModHost {
preview(key: string, value: number): void;
commit(key: string, value: number): void;
pick(value: string): void;
}
/** What the panel shows of a node. */
export interface ModShown {
values: ModValues;
wired: (key: string) => boolean;
/** The settings that do nothing as things stand. */
idle: ReadonlySet<string>;
picture: SwingPicture;
texts: ModTexts;
}
/** A device's colours: its plate, its buttons and knobs, its screen, and its lamp dark and lit. */
interface ModLook {
plate: PlateLook;
buttons: PickLook;
arc: number;
glass: number;
grid: number;
text: number;
dim: number;
trace: number;
/** A Vibrato's pitch goes from this below to the trace above. */
under: number;
lamp: [off: number, on: number];
}
/**
* A Tremolo in the cream and red of an old amp's front, a Vibrato in plum, a Phaser in mustard as
* the pedals of the 70s were, a Flanger in steel blue with a violet jet on its screen.
*/
const LOOKS: Record<ModKind, ModLook> = {
tremolo: {
plate: {
stops: [
[0, 0xf4ecd6],
[0.5, 0xe6d9b8],
[1, 0xcdbb92],
],
rim: 0x7a6740,
grain: 0x5c4a28,
},
buttons: { face: 0x4a3f2e, hover: 0x5c5040, picked: 0xc0392b, shadow: 0x241d14 },
arc: 0x8a2416,
glass: 0x110d08,
grid: 0x2e2618,
text: 0xffd28a,
dim: 0x7a6a50,
trace: 0xffb347,
under: 0xffb347,
lamp: [0x5a1a14, 0xff3b2a],
},
vibrato: {
plate: {
stops: [
[0, 0xf2c6dc],
[0.5, 0xe0a6c4],
[1, 0xc283a6],
],
rim: 0x6e2f50,
grain: 0x4f1d38,
},
buttons: { face: 0x4a2a3c, hover: 0x5c3a4e, picked: 0x8e3fc8, shadow: 0x24121c },
arc: 0x4a0f30,
glass: 0x0e0912,
grid: 0x2a1f30,
text: 0xf2c6ff,
dim: 0x7a6080,
trace: 0xffa8d8,
under: 0x8fb4ff,
lamp: [0x3e1f4a, 0xc56cff],
},
phaser: {
plate: {
stops: [
[0, 0xf6dc7a],
[0.5, 0xe9c552],
[1, 0xcfa632],
],
rim: 0x7a5a10,
grain: 0x5c420a,
},
buttons: { face: 0x3e3520, hover: 0x504530, picked: 0xd9531e, shadow: 0x1e190c },
arc: 0x5a3a00,
glass: 0x07100f,
grid: 0x18302c,
text: 0x9ff5e8,
dim: 0x4f7a72,
trace: 0x5ef0d8,
under: 0x5ef0d8,
lamp: [0x5a2a10, 0xff6a2a],
},
flanger: {
plate: {
stops: [
[0, 0xc9d4e2],
[0.5, 0xaebdd0],
[1, 0x8c9fb8],
],
rim: 0x3c4a60,
grain: 0x2a3648,
},
buttons: { face: 0x2a3446, hover: 0x3a4456, picked: 0x6a4fd0, shadow: 0x141a24 },
arc: 0x1c2a44,
glass: 0x0a0a14,
grid: 0x22223a,
text: 0xd8c8ff,
dim: 0x6a6a90,
trace: 0xb48cff,
under: 0xb48cff,
lamp: [0x1f3550, 0x5ab4ff],
},
};
const BEZEL = 0x2a2624;
const BEZEL_TEXT = 0xe4dccf;
const IDLE_ALPHA = 0.4;
/** Cycles of the wave a Tremolo's volume shapes, across its screen. */
const CARRIER = 48;
/** How finely a Vibrato's pitch is coloured: by chunks of so many points. */
const PITCH_CHUNK = 20;
function scopeStyle(size: number, fill: number): TextStyle {
const style = textStyle('small').clone();
style.fill = fill;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
function bezelStyle(): TextStyle {
const style = textStyle('small').clone();
style.fill = BEZEL_TEXT;
style.fontSize = 9;
style.fontWeight = 'bold';
style.letterSpacing = 1;
return style;
}
/** What both screens share: the glass, what they write and where the light is. */
abstract class ModScope extends Widget {
protected readonly g = new Graphics();
protected readonly light = new Graphics();
protected readonly under: Record<'left' | 'right', Text>;
picture: SwingPicture | null = null;
values: ModValues | null = null;
texts: ModTexts = { side: '', left: '', right: '' };
/** Where the light is, in seconds after the sound, and what it reads there (checks). */
at = 0;
reading = 0;
constructor(
protected readonly device: ModDevice,
protected readonly look: ModLook,
) {
super();
this.addChild(this.g, this.light);
this.under = { left: this.made(9, look.text, 0, 0), right: this.made(9, look.text, 1, 0) };
}
protected made(size: number, fill: number, ax: number, ay: number, text = ''): Text {
const t = crispText({ text, style: scopeStyle(size, fill) });
t.anchor.set(ax, ay);
t.eventMode = 'none';
this.addChild(t);
return t;
}
show(picture: SwingPicture, values: ModValues, texts: ModTexts): void {
this.picture = picture;
this.values = values;
this.texts = texts;
if (!picture.moving) this.at = 0;
this.reading = swingValueAt(this.device, values, this.at);
this.invalidate();
}
protected get glassH(): number {
return this.h - TEXT_ROW;
}
/** The glass and what is written under it; the rest is each screen's own. */
protected drawGlass(): void {
this.g.roundRect(0, 0, this.w, this.glassH, 4).fill(this.look.glass);
const y = this.glassH + 3;
this.under.left.text = this.texts.left;
this.under.left.position.set(0, y);
this.under.right.text = this.texts.right;
this.under.right.position.set(this.w, y);
}
/** `t` seconds after the sound: the light moves there. */
step(t: number): void {
if (!this.picture || !this.values) return;
this.at = t;
this.reading = swingValueAt(this.device, this.values, t);
this.drawLight();
}
/** A faint line where every swing of the LFO starts, as long as they are not too close. */
protected drawSwings(x0: number, w: number, top: number, bottom: number): void {
const rate = this.values?.numbers.rate ?? 0;
const span = this.picture?.span ?? 1;
if (!(rate > 0) || span * rate > 40) return;
for (let k = 1; k / rate < span - 1e-9; k++) {
const x = x0 + (k / rate / span) * w;
this.g.moveTo(x, top).lineTo(x, bottom);
}
this.g.stroke({ color: this.look.grid, width: 1 });
}
protected abstract drawLight(): void;
}
/** The Tremolo's and the Vibrato's screen: the volume, or the pitch, over the first seconds of a sound. */
class SwingScope extends ModScope {
private readonly marks: Record<'top' | 'bottom' | 'span', Text>;
constructor(device: ModDevice, look: ModLook) {
super(device, look);
this.marks = {
top: this.made(8, look.dim, 0, 0),
bottom: this.made(8, look.dim, 0, 1),
span: this.made(8, look.dim, 1, 1),
};
}
private get plot(): Box {
return { x: 8, y: 12, w: this.w - 16, h: this.glassH - 28 };
}
private xOf(t: number): number {
const p = this.plot;
return p.x + (t / (this.picture?.span ?? 1)) * p.w;
}
/** Where a Vibrato's pitch sits up the screen. */
private yOf(value: number): number {
const p = this.plot;
return p.y + (1 - (this.picture ? heightOf(this.picture, value) : 0.5)) * p.h;
}
override redraw(): void {
const g = this.g.clear();
this.drawGlass();
const picture = this.picture;
const p = this.plot;
const mid = p.y + p.h / 2;
this.marks.top.text = '';
this.marks.bottom.text = '';
if (picture) {
this.drawSwings(p.x, p.w, p.y - 4, p.y + p.h + 4);
const trace = picture.trace;
const xAt = (i: number) => p.x + (i / (trace.length - 1)) * p.w;
if (this.device.kind === 'tremolo') {
// Full volume up and down, and the middle the wave swings round.
g.moveTo(p.x, p.y)
.lineTo(p.x + p.w, p.y)
.moveTo(p.x, p.y + p.h)
.lineTo(p.x + p.w, p.y + p.h)
.stroke({ color: this.look.grid, width: 1 });
g.moveTo(p.x, mid)
.lineTo(p.x + p.w, mid)
.stroke({ color: this.look.dim, width: 1, alpha: 0.5 });
// The sound as a wave its volume shapes, and the volume as the wave's outline.
trace.forEach((level, i) => {
const y = mid - level * (p.h / 2) * Math.sin((2 * Math.PI * CARRIER * i) / (trace.length - 1));
if (i === 0) g.moveTo(xAt(i), y);
else g.lineTo(xAt(i), y);
});
g.stroke({ color: this.look.trace, width: 1, alpha: 0.35 });
for (const side of [-1, 1]) {
trace.forEach((level, i) => {
const y = mid + side * level * (p.h / 2);
if (i === 0) g.moveTo(xAt(i), y);
else g.lineTo(xAt(i), y);
});
g.stroke({ color: this.look.trace, width: 2, join: 'round' });
}
} else {
const reach = picture.high;
for (const share of [-1, -0.5, 0.5, 1])
g.moveTo(p.x, this.yOf(share * reach)).lineTo(p.x + p.w, this.yOf(share * reach));
g.stroke({ color: this.look.grid, width: 1 });
this.marks.top.text = `+${reach} st`;
this.marks.top.position.set(p.x + 2, p.y - 10);
this.marks.bottom.text = `-${reach} st`;
this.marks.bottom.position.set(p.x + 2, p.y + p.h + 10);
// The pitch the sound came at; under a wet below 1 it is heard beside the swing.
const wet = Math.max(0, Math.min(1, this.values?.numbers[wetKey(this.device)] ?? 1));
g.moveTo(p.x, mid)
.lineTo(p.x + p.w, mid)
.stroke({ color: this.look.dim, width: 1 + (1 - wet) * 2, alpha: 0.5 + 0.5 * (1 - wet) });
// The pitch it swings to: cool below, warm above, in chunks so a few strokes do.
for (let from = 0; from < trace.length - 1; from += PITCH_CHUNK) {
const to = Math.min(trace.length - 1, from + PITCH_CHUNK);
const middle = trace[Math.round((from + to) / 2)]!;
g.moveTo(xAt(from), this.yOf(trace[from]!));
for (let i = from + 1; i <= to; i++) g.lineTo(xAt(i), this.yOf(trace[i]!));
g.stroke({
color: mixColor(this.look.under, this.look.trace, heightOf(picture, middle)),
width: 2,
alpha: 0.35 + 0.65 * wet,
join: 'round',
});
}
}
this.marks.span.text = secondsText(picture.span);
this.marks.span.position.set(p.x + p.w, this.glassH - 2);
}
this.drawLight();
}
protected drawLight(): void {
const s = this.light.clear();
const picture = this.picture;
if (!picture?.moving || this.w <= 0) return;
const p = this.plot;
const x = this.xOf(this.at);
s.rect(x - 0.5, p.y - 6, 1, p.h + 12).fill({ color: 0xffffff, alpha: 0.35 });
const dots =
this.device.kind === 'tremolo'
? [-1, 1].map((side) => p.y + p.h / 2 + side * this.reading * (p.h / 2))
: [this.yOf(this.reading)];
for (const y of dots) {
s.circle(x, y, 6).fill({ color: this.look.trace, alpha: 0.3 });
s.circle(x, y, 3).fill(0xffffff);
}
}
}
/** The Hz written under a Phaser's or a Flanger's screen, and the faint ones between them. */
const DECADES: readonly [hz: number, text: string][] = [
[100, '100'],
[1000, '1k'],
[10000, '10k'],
];
const BETWEEN = [50, 200, 500, 2000, 5000];
/** The dB written left of the response, 0 first. */
const DB_MARKS = [0, -12, -24];
/**
* The Phaser's and the Flanger's screen: what they do to each frequency where the light is – the
* notches they cut, and how they ring – the range they sweep shaded behind it, and under it a lane
* of where that goes over the first seconds of a sound.
*/
class ResponseScope extends ModScope {
private readonly curve = new Graphics();
private readonly hzMarks: Text[];
private readonly dbMarks: Text[];
private readonly marks: Record<'lane' | 'span', Text>;
/** The notches drawn now, by Hz (checks). */
notches: number[] = [];
constructor(device: ModDevice, look: ModLook) {
super(device, look);
this.addChildAt(this.curve, 1);
this.hzMarks = DECADES.map(([, text]) => this.made(8, look.dim, 0.5, 0, text));
this.dbMarks = DB_MARKS.map((db) => this.made(8, look.dim, 1, 0.5, String(db)));
this.marks = { lane: this.made(8, look.dim, 0, 0), span: this.made(8, look.dim, 1, 0) };
}
/** The response, with room on its left for its dB. */
private get response(): Box {
return { x: 28, y: 10, w: this.w - 36, h: 100 };
}
private get lane(): Box {
return { x: 8, y: 130, w: this.w - 16, h: this.glassH - 136 };
}
private xOfHz(hz: number): number {
const r = this.response;
return r.x + Math.max(0, Math.min(1, shareOfHz(hz))) * r.w;
}
private yOfDb(db: number): number {
const r = this.response;
const clamped = Math.max(RESPONSE_FLOOR, Math.min(RESPONSE_TOP, db));
return r.y + ((RESPONSE_TOP - clamped) / (RESPONSE_TOP - RESPONSE_FLOOR)) * r.h;
}
/** Where the light's reading is marked on the frequencies: the Phaser's turn, the Flanger's first notch. */
private markHz(reading: number): number | null {
if (this.device.kind === 'phaser') return reading;
return reading > 0 ? 500 / reading : null;
}
override redraw(): void {
const g = this.g.clear();
this.drawGlass();
const r = this.response;
const picture = this.picture;
for (const hz of BETWEEN) g.moveTo(this.xOfHz(hz), r.y).lineTo(this.xOfHz(hz), r.y + r.h);
g.stroke({ color: this.look.grid, width: 1, alpha: 0.5 });
for (const [hz] of DECADES) g.moveTo(this.xOfHz(hz), r.y).lineTo(this.xOfHz(hz), r.y + r.h);
for (const db of DB_MARKS.slice(1)) g.moveTo(r.x, this.yOfDb(db)).lineTo(r.x + r.w, this.yOfDb(db));
g.stroke({ color: this.look.grid, width: 1 });
g.moveTo(r.x, this.yOfDb(0))
.lineTo(r.x + r.w, this.yOfDb(0))
.stroke({ color: this.look.dim, width: 1, alpha: 0.6 });
this.hzMarks.forEach((t, i) => t.position.set(this.xOfHz(DECADES[i]![0]), r.y + r.h + 2));
this.dbMarks.forEach((t, i) => t.position.set(r.x - 4, this.yOfDb(DB_MARKS[i]!)));
const lane = this.lane;
g.moveTo(lane.x, lane.y - 4)
.lineTo(lane.x + lane.w, lane.y - 4)
.stroke({ color: this.look.grid, width: 1 });
this.marks.lane.position.set(lane.x + 2, lane.y);
this.marks.span.position.set(lane.x + lane.w, lane.y);
this.marks.lane.text = '';
this.marks.span.text = '';
if (picture) {
// The range the light sweeps the mark through, shaded behind the response.
const ends = [this.markHz(picture.lowest), this.markHz(picture.highest)].filter(
(hz): hz is number => hz !== null,
);
if (ends.length === 2 && picture.moving) {
const a = this.xOfHz(Math.min(...ends));
const b = this.xOfHz(Math.max(...ends));
g.rect(a, r.y, Math.max(1, b - a), r.h).fill({ color: this.look.trace, alpha: 0.08 });
}
this.drawSwings(lane.x, lane.w, lane.y, lane.y + lane.h);
const trace = picture.trace;
trace.forEach((value, i) => {
const x = lane.x + (i / (trace.length - 1)) * lane.w;
const y = lane.y + lane.h - heightOf(picture, value) * lane.h;
if (i === 0) g.moveTo(x, y);
else g.lineTo(x, y);
});
g.stroke({ color: this.look.trace, width: 1.5, alpha: 0.8, join: 'round' });
this.marks.lane.text = this.device.kind === 'phaser' ? 'TURN' : `DELAY UP TO ${picture.high} ms`;
this.marks.span.text = secondsText(picture.span);
}
this.drawLight();
}
protected drawLight(): void {
const c = this.curve.clear();
const s = this.light.clear();
const picture = this.picture;
const values = this.values;
if (!picture || !values || this.w <= 0) return;
const r = this.response;
const frame = responseFrame(this.device, values, this.at, Math.max(1, Math.round(r.w)));
this.notches = notchesOf(frame.columns);
const bottom = r.y + r.h;
const step = r.w / frame.columns.length;
const xs = frame.columns.map((_, i) => r.x + (i + 0.5) * step);
// Under the response a faint fill, up to the longest trail where there is one, so it stays calm.
const under = frame.trail[0] ?? frame.columns;
c.poly([r.x, bottom, ...xs.flatMap((x, i) => [x, this.yOfDb(under[i]!.high)]), r.x + r.w, bottom]).fill({
color: this.look.trace,
alpha: 0.1,
});
// Behind the line the trail of what it swept just before, fading with age: each band reaches
// from longer ago to now, so they stack up towards the line.
for (const columns of frame.trail) {
const band = xs.flatMap((x, i) => [x, this.yOfDb(columns[i]!.high)]);
for (let i = xs.length - 1; i >= 0; i--) band.push(xs[i]!, this.yOfDb(columns[i]!.low));
c.poly(band).fill({ color: this.look.trace, alpha: 0.16 });
}
// The line itself, thin, fading out where its teeth pass faster than an eye follows: in runs
// of the same few steps of alpha, each starting where the last ended.
const { line, clear } = frame;
const steps = 8;
const at = (i: number) => [r.x + line[i]!.share * r.w, this.yOfDb(line[i]!.db)] as const;
for (let i = 0; i < line.length - 1;) {
const alpha = Math.round(clear[i]! * steps) / steps;
let j = i + 1;
while (j < line.length - 1 && Math.round(clear[j]! * steps) / steps === alpha) j++;
if (alpha > 0) {
c.moveTo(...at(i));
for (let k = i + 1; k <= j; k++) c.lineTo(...at(k));
c.stroke({ color: this.look.trace, width: 1, alpha, join: 'round' });
}
i = j;
}
const hz = this.markHz(this.reading);
if (hz !== null && shareOfHz(hz) >= 0 && shareOfHz(hz) <= 1)
s.rect(this.xOfHz(hz) - 0.5, r.y, 1, r.h).fill({ color: 0xffffff, alpha: 0.3 });
if (!picture.moving) return;
const lane = this.lane;
const x = lane.x + (this.at / picture.span) * lane.w;
const y = lane.y + lane.h - heightOf(picture, this.reading) * lane.h;
s.rect(x - 0.5, lane.y - 2, 1, lane.h + 4).fill({ color: 0xffffff, alpha: 0.35 });
s.circle(x, y, 5).fill({ color: this.look.trace, alpha: 0.3 });
s.circle(x, y, 2.5).fill(0xffffff);
}
}
export class ModulationPanel extends Widget {
private readonly plate = new Graphics();
private readonly bezel = new Graphics();
private readonly rules = new Graphics();
private readonly lampLight = new Graphics();
private readonly look: ModLook;
readonly scope: SwingScope | ResponseScope;
readonly pickButtons: PickButton<string>[];
readonly knobs: Record<string, Knob> = {};
private readonly captions: Record<string, { ink: Text; edge: Text }> = {};
private readonly groupNames: { ink: Text; edge: Text }[];
private readonly pickName: { ink: Text; edge: Text } | null;
private readonly title: { ink: Text; edge: Text };
private readonly lampCaption: { ink: Text; edge: Text };
private readonly screenName: Text;
/** What the screen sums up, above it on the right. */
readonly sideText: Text;
/** The settings shown idle now, and how lit the lamp is, 0 to 1 (checks). */
idle: ReadonlySet<string> = new Set();
lamp = 0;
constructor(
readonly device: ModDevice,
host: ModHost,
) {
super();
const L = MOD_LAYOUTS[device.kind];
this.look = LOOKS[device.kind];
this.minWidth = L.panel.w;
this.minHeight = L.panel.h;
this.scope =
device.kind === 'tremolo' || device.kind === 'vibrato'
? new SwingScope(device, this.look)
: new ResponseScope(device, this.look);
this.addChild(this.plate, this.rules, this.bezel, this.scope, this.lampLight);
this.title = engrave(this, device.title, 14);
this.lampCaption = engrave(this, 'RATE', 9);
this.screenName = crispText({ text: device.screenName, style: bezelStyle() });
this.screenName.eventMode = 'none';
this.sideText = crispText({ text: '', style: bezelStyle() });
this.sideText.anchor.set(1, 0);
this.sideText.eventMode = 'none';
this.addChild(this.screenName, this.sideText);
this.pickName = device.pick ? engrave(this, device.pick.label, 9) : null;
this.pickButtons = (device.pick?.options ?? []).map((option) => {
const button = new PickButton(option.value, option.caption, this.look.buttons, (picked) =>
host.pick(picked),
);
this.addChild(button);
return button;
});
this.groupNames = L.groups.map((group) => engrave(this, group.label, 9));
for (const look of device.knobs) {
const row = look.row;
const knob = new Knob('', row.default, {
min: row.min ?? 0,
max: row.max ?? 1,
step: row.step ?? 0.01,
taper: look.taper,
bipolar: look.bipolar,
default: row.default,
decimals: look.decimals,
size: L.knobs[look.key]!.size,
look: 'chrome',
typed: false,
format: look.format,
ink: INK,
arc: this.look.arc,
onInput: (value) => host.preview(look.key, value),
onChange: (value) => host.commit(look.key, value),
});
this.knobs[look.key] = knob;
this.captions[look.key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
}
override measure(): { w: number; h: number } {
return { ...MOD_LAYOUTS[this.device.kind].panel };
}
sync(shown: ModShown): void {
for (const look of this.device.knobs) {
const knob = this.knobs[look.key]!;
knob.setValue(shown.values.numbers[look.key]!);
knob.setEnabled(!shown.wired(look.key));
knob.alpha = shown.idle.has(look.key) ? IDLE_ALPHA : 1;
this.captions[look.key]!.ink.alpha = knob.alpha;
}
const pickIdle = this.device.pick !== null && shown.idle.has(this.device.pick.key);
for (const button of this.pickButtons) {
button.pick(button.value === shown.values.picked);
button.alpha = pickIdle ? IDLE_ALPHA : 1;
}
if (this.pickName) this.pickName.ink.alpha = pickIdle ? IDLE_ALPHA : 1;
this.idle = shown.idle;
this.scope.show(shown.picture, shown.values, shown.texts);
this.sideText.text = shown.texts.side;
this.lamp = lampOf(shown.picture, this.scope.reading);
this.drawLamp();
}
/** Whether anything moves: there is a swing, and it is heard. */
get moving(): boolean {
return this.scope.picture?.moving === true;
}
/** `t` seconds after the sound: the screen's light goes there, the lamp swings with it. */
step(t: number): void {
this.scope.step(t);
if (this.scope.picture) this.lamp = lampOf(this.scope.picture, this.scope.reading);
this.drawLamp();
}
private drawLamp(): void {
const { x, y, r } = MOD_LAYOUTS[this.device.kind].lamp;
const [off, on] = this.look.lamp;
const lit = this.lamp;
const g = this.lampLight.clear();
if (lit > 0) g.circle(x, y, r + 4).fill({ color: on, alpha: 0.3 * lit });
g.circle(x, y, r)
.fill(mixColor(off, on, lit))
.stroke({ color: 0x2a2c30, width: 1.5 });
g.circle(x - 2, y - 2, 2).fill({ color: 0xffffff, alpha: 0.2 + 0.5 * lit });
}
override layout(): void {
const L = MOD_LAYOUTS[this.device.kind];
const put = (widget: Widget, box: Box) => {
widget.position.set(Math.round(box.x), Math.round(box.y));
widget.resize(box.w, box.h);
};
put(this.scope, { ...L.screen, h: L.screen.h + TEXT_ROW });
this.screenName.position.set(L.screen.x, L.bezel.y + 3);
this.sideText.position.set(L.screen.x + L.screen.w, L.bezel.y + 3);
this.pickButtons.forEach((button, i) => put(button, L.picks[i]!));
if (this.pickName && L.pickGroup)
placeEngraving(this.pickName, L.pickGroup.x + L.pickGroup.w / 2, L.pickGroup.y);
placeEngraving(this.lampCaption, L.lampCaption.x, L.lampCaption.y);
for (const look of this.device.knobs) {
const spot = L.knobs[look.key]!;
const knob = this.knobs[look.key]!;
const size = knob.measure();
knob.position.set(Math.round(spot.x - size.w / 2), Math.round(spot.y - spot.size / 2));
knob.resize(size.w, size.h);
placeEngraving(this.captions[look.key]!, spot.x, spot.y - spot.size / 2 - KNOB_CAPTION);
}
L.groups.forEach((group, i) =>
placeEngraving(this.groupNames[i]!, group.box.x + group.box.w / 2, group.box.y),
);
placeEngraving(this.title, L.title.x + L.title.w / 2, L.title.y + 4);
}
override redraw(): void {
const L = MOD_LAYOUTS[this.device.kind];
drawPlate(this.plate.clear(), L.panel, this.look.plate, L.screws);
const b = L.bezel;
this.bezel
.clear()
.roundRect(b.x, b.y, b.w, b.h, 8)
.fill(BEZEL)
.roundRect(b.x + 0.5, b.y + 0.5, b.w - 1, b.h - 1, 8)
.stroke({ color: 0x161412, width: 1 });
// A rule each side of a group's name, engraved as the names are.
const r = this.rules.clear();
const named: [Box, Text][] = L.groups.map((group, i) => [group.box, this.groupNames[i]!.ink]);
if (L.pickGroup && this.pickName) named.push([L.pickGroup, this.pickName.ink]);
for (const [box, name] of named) {
const mid = box.y + 6;
const left = box.x + 6;
const right = box.x + box.w - 6;
const gap = name.width / 2 + 6;
const centre = box.x + box.w / 2;
if (centre - gap > left) r.moveTo(left, mid).lineTo(centre - gap, mid);
if (centre + gap < right) r.moveTo(centre + gap, mid).lineTo(right, mid);
}
r.stroke({ color: INK, width: 1, alpha: 0.5 });
this.drawLamp();
}
}