Files
feuerwerk/src/viewport/fxWorklet.ts
T
adminandClaude Opus 5.5 0701fe3856
feuerwerk / gate (push) Successful in 5m5s
feuerwerk / suite (push) Failing after 18m39s
Share the parts the sound devices and timelines repeated (#85)
Round 12 of the clean-up. Duplicate code had grown from 33 clones
(249 lines) after round 11 to 140 clones (1263 lines), almost all of
it in the panels and faces of the sound FX devices (#379 to #401).
It is down to 57 clones (416 lines) now, and src is 816 lines
shorter.

- deviceParts.ts holds what the device panels built by hand: the
  head, rows of knobs, the screen style and the row layout for
  groups of knobs. Twelve panels use it. panel.knobs stays because
  the debug hooks read it.
- DeviceFace is the base of the twelve faces. It finds the node and
  does wired, preview, commit, set, hide, reload and audition.
  compressorWired lives there as paramWired.
- TextPool replaces ten copies of "make texts as needed, hide the
  rest" in the wave and video tabs, the rulers, the piano roll and
  the song and composition timelines. clipStrip.ts is the clip label
  strip the wave and video tabs share.
- core/util/bytes.ts has the concat that ogg, matroska and avcColour
  each wrote out.
- Dead code is gone: clear on the video players (never called since
  #402), posterFileOf and videoTimeAt (only tests called them) and
  easeOut (the same as glide(t, 3)). Values only used in their own
  file lose their export.
- Comments over 125 columns are wrapped, and comments that told the
  history ("as before", "used to") say what is, in src and in the
  e2e modules.
- Left alone on purpose: AnimationEditor and CompositionEditor share
  only the play button, and the curve panels are built differently.
- Chip now places its boxes through put, which rounds. They were
  whole numbers already. The three row layouts are byte-identical
  before and after.
- README lists the video and composition assets and the Videos and
  Compositions pages.

Tests:
- Gate (melektaus-atx): CHECK EXIT 0, 313 files, 4835 tests.
- e2e 25, 34, 64, 65, 66, 69 (melektaus-atx): ALL PASSED, 330 checks.
- TextPool without keeping its texts and framesToRoom without making
  room both go red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:40:05 +02:00

476 lines
16 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`, a Phaser and a Flanger (#341) theirs, the pitch of a bus (#346) a
* `PitchShifter`, a Limiter (#326) a `Limiter`, a Tremolo and a Vibrato (#320) theirs, a streamed
* sound (#349) a `StreamQueue`, an Acid (#421) an `AcidSynth`. Loaded into
* the context once by the sound library; nothing on the page imports it.
*/
import { type AcidKnobs, type AcidNote, AcidSynth } from '../core/soundfx/acid';
import { type ChipShape, ChipPlayer } from '../core/soundfx/chip';
import { Crusher } from '../core/soundfx/crusher';
import { Limiter, type LimiterNumbers } from '../core/soundfx/dynamics';
import { LFO_SHAPES, type LfoNumbers, Tremolo, Vibrato } from '../core/soundfx/lfo';
import { Flanger, type FlangerNumbers, Phaser, type PhaserNumbers } from '../core/soundfx/phaser';
import { PitchPlayer, PitchShifter } from '../core/soundfx/pitchPlayer';
import { STREAM_RATE, StreamQueue } from '../core/music/streamQueue';
/** What the audio thread has and the page's types do not. */
declare const sampleRate: number;
declare const currentTime: number;
declare const currentFrame: number;
declare class AudioWorkletProcessor {
readonly port: MessagePort;
}
declare function registerProcessor<T>(
name: string,
processor: new (options: { processorOptions: T }) => object,
): void;
type ParamValues = Record<string, Float32Array>;
/** A processor that runs until it is told to stop, then lets go of its port so it can be collected. */
abstract class Stoppable extends AudioWorkletProcessor {
protected stopped = false;
protected stop(): void {
this.stopped = true;
this.port.close();
}
}
/** An effect between two stages of a voice or a bus; it runs until it is told to stop. */
abstract class Effect extends Stoppable {
constructor() {
super();
this.port.onmessage = () => this.stop();
}
process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: ParamValues): boolean {
if (this.stopped) return false;
this.render(inputs[0] ?? [], outputs[0]!, parameters);
return true;
}
protected abstract render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void;
}
/** A block of a voice, or the part of it from the voice's first sample on. */
interface Block {
out: Float32Array[];
input: Float32Array[];
parameters: ParamValues;
}
/** The second of the audio clock a voice is first heard at (#332); before it, silence. */
class Start {
constructor(private at: number) {}
/**
* The block from the voice's first sample on, the output, the input and every parameter cut
* there, or null while all of it lies before. Once the voice runs, the block as it is.
*/
cut(block: Block): Block | null {
if (this.at <= currentTime) return block;
const wait = Math.round((this.at - currentTime) * sampleRate);
for (const channel of block.out) channel.fill(0, 0, wait);
if (wait >= (block.out[0]?.length ?? 0)) return null;
this.at = 0;
const from = (values: Float32Array) => (values.length > 1 ? values.subarray(wait) : values);
return {
out: block.out.map((channel) => channel.subarray(wait)),
input: block.input.map(from),
parameters: Object.fromEntries(Object.entries(block.parameters).map(([name, v]) => [name, from(v)])),
};
}
}
/**
* A sound of its own, heard from a second of the audio clock on; it tells the page once it has
* ended, and runs until then or until it is told to stop.
*/
abstract class Source extends Stoppable {
private readonly start: Start;
constructor(startAt: number) {
super();
this.start = new Start(startAt);
}
process(_inputs: Float32Array[][], outputs: Float32Array[][], parameters: ParamValues): boolean {
if (this.stopped) return false;
const block = this.start.cut({ out: outputs[0]!, input: [], parameters });
if (!block) return true;
if (this.render(block.out, block.parameters)) return true;
this.port.postMessage('ended');
return false;
}
/** Fills `out`; false once the sound has ended. */
protected abstract render(out: Float32Array[], parameters: ParamValues): boolean;
}
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 Source {
private readonly player: PitchPlayer;
constructor({ processorOptions }: { processorOptions: PitchOptions }) {
super(processorOptions.startAt ?? 0);
const { file, channels, step, loop, loopStart, loopEnd, skip } = processorOptions;
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.stop();
};
}
protected render(out: Float32Array[], parameters: ParamValues): boolean {
return this.player.render(out, parameters.pitch!);
}
static get parameterDescriptors() {
return [{ name: 'pitch', defaultValue: 0 }];
}
}
registerProcessor('pitch-player', PitchProcessor);
/** The pitch of a bus (#346): what comes in goes out moved by `pitch`, a frame late. */
class ShiftProcessor extends Effect {
private readonly shifter = new PitchShifter(2, sampleRate);
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
this.shifter.render(input, out, parameters.pitch!);
}
static get parameterDescriptors() {
return [{ name: 'pitch', defaultValue: 0 }];
}
}
registerProcessor('pitch-shifter', ShiftProcessor);
export interface StreamOptions {
channels: number;
/** The second of the audio clock its first sample is heard at; before it, silence. */
startAt: number;
/** What it starts with, so it is there for the first sample; the rest comes by message. */
first: Float32Array[];
}
/** Samples of the stream read between two reports to the page. */
const STREAM_REPORT = 4800;
/**
* A streamed sound (#349): plays the samples the page sends it, in order, and tells it how far it has
* read so it can send more. 'end' says nothing more comes.
*/
class StreamProcessor extends Source {
private readonly queue: StreamQueue;
private reported = 0;
constructor({ processorOptions }: { processorOptions: StreamOptions }) {
super(processorOptions.startAt);
this.queue = new StreamQueue(processorOptions.channels, STREAM_RATE / sampleRate);
this.queue.push(processorOptions.first);
this.port.onmessage = (event: MessageEvent) => {
const data: unknown = event.data;
if (Array.isArray(data)) this.queue.push(data as Float32Array[]);
else if (data === 'end') this.queue.end();
else this.stop();
};
}
protected render(out: Float32Array[]): boolean {
const going = this.queue.render(out);
if (this.queue.used - this.reported >= STREAM_REPORT) {
this.reported = this.queue.used;
this.port.postMessage({ used: this.queue.used, skipped: this.queue.skipped });
}
return going;
}
}
registerProcessor('stream-player', StreamProcessor);
/** A Bitcrusher (#322). */
class CrushProcessor extends Effect {
private readonly crusher = new Crusher(sampleRate);
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
this.crusher.render(input, out, parameters.bits!, parameters.rate!, parameters.mix!);
}
static get parameterDescriptors() {
return [
{ name: 'bits', defaultValue: 16 },
{ name: 'rate', defaultValue: 48000 },
{ name: 'mix', defaultValue: 0 },
];
}
}
registerProcessor('bit-crusher', CrushProcessor);
export interface PhaserOptions {
stages: number;
}
const PHASER_PARAMS = ['frequency', 'rate', 'depth', 'feedback', 'mix'] as const;
/** A Phaser (#341). */
class PhaserProcessor extends Effect {
private readonly phaser: Phaser;
constructor({ processorOptions }: { processorOptions: PhaserOptions }) {
super();
this.phaser = new Phaser(sampleRate, processorOptions.stages);
}
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
this.phaser.render(input, out, parameters as PhaserNumbers);
}
static get parameterDescriptors() {
return PHASER_PARAMS.map((name) => ({ name, defaultValue: 0 }));
}
}
registerProcessor('phaser', PhaserProcessor);
export interface FlangerOptions {
/** The second of the audio clock its sound starts at: the sweep counts from there. */
startAt: number;
}
const FLANGER_PARAMS = ['offset', 'sweep', 'rate', 'depth', 'feedback', 'mix'] as const;
/** A Flanger (#341); its sweep starts with the voice. */
class FlangerProcessor extends Effect {
private readonly flanger = new Flanger(sampleRate);
private readonly start: Start;
constructor({ processorOptions }: { processorOptions: FlangerOptions }) {
super();
this.start = new Start(processorOptions.startAt);
}
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
// Nothing is heard before the voice starts, and the sweep waits for its first sample.
const block = this.start.cut({ out, input, parameters });
if (block) this.flanger.render(block.input, block.out, block.parameters as FlangerNumbers);
}
static get parameterDescriptors() {
return FLANGER_PARAMS.map((name) => ({ name, defaultValue: 0 }));
}
}
registerProcessor('flanger', FlangerProcessor);
const LIMITER_PARAMS = ['threshold', 'ceiling', 'release', 'autoGain'] as const;
/** A Limiter (#326). */
class LimitProcessor extends Effect {
private readonly limiter = new Limiter(sampleRate);
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
this.limiter.render(input, out, parameters as LimiterNumbers);
}
static get parameterDescriptors() {
return LIMITER_PARAMS.map((name) => ({ name, defaultValue: 0 }));
}
}
registerProcessor('limiter', LimitProcessor);
export interface LfoOptions {
/** The shape by its place in `LFO_SHAPES`. */
shape: number;
/** The second of the audio clock its voice starts at: the LFO is at phase 0 on that sample. */
startAt: number;
/** A Vibrato's average delay in seconds. */
center: number;
}
/** A Tremolo or a Vibrato (#320); its LFO starts with its voice. */
class LfoProcessor extends Effect {
private readonly effect: Tremolo | Vibrato;
private readonly start: Start;
constructor({ processorOptions }: { processorOptions: LfoOptions & { vibrato: boolean } }) {
super();
const shape = LFO_SHAPES[processorOptions.shape]?.value ?? 'sine';
this.effect = processorOptions.vibrato
? new Vibrato(shape, sampleRate, processorOptions.center)
: new Tremolo(shape, sampleRate);
this.start = new Start(processorOptions.startAt);
}
protected render(input: Float32Array[], out: Float32Array[], parameters: ParamValues): void {
// Nothing is heard before the voice starts, and the LFO waits for its first sample.
const block = this.start.cut({ out, input, parameters });
if (block) this.effect.render(block.input, block.out, block.parameters as LfoNumbers);
}
static get parameterDescriptors() {
return [
{ name: 'rate', defaultValue: 0 },
{ name: 'depth', defaultValue: 0 },
];
}
}
registerProcessor('lfo', LfoProcessor);
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 Source {
private readonly player: ChipPlayer;
constructor({ processorOptions }: { processorOptions: ChipOptions }) {
super(processorOptions.startAt);
this.player = new ChipPlayer(processorOptions, sampleRate);
this.port.onmessage = () => this.stop();
}
protected render(out: Float32Array[], parameters: ParamValues): boolean {
return this.player.render(out, parameters.frequency!, parameters.duty!, parameters.pitch!);
}
static get parameterDescriptors() {
return [
{ name: 'frequency', defaultValue: 440 },
{ name: 'duty', defaultValue: 0.5 },
{ name: 'pitch', defaultValue: 0 },
];
}
}
registerProcessor('chip-player', ChipProcessor);
export interface AcidOptions extends AcidNote {
/** The second of the audio clock its first sample is heard at; before it, silence. */
startAt: number;
}
/** The Acid's AudioParams: its knobs, its pitch, and the note's end and cut, set on the clock. */
const ACID_PARAMS = ['cutoff', 'resonance', 'envMod', 'pitch', 'gate', 'cut'] as const;
/** The Acid of a note (#421): a source of its own that plays out its release and then ends. */
class AcidProcessor extends Source {
private readonly synth: AcidSynth;
constructor({ processorOptions }: { processorOptions: AcidOptions }) {
super(processorOptions.startAt);
this.synth = new AcidSynth(processorOptions, sampleRate);
this.port.onmessage = () => this.stop();
}
protected render(out: Float32Array[], parameters: ParamValues): boolean {
const playing = this.synth.render(out[0]!, parameters as unknown as AcidKnobs);
for (const channel of out.slice(1)) channel.set(out[0]!);
return playing;
}
static get parameterDescriptors() {
return ACID_PARAMS.map((name) => ({ name, defaultValue: name === 'gate' ? 1 : 0 }));
}
}
registerProcessor('acid-player', AcidProcessor);
/**
* Says how many channels reach it at most (#340): whether what goes into the master of a pattern
* being baked is mono, so its bake is too and goes on the same way.
*/
class ChannelProbe extends AudioWorkletProcessor {
private most = 0;
process(inputs: Float32Array[][]): boolean {
const count = inputs[0]?.length ?? 0;
if (count > this.most) {
this.most = count;
this.port.postMessage(count);
}
return true;
}
}
registerProcessor('channel-probe', ChannelProbe);
/** Frames a recording's sound is handed over in: about a tenth of a second. */
const RECORD_BLOCK = 4096;
/**
* Hands what reaches it to the page, both sides, `RECORD_BLOCK` frames at a time (#405): the master
* as it leaves the limiter, for a recording of the game view. Told to stop, it sends what it holds
* and an empty piece after it, the sign that nothing more comes.
*/
class RecordTap extends Stoppable {
private readonly sides = [new Float32Array(RECORD_BLOCK), new Float32Array(RECORD_BLOCK)];
private filled = 0;
constructor() {
super();
this.port.onmessage = () => {
this.send();
this.port.postMessage([new Float32Array(0), new Float32Array(0)]);
this.stop();
};
}
process(inputs: Float32Array[][]): boolean {
if (this.stopped) return false;
const input = inputs[0] ?? [];
const left = input[0];
const frames = left?.length ?? 128;
this.sides.forEach((side, c) => {
const data = input[c] ?? left;
if (data) side.set(data, this.filled);
else side.fill(0, this.filled, this.filled + frames);
});
this.filled += frames;
if (this.filled + frames > RECORD_BLOCK) this.send();
return true;
}
private send(): void {
if (this.filled === 0) return;
const out = this.sides.map((side) => side.slice(0, this.filled));
this.filled = 0;
this.port.postMessage(
out,
out.map((side) => side.buffer),
);
}
}
registerProcessor('record-tap', RecordTap);