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
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user