Files
feuerwerk/src/editor/soundfx/chipView.ts
T
adminandClaude Opus 5.5 eb78f3cbf3
game-maker / gate (push) Successful in 4m5s
game-maker / suite (push) Failing after 16m5s
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>
2026-10-10 14:55:35 +02:00

331 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`;
}