Make 8-bit sounds without a file with a Chip node in instruments (#329)
game-maker / suite (push) Failing after 36s
game-maker / gate (push) Successful in 3m33s

An instrument could only play sound files so far. The Chip makes the
sound of a note itself, the way old game consoles and sfxr did: a raw
wave and what its frequency does over the note. A laser, a jump or a
pickup is now an instrument and ends by itself.

- Chip node with wave (square, triangle, saw, sine, noise and the
  periodic noise of the NES), follow note, frequency, duty, duty
  sweep, slide, delta slide, min frequency, vibrato, arpeggio and
  repeat. It counts in Hz, semitones and seconds, not sfxr's 0 to 1.
- It runs as a third processor in the FX worklet. The math is a plain
  class, ChipPlayer, tested in Node. Every sample is worked out 8
  times and averaged, as sfxr does.
- Frequency and duty are AudioParams, so an Envelope can move them
  and a parameter can tune them while the note plays. A Pitch behind
  a Chip moves its frequency directly, without the phase vocoder.
- Below its min frequency a Chip ends and tells the page. Without the
  worklet it is silent and its note ends at once.
- An Envelope with sustain 0 now ends its note once the decay is over,
  for every source under it, Samples too. Before, a note with
  duration 0 ran on silently until a Stop sound.
- A note made of Chips alone needs no file. playNote and the wait for
  the bytes no longer drop it for having none.
- Inputs that do nothing as things stand are grey in the inspector.
  idleParams is told which inputs are connected, so a connected one
  always counts.

The credit names sfxr (Tomas Pettersson, MIT) and jsfxr, which turned
out to be public domain, not MIT as the plan in #329 said.

Tests:
- Unit chip (new) 12, sound-library +4. All of tests/core,
  tests/viewport, tests/editor/graph and tests/editor/inspector:
  178 files, 2953 green.
- Counter-checks: semitones read as /24 turn 4 chip tests red. Without
  the sustain rule and the file check 4 library tests fail.
- e2e 34 (melektaus-atx): ALL PASSED, 60 checks (2 new). Offline in
  Chrome a square at 220 Hz plus 12 semitones and duty 0.25 gave 329
  periods in 0.75 s, up 0.249. 64 noise voices for 2 s rendered in
  2.0 s, about 63 at once. Live a 0.2 s note with sustain 0 was gone
  after 0.26 s.
- Gate (melektaus-atx): CHECK EXIT 0, 255 files, 3962 tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-06 22:54:44 +02:00
1 parent 165f022fc0
commit f46b2316f2
12 files changed
+870 -23

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
/**
* The Chip of an instrument (#329): a raw wave whose frequency slides, wobbles, jumps and starts
* over as in sfxr, rendered sample by sample as its worklet does it, and the node that makes it.
*/
import { describe, expect, it } from 'vitest';
import { CreateAssetCommand } from '../../src/core/commands/assetCommands';
import { createEmptyGraph } from '../../src/core/graph/Graph';
import { compileInstrument, instrumentSourcesOf } from '../../src/core/instrument/compile';
import { InstrumentChip } from '../../src/core/instrument/nodes';
import { noteFrequency } from '../../src/core/instrument/notes';
import type { BehaviorAsset, InstrumentAsset } from '../../src/core/model/Asset';
import { ChipPlayer, type ChipShape, PERIODIC_STEPS } from '../../src/core/soundfx/chip';
import { fxChainOf, type FxPlan } from '../../src/core/soundfx/nodes';
import { graphEditor, makeProject } from '../helpers';
const RATE = 48000;
const BLOCK = 128;
const FLAT: ChipShape = {
wave: 'square',
dutySweep: 0,
slide: 0,
deltaSlide: 0,
minFrequency: 0,
vibratoDepth: 0,
vibratoSpeed: 6,
arpeggio: 0,
arpeggioTime: 0.1,
repeat: 0,
};
/** A seeded random, so noise is the same on every run. */
function seeded(seed = 1): () => number {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
/** `seconds` of a Chip at `hz`, in blocks as the audio thread hands them over; `ended` once it stops. */
function render(shape: Partial<ChipShape>, hz: number, seconds: number, { duty = 0.5, pitch = 0 } = {}) {
const player = new ChipPlayer({ ...FLAT, ...shape }, RATE, seeded());
const out = new Float32Array(Math.round(seconds * RATE));
let ended = -1;
for (let at = 0; at < out.length; at += BLOCK) {
const block = out.subarray(at, Math.min(out.length, at + BLOCK));
const going = player.render(
[block],
new Float32Array([hz]),
new Float32Array([duty]),
new Float32Array([pitch]),
);
if (!going && ended < 0) ended = at;
}
return { out, ended };
}
/** Periods begun between two seconds: rises through 0. */
function cycles(out: Float32Array, from = 0, to = out.length / RATE): number {
let count = 0;
for (let i = Math.max(1, Math.round(from * RATE)); i < Math.round(to * RATE); i++)
if (out[i - 1]! <= 0 && out[i]! > 0) count += 1;
return count;
}
const share = (out: Float32Array, from: number, to: number) => {
const part = out.subarray(Math.round(from * RATE), Math.round(to * RATE));
return part.filter((v) => v > 0).length / part.length;
};
describe('the Chip, sample by sample (#329)', () => {
it('plays each wave at its frequency, a square at half the level as in sfxr', () => {
for (const wave of ['square', 'triangle', 'saw', 'sine'] as const) {
const { out } = render({ wave }, 440, 1);
expect(cycles(out), wave).toBeGreaterThanOrEqual(439);
expect(cycles(out), wave).toBeLessThanOrEqual(441);
const peak = out.reduce((top, v) => Math.max(top, Math.abs(v)), 0);
expect(peak, wave).toBeCloseTo(wave === 'square' ? 0.5 : 1, 1);
}
});
it('keeps a square up for its duty, and sweeps the duty by so much a second', () => {
expect(share(render({}, 480, 0.5, { duty: 0.25 }).out, 0, 0.5)).toBeCloseTo(0.25, 2);
const swept = render({ dutySweep: 0.4 }, 480, 1, { duty: 0.1 }).out;
expect(share(swept, 0, 0.1)).toBeCloseTo(0.12, 2);
expect(share(swept, 0.9, 1)).toBeCloseTo(0.48, 2);
});
it('a pitch moves the frequency itself, in semitones', () => {
expect(cycles(render({}, 440, 1, { pitch: 12 }).out)).toBeCloseTo(880, -1);
});
it('slides in semitones a second, and a delta slide speeds the slide up', () => {
// An octave a second from 220: 220 / ln 2 periods in the first second.
expect(cycles(render({ slide: 12 }, 220, 1).out)).toBeCloseTo(220 / Math.LN2, -1);
// 24 st/s² is an octave by the end of the first second, along t².
let integral = 0;
for (let i = 0; i < 1000; i++) integral += 2 ** (((i + 0.5) / 1000) ** 2) / 1000;
expect(cycles(render({ deltaSlide: 24 }, 220, 1).out)).toBeCloseTo(220 * integral, -1);
});
it('ends a note that slides below its lowest frequency, silent after it', () => {
// Two octaves down in a second: 110 Hz after 1 s.
const { out, ended } = render({ slide: -24, minFrequency: 110 }, 440, 1.5);
expect(ended / RATE).toBeGreaterThan(0.99);
expect(ended / RATE).toBeLessThan(1.01);
expect(out.subarray(Math.round(1.01 * RATE)).every((v) => v === 0)).toBe(true);
expect(render({ slide: -24 }, 440, 1.5).ended).toBe(-1);
});
it('jumps by the arpeggio at its time, and starts over every repeat', () => {
const jumped = render({ arpeggio: 12, arpeggioTime: 0.5 }, 220, 1).out;
expect(cycles(jumped, 0, 0.5)).toBeCloseTo(110, -1);
expect(cycles(jumped, 0.5, 1)).toBeCloseTo(220, -1);
const repeated = render({ slide: 48, repeat: 0.25 }, 220, 1).out;
const quarters = [0, 1, 2, 3].map((q) => cycles(repeated, q * 0.25, (q + 1) * 0.25));
expect(Math.max(...quarters) - Math.min(...quarters)).toBeLessThanOrEqual(1);
// The slide does not start over without it.
expect(cycles(render({ slide: 48 }, 220, 1).out, 0.75, 1)).toBeGreaterThan(quarters[3]! * 4);
});
it('wobbles by the vibrato: higher on the first half of its turn, lower on the second', () => {
const { out } = render({ vibratoDepth: 2, vibratoSpeed: 1 }, 440, 1);
const periods = (from: number) => {
let sum = 0;
for (let i = 0; i < 1000; i++)
sum += (440 * 2 ** ((2 * Math.sin(2 * Math.PI * (from + (i + 0.5) / 2000))) / 12)) / 2000;
return sum;
};
expect(cycles(out, 0, 0.5)).toBeCloseTo(periods(0), -1);
expect(cycles(out, 0.5, 1)).toBeCloseTo(periods(0.5), -1);
expect(periods(0) - periods(0.5)).toBeGreaterThan(30);
});
it('noise draws new values every period, the periodic one repeats the NES short noise once a period', () => {
// 480 Hz at 48 kHz is a period of 100 samples.
const noise = render({ wave: 'noise' }, 480, 0.1).out;
expect(noise.every((v) => v >= -1 && v <= 1)).toBe(true);
let same = 0;
for (let i = 100; i < 200; i++) if (noise[i] === noise[i + 100]) same += 1;
expect(same).toBeLessThan(10);
const periodic = render({ wave: 'periodic' }, 480, 0.1).out;
same = 0;
for (let i = 100; i < 4000; i++) if (Math.abs(periodic[i]! - periodic[i + 100]!) < 1e-6) same += 1;
expect(same / 3900).toBeGreaterThan(0.95);
expect(PERIODIC_STEPS).toBe(93);
});
});
function makeChip() {
const f = makeProject();
const command = new CreateAssetCommand('instrument', 'Chip');
f.stack.execute(command);
const instrument = command.asset as InstrumentAsset;
instrument.graph = createEmptyGraph();
const edit = graphEditor(f, instrument as unknown as BehaviorAsset);
const notes = edit.addNode('InstrumentNotes');
const chip = edit.addNode('InstrumentChip', { wave: 'saw', slide: -100 });
const output = edit.addNode('InstrumentOutput');
edit.connect(notes, 'any', chip, 'trigger');
return { ...f, instrument, edit, notes, chip, output };
}
const compile = (f: ReturnType<typeof makeChip>, note: number) =>
compileInstrument(f.instrument.graph, note, instrumentSourcesOf(f.project));
const chipOf = (plan: FxPlan | undefined) => (plan && 'pitch' in plan ? fxChainOf(plan) : null);
describe('the Chip node', () => {
it('plays at the pitch of the note, its numbers held to their rows', () => {
const f = makeChip();
f.edit.connect(f.chip, 'out', f.output, 'in');
const build = compile(f, 69);
expect(build.reason).toBe('');
const chain = chipOf(build.layers[0]);
expect(chain && 'chip' in chain && chain.chip).toMatchObject({
wave: 'saw',
frequency: 440,
duty: 0.5,
slide: -100,
vibratoSpeed: 6,
});
// Outside the Notes node's range it is silence.
expect(compile(f, 20).layers).toEqual([]);
});
it('plays the frequency typed in without "follow note", and what is connected before both', () => {
const f = makeChip();
f.edit.connect(f.chip, 'out', f.output, 'in');
f.chip.params.followNote = false;
f.chip.params.frequency = 1000;
const typed = chipOf(compile(f, 60).layers[0]);
expect(typed && 'chip' in typed && typed.chip?.frequency).toBe(1000);
f.edit.connect(f.notes, 'frequency', f.chip, 'frequency');
const fed = chipOf(compile(f, 60).layers[0]);
expect(fed && 'chip' in fed && fed.chip?.frequency).toBeCloseTo(noteFrequency(60), 6);
});
it('takes a Pitch behind it on its own way, and an Envelope on its frequency', () => {
const f = makeChip();
const pitch = f.edit.addNode('SoundFxPitch', { semitones: 7 });
const envelope = f.edit.addNode('InstrumentEnvelope', { from: 200, to: 2000 });
f.edit.connect(f.chip, 'out', pitch, 'in');
f.edit.connect(pitch, 'out', f.output, 'in');
f.edit.connect(envelope, 'value', f.chip, 'frequency');
const chain = chipOf(compile(f, 60).layers[0]);
expect(chain).toMatchObject({ pitch: 7, shifted: true });
expect(chain && 'chip' in chain && chain.chip?.frequencyMod).toMatchObject({ from: 200, to: 2000 });
});
it('greys what does nothing as things stand', () => {
const idle = (params: Record<string, unknown>, fed: string[] = []) =>
InstrumentChip.idleParams!(params as never, (key) => fed.includes(key));
expect(idle({})).toEqual(['frequency', 'vibratoSpeed', 'arpeggioTime']);
expect(idle({ wave: 'noise', followNote: false, vibratoDepth: 1, arpeggio: 12 })).toEqual([
'duty',
'dutySweep',
]);
// A connected input counts as moving, whatever was typed into it before.
expect(idle({}, ['frequency', 'vibratoDepth', 'arpeggio'])).toEqual(['followNote']);
});
});