File size: 7,170 Bytes
00a912e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
import { SoundTouchNode } from '@soundtouchjs/audio-worklet'
import processorUrl from '@soundtouchjs/audio-worklet/processor?url'
import { Howler } from 'howler'
import type { Howl } from 'howler'
import { SERVER_URL } from './config'

const MIN_TEMPO = 0.99
const MAX_TEMPO = 1.01
const CORS_AUDIO_HOST_SUFFIXES = ['music.126.net', 'music.163.com', 'qqmusic.qq.com', 'kugou.com', 'kugou.net']

interface InternalHowl extends Howl {
  _sounds?: Array<{ _node?: HTMLMediaElement }>
}

interface InternalHowler {
  _obtainHtml5Audio: () => HTMLAudioElement
}

interface StretchGraph {
  audio: HTMLMediaElement
  source: MediaElementAudioSourceNode
  node: SoundTouchNode
  context: AudioContext
  bypassed: boolean
  failed: boolean
  metricsSnapshots: number
  lastBlockCount: number
  lastUnderrunCount: number
}

export interface TimeStretchController {
  readonly enabled: boolean
  setEnabled: (enabled: boolean) => void
  setTempo: (tempo: number) => void
  reset: () => void
}

const graphByAudio = new WeakMap<HTMLMediaElement, StretchGraph>()
const registrationByContext = new WeakMap<BaseAudioContext, Promise<void>>()
let nextAudioUsesCors = false
let obtainAudioPatched = false

/**
 * Howler has no public option for HTMLMediaElement.crossOrigin. Patch its
 * element factory once and mark the next synchronously-created element for
 * anonymous CORS before Howler assigns the direct CDN URL.
 */
export function prepareDirectStreamForTimeStretch(streamUrl: string): boolean {
  let supportsCors = false
  try {
    const stream = new URL(streamUrl)
    const hostname = stream.hostname.toLowerCase()
    supportsCors =
      stream.origin === window.location.origin ||
      stream.origin === new URL(SERVER_URL).origin ||
      CORS_AUDIO_HOST_SUFFIXES.some((suffix) => hostname === suffix || hostname.endsWith(`.${suffix}`))
  } catch {
    supportsCors = false
  }

  const internalHowler = Howler as unknown as InternalHowler
  if (!obtainAudioPatched) {
    const obtainHtml5Audio = internalHowler._obtainHtml5Audio
    internalHowler._obtainHtml5Audio = function () {
      const audio = obtainHtml5Audio.call(this)
      if (nextAudioUsesCors) audio.crossOrigin = 'anonymous'
      else audio.removeAttribute('crossorigin')
      nextAudioUsesCors = false
      return audio
    }
    obtainAudioPatched = true
  }
  nextAudioUsesCors = supportsCors
  return supportsCors
}

function getAudioElement(howl: Howl): HTMLMediaElement | null {
  const sound = (howl as InternalHowl)._sounds?.[0]
  return sound?._node ?? null
}

function registerProcessor(context: AudioContext): Promise<void> {
  const existing = registrationByContext.get(context)
  if (existing) return existing
  const registration = SoundTouchNode.register(context, processorUrl)
  registrationByContext.set(context, registration)
  return registration
}

function disableGraph(graph: StretchGraph, failed = false): void {
  if (failed) graph.failed = true
  if (graph.bypassed) return
  graph.bypassed = true
  graph.node.playbackRate.setValueAtTime(1, graph.context.currentTime)
  graph.node.disconnect()
  graph.source.disconnect()
  graph.source.connect(graph.context.destination)
  graph.audio.playbackRate = 1
}

function resumeContext(graph: StretchGraph): void {
  if (graph.context.state === 'running') return
  void graph.context.resume().catch((error) => {
    console.warn('Unable to resume time-stretch audio context', error)
  })
}

function enableGraph(graph: StretchGraph): void {
  if (!graph.bypassed || graph.failed) return
  resumeContext(graph)
  graph.source.disconnect()
  graph.source.connect(graph.node)
  graph.node.connect(graph.context.destination)
  graph.bypassed = false
  graph.metricsSnapshots = 0
  graph.lastBlockCount = graph.node.metrics?.blockCount ?? 0
  graph.lastUnderrunCount = graph.node.metrics?.underrunCount ?? 0
}

/**
 * Attach SoundTouch's WSOLA AudioWorklet to Howler's HTML5 media element.
 * The returned controller is intentionally fail-closed: if the browser does
 * not support AudioWorklet or the processor underruns, native 1.0x playback
 * is restored instead of producing malformed audio.
 */
export async function attachTimeStretch(howl: Howl): Promise<TimeStretchController | null> {
  if (typeof AudioContext === 'undefined') return null
  const audio = getAudioElement(howl)
  if (!audio) return null

  const existing = graphByAudio.get(audio)
  if (existing) {
    return createController(existing)
  }

  const context = Howler.ctx
  if (!context) return null
  let source: MediaElementAudioSourceNode | null = null

  try {
    await registerProcessor(context)
    source = context.createMediaElementSource(audio)
    const node = new SoundTouchNode({ context, interpolationStrategy: 'lanczos' })
    node.setStretchParameters({ sequenceMs: 80, seekWindowMs: 20, overlapMs: 12, quickSeek: true })
    node.pitch.value = 1
    source.connect(node)
    node.connect(context.destination)

    const graph: StretchGraph = {
      audio,
      source,
      node,
      context,
      bypassed: false,
      failed: false,
      metricsSnapshots: 0,
      lastBlockCount: 0,
      lastUnderrunCount: 0,
    }
    graphByAudio.set(audio, graph)

    // These Howls use HTML5 Audio, so Howler's auto-suspend scan does not
    // count them as active WebAudio sounds. The media element is nevertheless
    // routed through this context; allowing Howler to suspend it would leave
    // currentTime advancing while producing no audio after about 30 seconds.
    Howler.autoSuspend = false
    resumeContext(graph)

    node.addEventListener('metrics', () => {
      const metrics = node.metrics
      if (!metrics) return
      graph.metricsSnapshots++
      const blockDelta = metrics.blockCount - graph.lastBlockCount
      const underrunDelta = metrics.underrunCount - graph.lastUnderrunCount
      graph.lastBlockCount = metrics.blockCount
      graph.lastUnderrunCount = metrics.underrunCount

      // WSOLA needs a short initial buffer, so startup underruns are expected.
      // After warm-up, sustained gaps indicate that this device cannot keep up.
      if (graph.metricsSnapshots > 3 && blockDelta > 0 && underrunDelta / blockDelta > 0.02) {
        disableGraph(graph, true)
      }
    })
    return createController(graph)
  } catch (error) {
    if (source) source.connect(context.destination)
    audio.playbackRate = 1
    console.warn('Time-stretch processor unavailable; keeping native 1.0x audio', error)
    return null
  }
}

function createController(graph: StretchGraph): TimeStretchController {
  return {
    get enabled() {
      return !graph.bypassed
    },
    setEnabled: (enabled) => {
      if (enabled) enableGraph(graph)
      else disableGraph(graph)
    },
    setTempo: (tempo) => {
      if (graph.bypassed) return
      resumeContext(graph)
      const clamped = Math.max(MIN_TEMPO, Math.min(MAX_TEMPO, tempo))
      graph.audio.playbackRate = clamped
      graph.node.playbackRate.setValueAtTime(clamped, graph.context.currentTime)
    },
    reset: () => {
      graph.audio.playbackRate = 1
      graph.node.playbackRate.setValueAtTime(1, graph.context.currentTime)
    },
  }
}