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
@@ -393,8 +393,11 @@ function held<K extends string>(
|
||||
/** Room between the nodes of a preset, left to right and top to bottom: a Chip is a tall node. */
|
||||
const COLUMN = 240;
|
||||
const ROW = 340;
|
||||
/** Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide. */
|
||||
const COLUMNS_OF: Readonly<Record<string, number>> = { [SoundFxFilter.type]: 4 };
|
||||
/**
|
||||
* Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide,
|
||||
* a Chip's device (#386) wider.
|
||||
*/
|
||||
const COLUMNS_OF: Readonly<Record<string, number>> = { [SoundFxFilter.type]: 4, [INSTRUMENT_CHIP]: 5 };
|
||||
|
||||
/** The column right of a node, past all it takes. */
|
||||
const columnAfter = (node: GraphNode): number => node.x / COLUMN + (COLUMNS_OF[node.type] ?? 1);
|
||||
|
||||
@@ -320,7 +320,7 @@ export const InstrumentChip: InstrumentNodeDef = {
|
||||
category: 'sound',
|
||||
instrument: true,
|
||||
description:
|
||||
'A raw wave as old game consoles made it, for every note that triggers it, the way sfxr makes its sounds. "Wave" is Square, Triangle, Saw, Sine, Noise, or Noise (periodic), the short metallic noise of the NES; a square and that noise are half as loud as the others, as in sfxr. With "follow note" it plays at the pitch of the note, else at "frequency" in Hz; a number connected to "frequency" goes before both. "duty" is how much of a square is up: 0.5 is even, 0.125 thin and nasal; "duty sweep" moves it by that much per second. "slide" glides the pitch in semitones per second, "delta slide" changes the slide by that much per second. Where it falls below "min frequency" the note ends; at 0 it never does. "vibrato depth" in semitones and "vibrato speed" in Hz make it wobble. "arpeggio" jumps it by that many semitones "arpeggio time" seconds into the note. "repeat" starts slide, duty sweep and arpeggio over every so many seconds, 0 never. The Chip plays until its note ends, faded out in a few milliseconds; under an Envelope it plays on through the release. An Envelope can move "frequency" and "duty" while it plays, a Pitch moves its frequency itself. Settings that do nothing as things stand are grey.',
|
||||
'A raw wave as old game consoles made it, for every note that triggers it, the way sfxr makes its sounds. "Wave" is Square, Triangle, Saw, Sine, Noise, or Noise (periodic), the short metallic noise of the NES; a square and that noise are half as loud as the others, as in sfxr. With "follow note" it plays at the pitch of the note, else at "frequency" in Hz; a number connected to "frequency" goes before both. "duty" is how much of a square is up: 0.5 is even, 0.125 thin and nasal; "duty sweep" moves it by that much per second. "slide" glides the pitch in semitones per second, "delta slide" changes the slide by that much per second. Where it falls below "min frequency" the note ends; at 0 it never does. "vibrato depth" in semitones and "vibrato speed" in Hz make it wobble. "arpeggio" jumps it by that many semitones "arpeggio time" seconds into the note. "repeat" starts slide, duty sweep and arpeggio over every so many seconds, 0 never. The Chip plays until its note ends, faded out in a few milliseconds; under an Envelope it plays on through the release. An Envelope can move "frequency" and "duty" while it plays, a Pitch moves its frequency itself. Settings that do nothing as things stand are grey. In the graph the node is a device: it draws its wave over two periods, and what slide, vibrato, arpeggio and repeat make of the pitch over a note, from A4 when it follows the note.',
|
||||
inputs: [
|
||||
triggerIn,
|
||||
...Object.values(CHIP_ROWS).map((row): PortDef =>
|
||||
|
||||
+41
-11
@@ -119,18 +119,12 @@ export class ChipPlayer {
|
||||
private sample(width: number): number {
|
||||
const p = this.phase;
|
||||
switch (this.shape.wave) {
|
||||
case 'square':
|
||||
return p < width ? 0.5 : -0.5;
|
||||
case 'saw':
|
||||
return 1 - 2 * p;
|
||||
case 'triangle':
|
||||
return p < 0.5 ? 4 * p - 1 : 3 - 4 * p;
|
||||
case 'sine':
|
||||
return Math.sin(2 * Math.PI * p);
|
||||
case 'noise':
|
||||
return this.noise[Math.min(NOISE_STEPS - 1, Math.floor(p * NOISE_STEPS))]!;
|
||||
case 'periodic':
|
||||
return this.lfsr & 1 ? 0.5 : -0.5;
|
||||
default:
|
||||
return toneValue(this.shape.wave, p, width);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +139,48 @@ export class ChipPlayer {
|
||||
for (let i = 0; i < NOISE_STEPS; i++) this.noise[i] = this.random() * 2 - 1;
|
||||
}
|
||||
|
||||
/** One step of the 15-bit noise register of the NES in its short mode: bit 0 and bit 6 feed back. */
|
||||
private clock(): void {
|
||||
const feedback = (this.lfsr ^ (this.lfsr >> 6)) & 1;
|
||||
this.lfsr = (this.lfsr >> 1) | (feedback << 14);
|
||||
this.lfsr = clocked(this.lfsr);
|
||||
}
|
||||
}
|
||||
|
||||
/** A wave with no noise in it at `phase` of its period, 0 to 1; also what a Chip's node draws (#386). */
|
||||
export function toneValue(
|
||||
wave: Exclude<ChipWave, 'noise' | 'periodic'>,
|
||||
phase: number,
|
||||
width: number,
|
||||
): number {
|
||||
switch (wave) {
|
||||
case 'square':
|
||||
return phase < width ? 0.5 : -0.5;
|
||||
case 'saw':
|
||||
return 1 - 2 * phase;
|
||||
case 'triangle':
|
||||
return phase < 0.5 ? 4 * phase - 1 : 3 - 4 * phase;
|
||||
case 'sine':
|
||||
return Math.sin(2 * Math.PI * phase);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The short noise of the NES as its register gives it, `steps` values from the start: what a
|
||||
* periodic Chip plays over its first period, the same every time.
|
||||
*/
|
||||
export function periodicSteps(steps = PERIODIC_STEPS): number[] {
|
||||
let lfsr = 1;
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < steps; i++) {
|
||||
lfsr = clocked(lfsr);
|
||||
out.push(lfsr & 1 ? 0.5 : -0.5);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One step of the 15-bit noise register of the NES in its short mode: bit 0 and bit 6 feed back. */
|
||||
function clocked(lfsr: number): number {
|
||||
const feedback = (lfsr ^ (lfsr >> 6)) & 1;
|
||||
return (lfsr >> 1) | (feedback << 14);
|
||||
}
|
||||
|
||||
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
|
||||
const finite = (value: number, fallback: number) => (Number.isFinite(value) ? value : fallback);
|
||||
@@ -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);
|
||||
|
||||
+20
-3
@@ -12,8 +12,16 @@ export interface KnobRange {
|
||||
step: number;
|
||||
/** 1 turns evenly; above it the low end gets more of the turn. */
|
||||
taper?: number;
|
||||
/**
|
||||
* A range round 0 (#386): 0 straight up, and the taper bends each half from there, so the small
|
||||
* numbers either way get most of the turn – a slide of a few semitones as much as one of hundreds.
|
||||
*/
|
||||
bipolar?: boolean;
|
||||
}
|
||||
|
||||
/** How far a bipolar range reaches from 0 either way. */
|
||||
const reach = (range: KnobRange) => Math.max(-range.min, range.max);
|
||||
|
||||
/** Pixels of a vertical drag that take the knob from one end to the other. */
|
||||
export const KNOB_TRAVEL = 200;
|
||||
/** How much of the turn Shift leaves to a drag. */
|
||||
@@ -25,13 +33,22 @@ export const KNOB_START = Math.PI * 0.75;
|
||||
export const KNOB_SWEEP = Math.PI * 1.5;
|
||||
|
||||
export function knobPosition(value: number, range: KnobRange): number {
|
||||
const taper = range.taper ?? 1;
|
||||
if (range.bipolar) {
|
||||
const share = clamp(value / reach(range), -1, 1);
|
||||
return clamp(0.5 + (Math.sign(share) * Math.abs(share) ** (1 / taper)) / 2, 0, 1);
|
||||
}
|
||||
const share = clamp((value - range.min) / (range.max - range.min), 0, 1);
|
||||
return share ** (1 / (range.taper ?? 1));
|
||||
return share ** (1 / taper);
|
||||
}
|
||||
|
||||
export function knobValue(position: number, range: KnobRange): number {
|
||||
const share = clamp(position, 0, 1) ** (range.taper ?? 1);
|
||||
return clamp(snapToStep(range.min + share * (range.max - range.min), range.step), range.min, range.max);
|
||||
const taper = range.taper ?? 1;
|
||||
const p = clamp(position, 0, 1);
|
||||
const value = range.bipolar
|
||||
? Math.sign(p - 0.5) * Math.abs(2 * p - 1) ** taper * reach(range)
|
||||
: range.min + p ** taper * (range.max - range.min);
|
||||
return clamp(snapToStep(value, range.step), range.min, range.max);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user