Show Tremolo, Vibrato, Phaser and Flanger as devices that swing (#398)

The four effects that swing were still drawn as rows of numbers. Like
the Delay, the Saturation and the Limiter, each node now shows a device
of a fixed size. Its screen draws what it does over the first seconds
of a sound, and a light plays that in real time, over and over.

- One device for all four (ModulationPanel, ModulationFace and
  modulationView), as KnobPanel is for Gain, Pitch, Mix and Pan. A short
  description gives each its title, plate, buttons and knobs.
- Tremolo, 640x280 in cream: a wave its volume shapes, the volume as
  its outline. It says DOWN TO 50%, EVERY 200 ms and DIPS -6 dB.
- Vibrato, 640x280 in plum: the pitch in semitones, cool below and warm
  above. Under a wet below 1 the dry pitch stands beside it. It says
  how late the voice is (LATE 0.93 ms).
- Phaser, 760x280 in mustard, and Flanger, 680x280 in steel blue: the
  response from 20 Hz to 20 kHz, and under it a lane of the turn or the
  delay over time. The response is drawn where the light is, so the
  notches swirl and the comb jets. A comb finer than a column fills it.
- The light loops over 1, 2, 4 or 8 s, two LFO swings at least, for a
  Flanger as long as its sweep takes to reach 20 ms. The face only
  keeps the editor drawing while something swings and is heard.
- A Rate lamp beside each name goes from dark to lit with the swing.
- Nothing is listened to. The pictures use the worklets' own numbers:
  lfoAt, vibratoSpeedUp (now exported from lfo.ts), the allpass chain
  with its feedback, and the comb, reckoned at 48 kHz.
- Phaser and Flanger take 4 preset columns. Phased Pad was looked at
  in a picture, nothing overlaps.
- Exported from nodes.ts: LFO_SHAPE, lfoShapeOf, TREMOLO_ROWS,
  VIBRATO_ROWS, PHASER_STAGE_ROW and phaserStagesOf. FLANGER_MAX_MS is
  exported from phaser.ts.

Decided here, as the request left the look open:

- A notch counts where the response falls below -3 dB, not some dB
  under the loudest point. Feedback rings peaks far above 0 dB, and the
  first rule counted 7 notches on a Phaser of 12 stages.
- Rate and Depth of Tremolo and Vibrato are big knobs, Wet is small.

Tests:
  Unit modulation-view (stein2): 22 tests, new. Phaser and Flanger
    give the dB of their worklets on a sine within 0.005 dB, the
    Tremolo its samples, the Vibrato the pitch it reads at.
  Counter-check: with the sign of the Phaser's feedback flipped in one
    place, exactly the worklet test fails.
  e2e 34 (atx): ALL PASSED, 142 checks, 4 new. Each device has its
    size, its ports outside, a light that runs by itself, and a button
    and a knob as one undo step each. Picking 8 stages on the Phaser
    draws 4 notches.
  Gate (atx): CHECK EXIT 0, 291 files, 4467 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 21:02:50 +02:00
1 parent 28909f0adf
commit b6749cc43d
12 files changed
+1860 -11

No files matched your search

+694
View File
@@ -0,0 +1,694 @@
/**
* 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,
responseColumns,
responseDb,
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 columns = responseColumns(
(hz) => responseDb(this.device, values, this.reading, hz),
Math.max(1, Math.round(r.w)),
);
this.notches = notchesOf(columns);
const bottom = r.y + r.h;
// Under the response a faint fill, the response itself as thin as a line where it is smooth
// and as tall as a column holds where a comb runs too fine for them.
columns.forEach((column, i) =>
c.rect(r.x + i, this.yOfDb(column.high), 1, bottom - this.yOfDb(column.high)),
);
c.fill({ color: this.look.trace, alpha: 0.12 });
columns.forEach((column, i) => {
const top = this.yOfDb(column.high);
c.rect(r.x + i, top - 0.75, 1, Math.max(1.5, this.yOfDb(column.low) - top + 0.75));
});
c.fill(this.look.trace);
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();
}
}