fix(audio): [PKG-AUDIO-05] checkpoint zero-time resume and native buffering evidence

This commit is contained in:
Harvey Zhao committed 2026-09-12 03:58:39 +08:00
1 parent 0218196df9
commit d18c545675
25 files changed
+14373 -32

No files matched your search

+38
View File
@@ -146,6 +146,44 @@ for (const { name, factory } of implementations) {
const historical = implementations.find(item => item.name === 'published-main').factory
for (const { name, factory: candidate } of implementations.filter(item => !item.name.startsWith('published'))) {
test(`${name}: native playback at zero resumes after seek without overriding proxy playing`, (t) => {
const { art } = audioHost(t)
Object.assign(art.video, { currentTime: 0, paused: false, ended: false, readyState: 4 })
const { audio } = candidate({ url: 'audio.aac' })(art)
audio.calls.length = 0
art.emit('video:seeking').emit('video:seeked')
assert.deepEqual(audio.calls, [['pause'], ['play']])
art.video.playing = false
audio.calls.length = 0
art.emit('video:seeked').emit('video:playing').emit('video:canplay')
assert.deepEqual(audio.calls, [], 'A proxy explicit false is authoritative')
art.video.playing = true
art.emit('video:seeked')
assert.deepEqual(audio.calls, [['play']])
})
test(`${name}: canplay recovers a paused audio after seeked preceded host readiness`, (t) => {
const { art } = audioHost(t)
const { audio } = candidate({ url: 'audio.aac' })(art)
art.currentTime = 3
art.emit('video:seeking').emit('video:seeked')
assert.equal(audio.currentTime, 3)
audio.calls.length = 0
art.emit('video:canplay')
assert.deepEqual(audio.calls, [], 'A paused host must not resume audio')
art.playing = true
art.emit('video:canplay')
assert.deepEqual(audio.calls, [['play']])
audio.paused = false
audio.calls.length = 0
art.emit('video:canplay')
assert.deepEqual(audio.calls, [], 'Already playing audio needs no additional play request')
art.emit('destroy')
audio.calls.length = 0
art.emit('video:canplay')
assert.deepEqual(audio.calls, [])
})
test(`${name}: closed callbacks and retained update cannot revive resources`, (t) => {
const { art, listeners } = audioHost(t)
const plugin = candidate({ url: 'audio.aac' })(art)
+22
View File
@@ -97,3 +97,25 @@ The docs `assets/sample/video.mp4` remains an existing sample with its original
tracked by BASE-MEDIA-01. The new synthetic fixtures do not resolve rights for existing media.
Browser binaries/codec support vary by OS; these projects do not replace Safari/iOS/Android
device checks. See the [official browser guidance](https://playwright.dev/docs/browsers).
## Withheld media diagnostics
`test/helpers/media-gate.js` binds a temporary HTTP server to loopback and withholds
the tail of the pinned media until the test calls release(). Its default response
keeps the full Content-Length and streams the prefix; the diagnostic completeRanges
mode sends a bounded 206 response and holds the later range. Both preserve exact
bytes and a strong ETag. Partial range semantics follow
[RFC 9110 section 15.3.7](https://www.rfc-editor.org/rfc/rfc9110.html#name-partial-content).
`node --test test/media-gate.test.js` verifies actual HTTP bytes, headers and release.
`audio-buffering.spec.js` requires trusted waiting after actual clock progression,
then release, resumed clocks and synchronization across old/new core/plugin pairs.
It currently has unresolved Windows WebKit failures; do not skip them or count
the native diagnostic below as equivalent acceptance. See refactor/audio-validation.md.
`media-gate-native.spec.js` removes all ArtPlayer instances and uses a native element
with preload=auto. It records whether playback and starvation were observed for
both response modes. A passing diagnostic only proves observation plus successful
playback after release; inspect its progressed/waiting flags before claiming buffering.
These capability observation timeouts do not relax the integration assertions.
Archive the report and results (including traces) before starting another browser suite.
+62
View File
@@ -0,0 +1,62 @@
import fs from 'node:fs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { createMediaGate } from '../helpers/media-gate.js'
import { useAudioFixture } from './audio-fixture.js'
import { expect, test } from './fixtures.js'
const { openAudio } = useAudioFixture()
for (const core of ['published', 'candidate']) {
for (const plugin of ['published', 'source']) {
for (const owner of ['video', 'audio']) {
test(`${core} core + ${plugin} audio: real ${owner} starvation resumes after network release`, async ({ page }, testInfo) => {
const file = owner === 'video' ? 'pattern.mp4' : 'audio-tone.m4a'
const bytes = fs.readFileSync(new URL(`./media/${file}`, import.meta.url))
const limit = owner === 'video' ? 96 * 1024 : 32 * 1024
const gate = await createMediaGate(bytes, owner === 'video' ? 'video/mp4' : 'audio/mp4', limit)
try {
await openAudio(page, core, plugin, testInfo, owner === 'audio' ? gate.url : '/test/audio-tone.m4a')
await page.evaluate((owner) => {
window.starvation = []
const media = owner === 'video' ? window.art.video : window.audioPlugin.audio
media.addEventListener('waiting', (event) => {
window.starvation.push({ trusted: event.isTrusted, time: media.currentTime, readyState: media.readyState, audioPaused: window.audioPlugin.audio.paused, videoTime: window.art.currentTime })
})
}, owner)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime > 0.3 && window.art.currentTime > 0.3)).toBe(true)
if (owner === 'video')
await page.evaluate(url => window.art.switchUrl(url), gate.url)
await expect.poll(() => page.evaluate(() => window.starvation.some(event => event.trusted && event.time > 0.3 && event.readyState < 3))).toBe(true)
expect(gate.blocked).toBeGreaterThan(0)
const stalled = await page.evaluate(() => ({ audio: window.audioPlugin.audio.currentTime, video: window.art.currentTime }))
if (owner === 'video') {
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
}
else {
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stalled.video + 0.3)
expect(await page.evaluate(() => window.art.video.paused)).toBe(false)
}
gate.release()
await expect.poll(() => page.evaluate(() => !window.audioPlugin.audio.paused && !window.art.video.paused)).toBe(true)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(stalled.audio + 0.3)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stalled.video + 0.3)
await expect.poll(() => page.evaluate(() => Math.abs(window.audioPlugin.audio.currentTime - window.art.currentTime))).toBeLessThan(0.5)
expect(await page.evaluate(() => [window.art.video.error, window.audioPlugin.audio.error])).toEqual([null, null])
}
finally {
try {
await testInfo.attach('media-gate-requests', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), bytes: bytes.length, limit, requests: gate.requests }) })
const events = page.isClosed() ? { closed: true } : await page.evaluate(() => window.starvation)
await testInfo.attach('media-gate', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), bytes: bytes.length, limit, requests: gate.requests, events }) })
if (!page.isClosed())
await page.evaluate(() => window.art?.destroy())
}
finally {
await gate.close()
}
}
})
}
}
}
+38 -7
View File
@@ -80,33 +80,64 @@ for (const core of ['published', 'candidate']) {
test(`${core} core + ${plugin} audio: native offset bounds recover to an in-range seek`, async ({ page }, testInfo) => {
await openAudio(page, core, plugin, testInfo)
await page.evaluate(() => {
window.boundaryAudio = new Audio('/test/audio-tone.m4a?boundary=native')
window.boundaryAudio.muted = true
document.querySelector('#play').onclick = () => Promise.all([window.art.play(), window.boundaryAudio.play()])
})
await expect.poll(() => page.evaluate(() => window.boundaryAudio.readyState)).toBeGreaterThanOrEqual(2)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => {
window.art.pause()
window.boundaryAudio.pause()
window.audioPlugin.update({ offset: -2 })
window.art.seek = 0.5
window.boundaryAudio.currentTime = -1.5
})
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking)).toBe(true)
const negative = await page.evaluate(() => ({ time: window.audioPlugin.audio.currentTime, error: window.audioPlugin.audio.error?.code || null }))
expect(negative).toEqual({ time: 0, error: null })
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking && !window.boundaryAudio.seeking)).toBe(true)
const negative = await page.evaluate(() => {
const state = audio => ({ time: audio.currentTime, error: audio.error?.code || null })
return { audio: state(window.audioPlugin.audio), native: state(window.boundaryAudio) }
})
await testInfo.attach('negative-offset-native', { contentType: 'application/json', body: JSON.stringify(negative) })
expect(negative.native.time).toBeGreaterThanOrEqual(0)
expect(negative.native.time).toBeCloseTo(0, 1)
expect(negative.audio.time).toBeGreaterThanOrEqual(0)
expect(negative.audio.time).toBeCloseTo(negative.native.time, 1)
expect([negative.audio.error, negative.native.error]).toEqual([null, null])
await page.evaluate(() => {
window.audioPlugin.update({ offset: 20 })
window.art.seek = 1
window.boundaryAudio.currentTime = 21
})
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking)).toBe(true)
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBe(16)
await expect.poll(() => page.evaluate(() => !window.art.video.seeking && !window.audioPlugin.audio.seeking && !window.boundaryAudio.seeking)).toBe(true)
const overflow = await page.evaluate(() => {
const state = audio => ({ time: audio.currentTime, duration: audio.duration, error: audio.error?.code || null })
return { audio: state(window.audioPlugin.audio), native: state(window.boundaryAudio) }
})
await testInfo.attach('overflow-offset-native', { contentType: 'application/json', body: JSON.stringify(overflow) })
expect(overflow.native.duration).toBe(16)
expect(overflow.native.time).toBeCloseTo(overflow.native.duration, 1)
expect(overflow.audio.time).toBeCloseTo(overflow.native.time, 1)
expect([overflow.audio.error, overflow.native.error]).toEqual([null, null])
expect(await page.evaluate(() => window.audioPlugin.audio.paused)).toBe(true)
await page.evaluate(() => {
window.audioPlugin.update({ offset: -0.5 })
window.art.seek = 3
window.boundaryAudio.currentTime = 2.5
})
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeCloseTo(2.5, 1)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.audioPlugin.audio.currentTime)).toBeGreaterThan(2.8)
await expect.poll(() => page.evaluate(() => Math.abs(window.audioPlugin.audio.currentTime - window.art.currentTime + 0.5))).toBeLessThan(0.5)
await testInfo.attach('offset-boundary', { contentType: 'application/json', body: JSON.stringify({ negative, overflow: 16, recovered: true }) })
await page.evaluate(() => window.art.destroy())
await testInfo.attach('offset-boundary', { contentType: 'application/json', body: JSON.stringify({ negative, overflow, recovered: true }) })
await page.evaluate(() => {
window.art.destroy()
window.boundaryAudio.pause()
window.boundaryAudio.removeAttribute('src')
window.boundaryAudio.load()
})
})
}
}
+2 -2
View File
@@ -72,8 +72,8 @@ export function useAudioFixture() {
plugins: [window.artplayerPluginAudioTrack({ url, sync: 0.1 })],
})
window.audioPlugin = window.art.plugins.artplayerPluginAudioTrack
for (const event of ['play', 'pause', 'seek', 'video:waiting', 'video:playing', 'video:seeking', 'video:seeked', 'video:pause', 'video:ended'])
window.art.on(event, () => window.audioHostEvents.push({ event, videoTime: window.art.currentTime, audioTime: window.audioPlugin.audio.currentTime, audioPaused: window.audioPlugin.audio.paused }))
for (const event of ['play', 'pause', 'seek', 'video:waiting', 'video:playing', 'video:canplay', 'video:seeking', 'video:seeked', 'video:pause', 'video:ended'])
window.art.on(event, () => window.audioHostEvents.push({ event, videoTime: window.art.currentTime, videoPaused: window.art.video.paused, videoReadyState: window.art.video.readyState, playing: window.art.playing, audioTime: window.audioPlugin.audio.currentTime, audioPaused: window.audioPlugin.audio.paused }))
for (const event of ['loadedmetadata', 'playing', 'pause', 'seeked', 'error', 'emptied'])
window.audioPlugin.audio.addEventListener(event, () => window.audioEvents.push({ event, time: window.audioPlugin.audio.currentTime, src: window.audioPlugin.audio.currentSrc }))
document.querySelector('#play').onclick = () => window.art.play()
+63
View File
@@ -0,0 +1,63 @@
import fs from 'node:fs'
import { hash } from '../../refactor/scripts/releases.mjs'
import { createMediaGate } from '../helpers/media-gate.js'
import { expect, test } from './fixtures.js'
for (const owner of ['video', 'audio']) {
for (const completeRanges of [false, true]) {
test(`native ${owner} ${completeRanges ? 'complete ranges' : 'streamed response'} capability without ArtPlayer instances`, async ({ page }, testInfo) => {
const file = owner === 'video' ? 'pattern.mp4' : 'audio-tone.m4a'
const bytes = fs.readFileSync(new URL(`./media/${file}`, import.meta.url))
const limit = owner === 'video' ? 96 * 1024 : 32 * 1024
const gate = await createMediaGate(bytes, owner === 'video' ? 'video/mp4' : 'audio/mp4', limit, completeRanges)
try {
await page.goto('/test/player.html')
await page.evaluate(({ owner, url }) => {
window.nativeMedia = document.createElement(owner)
window.nativeMedia.preload = 'auto'
window.nativeMedia.muted = true
window.nativeEvents = []
for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'playing', 'waiting', 'stalled', 'pause', 'error'])
window.nativeMedia.addEventListener(event, e => window.nativeEvents.push({ event, trusted: e.isTrusted, time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState }))
window.nativeMedia.src = url
document.body.append(window.nativeMedia)
document.querySelector('#play').onclick = () => window.nativeMedia.play()
}, { owner, url: gate.url })
await expect.poll(() => page.evaluate(() => window.nativeMedia.readyState >= 2 || Boolean(window.nativeMedia.error))).toBe(true)
await page.locator('#play').click()
let progressed = false
let waiting = false
try {
await page.waitForFunction(() => window.nativeMedia.currentTime > 0.3, null, { timeout: 3000 })
progressed = true
await page.waitForFunction(() => window.nativeEvents.some(event => event.event === 'waiting' && event.trusted && event.time > 0.3 && event.readyState < 3), null, { timeout: 7000 })
waiting = true
}
catch (error) {
if (error.name !== 'TimeoutError')
throw error
}
const before = await page.evaluate(() => ({ time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState, paused: window.nativeMedia.paused, error: window.nativeMedia.error?.code || null, instances: window.Artplayer.instances.length, events: window.nativeEvents }))
expect(before.instances).toBe(0)
expect(gate.blocked).toBeGreaterThan(0)
gate.release()
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.nativeMedia.currentTime)).toBeGreaterThan(before.time + 0.3)
expect(await page.evaluate(() => window.nativeMedia.error)).toBeNull()
await testInfo.attach('native-gate-capability', { contentType: 'application/json', body: JSON.stringify({ file, sha256: hash(bytes), limit, completeRanges, progressed, waiting, before, requests: gate.requests, after: await page.evaluate(() => ({ time: window.nativeMedia.currentTime, readyState: window.nativeMedia.readyState, paused: window.nativeMedia.paused })) }) })
}
finally {
try {
await page.evaluate(() => {
window.nativeMedia?.pause()
window.nativeMedia?.removeAttribute('src')
window.nativeMedia?.load()
})
}
finally {
await gate.close()
}
}
})
}
}
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import http from 'node:http'
// Serves real media bytes, withholding the tail until a test observes native starvation.
export async function createMediaGate(bytes, contentType, limit, completeRanges = false) {
assert(Number.isSafeInteger(limit) && limit > 0 && limit < bytes.length)
const requests = []
const pending = new Map()
let released = false
const etag = `"${createHash('sha256').update(bytes).digest('hex')}"`
const server = http.createServer((req, res) => {
if (!['GET', 'HEAD'].includes(req.method) || !req.url.startsWith('/media.')) {
res.writeHead(404).end()
return
}
let start = 0
let end = bytes.length - 1
if (req.headers.range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range)
start = match?.[1] ? Number(match[1]) : Math.max(0, bytes.length - Number(match?.[2]))
end = match?.[1] && match[2] ? Math.min(bytes.length - 1, Number(match[2])) : bytes.length - 1
if (!match || (!match[1] && !match[2]) || !Number.isSafeInteger(start) || start > end || start >= bytes.length) {
res.writeHead(416, { 'Content-Range': `bytes */${bytes.length}` }).end()
return
}
res.statusCode = 206
if (completeRanges && !released && start < limit)
end = Math.min(end, limit - 1)
res.setHeader('Content-Range', `bytes ${start}-${end}/${bytes.length}`)
}
res.setHeader('Content-Type', contentType)
res.setHeader('Content-Length', end - start + 1)
res.setHeader('Accept-Ranges', 'bytes')
res.setHeader('Cache-Control', 'no-store')
res.setHeader('ETag', etag)
res.setHeader('Access-Control-Allow-Origin', '*')
const record = { method: req.method, range: req.headers.range || null, start, end, sent: 0, wasHeld: false, closed: false }
requests.push(record)
res.on('close', () => {
record.closed = true
pending.delete(res)
})
res.on('error', (error) => {
record.error = error.message
})
if (req.method === 'HEAD') {
res.end()
return
}
const availableEnd = released ? end + 1 : Math.min(end + 1, limit)
const sentEnd = Math.max(start, availableEnd)
if (sentEnd > start) {
res.write(bytes.subarray(start, sentEnd))
record.sent += sentEnd - start
}
if (sentEnd > end) {
res.end()
}
else {
record.wasHeld = true
pending.set(res, { offset: sentEnd, end, record })
res.flushHeaders()
}
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
return {
url: `http://127.0.0.1:${address.port}/media.${contentType === 'video/mp4' ? 'mp4' : 'm4a'}`,
requests,
get blocked() { return pending.size },
release() {
released = true
for (const [res, { offset, end, record }] of pending) {
if (!res.destroyed) {
record.sent += end + 1 - offset
res.end(bytes.subarray(offset, end + 1))
}
}
pending.clear()
},
async close() {
server.closeAllConnections()
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
},
}
}
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
// eslint-disable-next-line test/no-import-node-test -- Test the real local HTTP fixture with Node's runner.
import test from 'node:test'
import { setImmediate } from 'node:timers/promises'
import { createMediaGate } from './helpers/media-gate.js'
test('media gate completes a bounded range and withholds the rest without altering bytes', async () => {
const bytes = Buffer.from('0123456789abcdef')
const gate = await createMediaGate(bytes, 'audio/mp4', 5, true)
try {
const first = await fetch(gate.url, { headers: { Range: 'bytes=0-' }, signal: AbortSignal.timeout(2000) })
assert.equal(first.status, 206)
assert.equal(first.headers.get('Content-Range'), 'bytes 0-4/16')
assert.equal(first.headers.get('Content-Length'), '5')
assert.deepEqual(Buffer.from(await first.arrayBuffer()), bytes.subarray(0, 5))
const tail = await fetch(gate.url, { headers: { Range: 'bytes=5-' }, signal: AbortSignal.timeout(2000) })
assert.equal(tail.status, 206)
assert.equal(tail.headers.get('ETag'), first.headers.get('ETag'))
assert.equal(gate.blocked, 1)
let finished = false
const remaining = tail.arrayBuffer().then((body) => {
finished = true
return Buffer.from(body)
})
await setImmediate()
assert.equal(finished, false)
gate.release()
assert.deepEqual(await remaining, bytes.subarray(5))
assert.equal(gate.blocked, 0)
assert(gate.requests.some(request => request.wasHeld))
}
finally {
await gate.close()
}
})
test('media gate streams a prefix within a full declared response and releases the remaining bytes', async () => {
const bytes = Buffer.from('0123456789abcdef')
const gate = await createMediaGate(bytes, 'audio/mp4', 5)
try {
const response = await fetch(gate.url, { headers: { Range: 'bytes=0-' }, signal: AbortSignal.timeout(2000) })
assert.equal(response.headers.get('Content-Range'), 'bytes 0-15/16')
assert.equal(response.headers.get('Content-Length'), '16')
const reader = response.body.getReader()
const first = await reader.read()
assert.deepEqual(Buffer.from(first.value), bytes.subarray(0, 5))
let finished = false
const tail = reader.read().then((value) => {
finished = true
return value
})
await setImmediate()
assert.equal(finished, false)
assert.equal(gate.blocked, 1)
gate.release()
assert.deepEqual(Buffer.from((await tail).value), bytes.subarray(5))
assert.equal((await reader.read()).done, true)
}
finally {
await gate.close()
}
})
test('media gate preserves HEAD and rejects invalid ranges before serving the released suffix', async () => {
const gate = await createMediaGate(Buffer.from('0123456789'), 'video/mp4', 4)
try {
const head = await fetch(gate.url, { method: 'HEAD' })
assert.equal(head.status, 200)
assert.equal(head.headers.get('Content-Length'), '10')
assert.equal(await head.text(), '')
assert.equal(gate.blocked, 0)
const invalid = await fetch(gate.url, { headers: { Range: 'bytes=20-' } })
assert.equal(invalid.status, 416)
assert.equal(invalid.headers.get('Content-Range'), 'bytes */10')
gate.release()
const suffix = await fetch(gate.url, { headers: { Range: 'bytes=-3' } })
assert.equal(suffix.headers.get('Content-Range'), 'bytes 7-9/10')
assert.equal(await suffix.text(), '789')
}
finally {
await gate.close()
}
})