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:
1 parent
28909f0adf
commit
b6749cc43d
12 files changed
+1860
-11
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user