3118485Phase perception app: tone editor, blind same/different test, cycling tone, and site buildJeremy Magland 1/** Main-thread controller for the cycling tone (see morph-worklet.ts). */
2import { getAudioContext } from './audio'
3import workletUrl from './morph-worklet.ts?worker&url'
5export type MorphParams = {
6 f0: number
7 amplitudes: number[]
8 /** radians */
9 phasesA: number[]
10 /** radians */
11 phasesB: number[]
12 cycleHz: number
13 gain: number
14}
16const FADE_SEC = 0.03
18export class MorphPlayer {
19 private ctx: AudioContext
20 private node: AudioWorkletNode
21 private gainNode: GainNode
22 private listeners = new Set<(s: number) => void>()
23 private stopTimer: ReturnType<typeof setTimeout> | null = null
24 running = false
26 private constructor(ctx: AudioContext, node: AudioWorkletNode, gainNode: GainNode) {
27 this.ctx = ctx
28 this.node = node
29 this.gainNode = gainNode
30 node.port.onmessage = (e: MessageEvent<{ s: number }>) => {
31 for (const fn of this.listeners) fn(e.data.s)
32 }
33 }
35 static async create(): Promise<MorphPlayer> {
36 const ctx = getAudioContext()
37 await ctx.audioWorklet.addModule(workletUrl)
38 const node = new AudioWorkletNode(ctx, 'phase-morph', { numberOfInputs: 0, outputChannelCount: [1] })
39 const gainNode = ctx.createGain()
40 gainNode.gain.value = 0
41 node.connect(gainNode).connect(ctx.destination)
42 return new MorphPlayer(ctx, node, gainNode)
43 }
45 /** Change parameters; safe to call while running. */
46 update(p: Partial<MorphParams>): void {
47 this.node.port.postMessage(p)
48 }
50 start(p: MorphParams): void {
51 if (this.stopTimer) {
52 clearTimeout(this.stopTimer)
53 this.stopTimer = null
54 }
55 void this.ctx.resume()
56 this.node.port.postMessage({ ...p, reset: true, active: true })
57 const g = this.gainNode.gain
58 const t = this.ctx.currentTime
59 g.cancelScheduledValues(t)
60 g.setValueAtTime(0, t)
61 g.linearRampToValueAtTime(1, t + FADE_SEC)
62 this.running = true
63 }
65 stop(): void {
66 if (!this.running) return
67 this.running = false
68 const g = this.gainNode.gain
69 const t = this.ctx.currentTime
70 g.cancelScheduledValues(t)
71 g.setValueAtTime(g.value, t)
72 g.linearRampToValueAtTime(0, t + FADE_SEC)
73 this.stopTimer = setTimeout(() => {
74 this.stopTimer = null
75 this.node.port.postMessage({ active: false })
76 }, FADE_SEC * 1000 + 20)
77 }
79 /** Subscribe to the cycle position s in [0, 1] (0 = tone A, 1 = tone B). */
80 subscribe(fn: (s: number) => void): () => void {
81 this.listeners.add(fn)
82 return () => {
83 this.listeners.delete(fn)
84 }
85 }
86}
88let playerPromise: Promise<MorphPlayer> | null = null
90/** The single shared player, created on first use (needs a user gesture for audio). */
91export function getMorphPlayer(): Promise<MorphPlayer> {
92 if (!playerPromise) {
93 playerPromise = MorphPlayer.create().catch((err: unknown) => {
94 playerPromise = null
95 throw err
96 })
97 }
98 return playerPromise
99}