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
This commit is contained in:
Meta42 authored and GitHub committed 2026-06-07 09:18:04 +08:00
1 parent fc90d4a0df
commit 4c7d0aa9fa
1 file changed
+63 -57
@@ -126,84 +126,90 @@ export default class AudioEngine {
await this.stopIterator()
this.audioIterator = this.audioSink.buffers(this.currentTime)
// Batch size: read multiple audio buffers per iteration to reduce
// IPC round-trip overhead. 16 buffers ≈ 340ms of audio.
const BATCH_SIZE = 16
while (true) {
if (localId !== this.asyncId || this.paused)
return
const nextPromise = this.audioIterator.next()
// Monitor for buffer starvation
const checkStarvation = setInterval(() => {
if (localId !== this.asyncId || this.paused) {
clearInterval(checkStarvation)
return
}
if (
this.audioContext.state === 'running'
&& this.audioContext.currentTime >= this.latestScheduledEndTime - 0.2
) {
this.audioContext.suspend()
this.events.emit('waiting')
}
}, 50)
let result
const batch = []
let batchDone = false
try {
result = await nextPromise
for (let i = 0; i < BATCH_SIZE; i++) {
const result = await this.audioIterator.next()
if (result.done) {
batchDone = true
break
}
batch.push(result.value)
}
}
catch (e) {
console.error('Audio iterator error:', e)
break
}
finally {
clearInterval(checkStarvation)
batchDone = true
}
if (localId !== this.asyncId || this.paused)
return
// Resume if was suspended
if (this.audioContext.state === 'suspended') {
if (batch.length > 0 && this.audioContext.state === 'suspended') {
await this.audioContext.resume()
this.events.emit('canplay')
this.events.emit('playing')
}
if (result.done)
// Schedule all buffers in the batch
for (const { buffer, timestamp } of batch) {
const node = this.audioContext.createBufferSource()
node.buffer = buffer
node.connect(this.gainNode)
node.playbackRate.value = this.playbackRate
const startAt
= this.audioContextStartTime
+ (timestamp - this.playbackTimeAtStart) / this.playbackRate
const duration = buffer.duration
const endAt = startAt + duration / this.playbackRate
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart
if (endMediaTime > this.latestScheduledEndTime) {
this.latestScheduledEndTime = endMediaTime
}
if (startAt >= this.audioContext.currentTime) {
node.start(startAt)
}
else {
node.start(
this.audioContext.currentTime,
(this.audioContext.currentTime - startAt) * this.playbackRate,
)
}
this.queuedNodes.add(node)
node.onended = () => this.queuedNodes.delete(node)
}
if (batchDone)
break
const { buffer, timestamp } = result.value
// Yield main thread after each batch so video rAF callbacks can run.
// Without this, the audio pump's tight loop starves the render loop.
await new Promise(resolve => setTimeout(resolve, 0))
// Schedule audio buffer
const node = this.audioContext.createBufferSource()
node.buffer = buffer
node.connect(this.gainNode)
node.playbackRate.value = this.playbackRate
const startAt
= this.audioContextStartTime
+ (timestamp - this.playbackTimeAtStart) / this.playbackRate
const duration = buffer.duration
const endAt = startAt + duration / this.playbackRate
if (endAt > this.latestScheduledEndTime) {
this.latestScheduledEndTime = endAt
// Backpressure: if we've scheduled too far ahead, wait for playback
// to catch up before decoding more. This prevents the audio pump from
// consuming all WebCodecs resources and starving the video decoder.
const BUFFER_AHEAD = 1 // seconds
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
if (localId !== this.asyncId || this.paused)
return
await new Promise(resolve => setTimeout(resolve, 50))
}
if (startAt >= this.audioContext.currentTime) {
node.start(startAt)
}
else {
node.start(
this.audioContext.currentTime,
(this.audioContext.currentTime - startAt) * this.playbackRate,
)
}
this.queuedNodes.add(node)
node.onended = () => this.queuedNodes.delete(node)
}
}
@@ -220,7 +226,7 @@ export default class AudioEngine {
}
this.audioContextStartTime = this.audioContext.currentTime
this.latestScheduledEndTime = this.audioContextStartTime
this.latestScheduledEndTime = this.playbackTimeAtStart
this.paused = false
const id = ++this.asyncId
@@ -241,7 +247,7 @@ export default class AudioEngine {
async seek(time) {
this.playbackTimeAtStart = Math.max(0, time)
this.audioContextStartTime = this.audioContext.currentTime
this.latestScheduledEndTime = this.audioContextStartTime
this.latestScheduledEndTime = this.playbackTimeAtStart
const id = ++this.asyncId
if (!this.paused) {