Files
feuerwerk/src/viewport/fxWorklet.ts
T
adminandClaude Opus 5.5 f46b2316f2
game-maker / suite (push) Failing after 36s
game-maker / gate (push) Successful in 3m33s
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>
2026-10-06 22:54:44 +02:00

172 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`, 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';
/** What the audio thread has and the page's types do not. */
declare const sampleRate: number;
declare const currentTime: number;
declare class AudioWorkletProcessor {
readonly port: MessagePort;
}
declare function registerProcessor<T>(
name: string,
processor: new (options: { processorOptions: T }) => object,
): void;
export interface PitchOptions {
/** The file the samples are of: they are sent with the first voice of it only and kept here. */
file: string;
channels?: Float32Array[];
/** The sound's rate over the context's. */
step: number;
loop: boolean;
/** Where a loop runs, in samples of the sound (#331); without them it is the whole sound. */
loopStart?: number;
loopEnd?: number;
/** The second of the audio clock its first sample is heard at (#332); before it, silence. */
startAt?: number;
/** Samples of the sound before where it starts, for a clip of a song taken up mid-way (#334). */
skip?: number;
}
/** Every sound a voice has been played from, for the next voice of it – processors share this scope. */
const sounds = new Map<string, Float32Array[]>();
class PitchProcessor extends AudioWorkletProcessor {
private readonly player: PitchPlayer;
private stopped = false;
private startAt: number;
constructor({ processorOptions }: { processorOptions: PitchOptions }) {
super();
const { file, channels, step, loop, loopStart, loopEnd, startAt, skip } = processorOptions;
this.startAt = startAt ?? 0;
if (channels) sounds.set(file, channels);
const region = loop && loopEnd !== undefined ? { start: loopStart ?? 0, end: loopEnd } : loop;
this.player = new PitchPlayer(sounds.get(file) ?? [], step, sampleRate, region, skip);
this.port.onmessage = (event: MessageEvent) => {
if (event.data === 'leave') this.player.leaveLoop();
else this.stopped = true;
};
}
process(
_inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>,
): boolean {
if (this.stopped) return false;
let out = outputs[0]!;
let pitch = parameters.pitch!;
// A block is silence until then, the one it falls in starts on that sample.
if (this.startAt > currentTime) {
const wait = Math.round((this.startAt - currentTime) * sampleRate);
if (wait >= (out[0]?.length ?? 0)) return true;
out = out.map((channel) => channel.subarray(wait));
if (pitch.length > 1) pitch = pitch.subarray(wait);
this.startAt = 0;
}
if (this.player.render(out, pitch)) return true;
this.port.postMessage('ended');
return false;
}
static get parameterDescriptors() {
return [{ name: 'pitch', defaultValue: 0 }];
}
}
registerProcessor('pitch-player', PitchProcessor);
/** A Bitcrusher (#322) between two stages of a voice; it runs until the voice tells it to stop. */
class CrushProcessor extends AudioWorkletProcessor {
private readonly crusher = new Crusher(sampleRate);
private stopped = false;
constructor() {
super();
this.port.onmessage = () => {
this.stopped = true;
};
}
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>,
): boolean {
if (this.stopped) return false;
this.crusher.render(inputs[0] ?? [], outputs[0]!, parameters.bits!, parameters.rate!, parameters.mix!);
return true;
}
static get parameterDescriptors() {
return [
{ name: 'bits', defaultValue: 16 },
{ name: 'rate', defaultValue: 48000 },
{ name: 'mix', defaultValue: 0 },
];
}
}
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);