mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
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:
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) {
|
||||
|
||||
Reference in new issue
Block a user