Show a Chip node as a device with its wave and its pitch (#386)
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Failing after 16m5s

The Chip had eleven numbers in rows and nothing that showed what they
do to a note. Its node is now a device of one size like the
Compressor's, and two screens draw the wave and the pitch from the
settings, worked right in the instrument graph.

- ChipPanel is 840x280 in the warm grey of a handheld console. A
  button per wave, the "follow note" lever and a chrome knob per
  number, grouped Oscillator, Slide, Vibrato, Arpeggio and Repeat.
- The wave screen draws two periods as loud as the Chip plays them,
  a square at its duty. toneValue and periodicSteps come out of
  chip.ts, so the screen and the worklet share one formula.
- The pitch screen draws what slide, delta slide, vibrato, arpeggio
  and repeat make of the pitch over a note. It ends where the note
  falls below "min frequency" and starts from A4 while the Chip
  follows the note.
- Idle settings are faded, wired ones greyed and locked, as the rows
  were. Every change is one undo step.
- Knobs take a bipolar range: 0 straight up, the taper bends each
  half. Slide, delta slide, duty sweep and arpeggio use it.
- The Compressor's auto-gain lever is now a Lever with a name of its
  own, which the Chip uses for "follow note".
- Presets give a Chip five columns, as a Filter has four since #385.

Found on the way:

- With the Chip this wide the Laser preset is about 3060 units. In a
  1280 px window Fit stops at the least zoom of 0.25 and cuts the
  Notes node off. The zoom limit is left as it is.

Tests:
- Unit: chip-view (9 new), knob (1 new), chip, chip-presets and
  hardware-view, 53 green
- e2e 34 on melektaus-atx: ALL PASSED, 131 checks (1 new). The
  first run was red on the check itself: a Laser's "min frequency"
  can lie above A4, and the screen then rightly says the note ends
  at once.
- Gate on melektaus-atx: CHECK EXIT 0, 285 files, 4377 tests
- Counter-check: a square drawn with its duty mirrored turns the wave
  test red

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-10 14:55:35 +02:00
1 parent c087993000
commit eb78f3cbf3
14 files changed
+1190 -26

No files matched your search

+52
View File
@@ -3,6 +3,7 @@ import { NameTransitionCommand } from '../../core/commands/graphCommands';
import { soundLibrary } from '../../viewport/SoundLibrary';
import type { Widget } from '../../ui';
import { currentNodePalette } from '../graph/NodePalette';
import { ChipPanel } from '../soundfx/ChipPanel';
import { CompressorPanel } from '../soundfx/CompressorPanel';
import { GainPanel } from '../soundfx/GainPanel';
import { CurvePanel } from '../soundfx/CurvePanel';
@@ -271,6 +272,51 @@ export function graphHooks({ state, editor }: HookContext) {
};
},
},
/**
* The panel a Chip node shows in the instrument graph (#386): whether it is shown and where, its
* knobs, which settings are idle, the picked wave, the lever, both screens and the node's ports.
*/
chip: {
panel: (nodeId: string) => {
const canvas = editor.instrumentEditor.canvas;
const panel = canvas.faceOf(nodeId)?.widget;
if (!(panel instanceof ChipPanel)) return null;
const node = canvas.nodeScreen(nodeId);
const ports = (keys: string[], isOutput: boolean) =>
Object.fromEntries(keys.map((key) => [key, canvas.portGlobal(nodeId, key, isOutput)]));
const trace = panel.pitchScreen.trace;
return {
shown: shownOnScreen(panel),
rect: screenRect(panel),
size: { w: panel.w, h: panel.h },
node: node?.rect ?? null,
inputs: ports(node?.inputs ?? [], false),
outputs: ports(node?.outputs ?? [], true),
knobs: Object.fromEntries(
Object.entries(panel.knobs).map(([key, knob]) => [
key,
{ value: knob.value, number: knob.shown, enabled: knob.enabled, dial: knob.dialGlobal() },
]),
),
idle: [...panel.idle],
wave: panel.waveButtons.find((b) => b.picked)?.wave ?? null,
waves: Object.fromEntries(panel.waveButtons.map((b) => [b.wave, screenCentre(b)])),
follow: { on: panel.follow.lit, at: screenCentre(panel.follow) },
waveScreen: panel.waveScreen.says.text,
pitch: trace && {
from: panel.pitchScreen.from.text,
span: trace.span,
end: trace.end,
low: trace.low,
high: trace.high,
restarts: trace.restarts.length,
points: trace.points.length,
/** Semitones from the start where the trace stops. */
last: trace.points.at(-1)?.st ?? null,
},
};
},
},
instrument: {
current: () => editor.instrumentEditor.currentAssetId,
info: () => editor.instrumentEditor.shownInfo,
@@ -379,6 +425,12 @@ function screenRect(widget: Widget): { x: number; y: number; w: number; h: numbe
return { x: from.x, y: from.y, w: to.x - from.x, h: to.y - from.y };
}
/** A widget's middle on screen, at the graph's zoom: `centreOf` adds its size unscaled. */
function screenCentre(widget: Widget): { x: number; y: number } {
const r = screenRect(widget);
return { x: r.x + r.w / 2, y: r.y + r.h / 2 };
}
/** The picked band's numbers as an EQ panel shows them; null for one its band type has not. */
function eqFields(panel: CurvePanel) {
return {
+80
View File
@@ -0,0 +1,80 @@
import { SetNodeParamCommand } from '../../core/commands/graphCommands';
import type { GraphNode } from '../../core/graph';
import { chipWave, InstrumentChip } from '../../core/instrument/nodes';
import { findGraphAsset } from '../../core/model/Project';
import type { ChipWave } from '../../core/soundfx/chip';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { compressorWired } from './CompressorEditor';
import { type ChipHost, ChipPanel } from './ChipPanel';
import { type ChipKey, chipValues } from './chipView';
import { NodeParamDrag } from './nodeParamDrag';
/** "follow note": on unless it was turned off. */
const FOLLOW = 'followNote';
/**
* A Chip node drawn as its device (#386): its wave and its pitch drawn from its settings, every one
* of them worked right in the graph. It listens to nothing, so it has nothing to move between changes.
*/
export class ChipFace implements NodeFace, ChipHost {
readonly widget: ChipPanel;
private readonly drag: NodeParamDrag;
constructor(
private readonly state: EditorState,
private readonly assetId: string,
private readonly nodeId: string,
) {
this.widget = new ChipPanel(this);
this.drag = new NodeParamDrag(state, 'chip-face');
}
private node(): GraphNode | null {
const node = findGraphAsset(this.state.project, this.assetId)?.graph.nodes.find(
(n) => n.id === this.nodeId,
);
return node?.type === InstrumentChip.type ? node : null;
}
private wired = (key: string): boolean =>
compressorWired(this.state.project, this.assetId, this.nodeId, key);
sync(): void {
const node = this.node();
if (!node) return;
this.widget.sync({
values: chipValues(node.params),
wave: chipWave(node.params),
follow: node.params[FOLLOW] !== false,
wired: this.wired,
idle: new Set(InstrumentChip.idleParams?.(node.params, this.wired) ?? []),
});
}
tick(): boolean {
return false;
}
hide(): void {}
preview(key: ChipKey, value: number): void {
const node = this.node();
if (node) this.drag.preview(node, key, value);
}
commit(key: ChipKey, value: number): void {
const node = this.node();
if (node) this.drag.commit(this.assetId, node, key, value);
}
pickWave(wave: ChipWave): void {
const node = this.node();
if (node && chipWave(node.params) !== wave)
this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, 'wave', wave));
}
switchFollow(on: boolean): void {
if (this.node()) this.state.execute(new SetNodeParamCommand(this.assetId, this.nodeId, FOLLOW, on));
}
}
+395
View File
@@ -0,0 +1,395 @@
/**
* The Chip as a device (#386), what its node shows in the instrument graph: the Compressor's
* plate (#379) in the warm grey of a handheld console, a button for each wave, a lever for
* "follow note", two green screens – the wave over two periods, and what the slide, the vibrato,
* the arpeggio and the repeat make of the pitch over a note – and a chrome knob for every number.
*/
import { type FederatedPointerEvent, Graphics, type Text, type TextStyle } from 'pixi.js';
import type { ChipWave } from '../../core/soundfx/chip';
import { CHIP_ROWS } from '../../core/instrument/nodes';
import { crispText, Knob, textStyle, Widget } from '../../ui';
import {
CHIP_GROUPS,
CHIP_KEYS,
CHIP_KNOBS,
CHIP_LAYOUT,
CHIP_PANEL,
CHIP_WAVES,
type ChipKey,
glyphPoints,
type ChipValues,
type PitchTrace,
pitchTrace,
secondsText,
semitoneText,
WAVE_NAMES,
wavePoints,
} from './chipView';
import { drawPlate, engrave, INK, Lever, placeEngraving, type PlateLook } from './CompressorPanel';
import { type Box, KNOB_CAPTION } from './hardwareView';
/** What the panel asks of the node it sits in. */
export interface ChipHost {
preview(key: ChipKey, value: number): void;
commit(key: ChipKey, value: number): void;
pickWave(wave: ChipWave): void;
switchFollow(on: boolean): void;
}
/** What the panel shows of a node. */
export interface ChipShown {
values: ChipValues;
wave: ChipWave;
follow: boolean;
wired: (key: ChipKey) => boolean;
/** The settings that do nothing as things stand, greyed as the node's rows were. */
idle: ReadonlySet<string>;
}
/** The warm grey of a handheld console. */
const CONSOLE: PlateLook = {
stops: [
[0, 0xdedbd3],
[0.5, 0xcdc9c0],
[1, 0xb5b0a6],
],
rim: 0x8c877e,
grain: 0x6e695f,
};
const BEZEL = 0x4a4c58;
const BEZEL_TEXT = 0xb9bccb;
/** The four greens of the first handheld's screen, light to dark. */
const LCD = { paper: 0x9bbc0f, grid: 0x8bac0f, mid: 0x306230, ink: 0x0f380f };
/** A button's lit wave: the colour of the console's A and B. */
const PICKED = 0xa3285f;
/** How far an idle setting fades. */
const IDLE_ALPHA = 0.4;
function lcdStyle(size = 10): TextStyle {
const style = textStyle('small').clone();
style.fill = LCD.ink;
style.fontFamily = 'monospace';
style.fontSize = size;
style.fontWeight = 'bold';
return style;
}
/** A line through the points, as one path. */
function polyline(g: Graphics, points: readonly { x: number; y: number }[]): Graphics {
points.forEach((p, i) => (i === 0 ? g.moveTo(p.x, p.y) : g.lineTo(p.x, p.y)));
return g;
}
function dashed(g: Graphics, x0: number, y0: number, x1: number, y1: number, dash = 4): void {
const length = Math.hypot(x1 - x0, y1 - y0);
for (let d = 0; d < length; d += dash * 2) {
const a = d / length;
const b = Math.min(1, (d + dash) / length);
g.moveTo(x0 + (x1 - x0) * a, y0 + (y1 - y0) * a).lineTo(x0 + (x1 - x0) * b, y0 + (y1 - y0) * b);
}
}
/** A wave's button: its shape drawn on it, lit in the console's magenta while it is the one. */
class WaveButton extends Widget {
private readonly g = new Graphics();
picked = false;
constructor(
readonly wave: ChipWave,
onPick: (wave: ChipWave) => void,
) {
super();
this.cursor = 'pointer';
this.addChild(this.g);
this.trackHover();
// A press on a button is not one on the node: it neither picks nor moves it.
this.on('pointerdown', (e: FederatedPointerEvent) => e.stopPropagation());
this.on('pointertap', (e: FederatedPointerEvent) => {
if (e.button === 0) onPick(wave);
});
}
pick(on: boolean): void {
if (on === this.picked) return;
this.picked = on;
this.invalidate();
}
override redraw(): void {
const { w, h } = this;
const g = this.g.clear();
const down = this.picked ? 1 : 0;
g.roundRect(0, 2, w, h - 2, 6).fill(0x2a2b30);
g.roundRect(0, down, w, h - 2, 6)
.fill(this.picked ? PICKED : this.hovered ? 0x5a5d68 : 0x474a54)
.stroke({ color: 0x24252a, width: 1 });
g.roundRect(3, down + 2, w - 6, 5, 3).fill({ color: 0xffffff, alpha: this.picked ? 0.25 : 0.12 });
const box = { x: 8, y: down + 7, w: w - 16, h: h - 18 };
const points = glyphPoints(this.wave).map((p) => ({
x: box.x + p.x * box.w,
y: box.y + box.h / 2 - (p.y * box.h) / 2,
}));
polyline(g, points).stroke({ color: this.picked ? 0xffffff : 0xd9dbe2, width: 1.5, join: 'round' });
}
}
/** The left screen: the wave over two periods, as loud as it plays. */
class WaveScreen extends Widget {
private readonly g = new Graphics();
readonly says: Text;
private wave: ChipWave = 'square';
private duty = 0.5;
constructor() {
super();
this.addChild(this.g);
this.says = crispText({ text: '', style: lcdStyle() });
this.says.position.set(5, 3);
this.addChild(this.says);
}
show(wave: ChipWave, duty: number): void {
const says = wave === 'square' ? `${WAVE_NAMES[wave]} ${Math.round(duty * 100)}%` : WAVE_NAMES[wave];
if (wave === this.wave && duty === this.duty && says === this.says.text) return;
this.wave = wave;
this.duty = duty;
this.says.text = says;
this.invalidate();
}
/** Where the wave is drawn: below the text, a little in from the sides. */
get plot(): Box {
return { x: 6, y: 18, w: this.w - 12, h: this.h - 24 };
}
override redraw(): void {
const g = this.g.clear();
g.roundRect(0, 0, this.w, this.h, 3).fill(LCD.paper);
const p = this.plot;
for (let i = 1; i < 4; i++) g.moveTo(p.x + (p.w * i) / 4, p.y).lineTo(p.x + (p.w * i) / 4, p.y + p.h);
g.moveTo(p.x, p.y + p.h / 2).lineTo(p.x + p.w, p.y + p.h / 2);
g.stroke({ color: LCD.grid, width: 1 });
const points = wavePoints(this.wave, this.duty).map((q) => ({
x: p.x + q.x * p.w,
y: p.y + p.h / 2 - (q.y * p.h) / 2,
}));
// A noise's steps are a pixel or two apart: a thick line would fill them in.
const noisy = this.wave === 'noise' || this.wave === 'periodic';
polyline(g, points).stroke({ color: LCD.ink, width: noisy ? 1 : 2, join: 'miter' });
}
}
/**
* The right screen: the pitch over a note, from where it starts. It says what it starts from and
* when the note ends at the top left, the time it covers at the bottom left, and how far it
* reaches either way on the right.
*/
class PitchScreen extends Widget {
private readonly g = new Graphics();
readonly from: Text;
private readonly high: Text;
private readonly low: Text;
private readonly span: Text;
trace: PitchTrace | null = null;
constructor() {
super();
this.addChild(this.g);
this.from = crispText({ text: '', style: lcdStyle() });
this.high = crispText({ text: '', style: lcdStyle(9) });
this.low = crispText({ text: '', style: lcdStyle(9) });
this.span = crispText({ text: '', style: lcdStyle(9) });
this.high.anchor.set(1, 0);
this.low.anchor.set(1, 1);
this.span.anchor.set(0, 1);
this.addChild(this.from, this.high, this.low, this.span);
}
show(trace: PitchTrace): void {
this.trace = trace;
this.from.text = trace.end === null ? trace.from : `${trace.from} · ENDS ${secondsText(trace.end)}`;
this.high.text = semitoneText(trace.high);
this.low.text = semitoneText(trace.low);
this.span.text = `0–${secondsText(trace.span)}`;
this.invalidate();
}
/** Where the trace is drawn: between the texts at the top and the bottom. */
get plot(): Box {
return { x: 6, y: 18, w: this.w - 12, h: this.h - 32 };
}
override redraw(): void {
const g = this.g.clear();
g.roundRect(0, 0, this.w, this.h, 3).fill(LCD.paper);
const trace = this.trace;
const p = this.plot;
this.from.position.set(5, 3);
this.high.position.set(this.w - 5, 3);
this.low.position.set(this.w - 5, this.h - 2);
this.span.position.set(5, this.h - 2);
if (!trace) return;
const x = (t: number) => p.x + (t / trace.span) * p.w;
const y = (st: number) => p.y + ((trace.high - st) / (trace.high - trace.low)) * p.h;
for (let i = 1; i < 4; i++) g.moveTo(p.x + (p.w * i) / 4, p.y).lineTo(p.x + (p.w * i) / 4, p.y + p.h);
g.stroke({ color: LCD.grid, width: 1 });
// Where the note starts, and where it ends below "min frequency".
dashed(g, p.x, y(0), p.x + p.w, y(0), 3);
g.stroke({ color: LCD.mid, width: 1 });
if (trace.floor !== null && trace.floor >= trace.low && trace.floor <= trace.high) {
dashed(g, p.x, y(trace.floor), p.x + p.w, y(trace.floor), 6);
g.stroke({ color: LCD.ink, width: 1 });
}
for (const t of trace.restarts) g.moveTo(x(t), p.y).lineTo(x(t), p.y + p.h);
g.stroke({ color: LCD.mid, width: 1, alpha: 0.6 });
polyline(
g,
trace.points.map((q) => ({ x: x(q.t), y: y(q.st) })),
).stroke({ color: LCD.ink, width: 2, join: 'round' });
if (trace.end !== null) {
const ex = x(trace.end);
g.moveTo(ex, p.y)
.lineTo(ex, p.y + p.h)
.stroke({ color: LCD.ink, width: 1 });
}
}
}
export class ChipPanel extends Widget {
private readonly plate = new Graphics();
private readonly bezel = new Graphics();
readonly waveButtons: WaveButton[];
readonly follow: Lever;
readonly waveScreen = new WaveScreen();
readonly pitchScreen = new PitchScreen();
readonly knobs = {} as Record<ChipKey, Knob>;
private readonly captions = {} as Record<ChipKey, { ink: Text; edge: Text }>;
private readonly groupNames: { ink: Text; edge: Text }[];
private readonly rules = new Graphics();
private readonly title: { ink: Text; edge: Text };
private readonly screenNames: Text[];
/** The settings shown idle now (checks). */
idle: ReadonlySet<string> = new Set();
constructor(host: ChipHost) {
super();
this.minWidth = CHIP_PANEL.w;
this.minHeight = CHIP_PANEL.h;
this.addChild(this.plate, this.rules, this.bezel, this.waveScreen, this.pitchScreen);
this.title = engrave(this, 'CHIP', 14);
const nameStyle = textStyle('small').clone();
nameStyle.fill = BEZEL_TEXT;
nameStyle.fontSize = 9;
nameStyle.fontWeight = 'bold';
nameStyle.letterSpacing = 1;
this.screenNames = ['WAVE', 'PITCH OVER A NOTE'].map((text) => {
const t = crispText({ text, style: nameStyle });
t.eventMode = 'none';
this.addChild(t);
return t;
});
this.waveButtons = CHIP_WAVES.map((wave) => {
const button = new WaveButton(wave, (picked) => host.pickWave(picked));
this.addChild(button);
return button;
});
this.follow = new Lever('FOLLOW NOTE', (on) => host.switchFollow(on));
this.addChild(this.follow);
this.groupNames = CHIP_GROUPS.map((group) => engrave(this, group.label, 9));
for (const key of CHIP_KEYS) {
const row = CHIP_ROWS[key];
const look = CHIP_KNOBS[key];
const knob = new Knob('', row.default, {
min: row.min ?? 0,
max: row.max ?? 0,
step: row.step ?? 1,
taper: look.taper,
bipolar: look.bipolar,
default: row.default,
decimals: look.decimals,
size: CHIP_LAYOUT.knobs[key].size,
look: 'chrome',
typed: false,
format: look.format,
ink: INK,
onInput: (value) => host.preview(key, value),
onChange: (value) => host.commit(key, value),
});
this.knobs[key] = knob;
this.captions[key] = engrave(this, look.label.toUpperCase(), 9);
this.addChild(knob);
}
}
override measure(): { w: number; h: number } {
return { ...CHIP_PANEL };
}
sync(shown: ChipShown): void {
for (const key of CHIP_KEYS) {
const knob = this.knobs[key];
knob.setValue(shown.values[key]);
knob.setEnabled(!shown.wired(key));
const idle = shown.idle.has(key);
knob.alpha = idle ? IDLE_ALPHA : 1;
this.captions[key].ink.alpha = idle ? IDLE_ALPHA : 1;
}
for (const button of this.waveButtons) button.pick(button.wave === shown.wave);
this.follow.set(shown.follow, shown.follow ? 'on' : 'off');
this.follow.alpha = shown.idle.has('followNote') ? IDLE_ALPHA : 1;
this.idle = shown.idle;
this.waveScreen.show(shown.wave, shown.values.duty);
this.pitchScreen.show(pitchTrace(shown.values, shown.follow));
}
override layout(): void {
const L = CHIP_LAYOUT;
const put = (w: Widget, b: Box) => {
w.position.set(b.x, b.y);
w.resize(b.w, b.h);
};
this.waveButtons.forEach((button, i) => put(button, L.waves[i]!));
put(this.follow, L.follow);
put(this.waveScreen, L.wave);
put(this.pitchScreen, L.pitch);
this.screenNames[0]!.position.set(L.wave.x, L.bezel.y + 3);
this.screenNames[1]!.position.set(L.pitch.x, L.bezel.y + 3);
for (const key of CHIP_KEYS) {
const spot = L.knobs[key];
const knob = this.knobs[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[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 + 6);
}
override redraw(): void {
const L = CHIP_LAYOUT;
drawPlate(this.plate.clear(), CHIP_PANEL, CONSOLE, 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: 0x2c2d35, width: 1 });
// A rule each side of a group's name, engraved as the names are.
const r = this.rules.clear();
L.groups.forEach((group, i) => {
const name = this.groupNames[i]!.ink;
const mid = group.box.y + 6;
const left = group.box.x + 6;
const right = group.box.x + group.box.w - 6;
const gap = name.width / 2 + 6;
const centre = group.box.x + group.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 });
}
}
+7 -7
View File
@@ -295,18 +295,18 @@ export class OutputLeds extends Widget {
}
}
/** Auto-gain: a lever and a lamp, and what it gives beside them. */
class AutoGainLever extends Widget {
/** A switch on a plate: a lever and a lamp, its name and what it gives beside them. Auto-gain's, a Chip's "follow note" (#386). */
export class Lever extends Widget {
private readonly g = new Graphics();
private readonly caption: { ink: Text; edge: Text };
readonly says: Text;
lit = false;
constructor(onToggle: (on: boolean) => void) {
constructor(name: string, onToggle: (on: boolean) => void) {
super();
this.cursor = 'pointer';
this.addChild(this.g);
this.caption = engrave(this, 'AUTO GAIN', 9);
this.caption = engrave(this, name, 9);
const style = textStyle('small').clone();
style.fill = INK;
style.fontSize = 11;
@@ -343,7 +343,7 @@ class AutoGainLever extends Widget {
const tip = this.lit ? body.y - 4 : body.y + body.h + 4;
g.moveTo(cx, cy).lineTo(cx, tip).stroke({ color: 0xb9bdc3, width: 5, cap: 'round' });
g.circle(cx, tip, 4).fill(0xe8eaed).stroke({ color: 0x6d7178, width: 1 });
placeEngraving(this.caption, 52 + 33, 2);
placeEngraving(this.caption, 52 + (this.w - 52) / 2, 2);
this.says.position.set(52, 20);
}
}
@@ -356,7 +356,7 @@ export class CompressorPanel extends Widget {
private readonly lcdReading: Text;
readonly meter = new ReductionMeter();
readonly leds = new OutputLeds();
readonly autoGain: AutoGainLever;
readonly autoGain: Lever;
readonly knobs = {} as Record<CompressorKey, Knob>;
private readonly captions: Record<CompressorKey, { ink: Text; edge: Text }>;
private readonly title: { ink: Text; edge: Text };
@@ -406,7 +406,7 @@ export class CompressorPanel extends Widget {
);
this.addChild(knob);
}
this.autoGain = new AutoGainLever((on) => host.switchAutoGain(on));
this.autoGain = new Lever('AUTO GAIN', (on) => host.switchAutoGain(on));
this.addChild(this.meter, this.leds, this.autoGain);
}
+330
View File
@@ -0,0 +1,330 @@
/**
* What a Chip's node draws (#386), as numbers: where the parts of its device sit, how its knobs
* turn, the wave it plays over two periods and what its settings make of the pitch over a note.
* Kept apart from the drawing so it is checked without a browser.
*/
import { CHIP_ROWS } from '../../core/instrument/nodes';
import { noteName } from '../../core/instrument/notes';
import type { PropertyBag } from '../../core/model/Properties';
import { type ChipWave, periodicSteps, toneValue } from '../../core/soundfx/chip';
import { formatNumber } from '../../ui/math';
import { type Box, type KnobSpot, KNOB_CAPTION, screwsOf, SMALL_DIAL } from './hardwareView';
export type ChipKey = keyof typeof CHIP_ROWS;
export type ChipValues = Record<ChipKey, number>;
export const CHIP_KEYS = Object.keys(CHIP_ROWS) as ChipKey[];
/** The numbers of a Chip node, each its row's default where none is typed in. */
export function chipValues(params: PropertyBag): ChipValues {
const values = {} as ChipValues;
for (const key of CHIP_KEYS) {
const value = params[key];
values[key] = typeof value === 'number' && Number.isFinite(value) ? value : CHIP_ROWS[key].default;
}
return values;
}
/** A frequency as short as a knob's number has room for: `440`, `3.53k`, `20.0k`. */
export function hzText(hz: number): string {
if (hz >= 10000) return `${(hz / 1000).toFixed(1)}k`;
if (hz >= 1000) return `${(hz / 1000).toFixed(2)}k`;
return formatNumber(hz, 0);
}
/** A setting at 0 that turns its part off says so. */
const offAtZero = (format: (value: number) => string) => (value: number) =>
value === 0 ? 'off' : format(value);
export interface ChipKnob {
/** The engraved name above it, its group's name above the group. */
label: string;
taper: number;
decimals: number;
bipolar?: boolean;
format?: (value: number) => string;
}
/**
* How each knob turns: frequencies and times give most of the turn to their low end, and the
* numbers that go either way have 0 straight up.
*/
export const CHIP_KNOBS: Record<ChipKey, ChipKnob> = {
frequency: { label: 'Freq', taper: 4, decimals: 0, format: hzText },
duty: { label: 'Duty', taper: 1, decimals: 2 },
dutySweep: { label: 'Sweep', taper: 2, decimals: 2, bipolar: true },
slide: { label: 'Speed', taper: 3, decimals: 0, bipolar: true },
deltaSlide: { label: 'Delta', taper: 3, decimals: 0, bipolar: true },
minFrequency: { label: 'Min Hz', taper: 4, decimals: 0, format: offAtZero(hzText) },
vibratoDepth: { label: 'Depth', taper: 2, decimals: 1 },
vibratoSpeed: { label: 'Speed', taper: 2, decimals: 1 },
arpeggio: { label: 'Jump', taper: 2, decimals: 0, bipolar: true },
arpeggioTime: { label: 'After', taper: 3, decimals: 3 },
repeat: { label: 'Every', taper: 3, decimals: 3, format: offAtZero((s) => formatNumber(s, 3)) },
};
/** The knobs as the plate groups them, left to right. */
export const CHIP_GROUPS: readonly { label: string; keys: readonly ChipKey[] }[] = [
{ label: 'OSCILLATOR', keys: ['frequency', 'duty', 'dutySweep'] },
{ label: 'SLIDE', keys: ['slide', 'deltaSlide', 'minFrequency'] },
{ label: 'VIBRATO', keys: ['vibratoDepth', 'vibratoSpeed'] },
{ label: 'ARPEGGIO', keys: ['arpeggio', 'arpeggioTime'] },
{ label: 'REPEAT', keys: ['repeat'] },
];
export const CHIP_WAVES: readonly ChipWave[] = ['square', 'triangle', 'saw', 'sine', 'noise', 'periodic'];
/** What the screen calls a wave. */
export const WAVE_NAMES: Record<ChipWave, string> = {
square: 'SQUARE',
triangle: 'TRIANGLE',
saw: 'SAW',
sine: 'SINE',
noise: 'NOISE',
periodic: 'NES NOISE',
};
// --- The plate. ---
/** The plate: as the Compressor's (#379), the panel never takes another size. */
export const CHIP_PANEL = { w: 840, h: 280 };
/** Each knob's room in its group, and the room between two groups. */
const KNOB_SLOT = 64;
const GROUP_GAP = 22;
/** The height of a group's name and its rule. */
const GROUP_LINE = 16;
export interface ChipLayout {
title: Box;
waves: Box[];
follow: Box;
/** The dark frame round both screens, and the screens in it. */
bezel: Box;
wave: Box;
pitch: Box;
groups: { label: string; box: Box }[];
knobs: Record<ChipKey, KnobSpot>;
screws: { x: number; y: number }[];
}
function chipLayout(): ChipLayout {
const { w } = CHIP_PANEL;
const waveButton = { w: 40, h: 28, gap: 6 };
const waves = CHIP_WAVES.map((_, i) => ({
x: 132 + i * (waveButton.w + waveButton.gap),
y: 13,
w: waveButton.w,
h: waveButton.h,
}));
const bezel = { x: 24, y: 52, w: w - 48, h: 118 };
const screenTop = bezel.y + 16;
const screenH = bezel.h - 16 - 8;
const wave = { x: bezel.x + 10, y: screenTop, w: 210, h: screenH };
const pitch = {
x: wave.x + wave.w + 12,
y: screenTop,
w: bezel.x + bezel.w - 10 - (wave.x + wave.w + 12),
h: screenH,
};
const rowWidth =
CHIP_GROUPS.reduce((sum, group) => sum + group.keys.length * KNOB_SLOT, 0) +
(CHIP_GROUPS.length - 1) * GROUP_GAP;
const groupTop = bezel.y + bezel.h + 6;
const dialY = groupTop + GROUP_LINE + KNOB_CAPTION + SMALL_DIAL / 2;
const knobs = {} as Record<ChipKey, KnobSpot>;
const groups: { label: string; box: Box }[] = [];
let x = (w - rowWidth) / 2;
for (const group of CHIP_GROUPS) {
groups.push({
label: group.label,
box: { x, y: groupTop, w: group.keys.length * KNOB_SLOT, h: GROUP_LINE },
});
for (const key of group.keys) {
knobs[key] = { x: x + KNOB_SLOT / 2, y: dialY, size: SMALL_DIAL };
x += KNOB_SLOT;
}
x += GROUP_GAP;
}
return {
title: { x: 34, y: 14, w: 80, h: 30 },
waves,
follow: { x: w - 164, y: 10, w: 126, h: 40 },
bezel,
wave,
pitch,
groups,
knobs,
screws: screwsOf(CHIP_PANEL),
};
}
export const CHIP_LAYOUT: ChipLayout = chipLayout();
// --- The wave screen: two periods of what the Chip plays. ---
/** The periods the wave screen shows. */
export const WAVE_PERIODS = 2;
const NOISE_STEPS = 32;
/** The noise drawn the same every time: a period of 32 values, as the Chip draws its own. */
function drawnNoise(): number[] {
let state = 386;
return Array.from({ length: NOISE_STEPS * WAVE_PERIODS }, () => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return (state / 2 ** 32) * 2 - 1;
});
}
/**
* The wave as a line from left to right over `WAVE_PERIODS` periods: `x` from 0 to 1, `y` from -1
* to 1 as loud as it plays. A square's duty is where it falls; a step stands upright, two points
* at one `x`.
*/
export function wavePoints(wave: ChipWave, duty: number, samples = 240): { x: number; y: number }[] {
if (wave === 'noise' || wave === 'periodic') {
const values = wave === 'noise' ? drawnNoise() : [...periodicSteps(), ...periodicSteps()];
return stepped(values);
}
if (wave === 'square') {
const width = Math.min(0.99, Math.max(0.01, duty));
const points: { x: number; y: number }[] = [];
for (let p = 0; p < WAVE_PERIODS; p++) {
const start = p / WAVE_PERIODS;
const fall = (p + width) / WAVE_PERIODS;
const end = (p + 1) / WAVE_PERIODS;
points.push({ x: start, y: 0.5 }, { x: fall, y: 0.5 }, { x: fall, y: -0.5 }, { x: end, y: -0.5 });
}
return points;
}
const points: { x: number; y: number }[] = [];
for (let i = 0; i <= samples; i++) {
const x = i / samples;
// The right edge is the end of the last period, not the start of another.
const phase = i === samples ? 1 : (x * WAVE_PERIODS) % 1;
// A saw jumps back up between two periods: its bottom and the next top at one `x`.
if (wave === 'saw' && i > 0 && i < samples && phase === 0) points.push({ x, y: -1 });
points.push({ x, y: toneValue(wave, phase, 0.5) });
}
return points;
}
/**
* A wave as small as a button draws it. The noises are drawn as a hint: the first periodic steps
* are all low, and 186 of them or 64 random ones are a grey bar at that size.
*/
export function glyphPoints(wave: ChipWave): { x: number; y: number }[] {
if (wave === 'noise') return stepped([0.2, -0.7, 0.9, -0.1, 0.6, -0.9, 0.3, -0.4, 0.8, -0.6]);
// A short pattern twice over: noise that repeats.
const pattern = [0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5, -0.5];
if (wave === 'periodic') return stepped([...pattern, ...pattern]);
return wavePoints(wave, 0.5, 24);
}
function stepped(values: readonly number[]): { x: number; y: number }[] {
const points: { x: number; y: number }[] = [];
values.forEach((y, i) => {
points.push({ x: i / values.length, y }, { x: (i + 1) / values.length, y });
});
return points;
}
// --- The pitch screen: what slide, vibrato, arpeggio and repeat do over a note. ---
/** The pitch the trace starts from when the Chip follows the note: A4. */
export const TRACE_NOTE = 69;
const TRACE_HZ = 440;
/** The longest and shortest stretch the pitch screen shows, in seconds. */
const LONGEST = 10;
const SHORTEST = 0.02;
/** The least the screen shows either way of the start, in semitones, so a flat line sits in the middle. */
const LEAST_REACH = 2;
export interface PitchTrace {
/** Seconds the screen covers. */
span: number;
/** The pitch in semitones from the start, while the note lasts. */
points: { t: number; st: number }[];
/** When the note falls below "min frequency" and ends; null if it plays on. */
end: number | null;
/** "min frequency" in semitones from the start; null when off. */
floor: number | null;
/** Where the screen reaches, in semitones from the start. */
low: number;
high: number;
/** When "repeat" starts the slide over. */
restarts: number[];
/** The pitch the trace starts from, in Hz, and what it is called. */
hz: number;
from: string;
}
/** The pitch `t` seconds into a note, in semitones from where it starts, and whether it is still playing. */
function pitchAt(v: ChipValues, hz: number, t: number): { st: number; playing: boolean } {
const since = v.repeat > 0 ? t % v.repeat : t;
const slid = Math.max(-240, Math.min(240, v.slide * since + (v.deltaSlide * since * since) / 2));
const jump = v.arpeggio !== 0 && since >= Math.max(0, v.arpeggioTime) ? v.arpeggio : 0;
const base = slid + jump;
const playing = !(v.minFrequency > 0 && hz * 2 ** (base / 12) < v.minFrequency);
const wobble = v.vibratoDepth > 0 ? v.vibratoDepth * Math.sin(2 * Math.PI * v.vibratoSpeed * t) : 0;
return { st: base + wobble, playing };
}
/**
* How long the screen looks: three rounds of a repeat, else long enough for the arpeggio, at least
* a second – and a quarter more than it takes the note to end, when it does.
*/
function spanOf(v: ChipValues, hz: number, samples: number): number {
const horizon =
v.repeat > 0
? Math.min(LONGEST, Math.max(SHORTEST, v.repeat * 3))
: Math.min(LONGEST, Math.max(1, v.arpeggio !== 0 ? v.arpeggioTime * 2 : 0));
for (let i = 0; i <= samples; i++) {
const t = (i / samples) * horizon;
if (!pitchAt(v, hz, t).playing) return Math.max(SHORTEST, t * 1.25);
}
return horizon;
}
/**
* The pitch over a note as the Chip plays it (#386), from `hz` – or from A4 when it follows the note,
* which is any note: the shape is the same on every one but where it ends below "min frequency".
*/
export function pitchTrace(v: ChipValues, follow: boolean, samples = 600): PitchTrace {
const hz = follow ? TRACE_HZ : Math.max(1, v.frequency);
const from = follow ? `${noteName(TRACE_NOTE)} ${TRACE_HZ} Hz` : `${hzText(hz)} Hz`;
const span = spanOf(v, hz, samples);
const points: { t: number; st: number }[] = [];
let end: number | null = null;
for (let i = 0; i <= samples; i++) {
const t = (i / samples) * span;
const at = pitchAt(v, hz, t);
if (!at.playing) {
end = t;
break;
}
points.push({ t, st: at.st });
}
const floor = v.minFrequency > 0 ? 12 * Math.log2(v.minFrequency / hz) : null;
let low = Math.min(0, ...points.map((p) => p.st));
let high = Math.max(0, ...points.map((p) => p.st));
if (end !== null && floor !== null) low = Math.min(low, floor);
const pad = Math.max(0.5, (high - low) * 0.12);
low = Math.min(-LEAST_REACH, low - pad);
high = Math.max(LEAST_REACH, high + pad);
const restarts: number[] = [];
if (v.repeat > 0) for (let t = v.repeat; t < (end ?? span); t += v.repeat) restarts.push(t);
return { span, points, end, floor, low, high, restarts, hz, from };
}
/** Semitones as the screen writes them: `+12 st`, `-29 st`. */
export function semitoneText(st: number): string {
const rounded = Math.round(st * 10) / 10;
const text = formatNumber(Math.abs(rounded), Math.abs(rounded) >= 10 ? 0 : 1);
return `${rounded > 0 ? '+' : rounded < 0 ? '-' : ''}${text} st`;
}
/** Seconds as the screen writes them. */
export function secondsText(s: number): string {
return s >= 1 ? `${formatNumber(s, 2)} s` : `${formatNumber(s * 1000, 0)} ms`;
}
+4 -1
View File
@@ -1,15 +1,18 @@
import type { GraphNode } from '../../core/graph';
import { InstrumentChip } from '../../core/instrument/nodes';
import { SoundFxCompressor, SoundFxFilter, SoundFxGain, SoundFxParametricEq } from '../../core/soundfx/nodes';
import type { NodeFace } from '../graph/nodeFace';
import type { EditorState } from '../state';
import { ChipFace } from './ChipFace';
import { CompressorFace } from './CompressorFace';
import { CurveFace } from './CurveFace';
import { EqPanel } from './EqPanel';
import { FilterPanel } from './FilterPanel';
import { GainFace } from './GainFace';
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384, #385), by type. */
/** The nodes drawn as a face of their own instead of rows (#380, #381, #384, #385, #386), by type. */
export function nodeFaceFor(state: EditorState, assetId: string, node: GraphNode): NodeFace | null {
if (node.type === InstrumentChip.type) return new ChipFace(state, assetId, node.id);
if (node.type === SoundFxCompressor.type) return new CompressorFace(state, assetId, node.id);
if (node.type === SoundFxParametricEq.type)
return new CurveFace(new EqPanel(state, true), assetId, node.id);