mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-16 01:26:16 -08:00
refactor(mediabunny): [PKG-MB-06] own audio clocks, buffers and context lifecycle
This commit is contained in:
1 parent
9d037dcbf9
commit
ccee9157a7
30 files changed
+16182
-736
No files matched your search
@@ -0,0 +1,254 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { mbCandidate } from '../helpers/mediabunny.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await mbCandidate()
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
|
||||
const hls = new Map(Object.entries(manifest.files).map(([name, expected]) => {
|
||||
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
|
||||
assert.equal(hash(bytes), expected.sha256)
|
||||
return [name, bytes]
|
||||
}))
|
||||
|
||||
for (const scenario of ['playback', 'buffer-pause', 'buffer-rate', 'resume-pause', 'resume-source', 'resume-destroy', 'av-clock', 'no-track']) {
|
||||
test(`MediaBunny ${implementation.name}: native audio ownership ${scenario}`, async ({ page, browserName }, testInfo) => {
|
||||
await page.route('**/mb-audio-hls/**', async (route) => {
|
||||
const name = new URL(route.request().url()).pathname.split('/').at(-1)
|
||||
const body = hls.get(name)
|
||||
await route.fulfill({ status: body ? 200 : 404, body: body || 'Missing fixture', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
|
||||
})
|
||||
await page.goto('/test/player.html?core=published')
|
||||
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports;
|
||||
${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
|
||||
const capabilities = await page.evaluate(() => ({ AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext, AudioDecoder: typeof AudioDecoder, VideoDecoder: typeof VideoDecoder }))
|
||||
let outcome = 'unverified'
|
||||
let result
|
||||
await page.evaluate(async (scenario) => {
|
||||
const listeners = new Map()
|
||||
window.mbEvents = []
|
||||
window.mbNodes = []
|
||||
window.mbClockSamples = []
|
||||
window.mbFlush = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
window.mbHost = {
|
||||
constructor: window.Artplayer,
|
||||
option: { url: '', autoSize: false },
|
||||
template: { $player: document.querySelector('.player') },
|
||||
on(name, fn) {
|
||||
const list = listeners.get(name) || []
|
||||
list.push(fn)
|
||||
listeners.set(name, list)
|
||||
},
|
||||
emit(name, ...args) {
|
||||
window.mbEvents.push(name)
|
||||
for (const fn of listeners.get(name) || []) fn(...args)
|
||||
},
|
||||
}
|
||||
const canvas = window.mbFactory()(window.mbHost)
|
||||
window.mbCanvas = canvas
|
||||
document.querySelector('.player').append(canvas)
|
||||
await canvas.engine.load(scenario === 'av-clock' ? '/mb-audio-hls/master.m3u8' : scenario === 'no-track' ? '/test/pattern.mp4' : '/test/audio-tone.m4a')
|
||||
const audio = canvas.engine.audio
|
||||
const context = audio.audioContext
|
||||
window.mbContext = context
|
||||
if (context) {
|
||||
const create = context.createBufferSource.bind(context)
|
||||
context.createBufferSource = () => {
|
||||
const node = create()
|
||||
const record = { node, stops: 0, disconnects: 0, starts: [] }
|
||||
const stop = node.stop.bind(node)
|
||||
const disconnect = node.disconnect.bind(node)
|
||||
const start = node.start.bind(node)
|
||||
node.stop = (...args) => {
|
||||
record.stops++
|
||||
return stop(...args)
|
||||
}
|
||||
node.disconnect = (...args) => {
|
||||
record.disconnects++
|
||||
return disconnect(...args)
|
||||
}
|
||||
node.start = (...args) => {
|
||||
record.starts.push({ args, rate: node.playbackRate.value, at: context.currentTime })
|
||||
return start(...args)
|
||||
}
|
||||
window.mbNodes.push(record)
|
||||
return node
|
||||
}
|
||||
}
|
||||
canvas.muted = true
|
||||
document.querySelector('#play').onclick = () => {
|
||||
window.mbPlay = canvas.play()
|
||||
}
|
||||
}, scenario)
|
||||
try {
|
||||
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
|
||||
expect(browserName).toBe('webkit')
|
||||
expect(capabilities.AudioDecoder).toBe('undefined')
|
||||
expect(await page.evaluate(() => window.mbCanvas.error?.code)).toBe(4)
|
||||
expect(await page.evaluate(() => window.mbCanvas.readyState)).toBeLessThan(4)
|
||||
outcome = 'unsupported-capability-control'
|
||||
return
|
||||
}
|
||||
expect(await page.evaluate(() => window.mbCanvas.error)).toBeNull()
|
||||
expect(await page.evaluate(() => window.mbCanvas.readyState)).toBe(4)
|
||||
if (scenario.startsWith('resume-')) {
|
||||
await page.evaluate(async () => {
|
||||
const context = window.mbContext
|
||||
await context.suspend()
|
||||
const resume = context.resume.bind(context)
|
||||
context.resume = () => {
|
||||
const native = resume()
|
||||
window.mbResumeCalled = true
|
||||
return native.then(() => new Promise((resolve) => {
|
||||
window.mbReleaseResume = resolve
|
||||
}))
|
||||
}
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => typeof window.mbReleaseResume)).toBe('function')
|
||||
result = await page.evaluate(async (scenario) => {
|
||||
if (scenario === 'resume-pause')
|
||||
window.mbCanvas.pause()
|
||||
else if (scenario === 'resume-source')
|
||||
await window.mbCanvas.engine.load('/test/pattern.mp4')
|
||||
else window.mbHost.emit('destroy')
|
||||
window.mbReleaseResume()
|
||||
await window.mbPlay
|
||||
await window.mbFlush()
|
||||
const audio = window.mbCanvas.engine.audio
|
||||
return { paused: audio.paused, queued: audio.queuedNodes.size, created: window.mbNodes.length, iterator: Boolean(audio.audioIterator), audioSink: Boolean(audio.audioSink) }
|
||||
}, scenario)
|
||||
expect(result).toMatchObject({ paused: true, queued: 0, created: 0, iterator: false })
|
||||
if (scenario !== 'resume-pause')
|
||||
expect(result.audioSink).toBe(false)
|
||||
}
|
||||
else if (scenario.startsWith('buffer-')) {
|
||||
await page.evaluate(() => {
|
||||
const sink = window.mbCanvas.engine.audio.audioSink
|
||||
const buffers = sink.buffers.bind(sink)
|
||||
let first = true
|
||||
sink.buffers = (...args) => {
|
||||
const iterator = buffers(...args)
|
||||
const next = iterator.next.bind(iterator)
|
||||
iterator.next = async (...args) => {
|
||||
const value = await next(...args)
|
||||
if (first && !value.done) {
|
||||
first = false
|
||||
window.mbHeldBuffer = value.value.buffer
|
||||
return new Promise((resolve) => {
|
||||
window.mbReleaseBuffer = () => resolve(value)
|
||||
})
|
||||
}
|
||||
return value
|
||||
}
|
||||
return iterator
|
||||
}
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => typeof window.mbReleaseBuffer)).toBe('function')
|
||||
await page.evaluate((scenario) => {
|
||||
if (scenario === 'buffer-pause')
|
||||
window.mbCanvas.pause()
|
||||
else window.mbCanvas.playbackRate = 2
|
||||
window.mbReleaseBuffer()
|
||||
}, scenario)
|
||||
if (scenario === 'buffer-rate')
|
||||
await expect.poll(() => page.evaluate(() => window.mbNodes.length)).toBeGreaterThan(0)
|
||||
result = await page.evaluate(async () => {
|
||||
await window.mbFlush()
|
||||
const audio = window.mbCanvas.engine.audio
|
||||
return { obsoleteScheduled: window.mbNodes.some(record => record.node.buffer === window.mbHeldBuffer), rates: window.mbNodes.flatMap(record => record.starts.map(start => start.rate)), queued: audio.queuedNodes.size, paused: audio.paused }
|
||||
})
|
||||
expect(result.obsoleteScheduled).toBe(false)
|
||||
if (scenario === 'buffer-pause')
|
||||
expect(result).toMatchObject({ queued: 0, paused: true })
|
||||
else expect(result.rates.every(rate => rate === 2)).toBe(true)
|
||||
}
|
||||
else {
|
||||
if (scenario === 'av-clock') {
|
||||
await page.evaluate(async () => {
|
||||
const video = window.mbCanvas.engine.video
|
||||
const times = new WeakMap()
|
||||
const prototype = Object.getPrototypeOf(video.videoSink)
|
||||
const canvases = prototype.canvases
|
||||
prototype.canvases = function (...args) {
|
||||
const iterator = canvases.apply(this, args)
|
||||
const next = iterator.next.bind(iterator)
|
||||
iterator.next = async (...values) => {
|
||||
const result = await next(...values)
|
||||
if (!result.done)
|
||||
times.set(result.value.canvas, result.value.timestamp)
|
||||
return result
|
||||
}
|
||||
return iterator
|
||||
}
|
||||
const draw = video.ctx.drawImage.bind(video.ctx)
|
||||
video.ctx.drawImage = (...args) => {
|
||||
if (times.has(args[0]))
|
||||
window.mbClockSamples.push({ frame: times.get(args[0]), audio: window.mbCanvas.engine.audio.currentTime, rate: window.mbCanvas.playbackRate, paused: window.mbCanvas.paused })
|
||||
return draw(...args)
|
||||
}
|
||||
await window.mbCanvas.engine.seek(1)
|
||||
})
|
||||
}
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(scenario === 'av-clock' ? 1.3 : 0.3)
|
||||
if (scenario !== 'no-track')
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.engine.audio.queuedNodes.size)).toBeGreaterThan(0)
|
||||
const pause = await page.evaluate(async () => {
|
||||
window.mbCanvas.pause()
|
||||
const time = window.mbCanvas.currentTime
|
||||
await window.mbFlush()
|
||||
return { time, after: window.mbCanvas.currentTime, queued: window.mbCanvas.engine.audio.queuedNodes.size, retained: window.mbNodes.filter(record => !record.disconnects || record.node.onended !== null).length }
|
||||
})
|
||||
expect(pause.after).toBe(pause.time)
|
||||
expect(pause).toMatchObject({ queued: 0, retained: 0 })
|
||||
await page.evaluate(() => {
|
||||
window.mbCanvas.volume = 0.5
|
||||
window.mbCanvas.muted = false
|
||||
})
|
||||
expect(await page.evaluate(() => window.mbCanvas.engine.audio.gainNode.gain.value)).toBeCloseTo(0.25, 6)
|
||||
await page.evaluate(() => {
|
||||
window.mbCanvas.muted = true
|
||||
window.mbCanvas.playbackRate = 2
|
||||
})
|
||||
expect(await page.evaluate(() => window.mbCanvas.engine.audio.gainNode.gain.value)).toBe(0)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(pause.time + 0.35)
|
||||
result = await page.evaluate(() => {
|
||||
const audio = window.mbCanvas.engine.audio
|
||||
const clock = audio.currentTime
|
||||
const elapsed = audio.audioContext.currentTime - audio.audioContextStartTime
|
||||
const nodes = [...audio.queuedNodes]
|
||||
return { clock, rate: audio.playbackRate, anchor: audio.playbackTimeAtStart, elapsed, nodeRates: nodes.map(node => node.playbackRate.value), hasSink: Boolean(audio.audioSink), samples: window.mbClockSamples, pause: window.mbCanvas.paused }
|
||||
})
|
||||
expect(Math.abs(result.clock - (result.anchor + result.elapsed * 2))).toBeLessThan(0.03)
|
||||
expect(result).toMatchObject({ rate: 2, pause: false, hasSink: scenario !== 'no-track' })
|
||||
if (scenario === 'no-track') {
|
||||
expect(result.nodeRates).toEqual([])
|
||||
}
|
||||
else {
|
||||
expect(result.nodeRates.length).toBeGreaterThan(0)
|
||||
expect(result.nodeRates.every(rate => rate === 2)).toBe(true)
|
||||
}
|
||||
if (scenario === 'av-clock') {
|
||||
const playing = result.samples.filter(sample => !sample.paused)
|
||||
expect(playing.filter(sample => sample.rate === 1).length).toBeGreaterThan(2)
|
||||
expect(playing.filter(sample => sample.rate === 2).length).toBeGreaterThan(2)
|
||||
expect(Math.max(...playing.map(sample => Math.abs(sample.frame - sample.audio)))).toBeLessThan(0.25)
|
||||
}
|
||||
}
|
||||
outcome = 'native-audio-ownership-observed'
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => window.mbHost.emit('destroy'))
|
||||
if (capabilities.AudioContext !== 'undefined' || capabilities.webkitAudioContext !== 'undefined') {
|
||||
await expect.poll(() => page.evaluate(() => window.mbContext.state)).toBe('closed')
|
||||
expect(await page.evaluate(() => window.mbCanvas.engine.audio.queuedNodes.size)).toBe(0)
|
||||
}
|
||||
const state = await page.evaluate(() => ({ context: window.mbContext?.state, nodes: window.mbNodes.map(record => ({ stops: record.stops, disconnects: record.disconnects, starts: record.starts })), error: window.mbCanvas.error, events: window.mbEvents }))
|
||||
await testInfo.attach('mediabunny-audio', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), scenario, capabilities, outcome, result, state, scope: 'Native AudioContext, BufferSourceNodes and SDK decode. Delayed resume/buffer delivery is controlled. AV samples compare decoded canvas timestamps to the audio clock over short 1x/2x playback; acoustic output, long-run drift and physical devices remain MB-09.' }) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Actual proxy engine with controlled Web Audio and SDK pump interleavings.
|
||||
import test from 'node:test'
|
||||
import { mbCandidate, mbEnvironment } from './helpers/mediabunny.js'
|
||||
|
||||
const implementation = await mbCandidate()
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve))
|
||||
function deferred() {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const item = value => ({ value, done: !value })
|
||||
const buffer = (timestamp = 0, duration = 0.1) => ({ timestamp, buffer: { duration } })
|
||||
function iterator(values = []) {
|
||||
let reads = 0
|
||||
let returns = 0
|
||||
return {
|
||||
async next() {
|
||||
reads++
|
||||
return item(values.shift())
|
||||
},
|
||||
async return() {
|
||||
returns++
|
||||
return item()
|
||||
},
|
||||
get reads() { return reads },
|
||||
get returns() { return returns },
|
||||
}
|
||||
}
|
||||
function environment(options = {}) {
|
||||
const env = mbEnvironment(implementation, options)
|
||||
const contexts = []
|
||||
const nodes = []
|
||||
const warnings = []
|
||||
env.globals.console = { ...console, error() {}, warn: (...args) => warnings.push(args) }
|
||||
env.eventNames.push('canplay', 'playing', 'ended')
|
||||
env.globals.window.AudioContext = class {
|
||||
constructor(options) {
|
||||
this.options = options
|
||||
this.currentTime = 10
|
||||
this.state = 'running'
|
||||
this.closed = 0
|
||||
this.resumes = 0
|
||||
this.destination = {}
|
||||
contexts.push(this)
|
||||
}
|
||||
|
||||
createGain() {
|
||||
this.gain = {
|
||||
gain: { value: 0 },
|
||||
connect() {},
|
||||
disconnect() { this.disconnected = true },
|
||||
}
|
||||
return this.gain
|
||||
}
|
||||
|
||||
createBufferSource() {
|
||||
const node = {
|
||||
playbackRate: { value: 1 },
|
||||
starts: [],
|
||||
stops: 0,
|
||||
disconnects: 0,
|
||||
onended: null,
|
||||
connect() {},
|
||||
start(...args) { this.starts.push(args) },
|
||||
stop() { this.stops++ },
|
||||
disconnect() { this.disconnects++ },
|
||||
}
|
||||
nodes.push(node)
|
||||
return node
|
||||
}
|
||||
|
||||
async resume() {
|
||||
this.resumes++
|
||||
this.state = 'running'
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed++
|
||||
this.state = 'closed'
|
||||
}
|
||||
}
|
||||
const canvas = env.factory()(env.art)
|
||||
return { ...env, canvas, audio: canvas.engine.audio, contexts, nodes, warnings }
|
||||
}
|
||||
function withContext() {
|
||||
const env = environment()
|
||||
env.audio.ensureAudioContext()
|
||||
return { ...env, context: env.contexts[0] }
|
||||
}
|
||||
|
||||
test('MediaBunny stale audio pump cannot cancel the current pending read', async () => {
|
||||
const env = withContext()
|
||||
const read = deferred()
|
||||
let returns = 0
|
||||
const frames = { next: () => read.promise, return: async () => {
|
||||
returns++
|
||||
return item()
|
||||
} }
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => frames }
|
||||
const pumping = env.audio.runIterator(env.audio.asyncId)
|
||||
const obsolete = env.audio.runIterator(env.audio.asyncId - 1)
|
||||
try {
|
||||
await flush()
|
||||
assert.equal(returns, 0)
|
||||
assert.equal(env.audio.audioIterator, frames)
|
||||
}
|
||||
finally {
|
||||
env.audio.pause()
|
||||
read.resolve(item())
|
||||
await Promise.all([pumping, obsolete])
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
})
|
||||
|
||||
test('MediaBunny failed audio iterator construction remains retryable', async () => {
|
||||
const env = withContext()
|
||||
const failure = new Error('sink failed')
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers() {
|
||||
throw failure
|
||||
} }
|
||||
await assert.rejects(env.audio.runIterator(env.audio.asyncId), error => error === failure)
|
||||
assert.equal(env.audio.audioIterator, null)
|
||||
env.audio.audioSink = { buffers: () => iterator() }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny source cancellation clears sink and input after node cleanup failure', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.input = {}
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const failure = new Error('stop failed')
|
||||
env.nodes[0].stop = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.audio.cancelPending(), error => error === failure)
|
||||
assert.equal(env.audio.input, null)
|
||||
assert.equal(env.audio.audioSink, null)
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
assert.equal(env.nodes[0].disconnects, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny gain setup failure releases context without installing partial references', async () => {
|
||||
const env = environment()
|
||||
const Context = env.globals.window.AudioContext
|
||||
const failure = new Error('gain failed')
|
||||
env.globals.window.AudioContext = class extends Context {
|
||||
createGain() {
|
||||
const gain = super.createGain()
|
||||
Object.defineProperty(gain.gain, 'value', {
|
||||
get() { return 0 },
|
||||
set() { throw failure },
|
||||
})
|
||||
return gain
|
||||
}
|
||||
}
|
||||
assert.throws(() => env.audio.ensureAudioContext(), error => error === failure)
|
||||
assert.equal(env.contexts[0].closed, 1)
|
||||
assert.equal(env.contexts[0].gain.disconnected, true)
|
||||
assert.equal(env.audio.audioContext, null)
|
||||
assert.equal(env.audio.gainNode, null)
|
||||
env.globals.window.AudioContext = Context
|
||||
env.audio.ensureAudioContext()
|
||||
assert.equal(env.audio.audioContext, env.contexts[1])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny context setup cannot resurrect audio after synchronous destroy', () => {
|
||||
const env = environment()
|
||||
const Context = env.globals.window.AudioContext
|
||||
env.globals.window.AudioContext = class extends Context {
|
||||
createGain() {
|
||||
const gain = super.createGain()
|
||||
gain.connect = () => env.audio.destroy()
|
||||
return gain
|
||||
}
|
||||
}
|
||||
env.audio.ensureAudioContext()
|
||||
assert.equal(env.audio.audioContext, null)
|
||||
assert.equal(env.audio.gainNode, null)
|
||||
assert.equal(env.contexts[0].closed, 1)
|
||||
assert.equal(env.contexts[0].gain.disconnected, true)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny ended node disconnect failure is observed after removing retained references', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const failure = new Error('disconnect failed')
|
||||
env.nodes[0].disconnect = () => {
|
||||
throw failure
|
||||
}
|
||||
env.nodes[0].onended()
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
assert.equal(env.nodes[0].onended, null)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio preserves clock, gain square law, mute and paused position', async () => {
|
||||
const env = withContext()
|
||||
env.audio.setVolume(0.5, false)
|
||||
assert.equal(env.context.gain.gain.value, 0.25)
|
||||
env.audio.setVolume(0.5, true)
|
||||
assert.equal(env.context.gain.gain.value, 0)
|
||||
await env.audio.seek(3)
|
||||
await env.audio.play()
|
||||
env.context.currentTime = 12
|
||||
assert.equal(env.audio.currentTime, 5)
|
||||
env.audio.pause()
|
||||
env.context.currentTime = 20
|
||||
assert.equal(env.audio.currentTime, 5)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio preserves current media time during a playing rate change', async () => {
|
||||
const env = withContext()
|
||||
await env.audio.seek(3)
|
||||
await env.audio.play()
|
||||
env.context.currentTime = 12
|
||||
env.audio.setPlaybackRate(2)
|
||||
assert.equal(env.audio.currentTime, 5)
|
||||
env.context.currentTime = 13
|
||||
assert.equal(env.audio.currentTime, 7)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio preserves future and partial late buffer scheduling math', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioContextStartTime = 10
|
||||
env.audio.playbackTimeAtStart = 2
|
||||
env.audio.playbackRate = 2
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(1.8, 0.4), buffer(2.4, 0.4)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
assert.deepEqual(env.nodes.map(node => node.starts[0]), [[10, 0.1999999999999993], [10.2]])
|
||||
assert.deepEqual(env.nodes.map(node => node.playbackRate.value), [2, 2])
|
||||
assert(Math.abs(env.audio.latestScheduledEndTime - 2.8) < 0.000001)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio detaches iterator synchronously and releases it once', async () => {
|
||||
const env = environment()
|
||||
const release = deferred()
|
||||
let returns = 0
|
||||
env.audio.audioIterator = { return: () => {
|
||||
returns++
|
||||
return release.promise
|
||||
} }
|
||||
const first = env.audio.stopIterator()
|
||||
const detached = env.audio.audioIterator
|
||||
const second = env.audio.stopIterator()
|
||||
release.resolve(item())
|
||||
await Promise.all([first, second])
|
||||
assert.equal(detached, null)
|
||||
assert.equal(returns, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny old audio iterator disposal cannot erase a current iterator', async () => {
|
||||
const env = environment()
|
||||
const release = deferred()
|
||||
env.audio.audioIterator = { return: () => release.promise }
|
||||
const closing = env.audio.stopIterator()
|
||||
const current = iterator()
|
||||
env.audio.audioIterator = current
|
||||
release.resolve(item())
|
||||
await closing
|
||||
assert.equal(env.audio.audioIterator, current)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
for (const ending of ['pause', 'destroy', 'source']) {
|
||||
test(`MediaBunny pending audio resume cannot restart after ${ending}`, async () => {
|
||||
const env = withContext()
|
||||
env.context.state = 'suspended'
|
||||
const resume = deferred()
|
||||
env.context.resume = () => resume.promise
|
||||
const playing = env.audio.play()
|
||||
if (ending === 'pause') {
|
||||
env.audio.pause()
|
||||
}
|
||||
else if (ending === 'destroy') {
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
else {
|
||||
env.canvas.engine.performLoad = async () => {}
|
||||
await env.canvas.engine.load('next.mp4')
|
||||
}
|
||||
resume.resolve()
|
||||
await playing
|
||||
assert.equal(env.audio.paused, true)
|
||||
assert.equal(env.nodes.length, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
for (const ending of ['destroy', 'source']) {
|
||||
test(`MediaBunny stale audio capability cannot recreate context or metadata after ${ending}`, async () => {
|
||||
const env = environment()
|
||||
const capability = deferred()
|
||||
let metadata = 0
|
||||
const loading = env.audio.load({ input: {}, audioTrack: { codec: 'aac', canDecode: () => capability.promise }, duration: 8 }, () => metadata++)
|
||||
await flush()
|
||||
if (ending === 'destroy') {
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
else {
|
||||
env.canvas.engine.performLoad = async () => {}
|
||||
await env.canvas.engine.load('next.mp4')
|
||||
}
|
||||
capability.resolve(false)
|
||||
await loading
|
||||
assert.equal(metadata, 0)
|
||||
assert.equal(env.contexts.length, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('MediaBunny audio nodes disconnect and clear ended listeners after pause', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11), buffer(12)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
assert.equal(env.nodes.length, 2)
|
||||
env.audio.pause()
|
||||
assert.deepEqual(env.nodes.map(node => [node.stops, node.disconnects, node.onended]), [[1, 1, null], [1, 1, null]])
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny naturally ended audio node disconnects and releases its callback', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const node = env.nodes[0]
|
||||
node.onended()
|
||||
assert.equal(node.disconnects, 1)
|
||||
assert.equal(node.onended, null)
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio cleanup attempts all nodes when one stop throws', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11), buffer(12)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const failure = new Error('stop failed')
|
||||
env.nodes[0].stop = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.audio.stopQueuedNodes(), error => error === failure)
|
||||
assert.equal(env.nodes[1].stops, 1)
|
||||
assert.equal(env.nodes[0].disconnects, 1)
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
for (const ending of ['pause', 'destroy', 'rate']) {
|
||||
test(`MediaBunny audio pump cannot schedule a batch after pending resume and ${ending}`, async () => {
|
||||
const env = withContext()
|
||||
env.context.state = 'suspended'
|
||||
env.audio.paused = false
|
||||
env.audio.audioContextStartTime = 10
|
||||
const resume = deferred()
|
||||
env.context.resume = () => resume.promise
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(1)]) }
|
||||
const pumping = env.audio.runIterator(env.audio.asyncId)
|
||||
await flush()
|
||||
if (ending === 'pause') {
|
||||
env.audio.pause()
|
||||
}
|
||||
else if (ending === 'destroy') {
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
else {
|
||||
env.audio.audioSink = null
|
||||
env.audio.setPlaybackRate(2)
|
||||
}
|
||||
env.emitted.length = 0
|
||||
resume.resolve()
|
||||
await pumping
|
||||
assert.equal(env.nodes.length, 0)
|
||||
assert.equal(env.emitted.some(event => ['video:canplay', 'video:playing'].includes(event.name)), false)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('MediaBunny rate change cancels already scheduled old-rate audio nodes', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const old = env.nodes[0]
|
||||
env.audio.audioSink = null
|
||||
env.audio.setPlaybackRate(2)
|
||||
assert.equal(old.stops, 1)
|
||||
assert.equal(old.disconnects, 1)
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny seeking before first context exists preserves paused position without throwing', async () => {
|
||||
const env = environment()
|
||||
await env.audio.seek(3)
|
||||
assert.equal(env.audio.currentTime, 3)
|
||||
assert.equal(env.audio.paused, true)
|
||||
assert.equal(env.contexts.length, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny video ended stops the shared audio clock and scheduled nodes', async () => {
|
||||
const env = withContext()
|
||||
await env.audio.play()
|
||||
env.canvas.engine.paused = false
|
||||
env.context.currentTime = 12
|
||||
env.canvas.events.emit('ended')
|
||||
const stoppedAt = env.audio.currentTime
|
||||
env.context.currentTime = 14
|
||||
assert.equal(env.audio.paused, true)
|
||||
assert.equal(env.audio.currentTime, stoppedAt)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio destruction disconnects gain and observes context close', async () => {
|
||||
const env = withContext()
|
||||
const gain = env.context.gain
|
||||
env.art.emit('destroy')
|
||||
await flush()
|
||||
assert.equal(gain.disconnected, true)
|
||||
assert.equal(env.audio.gainNode, null)
|
||||
assert.equal(env.context.closed, 1)
|
||||
assert.equal(env.audio.audioContext, null)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.context.closed, 1)
|
||||
})
|
||||
|
||||
test('MediaBunny actual audio owner shares native resume across coordinator play-pause-play', async () => {
|
||||
const env = withContext()
|
||||
const pending = deferred()
|
||||
let resumes = 0
|
||||
env.context.state = 'suspended'
|
||||
env.context.resume = () => {
|
||||
resumes++
|
||||
return pending.promise
|
||||
}
|
||||
let starts = 0
|
||||
env.canvas.engine.video.start = () => starts++
|
||||
const first = env.canvas.play()
|
||||
env.canvas.pause()
|
||||
const second = env.canvas.play()
|
||||
pending.resolve()
|
||||
await Promise.all([first, second])
|
||||
assert.equal(resumes, 1)
|
||||
assert.equal(starts, 1)
|
||||
assert.equal(env.audio.paused, false)
|
||||
assert.equal(env.canvas.paused, false)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny canceled audio play settles without waiting for native resume and observes its rejection', async () => {
|
||||
const env = withContext()
|
||||
const pending = deferred()
|
||||
env.context.state = 'suspended'
|
||||
env.context.resume = () => pending.promise
|
||||
let settled = false
|
||||
const playing = env.audio.play().then(() => settled = true)
|
||||
env.audio.pause()
|
||||
await flush()
|
||||
assert.equal(settled, true)
|
||||
pending.reject(new Error('obsolete resume rejection'))
|
||||
await playing
|
||||
await flush()
|
||||
assert.equal(env.audio.paused, true)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny active resume rejection preserves the error and allows another play', async () => {
|
||||
const env = withContext()
|
||||
const failure = new Error('resume denied')
|
||||
env.context.state = 'suspended'
|
||||
env.context.resume = async () => {
|
||||
throw failure
|
||||
}
|
||||
await assert.rejects(env.audio.play(), error => error === failure)
|
||||
assert.equal(env.audio.paused, true)
|
||||
env.context.resume = async () => {}
|
||||
await env.audio.play()
|
||||
assert.equal(env.audio.paused, false)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio preserves no-track metadata and fallback context construction', async () => {
|
||||
const env = environment()
|
||||
const Context = env.globals.window.AudioContext
|
||||
const attempts = []
|
||||
env.globals.window.AudioContext = class extends Context {
|
||||
constructor(option) {
|
||||
attempts.push(option)
|
||||
if (option)
|
||||
throw new Error('sample rate not supported')
|
||||
super()
|
||||
}
|
||||
}
|
||||
env.audio.ensureAudioContext(96000)
|
||||
assert.deepEqual(attempts.map(option => option?.sampleRate), [96000, undefined])
|
||||
let metadata = 0
|
||||
await env.audio.load({ input: {}, audioTrack: null, duration: 8 }, () => metadata++)
|
||||
assert.equal(metadata, 1)
|
||||
assert.equal(env.audio.duration, 8)
|
||||
assert.equal(env.contexts.length, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny expired buffers do not create silent retained source nodes', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioContextStartTime = 0
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(1, 0.1)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
assert.equal(env.nodes.length, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio seek stops old scheduled nodes even when called directly while playing', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const old = env.nodes[0]
|
||||
env.audio.audioSink = null
|
||||
await env.audio.seek(3)
|
||||
assert.equal(old.stops, 1)
|
||||
assert.equal(old.disconnects, 1)
|
||||
assert.equal(env.audio.currentTime, 3)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny background audio decode error stops playback and reports one media error', async () => {
|
||||
const env = withContext()
|
||||
const failure = new Error('decoder failed')
|
||||
env.audio.audioSink = { buffers: () => ({ next: async () => {
|
||||
throw failure
|
||||
}, return: async () => item() }) }
|
||||
await env.canvas.play()
|
||||
await flush()
|
||||
assert.deepEqual({ ...env.canvas.error }, { code: 4, message: 'decoder failed' })
|
||||
assert.equal(env.canvas.paused, true)
|
||||
assert.equal(env.audio.paused, true)
|
||||
assert.equal(env.emitted.filter(event => event.name === 'video:error').length, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny decoder error is not replaced by iterator release failure', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
const failure = new Error('decoder failed')
|
||||
env.audio.audioSink = { buffers: () => ({ next: async () => {
|
||||
throw failure
|
||||
}, return: async () => {
|
||||
throw new Error('cleanup failed')
|
||||
} }) }
|
||||
await assert.rejects(env.audio.runIterator(env.audio.asyncId), error => error === failure)
|
||||
await flush()
|
||||
assert.equal(env.warnings.length, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny destroy attempts context cleanup after a node stop error and preserves the first failure', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const failure = new Error('node failed')
|
||||
env.nodes[0].stop = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.audio.destroy(), error => error === failure)
|
||||
assert.equal(env.context.closed, 1)
|
||||
assert.equal(env.context.gain.disconnected, true)
|
||||
assert.equal(env.audio.audioContext, null)
|
||||
assert.equal(env.audio.audioIterator, null)
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny destroyed audio context close rejection remains observed', async () => {
|
||||
const env = withContext()
|
||||
const failure = new Error('close failed')
|
||||
env.context.close = async () => {
|
||||
throw failure
|
||||
}
|
||||
env.art.emit('destroy')
|
||||
await flush()
|
||||
assert.equal(env.warnings.length, 1)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
assert.equal(env.audio.audioContext, null)
|
||||
})
|
||||
|
||||
test('MediaBunny paused audio pump clears owned batch and backpressure timers', async () => {
|
||||
const timers = new Map()
|
||||
let next = 0
|
||||
const env = environment({ setTimeout(callback, delay) {
|
||||
const id = next++
|
||||
timers.set(id, { callback, delay })
|
||||
return id
|
||||
}, clearTimeout(id) { timers.delete(id) } })
|
||||
env.audio.ensureAudioContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioContextStartTime = 10
|
||||
const frames = iterator(Array.from({ length: 32 }, (_, index) => buffer(index / 10)))
|
||||
env.audio.audioSink = { buffers: () => frames }
|
||||
const pumping = env.audio.runIterator(env.audio.asyncId)
|
||||
await flush()
|
||||
assert.equal(frames.reads, 16)
|
||||
assert.deepEqual([...timers.values()].map(timer => timer.delay), [0])
|
||||
const [id, timer] = [...timers][0]
|
||||
timers.delete(id)
|
||||
timer.callback()
|
||||
await flush()
|
||||
assert.deepEqual([...timers.values()].map(timer => timer.delay), [50])
|
||||
env.audio.pause()
|
||||
assert.equal(timers.size, 0)
|
||||
await pumping
|
||||
assert.equal(frames.returns, 1)
|
||||
assert.equal(env.nodes.every(node => node.disconnects === 1), true)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
@@ -94,7 +94,7 @@ test('MediaBunny coordinator restores paused state after a play rejection and al
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny coordinator shares delayed audio resume across play-pause-play and starts video once', async () => {
|
||||
test('MediaBunny coordinator delegates each new play intent to the audio owner and starts video once', async () => {
|
||||
const env = environment()
|
||||
const audio = deferred()
|
||||
let attempts = 0
|
||||
@@ -107,7 +107,7 @@ test('MediaBunny coordinator shares delayed audio resume across play-pause-play
|
||||
const second = env.engine.play()
|
||||
audio.resolve()
|
||||
await Promise.all([first, second])
|
||||
assert.equal(attempts, 1)
|
||||
assert.equal(attempts, 2)
|
||||
assert.equal(env.engine.paused, false)
|
||||
assert.equal(env.calls.filter(name => name === 'video:start').length, 1)
|
||||
assert.equal(env.calls.filter(name => name === 'play').length, 1)
|
||||
|
||||
Reference in new issue
Block a user