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

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

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

Found on the way:

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

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

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

No files matched your search

+183
View File
@@ -0,0 +1,183 @@
/**
* A Chip's node as a device (#386): every part on the plate and none over another, the wave as
* the Chip plays it, and the pitch over a note as slide, vibrato, arpeggio and repeat make it.
*/
import { describe, expect, it } from 'vitest';
import { CHIP_ROWS } from '../../src/core/instrument/nodes';
import { ChipPlayer, type ChipShape } from '../../src/core/soundfx/chip';
import {
CHIP_GROUPS,
CHIP_KEYS,
CHIP_KNOBS,
CHIP_LAYOUT,
CHIP_PANEL,
chipValues,
type ChipValues,
hzText,
pitchTrace,
semitoneText,
wavePoints,
} from '../../src/editor/soundfx/chipView';
import { type Box, knobBox } from '../../src/editor/soundfx/hardwareView';
import { knobPosition } from '../../src/ui/knob';
const overlap = (a: Box, b: Box) => a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
const inside = (a: Box) => a.x >= 0 && a.y >= 0 && a.x + a.w <= CHIP_PANEL.w && a.y + a.h <= CHIP_PANEL.h;
const values = (changes: Partial<ChipValues> = {}): ChipValues => ({ ...chipValues({}), ...changes });
describe('the Chip plate', () => {
const L = CHIP_LAYOUT;
const parts: [string, Box][] = [
['title', L.title],
['follow', L.follow],
['bezel', L.bezel],
...L.waves.map((b, i): [string, Box] => [`wave ${i}`, b]),
...L.groups.map((g): [string, Box] => [g.label, g.box]),
...CHIP_KEYS.map((key): [string, Box] => [key, knobBox(L.knobs[key])]),
];
it('has every part on it, none over another, and clear of the screws', () => {
for (const [name, box] of parts) expect(inside(box), name).toBe(true);
for (let i = 0; i < parts.length; i++)
for (let j = i + 1; j < parts.length; j++)
expect(overlap(parts[i]![1], parts[j]![1]), `${parts[i]![0]} / ${parts[j]![0]}`).toBe(false);
for (const screw of L.screws) {
const head = { x: screw.x - 6, y: screw.y - 6, w: 12, h: 12 };
for (const [name, box] of parts) expect(overlap(head, box), `screw / ${name}`).toBe(false);
}
});
it('has both screens in the bezel side by side, and a knob for every number in a group', () => {
for (const screen of [L.wave, L.pitch]) {
expect(screen.x).toBeGreaterThan(L.bezel.x);
expect(screen.x + screen.w).toBeLessThan(L.bezel.x + L.bezel.w);
expect(screen.y + screen.h).toBeLessThan(L.bezel.y + L.bezel.h);
}
expect(L.wave.x + L.wave.w).toBeLessThan(L.pitch.x);
expect(CHIP_GROUPS.flatMap((g) => g.keys).sort()).toEqual([...CHIP_KEYS].sort());
expect(CHIP_KEYS.sort()).toEqual(Object.keys(CHIP_ROWS).sort());
});
it('turns a frequency and a slide so the usual numbers get the turn', () => {
const turn = (key: keyof typeof CHIP_KNOBS, value: number) => {
const row = CHIP_ROWS[key];
return knobPosition(value, { min: row.min!, max: row.max!, step: row.step!, ...CHIP_KNOBS[key] });
};
// A4 sits a good way round, not at a fiftieth of it.
expect(turn('frequency', 440)).toBeGreaterThan(0.35);
expect(turn('slide', 0)).toBe(0.5);
expect(turn('slide', -120)).toBeLessThan(0.4);
expect(hzText(440)).toBe('440');
expect(hzText(3528)).toBe('3.53k');
expect(hzText(20000)).toBe('20.0k');
expect(CHIP_KNOBS.repeat.format!(0)).toBe('off');
expect(CHIP_KNOBS.minFrequency.format!(30)).toBe('30');
});
});
describe('the wave screen', () => {
/** What the Chip itself plays over two periods of a wave at a frequency that makes them 400 samples. */
function played(wave: ChipShape['wave'], duty: number): number[] {
const shape: ChipShape = {
wave,
dutySweep: 0,
slide: 0,
deltaSlide: 0,
minFrequency: 0,
vibratoDepth: 0,
vibratoSpeed: 0,
arpeggio: 0,
arpeggioTime: 0,
repeat: 0,
};
const out = new Float32Array(400);
new ChipPlayer(shape, 400 * 100).render(
[out],
new Float32Array([200]),
new Float32Array([duty]),
new Float32Array([0]),
);
return [...out];
}
/** The drawn line's height at `x`: the last point at or before it. */
const drawnAt = (points: { x: number; y: number }[], x: number) =>
points.filter((p) => p.x <= x + 1e-9).at(-1)!.y;
it('draws a wave as the Chip plays it, a square at its duty and half as loud', () => {
for (const [wave, duty] of [
['square', 0.25],
['saw', 0.5],
['triangle', 0.5],
['sine', 0.5],
] as const) {
const points = wavePoints(wave, duty, 400);
const samples = played(wave, duty);
// Away from the steps, where 8 samples averaged blur them.
for (const i of [30, 70, 130, 230, 330])
expect(drawnAt(points, i / 400), `${wave} at ${i}`).toBeCloseTo(samples[i]!, 1);
}
const square = wavePoints('square', 0.25);
expect(Math.max(...square.map((p) => p.y))).toBe(0.5);
expect(square.find((p) => p.y === -0.5)!.x).toBeCloseTo(0.125, 9);
});
it('stands the steps of a square and a saw upright, and starts and ends at the edges', () => {
for (const wave of ['square', 'saw', 'noise', 'periodic'] as const) {
const points = wavePoints(wave, 0.5);
expect(points[0]!.x).toBe(0);
expect(points.at(-1)!.x).toBe(1);
for (let i = 1; i < points.length; i++) expect(points[i]!.x).toBeGreaterThanOrEqual(points[i - 1]!.x);
}
const saw = wavePoints('saw', 0.5);
const middle = saw.filter((p) => p.x === 0.5).map((p) => p.y);
expect(middle).toEqual([-1, 1]);
// Two periods of noise, each of 32 values, the same every time.
expect(wavePoints('noise', 0.5)).toEqual(wavePoints('noise', 0.5));
expect(wavePoints('noise', 0.5).length).toBe(128);
});
});
describe('the pitch screen', () => {
it('is a flat second from the note when nothing moves it', () => {
const trace = pitchTrace(values(), true);
expect(trace.span).toBe(1);
expect(trace.end).toBeNull();
expect(trace.points.every((p) => p.st === 0)).toBe(true);
expect(trace.from).toBe('A4 440 Hz');
expect([trace.low, trace.high]).toEqual([-2, 2]);
});
it('ends a falling note where it passes "min frequency", and looks a quarter past it', () => {
// The kick of the drum kit: 160 Hz down 120 st/s to 30 Hz, which is 29 semitones down.
const trace = pitchTrace(values({ frequency: 160, slide: -120, minFrequency: 30 }), false);
const reach = 12 * Math.log2(30 / 160);
expect(trace.end).toBeCloseTo(-reach / 120, 2);
expect(trace.span).toBeCloseTo(trace.end! * 1.25, 2);
expect(trace.floor).toBeCloseTo(reach, 9);
expect(trace.low).toBeLessThan(reach);
expect(trace.from).toBe('160 Hz');
expect(Math.min(...trace.points.map((p) => p.st))).toBeGreaterThanOrEqual(reach);
});
it('shows three rounds of a repeat, with the arpeggio jumping in each', () => {
const trace = pitchTrace(values({ arpeggio: 12, arpeggioTime: 0.04, repeat: 0.08 }), true);
expect(trace.span).toBeCloseTo(0.24, 9);
expect(trace.restarts.length).toBe(2);
const at = (t: number) => trace.points.find((p) => p.t >= t)!.st;
expect(at(0.02)).toBe(0);
expect(at(0.06)).toBe(12);
expect(at(0.1)).toBe(0);
expect(at(0.14)).toBe(12);
expect(trace.high).toBeGreaterThan(12);
});
it('wobbles by the vibrato either way of the slide', () => {
const trace = pitchTrace(values({ vibratoDepth: 0.5, vibratoSpeed: 5, slide: 6 }), true);
const quarter = trace.points.find((p) => p.t >= 0.05)!;
expect(quarter.st).toBeCloseTo(6 * quarter.t + 0.5 * Math.sin(2 * Math.PI * 5 * quarter.t), 9);
expect(semitoneText(trace.high)).toMatch(/^\+\d/);
expect(semitoneText(-29.4)).toBe('-29 st');
expect(semitoneText(0)).toBe('0 st');
});
});
+16
View File
@@ -46,6 +46,22 @@ describe('a knob', () => {
expect(wheeledValue(0.003, -100, attack)).toBeGreaterThan(0.003);
});
it('has 0 straight up on a bipolar range, and gives the small numbers either way most of the turn (#386)', () => {
const slide = { min: -2000, max: 2000, step: 1, taper: 3, bipolar: true };
expect(knobPosition(0, slide)).toBe(0.5);
expect(knobPosition(2000, slide)).toBe(1);
expect(knobPosition(-2000, slide)).toBe(0);
// 250 st/s, an eighth of the reach, is halfway up the right half; even it would be at 0.5625.
expect(knobPosition(250, slide)).toBeCloseTo(0.75, 9);
expect(knobPosition(-250, slide)).toBeCloseTo(0.25, 9);
expect(knobValue(0.75, slide)).toBe(250);
expect(knobValue(0.5, slide)).toBe(0);
for (const value of [-1200, -7, 0, 3, 640])
expect(knobValue(knobPosition(value, slide), slide)).toBe(value);
expect(knobOrigin(slide)).toBe(0.5);
expect(draggedValue(0, -1, true, slide)).toBe(1);
});
it('fills its arc from 0 when the range has it inside, else from the low end', () => {
expect(knobOrigin(makeup)).toBeCloseTo(0.5, 9);
expect(knobOrigin(threshold)).toBe(0);