Show a Chip node as a device with its wave and its pitch (#386)
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:
1 parent
c087993000
commit
eb78f3cbf3
14 files changed
+1190
-26
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user