Skip to content

Commit 4c7d0aa

Browse files
authored
fix: audio pump starvation on large files with audio (#1041)
Root causes fixed: 1. Remove starvation check that suspends AudioContext — it freezes the video clock and causes playback to stall permanently 2. Add backpressure (1s BUFFER_AHEAD) to prevent audio pump from decoding unbounded buffers, starving the video decoder 3. Add setTimeout(0) yield between batches so video rAF callbacks can run 4. Batch audio buffer reads (16 per iteration) to reduce IPC overhead 5. Fix latestScheduledEndTime time-domain: use media-time (same domain as currentTime) instead of AudioContext-time, so backpressure check works correctly after seeking Without these fixes, playing a large file (9+ GB) with audio causes: - Audio pump decodes hundreds of buffers in 1 second (195s of audio decoded in 1s wall clock) - Main thread blocked by 177 IPC calls/sec from audio iterator - Video frames never render (rAF starved) - After seek: backpressure never triggers due to time-domain mismatch, causing persistent stutter and eventual silence
1 parent fc90d4a commit 4c7d0aa

1 file changed

Lines changed: 59 additions & 53 deletions

File tree

packages/artplayer-proxy-mediabunny/src/AudioEngine.js

Lines changed: 59 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -126,84 +126,90 @@ export default class AudioEngine {
126126
await this.stopIterator()
127127
this.audioIterator = this.audioSink.buffers(this.currentTime)
128128

129+
// Batch size: read multiple audio buffers per iteration to reduce
130+
// IPC round-trip overhead. 16 buffers ≈ 340ms of audio.
131+
const BATCH_SIZE = 16
132+
129133
while (true) {
130134
if (localId !== this.asyncId || this.paused)
131135
return
132136

133-
const nextPromise = this.audioIterator.next()
134-
135-
// Monitor for buffer starvation
136-
const checkStarvation = setInterval(() => {
137-
if (localId !== this.asyncId || this.paused) {
138-
clearInterval(checkStarvation)
139-
return
140-
}
141-
142-
if (
143-
this.audioContext.state === 'running'
144-
&& this.audioContext.currentTime >= this.latestScheduledEndTime - 0.2
145-
) {
146-
this.audioContext.suspend()
147-
this.events.emit('waiting')
148-
}
149-
}, 50)
150-
151-
let result
137+
const batch = []
138+
let batchDone = false
152139
try {
153-
result = await nextPromise
140+
for (let i = 0; i < BATCH_SIZE; i++) {
141+
const result = await this.audioIterator.next()
142+
if (result.done) {
143+
batchDone = true
144+
break
145+
}
146+
batch.push(result.value)
147+
}
154148
}
155149
catch (e) {
156150
console.error('Audio iterator error:', e)
157-
break
158-
}
159-
finally {
160-
clearInterval(checkStarvation)
151+
batchDone = true
161152
}
162153

163154
if (localId !== this.asyncId || this.paused)
164155
return
165156

166157
// Resume if was suspended
167-
if (this.audioContext.state === 'suspended') {
158+
if (batch.length > 0 && this.audioContext.state === 'suspended') {
168159
await this.audioContext.resume()
169160
this.events.emit('canplay')
170161
this.events.emit('playing')
171162
}
172163

173-
if (result.done)
174-
break
164+
// Schedule all buffers in the batch
165+
for (const { buffer, timestamp } of batch) {
166+
const node = this.audioContext.createBufferSource()
167+
node.buffer = buffer
168+
node.connect(this.gainNode)
169+
node.playbackRate.value = this.playbackRate
175170

176-
const { buffer, timestamp } = result.value
171+
const startAt
172+
= this.audioContextStartTime
173+
+ (timestamp - this.playbackTimeAtStart) / this.playbackRate
177174

178-
// Schedule audio buffer
179-
const node = this.audioContext.createBufferSource()
180-
node.buffer = buffer
181-
node.connect(this.gainNode)
182-
node.playbackRate.value = this.playbackRate
175+
const duration = buffer.duration
176+
const endAt = startAt + duration / this.playbackRate
183177

184-
const startAt
185-
= this.audioContextStartTime
186-
+ (timestamp - this.playbackTimeAtStart) / this.playbackRate
178+
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart
179+
if (endMediaTime > this.latestScheduledEndTime) {
180+
this.latestScheduledEndTime = endMediaTime
181+
}
187182

188-
const duration = buffer.duration
189-
const endAt = startAt + duration / this.playbackRate
183+
if (startAt >= this.audioContext.currentTime) {
184+
node.start(startAt)
185+
}
186+
else {
187+
node.start(
188+
this.audioContext.currentTime,
189+
(this.audioContext.currentTime - startAt) * this.playbackRate,
190+
)
191+
}
190192

191-
if (endAt > this.latestScheduledEndTime) {
192-
this.latestScheduledEndTime = endAt
193+
this.queuedNodes.add(node)
194+
node.onended = () => this.queuedNodes.delete(node)
193195
}
194196

195-
if (startAt >= this.audioContext.currentTime) {
196-
node.start(startAt)
197-
}
198-
else {
199-
node.start(
200-
this.audioContext.currentTime,
201-
(this.audioContext.currentTime - startAt) * this.playbackRate,
202-
)
203-
}
197+
if (batchDone)
198+
break
199+
200+
// Yield main thread after each batch so video rAF callbacks can run.
201+
// Without this, the audio pump's tight loop starves the render loop.
202+
await new Promise(resolve => setTimeout(resolve, 0))
204203

205-
this.queuedNodes.add(node)
206-
node.onended = () => this.queuedNodes.delete(node)
204+
// Backpressure: if we've scheduled too far ahead, wait for playback
205+
// to catch up before decoding more. This prevents the audio pump from
206+
// consuming all WebCodecs resources and starving the video decoder.
207+
const BUFFER_AHEAD = 1 // seconds
208+
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
209+
if (localId !== this.asyncId || this.paused)
210+
return
211+
await new Promise(resolve => setTimeout(resolve, 50))
212+
}
207213
}
208214
}
209215

@@ -220,7 +226,7 @@ export default class AudioEngine {
220226
}
221227

222228
this.audioContextStartTime = this.audioContext.currentTime
223-
this.latestScheduledEndTime = this.audioContextStartTime
229+
this.latestScheduledEndTime = this.playbackTimeAtStart
224230
this.paused = false
225231

226232
const id = ++this.asyncId
@@ -241,7 +247,7 @@ export default class AudioEngine {
241247
async seek(time) {
242248
this.playbackTimeAtStart = Math.max(0, time)
243249
this.audioContextStartTime = this.audioContext.currentTime
244-
this.latestScheduledEndTime = this.audioContextStartTime
250+
this.latestScheduledEndTime = this.playbackTimeAtStart
245251

246252
const id = ++this.asyncId
247253
if (!this.paused) {

0 commit comments

Comments
 (0)