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() await this.stopIterator()
this.audioIterator = this.audioSink.buffers(this.currentTime) 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) { while (true) {
if (localId !== this.asyncId || this.paused) if (localId !== this.asyncId || this.paused)
return return
const nextPromise = this.audioIterator.next() const batch = []
let batchDone = false
// 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
try { 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) { catch (e) {
console.error('Audio iterator error:', e) console.error('Audio iterator error:', e)
break batchDone = true
}
finally {
clearInterval(checkStarvation)
} }
if (localId !== this.asyncId || this.paused) if (localId !== this.asyncId || this.paused)
return return
// Resume if was suspended // Resume if was suspended
if (this.audioContext.state === 'suspended') { if (batch.length > 0 && this.audioContext.state === 'suspended') {
await this.audioContext.resume() await this.audioContext.resume()
this.events.emit('canplay') this.events.emit('canplay')
this.events.emit('playing') 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 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 // Backpressure: if we've scheduled too far ahead, wait for playback
const node = this.audioContext.createBufferSource() // to catch up before decoding more. This prevents the audio pump from
node.buffer = buffer // consuming all WebCodecs resources and starving the video decoder.
node.connect(this.gainNode) const BUFFER_AHEAD = 1 // seconds
node.playbackRate.value = this.playbackRate while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
if (localId !== this.asyncId || this.paused)
const startAt return
= this.audioContextStartTime await new Promise(resolve => setTimeout(resolve, 50))
+ (timestamp - this.playbackTimeAtStart) / this.playbackRate
const duration = buffer.duration
const endAt = startAt + duration / this.playbackRate
if (endAt > this.latestScheduledEndTime) {
this.latestScheduledEndTime = endAt
} }
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.audioContextStartTime = this.audioContext.currentTime
this.latestScheduledEndTime = this.audioContextStartTime this.latestScheduledEndTime = this.playbackTimeAtStart
this.paused = false this.paused = false
const id = ++this.asyncId const id = ++this.asyncId
@@ -241,7 +247,7 @@ export default class AudioEngine {
async seek(time) { async seek(time) {
this.playbackTimeAtStart = Math.max(0, time) this.playbackTimeAtStart = Math.max(0, time)
this.audioContextStartTime = this.audioContext.currentTime this.audioContextStartTime = this.audioContext.currentTime
this.latestScheduledEndTime = this.audioContextStartTime this.latestScheduledEndTime = this.playbackTimeAtStart
const id = ++this.asyncId const id = ++this.asyncId
if (!this.paused) { if (!this.paused) {