/** * 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( name: string, processor: new (options: { processorOptions: T }) => object, ): void; type ParamValues = Record; /** 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(); 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);