Make 8-bit sounds without a file with a Chip node in instruments (#329)
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:
1 parent
165f022fc0
commit
f46b2316f2
12 files changed
+870
-23
No files matched your search
@@ -21,6 +21,7 @@ import { volumeOf } from '../properties/sounds';
|
||||
import { soundFxBuildFor } from '../soundfx/compile';
|
||||
import {
|
||||
type FxAmount,
|
||||
type FxChipPlan,
|
||||
type FxEnvelopePlan,
|
||||
type FxModPlan,
|
||||
type FxPlan,
|
||||
@@ -35,7 +36,10 @@ import {
|
||||
withStages,
|
||||
} from '../soundfx/nodes';
|
||||
import {
|
||||
CHIP_ROWS,
|
||||
chipWave,
|
||||
ENVELOPE_ROWS,
|
||||
INSTRUMENT_CHIP,
|
||||
INSTRUMENT_ENVELOPE,
|
||||
INSTRUMENT_NOTES,
|
||||
INSTRUMENT_OUTPUT,
|
||||
@@ -190,6 +194,11 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
|
||||
const trigger = inputs.trigger;
|
||||
return { out: plan && trigger && 'on' in trigger && trigger.on ? { plans: [plan] } : SILENT };
|
||||
}
|
||||
case INSTRUMENT_CHIP: {
|
||||
const trigger = inputs.trigger;
|
||||
if (!trigger || !('on' in trigger) || !trigger.on) return { out: SILENT };
|
||||
return { out: { plans: [{ pitch: [], stages: [], chip: chipOf(node, note, amounts) }] } };
|
||||
}
|
||||
case INSTRUMENT_SOUND_FX: {
|
||||
const fx = sources.soundFx(textOf(node.params.fx ?? ''));
|
||||
const arriving = plansAt('in');
|
||||
@@ -247,6 +256,30 @@ export function compileInstrument(graph: Graph, note: number, sources: Instrumen
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A Chip's numbers, held to its rows. Its frequency is the note's where it follows the note and
|
||||
* nothing is connected – what it is connected to goes first, else the frequency typed in.
|
||||
*/
|
||||
function chipOf(node: GraphNode, played: number, amounts: Readonly<Record<string, FxAmount>>): FxChipPlan {
|
||||
const rows = CHIP_ROWS;
|
||||
const own =
|
||||
edgesInto(graph, node.id, rows.frequency.key).length === 0 && node.params.followNote !== false;
|
||||
return {
|
||||
wave: chipWave(node.params),
|
||||
frequency: own ? { value: noteFrequency(played) } : held(amounts.frequency, rows.frequency),
|
||||
duty: held(amounts.duty, rows.duty),
|
||||
dutySweep: held(amounts.dutySweep, rows.dutySweep),
|
||||
slide: held(amounts.slide, rows.slide),
|
||||
deltaSlide: held(amounts.deltaSlide, rows.deltaSlide),
|
||||
minFrequency: held(amounts.minFrequency, rows.minFrequency),
|
||||
vibratoDepth: held(amounts.vibratoDepth, rows.vibratoDepth),
|
||||
vibratoSpeed: held(amounts.vibratoSpeed, rows.vibratoSpeed),
|
||||
arpeggio: held(amounts.arpeggio, rows.arpeggio),
|
||||
arpeggioTime: held(amounts.arpeggioTime, rows.arpeggioTime),
|
||||
repeat: held(amounts.repeat, rows.repeat),
|
||||
};
|
||||
}
|
||||
|
||||
const output = graph.nodes.find((n) => n.type === INSTRUMENT_OUTPUT);
|
||||
if (!output) return { layers: [], problems: walk.problems, reason: 'No Output node' };
|
||||
// Saved while the Output still took any number of sounds: only the first would be heard.
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
INSTRUMENT_SOUND_FX,
|
||||
} from '../graph/nested';
|
||||
import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import { asNumber } from '../graph/values';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import type { FlatDef, PropertyDef } from '../properties/PropertySchema';
|
||||
import { soundFxParamPort } from '../soundfx/nodes';
|
||||
import type { ChipWave } from '../soundfx/chip';
|
||||
import { type FxChipPlan, soundFxParamPort } from '../soundfx/nodes';
|
||||
import { HIGHEST_NOTE, heldNote, LOWEST_NOTE, MIDDLE_C, NOTE_OPTIONS, noteName } from './notes';
|
||||
|
||||
export { INSTRUMENT_NOTES, INSTRUMENT_OUTPUT, INSTRUMENT_PARAMETERS, INSTRUMENT_SAMPLE, INSTRUMENT_SOUND_FX };
|
||||
@@ -256,12 +258,90 @@ export const InstrumentEnvelope: InstrumentNodeDef = {
|
||||
category: 'sound',
|
||||
instrument: true,
|
||||
description:
|
||||
'Shapes a note as it is played: from silence up to full in "attack" seconds, down to the level "sustain" (0 to 1) in "decay", held there while the note is, and back to silence in "release" once it ends. The rise is a straight line, the falls ease out the way a struck string does. On the way of the sound it is the volume: a Gate or a Loop plays on through the release, and the note lasts until it has faded. A One-shot under it alone plays through as it would. "value" moves a number with the same curve, from "from" at rest to "to" at the top: the cutoff or resonance of a Filter, a Pitch, a Gain, the share of a Mix, a band of the EQ. For a curve of its own, take a second Envelope and leave its "in" open. The numbers count from the moment the note starts.',
|
||||
'Shapes a note as it is played: from silence up to full in "attack" seconds, down to the level "sustain" (0 to 1) in "decay", held there while the note is, and back to silence in "release" once it ends. The rise is a straight line, the falls ease out the way a struck string does. On the way of the sound it is the volume: a Gate or a Loop plays on through the release, and the note lasts until it has faded. A One-shot under it alone plays through as it would. With "sustain" at 0 the note ends once its decay is over, however long it is held, whatever plays under it. "value" moves a number with the same curve, from "from" at rest to "to" at the top: the cutoff or resonance of a Filter, a Pitch, a Gain, the share of a Mix, a band of the EQ. For a curve of its own, take a second Envelope and leave its "in" open. The numbers count from the moment the note starts.',
|
||||
inputs: [audioIn, ...Object.values(ENVELOPE_ROWS).map(numberIn)],
|
||||
outputs: [audioOut, { key: 'value', label: 'value', type: 'envelope' }],
|
||||
params: [],
|
||||
};
|
||||
|
||||
export const INSTRUMENT_CHIP = 'InstrumentChip';
|
||||
|
||||
const CHIP_WAVES: { value: ChipWave; label: string }[] = [
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'triangle', label: 'Triangle' },
|
||||
{ value: 'saw', label: 'Saw' },
|
||||
{ value: 'sine', label: 'Sine' },
|
||||
{ value: 'noise', label: 'Noise' },
|
||||
{ value: 'periodic', label: 'Noise (periodic)' },
|
||||
];
|
||||
const WAVE: PropertyDef = {
|
||||
key: 'wave',
|
||||
label: 'Wave',
|
||||
kind: 'enum',
|
||||
default: 'square',
|
||||
options: CHIP_WAVES,
|
||||
};
|
||||
|
||||
export function chipWave(params: PropertyBag): ChipWave {
|
||||
return CHIP_WAVES.find((option) => option.value === params[WAVE.key])?.value ?? 'square';
|
||||
}
|
||||
|
||||
function chipRow(
|
||||
key: string,
|
||||
label: string,
|
||||
min: number,
|
||||
max: number,
|
||||
step: number,
|
||||
value = 0,
|
||||
): FlatDef<'number'> {
|
||||
return { key, label, kind: 'number', default: value, min, max, step };
|
||||
}
|
||||
|
||||
export const CHIP_ROWS = {
|
||||
frequency: chipRow('frequency', 'Frequency (Hz)', 1, 20000, 1, 440),
|
||||
duty: chipRow('duty', 'Duty', 0.01, 0.99, 0.01, 0.5),
|
||||
dutySweep: chipRow('dutySweep', 'Duty sweep (/s)', -10, 10, 0.01),
|
||||
slide: chipRow('slide', 'Slide (st/s)', -2000, 2000, 1),
|
||||
deltaSlide: chipRow('deltaSlide', 'Delta slide (st/s²)', -20000, 20000, 1),
|
||||
minFrequency: chipRow('minFrequency', 'Min frequency (Hz)', 0, 20000, 1),
|
||||
vibratoDepth: chipRow('vibratoDepth', 'Vibrato depth (st)', 0, 12, 0.1),
|
||||
vibratoSpeed: chipRow('vibratoSpeed', 'Vibrato speed (Hz)', 0, 70, 0.1, 6),
|
||||
arpeggio: chipRow('arpeggio', 'Arpeggio (st)', -48, 48, 1),
|
||||
arpeggioTime: chipRow('arpeggioTime', 'Arpeggio time (s)', 0, 10, 0.001, 0.1),
|
||||
repeat: chipRow('repeat', 'Repeat (s)', 0, 10, 0.001),
|
||||
} satisfies Record<Exclude<keyof FxChipPlan, 'wave'>, FlatDef<'number'>>;
|
||||
|
||||
/** The numbers an Envelope can move while a note plays: the rest is fixed as it starts. */
|
||||
const CHIP_FOLLOWING = new Set(['frequency', 'duty']);
|
||||
|
||||
export const InstrumentChip: InstrumentNodeDef = {
|
||||
type: INSTRUMENT_CHIP,
|
||||
label: 'Chip',
|
||||
category: 'sound',
|
||||
instrument: true,
|
||||
description:
|
||||
'A raw wave as old game consoles made it, for every note that triggers it, the way sfxr makes its sounds. "Wave" is Square, Triangle, Saw, Sine, Noise, or Noise (periodic), the short metallic noise of the NES; a square and that noise are half as loud as the others, as in sfxr. With "follow note" it plays at the pitch of the note, else at "frequency" in Hz; a number connected to "frequency" goes before both. "duty" is how much of a square is up: 0.5 is even, 0.125 thin and nasal; "duty sweep" moves it by that much per second. "slide" glides the pitch in semitones per second, "delta slide" changes the slide by that much per second. Where it falls below "min frequency" the note ends; at 0 it never does. "vibrato depth" in semitones and "vibrato speed" in Hz make it wobble. "arpeggio" jumps it by that many semitones "arpeggio time" seconds into the note. "repeat" starts slide, duty sweep and arpeggio over every so many seconds, 0 never. The Chip plays until its note ends, faded out in a few milliseconds; under an Envelope it plays on through the release. An Envelope can move "frequency" and "duty" while it plays, a Pitch moves its frequency itself. Settings that do nothing as things stand are grey.',
|
||||
inputs: [
|
||||
triggerIn,
|
||||
...Object.values(CHIP_ROWS).map((row): PortDef =>
|
||||
CHIP_FOLLOWING.has(row.key) ? { ...numberIn(row), followsEnvelope: true } : numberIn(row),
|
||||
),
|
||||
],
|
||||
outputs: [audioOut],
|
||||
params: [WAVE, FOLLOW],
|
||||
idleParams: (params, fed = () => false) => {
|
||||
const off = (key: keyof typeof CHIP_ROWS) =>
|
||||
!fed(key) && asNumber(params[key] ?? CHIP_ROWS[key].default) === 0;
|
||||
const square = chipWave(params) === 'square';
|
||||
return [
|
||||
...(fed('frequency') ? [FOLLOW.key] : params[FOLLOW.key] === false ? [] : ['frequency']),
|
||||
...(square ? [] : ['duty', 'dutySweep']),
|
||||
...(off('vibratoDepth') ? ['vibratoSpeed'] : []),
|
||||
...(off('arpeggio') ? ['arpeggioTime'] : []),
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export const InstrumentOutput: InstrumentNodeDef = {
|
||||
type: INSTRUMENT_OUTPUT,
|
||||
label: 'Output',
|
||||
@@ -277,6 +357,7 @@ export const InstrumentOutput: InstrumentNodeDef = {
|
||||
export const INSTRUMENT_NODES: readonly InstrumentNodeDef[] = [
|
||||
InstrumentNotes,
|
||||
InstrumentSample,
|
||||
InstrumentChip,
|
||||
InstrumentParameters,
|
||||
InstrumentSoundFx,
|
||||
InstrumentEnvelope,
|
||||
|
||||
Reference in new issue
Block a user