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>
711 lines
26 KiB
TypeScript
711 lines
26 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
}
|