/** * AudioWorklet processor for a continuous tone whose harmonic phases glide * between two settings, A and B. * * phi_k(t) = phiA_k + s(t) * delta_k, s(t) = (1 - cos(2 pi f_cycle t)) / 2 * * where delta_k is the shortest signed angle from phiA_k to phiB_k. s runs * smoothly from 0 (tone A) to 1 (tone B) and back once per cycle. Parameters * arrive over the message port and take effect immediately; the running * phase accumulators are never reset, so updates do not click. */ declare const sampleRate: number declare function registerProcessor(name: string, ctor: unknown): void declare class AudioWorkletProcessor { readonly port: MessagePort } type Params = { f0?: number amplitudes?: number[] phasesA?: number[] phasesB?: number[] cycleHz?: number gain?: number active?: boolean /** restart the cycle at s = 0 (tone A) */ reset?: boolean } const TWO_PI = 2 * Math.PI const REPORT_EVERY = 2048 // frames between position messages (~43 ms at 48 kHz) const wrapAngle = (x: number): number => { let y = x % TWO_PI if (y > Math.PI) y -= TWO_PI if (y <= -Math.PI) y += TWO_PI return y } class PhaseMorphProcessor extends AudioWorkletProcessor { private f0 = 220 private amplitudes: number[] = [] private phasesA: number[] = [] private phasesB: number[] = [] private delta: number[] = [] private cycleHz = 0.5 private gain = 0 private active = false private theta = 0 private mod = 0 private sinceReport = 0 constructor() { super() this.port.onmessage = (e: MessageEvent) => this.set(e.data) } private set(p: Params): void { if (p.f0 !== undefined) this.f0 = p.f0 if (p.amplitudes) this.amplitudes = p.amplitudes if (p.phasesA) this.phasesA = p.phasesA if (p.phasesB) this.phasesB = p.phasesB if (p.phasesA || p.phasesB) { this.delta = this.phasesA.map((a, k) => wrapAngle((this.phasesB[k] ?? 0) - a)) } if (p.cycleHz !== undefined) this.cycleHz = p.cycleHz if (p.gain !== undefined) this.gain = p.gain if (p.reset) { this.mod = 0 this.sinceReport = 0 } if (p.active !== undefined) this.active = p.active } process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean { const out = outputs[0]?.[0] if (!out || !this.active) return true const { amplitudes, phasesA, delta, gain } = this const dTheta = (TWO_PI * this.f0) / sampleRate const dMod = (TWO_PI * this.cycleHz) / sampleRate let theta = this.theta let mod = this.mod for (let i = 0; i < out.length; i++) { const s = 0.5 - 0.5 * Math.cos(mod) let sum = 0 for (let k = 0; k < amplitudes.length; k++) { const a = amplitudes[k] if (a === 0) continue sum += a * Math.sin((k + 1) * theta + phasesA[k] + s * delta[k]) } out[i] = gain * sum theta += dTheta if (theta >= TWO_PI) theta -= TWO_PI mod += dMod if (mod >= TWO_PI) mod -= TWO_PI } this.theta = theta this.mod = mod this.sinceReport += out.length if (this.sinceReport >= REPORT_EVERY) { this.sinceReport = 0 this.port.postMessage({ s: 0.5 - 0.5 * Math.cos(mod) }) } return true } } registerProcessor('phase-morph', PhaseMorphProcessor)