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
@@ -129,8 +129,10 @@ export interface NodeDef {
|
||||
/**
|
||||
* Settings that do nothing at this value set, so neither the node body nor the inspector shows
|
||||
* them – like the Direction of a linear key. Unlike `hiddenParams`, nothing is left to pick there.
|
||||
* An unconnected input named here stays in the inspector, greyed out. `fed` says whether an
|
||||
* input has an edge, which a value typed into it then does not count against.
|
||||
*/
|
||||
idleParams?(params: PropertyBag): string[];
|
||||
idleParams?(params: PropertyBag, fed?: (key: string) => boolean): string[];
|
||||
/**
|
||||
* Which side the ports this node declares for itself (`GraphNode.ports`) appear on. Setting it
|
||||
* is what gives a node the port editor in the inspector. `both`: every port is an output, and an
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* The Chip of an instrument (#329) sample by sample, as its worklet runs it: a raw wave as old
|
||||
* consoles made it, and what its frequency does over a note. It follows sfxr – a slide that speeds
|
||||
* up or slows down, vibrato, one jump of an arpeggio, all of it again after "repeat", a square whose
|
||||
* duty sweeps, noise drawn anew every period, 8 samples per sample averaged – in seconds, semitones
|
||||
* and Hz rather than sfxr's 0 to 1. sfxr is Copyright (c) 2007 Tomas Pettersson, MIT licence;
|
||||
* jsfxr, the port it was read in, is public domain.
|
||||
*
|
||||
* Frequency, duty and pitch come as an AudioParam hands them over: one value for the block or one
|
||||
* per sample. Everything else is fixed when the note starts.
|
||||
*/
|
||||
export type ChipWave = 'square' | 'triangle' | 'saw' | 'sine' | 'noise' | 'periodic';
|
||||
|
||||
export interface ChipShape {
|
||||
wave: ChipWave;
|
||||
/** How far the duty of a square moves per second. */
|
||||
dutySweep: number;
|
||||
/** Semitones per second the frequency slides, and how much that changes per second. */
|
||||
slide: number;
|
||||
deltaSlide: number;
|
||||
/** Hz: a note that slides below it ends there; 0 for none. */
|
||||
minFrequency: number;
|
||||
vibratoDepth: number;
|
||||
vibratoSpeed: number;
|
||||
/** Semitones the note jumps by `arpeggioTime` seconds after it starts; 0 for none. */
|
||||
arpeggio: number;
|
||||
arpeggioTime: number;
|
||||
/** Seconds after which slide, duty sweep and arpeggio start over; 0 for never. */
|
||||
repeat: number;
|
||||
}
|
||||
|
||||
const OVERSAMPLE = 8;
|
||||
/** How many values of noise make one period: sfxr's. */
|
||||
const NOISE_STEPS = 32;
|
||||
/** How often the short noise of the NES has to be clocked to repeat once a period. */
|
||||
export const PERIODIC_STEPS = 93;
|
||||
/** Where a slide stops in either direction, so it cannot run off to infinity: 20 octaves. */
|
||||
const SLIDE_LIMIT = 240;
|
||||
const LOWEST_HZ = 1;
|
||||
const LOWEST_DUTY = 0.01;
|
||||
|
||||
export class ChipPlayer {
|
||||
private phase = 0;
|
||||
/** Samples since the note started or last started over. */
|
||||
private since = 0;
|
||||
private slid = 0;
|
||||
private slideRate: number;
|
||||
private dutyMoved = 0;
|
||||
private vibrato = 0;
|
||||
private readonly noise = new Float32Array(NOISE_STEPS);
|
||||
private lfsr = 1;
|
||||
private lfsrPhase = 0;
|
||||
private over = false;
|
||||
|
||||
constructor(
|
||||
private readonly shape: ChipShape,
|
||||
private readonly sampleRate: number,
|
||||
private readonly random: () => number = Math.random,
|
||||
) {
|
||||
this.slideRate = shape.slide;
|
||||
this.refill();
|
||||
}
|
||||
|
||||
/** Fills the block; false once a slide has fallen below the lowest frequency, with silence after it. */
|
||||
render(
|
||||
output: readonly Float32Array[],
|
||||
frequency: Float32Array,
|
||||
duty: Float32Array,
|
||||
pitch: Float32Array,
|
||||
): boolean {
|
||||
const { shape, sampleRate: rate } = this;
|
||||
const length = output[0]?.length ?? 0;
|
||||
const repeatAt = shape.repeat > 0 ? Math.max(1, Math.round(shape.repeat * rate)) : 0;
|
||||
const jumpAt = Math.round(Math.max(0, shape.arpeggioTime) * rate);
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (repeatAt > 0 && this.since >= repeatAt) this.startOver();
|
||||
const jump = shape.arpeggio !== 0 && this.since >= jumpAt ? shape.arpeggio : 0;
|
||||
const base = finite(at(frequency, i), 0) * 2 ** ((finite(at(pitch, i), 0) + this.slid + jump) / 12);
|
||||
if (this.over || (shape.minFrequency > 0 && base < shape.minFrequency)) {
|
||||
this.over = true;
|
||||
for (const channel of output) channel.fill(0, i);
|
||||
return false;
|
||||
}
|
||||
const wobble = shape.vibratoDepth > 0 ? shape.vibratoDepth * Math.sin(this.vibrato) : 0;
|
||||
const hz = Math.max(LOWEST_HZ, Math.min(rate, base * 2 ** (wobble / 12)));
|
||||
const width = Math.max(
|
||||
LOWEST_DUTY,
|
||||
Math.min(1 - LOWEST_DUTY, finite(at(duty, i), 0.5) + this.dutyMoved),
|
||||
);
|
||||
const step = hz / (rate * OVERSAMPLE);
|
||||
let sum = 0;
|
||||
for (let k = 0; k < OVERSAMPLE; k++) {
|
||||
this.phase += step;
|
||||
if (this.phase >= 1) {
|
||||
this.phase -= Math.floor(this.phase);
|
||||
if (shape.wave === 'noise') this.refill();
|
||||
}
|
||||
if (shape.wave === 'periodic') {
|
||||
this.lfsrPhase += step * PERIODIC_STEPS;
|
||||
while (this.lfsrPhase >= 1) {
|
||||
this.lfsrPhase -= 1;
|
||||
this.clock();
|
||||
}
|
||||
}
|
||||
sum += this.sample(width);
|
||||
}
|
||||
const value = sum / OVERSAMPLE;
|
||||
for (const channel of output) channel[i] = value;
|
||||
this.since += 1;
|
||||
this.slideRate += shape.deltaSlide / rate;
|
||||
this.slid = Math.max(-SLIDE_LIMIT, Math.min(SLIDE_LIMIT, this.slid + this.slideRate / rate));
|
||||
this.dutyMoved += shape.dutySweep / rate;
|
||||
this.vibrato = (this.vibrato + (2 * Math.PI * shape.vibratoSpeed) / rate) % (2 * Math.PI);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** One value of the wave where its period stands now: a square and the short noise at half the level, as in sfxr. */
|
||||
private sample(width: number): number {
|
||||
const p = this.phase;
|
||||
switch (this.shape.wave) {
|
||||
case 'square':
|
||||
return p < width ? 0.5 : -0.5;
|
||||
case 'saw':
|
||||
return 1 - 2 * p;
|
||||
case 'triangle':
|
||||
return p < 0.5 ? 4 * p - 1 : 3 - 4 * p;
|
||||
case 'sine':
|
||||
return Math.sin(2 * Math.PI * p);
|
||||
case 'noise':
|
||||
return this.noise[Math.min(NOISE_STEPS - 1, Math.floor(p * NOISE_STEPS))]!;
|
||||
case 'periodic':
|
||||
return this.lfsr & 1 ? 0.5 : -0.5;
|
||||
}
|
||||
}
|
||||
|
||||
private startOver(): void {
|
||||
this.since = 0;
|
||||
this.slid = 0;
|
||||
this.slideRate = this.shape.slide;
|
||||
this.dutyMoved = 0;
|
||||
}
|
||||
|
||||
private refill(): void {
|
||||
for (let i = 0; i < NOISE_STEPS; i++) this.noise[i] = this.random() * 2 - 1;
|
||||
}
|
||||
|
||||
/** One step of the 15-bit noise register of the NES in its short mode: bit 0 and bit 6 feed back. */
|
||||
private clock(): void {
|
||||
const feedback = (this.lfsr ^ (this.lfsr >> 6)) & 1;
|
||||
this.lfsr = (this.lfsr >> 1) | (feedback << 14);
|
||||
}
|
||||
}
|
||||
|
||||
const at = (values: Float32Array, i: number) => (values.length > 1 ? values[i]! : values[0]!);
|
||||
const finite = (value: number, fallback: number) => (Number.isFinite(value) ? value : fallback);
|
||||
@@ -4,6 +4,7 @@ import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { DEFAULT_TEMPO, heldTempo, secondsOfQuarters } from '../music/time';
|
||||
import type { FlatDef } from '../properties/PropertySchema';
|
||||
import type { ChipShape, ChipWave } from './chip';
|
||||
|
||||
export { SOUND_FX_INPUT, SOUND_FX_OUTPUT };
|
||||
|
||||
@@ -104,6 +105,23 @@ export interface FxSource {
|
||||
gate: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Chip as a way of a note plays it (#329): its frequency and duty, which an Envelope can move as
|
||||
* `frequencyMod` and `dutyMod`, and the rest of its shape, fixed when the note starts.
|
||||
*/
|
||||
export interface FxChip extends ChipShape {
|
||||
frequency: number;
|
||||
duty: number;
|
||||
frequencyMod?: FxMod;
|
||||
dutyMod?: FxMod;
|
||||
}
|
||||
|
||||
/** A Chip with its numbers still open. */
|
||||
export type FxChipPlan = { wave: ChipWave } & Record<
|
||||
Exclude<keyof FxChip, 'wave' | 'frequencyMod' | 'dutyMod'>,
|
||||
FxAmount
|
||||
>;
|
||||
|
||||
/**
|
||||
* A sound on its way through the graph: how many semitones it is played up or down, and what it
|
||||
* passes, in order. `gain` is a factor, a filter's `gain` is in dB as Web Audio takes it. `shifted`
|
||||
@@ -119,6 +137,7 @@ export type FxChain =
|
||||
shifted: boolean;
|
||||
stages: readonly FxStage[];
|
||||
source?: FxSource;
|
||||
chip?: FxChip;
|
||||
pitchMods?: readonly FxMod[];
|
||||
}
|
||||
| { mix: number; curve?: MixCurve; a: FxChain; b: FxChain; stages: readonly FxStage[]; mixMod?: FxMod };
|
||||
@@ -161,7 +180,7 @@ export type FxStagePlan =
|
||||
|
||||
/** A chain with its parameters still open: compiled once, filled in for every voice by `fxChainOf`. */
|
||||
export type FxPlan =
|
||||
| { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[]; source?: FxSource }
|
||||
| { pitch: readonly FxAmount[]; stages: readonly FxStagePlan[]; source?: FxSource; chip?: FxChipPlan }
|
||||
| { mix: FxAmount; curve?: MixCurve; a: FxPlan; b: FxPlan; stages: readonly FxStagePlan[] };
|
||||
|
||||
export const EMPTY_PLAN: FxPlan = { pitch: [], stages: [] };
|
||||
@@ -310,7 +329,7 @@ export const SoundFxPitch: SoundFxNodeDef = {
|
||||
label: 'Pitch',
|
||||
category: 'sound',
|
||||
description:
|
||||
'Plays the sound higher or lower, in semitones: 12 is an octave up, 0.5 a quarter tone. The sound keeps its length, also when the pitch changes while it plays. The pitch is made where the sound is read, so an effect before the Pitch acts as if it came after it.',
|
||||
'Plays the sound higher or lower, in semitones: 12 is an octave up, 0.5 a quarter tone. The sound keeps its length, also when the pitch changes while it plays. The pitch is made where the sound is read, so an effect before the Pitch acts as if it came after it. On a Chip it moves the frequency of the Chip itself.',
|
||||
inputs: [audioIn, followingIn(SEMITONES)],
|
||||
outputs: [audioOut],
|
||||
params: [],
|
||||
@@ -644,6 +663,7 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_T
|
||||
shifted: plan.pitch.length > 0,
|
||||
stages,
|
||||
...(pitchMods.length > 0 && { pitchMods }),
|
||||
...(plan.chip && { chip: chipOf(plan.chip, params) }),
|
||||
};
|
||||
return plan.source ? { ...chain, source: plan.source } : chain;
|
||||
}
|
||||
@@ -658,6 +678,28 @@ export function fxChainOf(plan: FxPlan, params: FxParams = {}, tempo = DEFAULT_T
|
||||
};
|
||||
}
|
||||
|
||||
function chipOf(plan: FxChipPlan, params: FxParams): FxChip {
|
||||
const value = (amount: FxAmount) => amountOf(amount, params);
|
||||
const frequencyMod = modOf(plan.frequency, params);
|
||||
const dutyMod = modOf(plan.duty, params);
|
||||
return {
|
||||
wave: plan.wave,
|
||||
frequency: value(plan.frequency),
|
||||
duty: value(plan.duty),
|
||||
dutySweep: value(plan.dutySweep),
|
||||
slide: value(plan.slide),
|
||||
deltaSlide: value(plan.deltaSlide),
|
||||
minFrequency: value(plan.minFrequency),
|
||||
vibratoDepth: value(plan.vibratoDepth),
|
||||
vibratoSpeed: value(plan.vibratoSpeed),
|
||||
arpeggio: value(plan.arpeggio),
|
||||
arpeggioTime: value(plan.arpeggioTime),
|
||||
repeat: value(plan.repeat),
|
||||
...(frequencyMod && { frequencyMod }),
|
||||
...(dutyMod && { dutyMod }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a plan can ring on after its sources end (#324): it has an echo somewhere. */
|
||||
export function fxRings(plan: FxPlan): boolean {
|
||||
if (plan.stages.some((stage) => stage.kind === 'delay')) return true;
|
||||
|
||||
@@ -210,7 +210,7 @@ export class NodeView extends Widget {
|
||||
this.problem = problem;
|
||||
const hidden = new Set([
|
||||
...(this.def.hiddenParams?.(node.params) ?? []),
|
||||
...(this.def.idleParams?.(node.params) ?? []),
|
||||
...(this.def.idleParams?.(node.params, (key) => edgesInto(graph, node.id, key).length > 0) ?? []),
|
||||
]);
|
||||
const title = this.def.label;
|
||||
const derived = title !== nodeDef(node.type)?.label;
|
||||
|
||||
@@ -93,7 +93,7 @@ export function nodeSignature(project: Project, assetId: string, nodeId: string)
|
||||
.map((v) => v.key)
|
||||
.join(',');
|
||||
// A setting that does nothing has no row, so picking an easing that needs it has to rebuild.
|
||||
const idle = def?.idleParams?.(node.params).join(',') ?? '';
|
||||
const idle = def?.idleParams?.(node.params, (key) => connected.includes(key)).join(',') ?? '';
|
||||
return `${portSignature(node, asset.graph, lookup, assetId)}|${declared}|${connected.join(
|
||||
',',
|
||||
)}|${source}:${choices}|${target}|${transitions}|${soundFxParamsMismatch(node, asset.graph) ?? ''}|${idle}`;
|
||||
@@ -151,7 +151,8 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
buildPortSection(host, assetId, node, variablesOf(asset), typeName);
|
||||
}
|
||||
|
||||
const idle = new Set(def.idleParams?.(node.params) ?? []);
|
||||
const fed = (key: string) => edgesInto(asset.graph, node.id, key).length > 0;
|
||||
const idle = new Set(def.idleParams?.(node.params, fed) ?? []);
|
||||
const settings = def.params.filter(
|
||||
(param) => !(typed && param.key === STRUCT_TYPE_PARAM) && !idle.has(param.key),
|
||||
);
|
||||
@@ -167,7 +168,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
}
|
||||
if (isStateNode(node)) buildTransitionSection(host, assetId, node, asset.graph);
|
||||
|
||||
const free = inlineInputs(def).filter((p) => edgesInto(asset.graph, node.id, p.key).length === 0);
|
||||
const free = inlineInputs(def).filter((p) => !fed(p.key));
|
||||
if (free.length > 0) addTitle(host, 'INPUTS (unconnected)');
|
||||
for (const port of free) {
|
||||
const inline = inlineInputDef(port);
|
||||
@@ -178,6 +179,7 @@ export function buildNodeSection(host: InspectorHost, assetId: string, nodeId: s
|
||||
paramHandlers(host, assetId, nodeId, inline),
|
||||
);
|
||||
host.fields.set(`input:${port.key}`, row);
|
||||
row.row.setEnabled(!idle.has(port.key));
|
||||
host.body.add(row.row);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FLAT_Q,
|
||||
fxChainOf,
|
||||
type FxChain,
|
||||
type FxChip,
|
||||
type FxCrush,
|
||||
type FxDelay,
|
||||
type FxEnvelope,
|
||||
@@ -24,7 +25,7 @@ import { dbToGain, SOUND_ITEM_MAX_DB } from '../core/music/song';
|
||||
import { DEFAULT_TEMPO } from '../core/music/time';
|
||||
import { type CutRange, cutChannels, cutRange, parseCutName } from '../core/model/sound';
|
||||
import type { AudioSink, ClipTiming, NoteTiming } from '../runtime/systems/Audio';
|
||||
import type { PitchOptions } from './fxWorklet';
|
||||
import type { ChipOptions, PitchOptions } from './fxWorklet';
|
||||
import fxWorkletUrl from './fxWorklet.ts?worker&url';
|
||||
import { wake } from './wake';
|
||||
|
||||
@@ -325,7 +326,7 @@ export class SoundLibrary implements AudioSink {
|
||||
const files = [...new Set(layers.flatMap(filesOf))].filter(
|
||||
(file) => file !== '' && !this.hasFailed(file),
|
||||
);
|
||||
if (files.length === 0) return 0;
|
||||
if (files.length === 0 && !layers.some(hasChip)) return 0;
|
||||
const fx = { layers, params, tempo: timing.tempo ?? DEFAULT_TEMPO };
|
||||
const at = timing.at ?? 0;
|
||||
const bus = timing.bus ?? '';
|
||||
@@ -573,7 +574,8 @@ export class SoundLibrary implements AudioSink {
|
||||
this.pending = this.pending.filter((other) => other !== waiting);
|
||||
// A `Stop sound` – or the destruction of the behavior that asked – that happened while the
|
||||
// bytes were in flight has to win, or a sound the run already ended would start afterwards.
|
||||
if (buffers.every((buffer) => !buffer) || waiting.stopped) return;
|
||||
const silent = buffers.every((buffer) => !buffer) && !start.fx.layers.some(hasChip);
|
||||
if (silent || waiting.stopped) return;
|
||||
if (this.cancelsFor(start.file, start.owner) !== askedAt) return;
|
||||
this.start({ ...asked, fx: waiting.fx });
|
||||
for (const { params, time, ease } of waiting.timed) this.tune(id, params, time, ease);
|
||||
@@ -1035,6 +1037,10 @@ export class SoundLibrary implements AudioSink {
|
||||
this.started += 1;
|
||||
for (const driver of wiring.drivers) driver.start();
|
||||
for (const source of sources) source.start(when, wiring.offset);
|
||||
// Silent once its decay is over, however long the note is held (#329).
|
||||
for (const run of voice.envelopes)
|
||||
if (run.envelope.sustain <= 0)
|
||||
this.endAt(voice, run.sources, when + run.envelope.attack + run.envelope.decay);
|
||||
if (duration > 0) this.release(id, when + duration);
|
||||
if (clip) this.endAt(voice, sources, clip.end);
|
||||
}
|
||||
@@ -1111,9 +1117,9 @@ export class SoundLibrary implements AudioSink {
|
||||
* its volume, and can fade out by itself when its note ends.
|
||||
*/
|
||||
private source(wiring: Wiring, chain: Way): AudioNode {
|
||||
const sound = this.sound(wiring, chain);
|
||||
const sound = chain.chip ? this.chip(wiring, chain, chain.chip) : this.sound(wiring, chain);
|
||||
const own = chain.source;
|
||||
if (!wiring.rings && !own && !wiring.clip) return sound;
|
||||
if (!wiring.rings && !own && !chain.chip && !wiring.clip) return sound;
|
||||
const fade = gainNode(wiring.context, own?.volume ?? 1);
|
||||
wiring.sources.at(-1)!.fade = fade;
|
||||
return sound.connect(fade);
|
||||
@@ -1198,6 +1204,69 @@ export class SoundLibrary implements AudioSink {
|
||||
return source;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Chip (#329): a worklet of its own that ends with the note. A Pitch on its way moves its
|
||||
* frequency itself. Without the worklet the way is silent and ends at once.
|
||||
*/
|
||||
private chip(wiring: Wiring, { pitch, shifted, pitchMods }: Way, chip: FxChip): AudioNode {
|
||||
const { context, knobs } = wiring;
|
||||
wiring.pitches.push(pitch);
|
||||
const { frequency, duty, frequencyMod, dutyMod, ...shape } = chip;
|
||||
if (!this.hasWorklet) {
|
||||
const silent = context.createBufferSource();
|
||||
silent.buffer = context.createBuffer(1, 1, context.sampleRate);
|
||||
wiring.sources.push({
|
||||
start: (at) => silent.start(at),
|
||||
stop: () => silent.stop(),
|
||||
ended: (then) => {
|
||||
silent.onended = then;
|
||||
},
|
||||
fade: null,
|
||||
gate: true,
|
||||
leave: null,
|
||||
});
|
||||
if (shifted) knobs.push({ param: null, target: pitch, even: 'value' });
|
||||
knobs.push(
|
||||
{ param: null, target: frequency, even: 'ratio' },
|
||||
{ param: null, target: duty, even: 'value' },
|
||||
);
|
||||
return silent;
|
||||
}
|
||||
const options: ChipOptions = { ...shape, startAt: wiring.at };
|
||||
const node = new AudioWorkletNode(context, 'chip-player', {
|
||||
numberOfInputs: 0,
|
||||
outputChannelCount: [1],
|
||||
parameterData: { frequency: frequencyMod ? 0 : frequency, duty: dutyMod ? 0 : duty, pitch },
|
||||
processorOptions: options,
|
||||
});
|
||||
wiring.sources.push({
|
||||
start: () => {},
|
||||
stop: () => {
|
||||
node.port.postMessage('stop');
|
||||
node.disconnect();
|
||||
},
|
||||
ended: (then) => {
|
||||
node.port.onmessage = (event: MessageEvent) => {
|
||||
if (event.data === 'ended') then();
|
||||
};
|
||||
},
|
||||
fade: null,
|
||||
gate: true,
|
||||
leave: null,
|
||||
});
|
||||
const param = (name: string) => node.parameters.get(name)!;
|
||||
if (shifted) knobs.push({ param: param('pitch'), target: pitch, even: 'value' });
|
||||
knobs.push(
|
||||
{ param: frequencyMod ? null : param('frequency'), target: frequency, even: 'ratio' },
|
||||
{ param: dutyMod ? null : param('duty'), target: duty, even: 'value' },
|
||||
);
|
||||
const follow = this.follower(wiring);
|
||||
if (frequencyMod) follow(frequencyMod, param('frequency'), true);
|
||||
if (dutyMod) follow(dutyMod, param('duty'), false);
|
||||
for (const mod of pitchMods ?? []) follow(mod, param('pitch'), false);
|
||||
return node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends every knob of a voice towards what its values say now, in `time` seconds of the audio
|
||||
* clock along `ease`, or else in a quick glide. Says whether any of them had somewhere to go.
|
||||
@@ -1265,14 +1334,14 @@ export class SoundLibrary implements AudioSink {
|
||||
return this.failed.has(parseCutName(name).file);
|
||||
}
|
||||
|
||||
/** Loads the FX worklet (Pitch, Bitcrusher) into the context once; says whether it is there. */
|
||||
/** Loads the FX worklet (Pitch, Bitcrusher, Chip) into the context once; says whether it is there. */
|
||||
private fxWorklet(): Promise<boolean> {
|
||||
return (this.workletLoading ??= (async () => {
|
||||
try {
|
||||
await this.audio().audioWorklet.addModule(fxWorkletUrl);
|
||||
return (this.hasWorklet = true);
|
||||
} catch (error: unknown) {
|
||||
console.error('[sounds] cannot load the FX worklet, a Pitch or Bitcrusher is not heard', error);
|
||||
console.error('[sounds] cannot load the FX worklet, a Pitch, Bitcrusher or Chip is not heard', error);
|
||||
return (this.hasWorklet = false);
|
||||
}
|
||||
})());
|
||||
@@ -1320,10 +1389,15 @@ interface LastVoice {
|
||||
envelopes: number;
|
||||
}
|
||||
|
||||
/** Whether a plan has a Pitch or a Bitcrusher anywhere, and so needs the worklet. */
|
||||
/** Whether a plan has a Pitch, a Bitcrusher or a Chip anywhere, and so needs the worklet. */
|
||||
function needsWorklet(plan: FxPlan): boolean {
|
||||
if (plan.stages.some((stage) => stage.kind === 'crush')) return true;
|
||||
return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0;
|
||||
return 'mix' in plan ? needsWorklet(plan.a) || needsWorklet(plan.b) : plan.pitch.length > 0 || !!plan.chip;
|
||||
}
|
||||
|
||||
/** Whether a way of a plan plays a Chip (#329), which needs no file. */
|
||||
function hasChip(plan: FxPlan): boolean {
|
||||
return 'mix' in plan ? hasChip(plan.a) || hasChip(plan.b) : plan.chip !== undefined;
|
||||
}
|
||||
|
||||
/** The files the ways of a plan read with a source of their own (#331). */
|
||||
@@ -1670,7 +1744,10 @@ function levelsOf(chain: FxChain, into: number[] = []): number[] {
|
||||
into.push(1 - chain.mix);
|
||||
levelsOf(chain.b, into);
|
||||
into.push(chain.mix);
|
||||
} else if (chain.shifted) into.push(chain.pitch);
|
||||
} else {
|
||||
if (chain.shifted) into.push(chain.pitch);
|
||||
if (chain.chip) into.push(chain.chip.frequency, chain.chip.duty);
|
||||
}
|
||||
for (const stage of chain.stages)
|
||||
if (stage.kind === 'delay') into.push(stage.time, stage.feedback, stage.damping, stage.mix);
|
||||
else if (stage.kind === 'pass') into.push(stage.cutoff, stage.q);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/**
|
||||
* The audio thread's side of the effects that Web Audio has no node for. A pitched voice (#317)
|
||||
* plays one sound through a `PitchPlayer`, its pitch the AudioParam `pitch` in semitones, so it
|
||||
* glides, ramps and eases like any other knob. A Bitcrusher (#322) runs a `Crusher`. Loaded into
|
||||
* the context once by the sound library; nothing on the page imports it.
|
||||
* glides, ramps and eases like any other knob. A Bitcrusher (#322) runs a `Crusher`, a Chip
|
||||
* (#329) a `ChipPlayer`. Loaded into the context once by the sound library; nothing on the page
|
||||
* imports it.
|
||||
*/
|
||||
import { type ChipShape, ChipPlayer } from '../core/soundfx/chip';
|
||||
import { Crusher } from '../core/soundfx/crusher';
|
||||
import { PitchPlayer } from '../core/soundfx/pitchPlayer';
|
||||
|
||||
@@ -115,3 +117,55 @@ class CrushProcessor extends AudioWorkletProcessor {
|
||||
}
|
||||
|
||||
registerProcessor('bit-crusher', CrushProcessor);
|
||||
|
||||
export interface ChipOptions extends ChipShape {
|
||||
/** The second of the audio clock its first sample is heard at; before it, silence. */
|
||||
startAt: number;
|
||||
}
|
||||
|
||||
/** The Chip of a note (#329): a source of its own, its frequency, duty and pitch AudioParams. */
|
||||
class ChipProcessor extends AudioWorkletProcessor {
|
||||
private readonly player: ChipPlayer;
|
||||
private stopped = false;
|
||||
private startAt: number;
|
||||
|
||||
constructor({ processorOptions }: { processorOptions: ChipOptions }) {
|
||||
super();
|
||||
this.player = new ChipPlayer(processorOptions, sampleRate);
|
||||
this.startAt = processorOptions.startAt;
|
||||
this.port.onmessage = () => {
|
||||
this.stopped = true;
|
||||
};
|
||||
}
|
||||
|
||||
process(
|
||||
_inputs: Float32Array[][],
|
||||
outputs: Float32Array[][],
|
||||
parameters: Record<string, Float32Array>,
|
||||
): boolean {
|
||||
if (this.stopped) return false;
|
||||
let out = outputs[0]!;
|
||||
let { frequency, duty, pitch } = parameters as Record<'frequency' | 'duty' | 'pitch', Float32Array>;
|
||||
if (this.startAt > currentTime) {
|
||||
const wait = Math.round((this.startAt - currentTime) * sampleRate);
|
||||
if (wait >= (out[0]?.length ?? 0)) return true;
|
||||
const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values);
|
||||
out = out.map((channel) => channel.subarray(wait));
|
||||
[frequency, duty, pitch] = [from(frequency), from(duty), from(pitch)];
|
||||
this.startAt = 0;
|
||||
}
|
||||
if (this.player.render(out, frequency, duty, pitch)) return true;
|
||||
this.port.postMessage('ended');
|
||||
return false;
|
||||
}
|
||||
|
||||
static get parameterDescriptors() {
|
||||
return [
|
||||
{ name: 'frequency', defaultValue: 440 },
|
||||
{ name: 'duty', defaultValue: 0.5 },
|
||||
{ name: 'pitch', defaultValue: 0 },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('chip-player', ChipProcessor);
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,14 @@
|
||||
* The device is stubbed – nothing makes a noise in a unit test, and Web Audio does not exist here.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { FxEnvelopePlan, FxPlan, FxStage, FxStagePlan, FxVoice } from '../../src/core/soundfx/nodes';
|
||||
import type {
|
||||
FxChipPlan,
|
||||
FxEnvelopePlan,
|
||||
FxPlan,
|
||||
FxStage,
|
||||
FxStagePlan,
|
||||
FxVoice,
|
||||
} from '../../src/core/soundfx/nodes';
|
||||
import type { SequencePlay } from '../../src/core/music/playbacks';
|
||||
import type { ScheduledNote } from '../../src/core/music/Scheduler';
|
||||
import type { PitchOptions } from '../../src/viewport/fxWorklet';
|
||||
@@ -76,6 +83,7 @@ interface FakeSource {
|
||||
* what was posted to it.
|
||||
*/
|
||||
interface FakeWorklet {
|
||||
kind?: string;
|
||||
options: {
|
||||
outputChannelCount: number[];
|
||||
parameterData: Record<string, number>;
|
||||
@@ -303,7 +311,8 @@ beforeEach(() => {
|
||||
const parameters = new Map(
|
||||
Object.entries(options.parameterData).map(([key, value]) => [key, param(value)] as const),
|
||||
);
|
||||
const node: FakeWorklet = wired(name === 'bit-crusher' ? 'crush' : 'pitch', {
|
||||
const kind = name === 'bit-crusher' ? 'crush' : name === 'chip-player' ? 'chip' : 'pitch';
|
||||
const node: FakeWorklet = wired(kind, {
|
||||
options,
|
||||
pitch: parameters.get('pitch') ?? param(0),
|
||||
parameters,
|
||||
@@ -1399,6 +1408,25 @@ describe('notes with an Envelope (#335)', () => {
|
||||
expect(library.levels('instrument:i')).toEqual([[200, expect.closeTo(-3.01, 2)]]);
|
||||
});
|
||||
|
||||
it('ends once its decay is over where the sustain is 0, however long the note is held (#329)', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const plan: FxPlan = {
|
||||
...way(true, false),
|
||||
stages: [{ kind: 'envelope', envelope: { ...PLANNED, sustain: { value: 0 } } }],
|
||||
};
|
||||
const voice = await note(library, plan);
|
||||
// Attack 0.1 and decay 0.2: faded out from 0.3 on.
|
||||
expect(gains[1]!.ramps).toEqual([
|
||||
['set', 0.5, expect.closeTo(0.3, 9)],
|
||||
['linear', 0, expect.closeTo(0.31, 9)],
|
||||
]);
|
||||
clockTo(0.3);
|
||||
expect(library.playing(voice)).toBe(true);
|
||||
clockTo();
|
||||
expect(sources[0]!.stopped).toBe(true);
|
||||
expect(library.playing(voice)).toBe(false);
|
||||
});
|
||||
|
||||
it('moves a pitch on top of the one its way has, through the worklet', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const plan: FxPlan = {
|
||||
@@ -1415,6 +1443,63 @@ describe('notes with an Envelope (#335)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Chip (#329)', () => {
|
||||
const CHIP: FxChipPlan = {
|
||||
wave: 'square',
|
||||
frequency: { value: 440, param: 'hz', min: 1, max: 20000 },
|
||||
duty: { value: 0.5 },
|
||||
dutySweep: { value: 0 },
|
||||
slide: { value: 12 },
|
||||
deltaSlide: { value: 0 },
|
||||
minFrequency: { value: 0 },
|
||||
vibratoDepth: { value: 0 },
|
||||
vibratoSpeed: { value: 6 },
|
||||
arpeggio: { value: 0 },
|
||||
arpeggioTime: { value: 0.1 },
|
||||
repeat: { value: 0 },
|
||||
};
|
||||
const pitched: FxPlan = { pitch: [{ value: 3 }], stages: [], chip: CHIP };
|
||||
|
||||
it('plays a note without any file on a worklet of its own, its frequency, duty and pitch knobs', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const voice = library.playNote('instrument:i', [pitched], {}, 1, '', 0, { at: 0.5 });
|
||||
await settle();
|
||||
expect(modules).toHaveLength(1);
|
||||
expect(library.stats()).toMatchObject({ started: 1, playing: 1, decoded: 0 });
|
||||
expect(worklets[0]!.kind).toBe('chip');
|
||||
expect(worklets[0]!.options.outputChannelCount).toEqual([1]);
|
||||
expect(worklets[0]!.options.parameterData).toEqual({ frequency: 440, duty: 0.5, pitch: 3 });
|
||||
expect(worklets[0]!.options.processorOptions).toMatchObject({ wave: 'square', slide: 12, startAt: 0.5 });
|
||||
expect(library.levels('instrument:i')).toEqual([[3, 440, 0.5]]);
|
||||
library.tune(voice, { hz: 880 });
|
||||
expect(worklets[0]!.parameters.get('frequency')!.glides).toEqual([880]);
|
||||
});
|
||||
|
||||
it('ends with its note, faded out, or by itself once it has slid below its lowest frequency', async () => {
|
||||
const library = new SoundLibrary();
|
||||
const released = library.playNote('instrument:i', [pitched], {}, 1, '', 0);
|
||||
await settle();
|
||||
library.release(released);
|
||||
clockTo();
|
||||
expect(worklets[0]!.posted).toEqual(['stop']);
|
||||
expect(library.playing(released)).toBe(false);
|
||||
const sliding = library.playNote('instrument:i', [pitched], {}, 1, '', 0);
|
||||
worklets[1]!.port.onmessage?.({ data: 'ended' });
|
||||
expect(library.playing(sliding)).toBe(false);
|
||||
});
|
||||
|
||||
it('is silent without the worklet, and ends at once', async () => {
|
||||
loadModule = () => Promise.reject(new Error('no worklets here'));
|
||||
const library = new SoundLibrary();
|
||||
const voice = library.playNote('instrument:i', [pitched], {}, 1, '', 0);
|
||||
await settle();
|
||||
expect(worklets).toHaveLength(0);
|
||||
expect(library.levels('instrument:i')).toEqual([[3, 440, 0.5]]);
|
||||
sources[0]!.onended?.();
|
||||
expect(library.playing(voice)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clips and tracks of a song (#334)', () => {
|
||||
it('starts a clip at its second, as far into its sound as it says, round and round', async () => {
|
||||
const library = new SoundLibrary();
|
||||
|
||||
@@ -819,6 +819,68 @@ export async function sound(t) {
|
||||
crushed.halfOff < 1e-6,
|
||||
JSON.stringify(crushed),
|
||||
);
|
||||
// #329: the Chip on the worklet, rendered offline. A square at 220 Hz with a pitch of 12 and a duty
|
||||
// of 0.25, starting at 0.25 s: silence until then, 440 periods a second after it, up a
|
||||
// quarter of each. Sliding two octaves a second down from 440 Hz with a lowest frequency of
|
||||
// 110 Hz, it ends at 1 s and tells the page. Then 64 voices for 2 s, timed.
|
||||
const chipped = await evaluate(`
|
||||
(async () => {
|
||||
const RATE = 48000;
|
||||
const shape = { wave: 'square', dutySweep: 0, slide: 0, deltaSlide: 0, minFrequency: 0, vibratoDepth: 0,
|
||||
vibratoSpeed: 6, arpeggio: 0, arpeggioTime: 0.1, repeat: 0, startAt: 0 };
|
||||
const render = async (voices, seconds, params, options) => {
|
||||
const context = new OfflineAudioContext(1, RATE * seconds, RATE);
|
||||
await context.audioWorklet.addModule(__gm.sound.fxWorkletUrl());
|
||||
const said = [];
|
||||
for (let v = 0; v < voices; v++) {
|
||||
const node = new AudioWorkletNode(context, 'chip-player', {
|
||||
numberOfInputs: 0,
|
||||
outputChannelCount: [1],
|
||||
parameterData: params,
|
||||
processorOptions: { ...shape, ...options },
|
||||
});
|
||||
node.port.onmessage = (event) => said.push(event.data);
|
||||
node.connect(context.destination);
|
||||
}
|
||||
const started = performance.now();
|
||||
const out = (await context.startRendering()).getChannelData(0);
|
||||
const ms = performance.now() - started;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return { out, ms, said };
|
||||
};
|
||||
const cycles = (out, from, to) => {
|
||||
let n = 0;
|
||||
for (let i = Math.round(from * RATE) + 1; i < Math.round(to * RATE); i++) if (out[i - 1] <= 0 && out[i] > 0) n++;
|
||||
return n;
|
||||
};
|
||||
const tone = await render(1, 1, { frequency: 220, duty: 0.25, pitch: 12 }, { startAt: 0.25 });
|
||||
const heard = tone.out.subarray(0.25 * RATE);
|
||||
const firstHeard = tone.out.findIndex((v) => v !== 0);
|
||||
const slid = await render(1, 1.5, { frequency: 440, duty: 0.5, pitch: 0 }, { slide: -24, minFrequency: 110 });
|
||||
const lastHeard = slid.out.findLastIndex((v) => v !== 0);
|
||||
const load = await render(64, 2, { frequency: 330, duty: 0.5, pitch: 0 }, { wave: 'noise', slide: -6, vibratoDepth: 0.5 });
|
||||
return {
|
||||
firstHeard,
|
||||
periods: cycles(tone.out, 0.25, 1),
|
||||
up: Math.round((heard.filter((v) => v > 0).length / heard.length) * 1000) / 1000,
|
||||
peak: heard.reduce((top, v) => Math.max(top, Math.abs(v)), 0),
|
||||
lastHeard: lastHeard / RATE,
|
||||
said: slid.said,
|
||||
voices: 64,
|
||||
renderMs: Math.round(load.ms),
|
||||
realTimeVoices: Math.round((64 * 2000) / load.ms),
|
||||
};
|
||||
})()`);
|
||||
check(
|
||||
'the Chip plays its wave at its frequency and pitch from its start, and ends by itself below its lowest frequency',
|
||||
chipped.firstHeard === 0.25 * 48000 &&
|
||||
Math.abs(chipped.periods - 330) <= 1 &&
|
||||
Math.abs(chipped.up - 0.25) < 0.01 &&
|
||||
Math.abs(chipped.peak - 0.5) < 0.01 &&
|
||||
Math.abs(chipped.lastHeard - 1) < 0.01 &&
|
||||
chipped.said.join() === 'ended',
|
||||
JSON.stringify(chipped),
|
||||
);
|
||||
// A Mix "Both at center", rendered offline: a steady 1 on a and 0.5 on b in two channels, so each
|
||||
// share reads off as it is. Then mix glides 0 → 1 over a second: at its middle both stay full.
|
||||
const atCenter = await evaluate(`
|
||||
@@ -1185,6 +1247,36 @@ export async function sound(t) {
|
||||
JSON.stringify({ last: shaped.stats.last, shapedGone }),
|
||||
);
|
||||
|
||||
// #329: the Sample swapped for a Chip, the Envelope's sustain at 0 and its decay 0.1 s. The note of
|
||||
// 0.2 s is over once the decay is, on its own – held to its end and through the release of
|
||||
// 1 s it would take 1.2 s.
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(insId)}).graph;
|
||||
const notes = graph.nodes.find((n) => n.type === 'InstrumentNotes');
|
||||
graph.nodes.push(${JSON.stringify(node('ins-chip', 'InstrumentChip', 120, 200, { wave: 'saw', slide: -12 }))});
|
||||
graph.edges.find((e) => e.to.node === 'ins-env' && e.to.port === 'in').from = { node: 'ins-chip', port: 'out' };
|
||||
graph.edges.push({ id: 'ins-e-chip', from: { node: notes.id, port: 'any' }, to: { node: 'ins-chip', port: 'trigger' } });
|
||||
Object.assign(graph.nodes.find((n) => n.id === 'ins-env').params, { attack: 0.005, decay: 0.1, sustain: 0 });`);
|
||||
const beforeChip = await stats();
|
||||
await evaluate(
|
||||
`__gm.music.play(${JSON.stringify({ notes: [{ tick: 0, length: 240, note: 60, velocity: 1, instrument: insId }] })}, { tempo: 150 })`,
|
||||
);
|
||||
const chipNote = await waitFor(noteNow, (s) => s.stats.started > beforeChip.started);
|
||||
const chipOut = await trace(noteNow, (s) => s.stats.playing === 0);
|
||||
await evaluate('__gm.music.stop()');
|
||||
const chipGone = +(chipOut.at(-1).clock - (chipNote.stats.last?.at ?? 0)).toFixed(3);
|
||||
// 0.105 s of attack and decay; up to the 150 ms the polls take on top.
|
||||
check(
|
||||
'a Chip plays a note without a sound file, and an Envelope with sustain 0 ends it after its decay',
|
||||
chipNote.stats.worklet === true &&
|
||||
chipNote.stats.last?.file === `instrument:${insId}` &&
|
||||
chipNote.stats.last?.envelopes === 1 &&
|
||||
chipNote.stats.last?.duration === 0 &&
|
||||
chipOut.at(-1).stats.playing === 0 &&
|
||||
chipGone < 0.5,
|
||||
JSON.stringify({ last: chipNote.stats.last, chipGone }),
|
||||
);
|
||||
|
||||
await mutate(`
|
||||
const graph = __gm.state.project.assets.find((a) => a.id === 'sn-logic').graph;
|
||||
graph.nodes = graph.nodes.filter((n) => n.id !== 'sn-note');
|
||||
|
||||
Reference in new issue
Block a user