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>
331 lines
13 KiB
TypeScript
331 lines
13 KiB
TypeScript
/**
|
||
* 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`;
|
||
}
|