mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-29 11:11:20 -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()
|
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) {
|
||||||
|
|||||||
Reference in new issue
Block a user