/** Main-thread controller for the cycling tone (see morph-worklet.ts). */ import { getAudioContext } from './audio' import workletUrl from './morph-worklet.ts?worker&url' export type MorphParams = { f0: number amplitudes: number[] /** radians */ phasesA: number[] /** radians */ phasesB: number[] cycleHz: number gain: number } const FADE_SEC = 0.03 export class MorphPlayer { private ctx: AudioContext private node: AudioWorkletNode private gainNode: GainNode private listeners = new Set<(s: number) => void>() private stopTimer: ReturnType | null = null running = false private constructor(ctx: AudioContext, node: AudioWorkletNode, gainNode: GainNode) { this.ctx = ctx this.node = node this.gainNode = gainNode node.port.onmessage = (e: MessageEvent<{ s: number }>) => { for (const fn of this.listeners) fn(e.data.s) } } static async create(): Promise { const ctx = getAudioContext() await ctx.audioWorklet.addModule(workletUrl) const node = new AudioWorkletNode(ctx, 'phase-morph', { numberOfInputs: 0, outputChannelCount: [1] }) const gainNode = ctx.createGain() gainNode.gain.value = 0 node.connect(gainNode).connect(ctx.destination) return new MorphPlayer(ctx, node, gainNode) } /** Change parameters; safe to call while running. */ update(p: Partial): void { this.node.port.postMessage(p) } start(p: MorphParams): void { if (this.stopTimer) { clearTimeout(this.stopTimer) this.stopTimer = null } void this.ctx.resume() this.node.port.postMessage({ ...p, reset: true, active: true }) const g = this.gainNode.gain const t = this.ctx.currentTime g.cancelScheduledValues(t) g.setValueAtTime(0, t) g.linearRampToValueAtTime(1, t + FADE_SEC) this.running = true } stop(): void { if (!this.running) return this.running = false const g = this.gainNode.gain const t = this.ctx.currentTime g.cancelScheduledValues(t) g.setValueAtTime(g.value, t) g.linearRampToValueAtTime(0, t + FADE_SEC) this.stopTimer = setTimeout(() => { this.stopTimer = null this.node.port.postMessage({ active: false }) }, FADE_SEC * 1000 + 20) } /** Subscribe to the cycle position s in [0, 1] (0 = tone A, 1 = tone B). */ subscribe(fn: (s: number) => void): () => void { this.listeners.add(fn) return () => { this.listeners.delete(fn) } } } let playerPromise: Promise | null = null /** The single shared player, created on first use (needs a user gesture for audio). */ export function getMorphPlayer(): Promise { if (!playerPromise) { playerPromise = MorphPlayer.create().catch((err: unknown) => { playerPromise = null throw err }) } return playerPromise }