/ proof-of-concept / phase-perception
Sign in
proof-of-concept / phase-perception
phase-perception / src / morph-worklet.ts
109 lines · 3.2 KBCodeBlameHistory
2 * AudioWorklet processor for a continuous tone whose harmonic phases glide
3 * between two settings, A and B.
4 *
5 * phi_k(t) = phiA_k + s(t) * delta_k, s(t) = (1 - cos(2 pi f_cycle t)) / 2
6 *
7 * where delta_k is the shortest signed angle from phiA_k to phiB_k. s runs
8 * smoothly from 0 (tone A) to 1 (tone B) and back once per cycle. Parameters
9 * arrive over the message port and take effect immediately; the running
10 * phase accumulators are never reset, so updates do not click.
11 */
13declare const sampleRate: number
14declare function registerProcessor(name: string, ctor: unknown): void
15declare class AudioWorkletProcessor {
16 readonly port: MessagePort
19type Params = {
20 f0?: number
21 amplitudes?: number[]
22 phasesA?: number[]
23 phasesB?: number[]
24 cycleHz?: number
25 gain?: number
26 active?: boolean
27 /** restart the cycle at s = 0 (tone A) */
28 reset?: boolean
31const TWO_PI = 2 * Math.PI
32const REPORT_EVERY = 2048 // frames between position messages (~43 ms at 48 kHz)
34const wrapAngle = (x: number): number => {
35 let y = x % TWO_PI
36 if (y > Math.PI) y -= TWO_PI
37 if (y <= -Math.PI) y += TWO_PI
38 return y
41class PhaseMorphProcessor extends AudioWorkletProcessor {
42 private f0 = 220
43 private amplitudes: number[] = []
44 private phasesA: number[] = []
45 private phasesB: number[] = []
46 private delta: number[] = []
47 private cycleHz = 0.5
48 private gain = 0
49 private active = false
50 private theta = 0
51 private mod = 0
52 private sinceReport = 0
54 constructor() {
55 super()
56 this.port.onmessage = (e: MessageEvent<Params>) => this.set(e.data)
57 }
59 private set(p: Params): void {
60 if (p.f0 !== undefined) this.f0 = p.f0
61 if (p.amplitudes) this.amplitudes = p.amplitudes
62 if (p.phasesA) this.phasesA = p.phasesA
63 if (p.phasesB) this.phasesB = p.phasesB
64 if (p.phasesA || p.phasesB) {
65 this.delta = this.phasesA.map((a, k) => wrapAngle((this.phasesB[k] ?? 0) - a))
66 }
67 if (p.cycleHz !== undefined) this.cycleHz = p.cycleHz
68 if (p.gain !== undefined) this.gain = p.gain
69 if (p.reset) {
70 this.mod = 0
71 this.sinceReport = 0
72 }
73 if (p.active !== undefined) this.active = p.active
74 }
76 process(_inputs: Float32Array[][], outputs: Float32Array[][]): boolean {
77 const out = outputs[0]?.[0]
78 if (!out || !this.active) return true
79 const { amplitudes, phasesA, delta, gain } = this
80 const dTheta = (TWO_PI * this.f0) / sampleRate
81 const dMod = (TWO_PI * this.cycleHz) / sampleRate
82 let theta = this.theta
83 let mod = this.mod
84 for (let i = 0; i < out.length; i++) {
85 const s = 0.5 - 0.5 * Math.cos(mod)
86 let sum = 0
87 for (let k = 0; k < amplitudes.length; k++) {
88 const a = amplitudes[k]
89 if (a === 0) continue
90 sum += a * Math.sin((k + 1) * theta + phasesA[k] + s * delta[k])
91 }
92 out[i] = gain * sum
93 theta += dTheta
94 if (theta >= TWO_PI) theta -= TWO_PI
95 mod += dMod
96 if (mod >= TWO_PI) mod -= TWO_PI
97 }
98 this.theta = theta
99 this.mod = mod
100 this.sinceReport += out.length
101 if (this.sinceReport >= REPORT_EVERY) {
102 this.sinceReport = 0
103 this.port.postMessage({ s: 0.5 - 0.5 * Math.cos(mod) })
104 }
105 return true
106 }
109registerProcessor('phase-morph', PhaseMorphProcessor)
↑↓ move↵openescclose