fix(asr): [PKG-ASR-10] cancel recognition on native media errors

This commit is contained in:
Harvey Zhao committed 2026-09-15 14:25:59 +08:00
1 parent 3c89b48514
commit 9cf4a58480
21 files changed
+18402 -12

No files matched your search

+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Exercise cancellation after a native media error without depending on browser timing.
import test from 'node:test'
import { asrAudioEnvironment, deferredAudio } from './helpers/asr-audio.js'
import { compilePackage } from './helpers/load.js'
const artifact = process.env.ARTPLAYER_ASR_ARTIFACT
const implementation = { name: artifact || 'candidate-asr', code: artifact ? fs.readFileSync(artifact, 'utf8') : await compilePackage('artplayer-plugin-asr', 'umd') }
for (const capture of [false, true]) {
const option = capture ? { audioInput: { type: 'capture' } } : {}
test(`ASR media error cancels pending recognition and permits ${capture ? 'capture' : 'direct'} recovery`, async () => {
const pending = deferredAudio()
let calls = 0
const environment = asrAudioEnvironment(implementation, { ...option, sampleRate: 8000, interval: 1, onAudioChunk: () => ++calls === 1 ? pending.promise : 'Recovered' })
await environment.emit('play')
environment.send(new Float32Array(8).fill(0.5))
const recognizing = environment.tick()
assert.equal(calls, 1)
const oldMessage = environment.recorders[0].port.onmessage
await environment.emit('video:error')
assert.equal(environment.intervals.size, 0, 'Media error must stop collection even without a pause event')
assert.equal(environment.recorders[0].port.onmessage, null)
pending.resolve('Obsolete')
await recognizing
assert.equal(environment.layer.innerHTML, '')
assert.equal(environment.timeouts.size, 0)
assert.equal(environment.contexts[0].state, capture ? 'closed' : 'running')
assert.equal(environment.tracks[0].stops, capture ? 1 : 0)
oldMessage({ data: new Float32Array(8).fill(1) })
assert.equal(calls, 1)
await environment.emit('play')
assert.equal(environment.contexts.length, capture ? 2 : 1)
assert.equal(environment.intervals.size, 1)
environment.send(new Float32Array(8).fill(0.25))
await environment.tick()
assert.match(environment.layer.innerHTML, /Recovered/)
await environment.emit('destroy')
assert(environment.contexts.every(context => context.state === 'closed'))
assert.equal(environment.listeners.get('video:error').length, 0)
})
test(`ASR media error during ${capture ? 'capture' : 'direct'} worklet loading cancels late initialization`, async () => {
const moduleGate = deferredAudio()
const moduleEntered = deferredAudio()
const environment = asrAudioEnvironment(implementation, option, { moduleGate, moduleEntered })
const starting = environment.emit('play')
await moduleEntered.promise
await environment.emit('video:error')
moduleGate.resolve()
await starting
assert.equal(environment.contexts[0].state, 'closed')
assert.equal(environment.recorders.length, 0)
assert.equal(environment.nodes.filter(node => ['element', 'stream'].includes(node.kind)).length, 0)
assert.equal(environment.intervals.size, 0)
assert.equal(environment.blobs.size, 0)
assert.equal(environment.errors.length, 0)
await environment.emit('play')
assert.equal(environment.intervals.size, 1)
await environment.emit('destroy')
assert(environment.contexts.every(context => context.state === 'closed'))
})
}
test('ASR media error cancels a capture restart waiting for close without hiding an existing subtitle', async () => {
const environment = asrAudioEnvironment(implementation, { audioInput: { type: 'capture' } })
environment.plugin.append('Existing')
const timers = [...environment.timeouts.keys()]
await environment.emit('play')
const closing = deferredAudio()
const close = environment.contexts[0].close.bind(environment.contexts[0])
environment.contexts[0].close = async () => {
await close()
await closing.promise
}
const restarting = environment.emit('restart')
const failing = environment.emit('video:error')
closing.resolve()
await Promise.all([restarting, failing])
assert.equal(environment.captures(), 1)
assert.equal(environment.intervals.size, 0)
assert.match(environment.layer.innerHTML, /Existing/)
assert.deepEqual([...environment.timeouts.keys()], timers)
await environment.emit('destroy')
await environment.emit('video:error')
assert.equal(environment.timeouts.size, 0)
assert.equal(environment.contexts.length, 1)
})
+1
View File
@@ -21,6 +21,7 @@ test('Source invocation clears inherited artifact selection without changing cal
assert(installedTests.includes('ambilight-proxy.spec.js'))
assert(installedTests.includes('canvas-lifecycle.spec.js'))
assert(installedTests.includes('asr-no-audio.spec.js'))
assert(installedTests.includes('asr-media-error.spec.js'))
for (const file of installedTests)
assert(fs.existsSync(path.resolve('test/browser', file)))
})
+104
View File
@@ -0,0 +1,104 @@
import process from 'node:process'
import { browserCandidate } from '../helpers/browser-candidate.js'
import { expect, test } from './fixtures.js'
let candidate
test.beforeAll(async () => {
candidate = await browserCandidate('artplayer-plugin-asr', process.env.ARTPLAYER_ASR_ARTIFACT)
})
test.afterEach(async ({ page }, testInfo) => {
const state = await page.evaluate(() => window.mediaErrorState?.()).catch(error => ({ error: error.message }))
await testInfo.attach('asr-media-error-final', { contentType: 'application/json', body: JSON.stringify(state ?? null) })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
})
for (const core of ['published-5.3.0', 'published', 'candidate']) {
for (const mode of ['default', 'capture']) {
test(`ASR ${mode} + ${core}: native media error invalidates pending recognition and permits recovery`, async ({ page }, testInfo) => {
await page.goto(`/test/player.html?core=${core}`)
const capabilities = await page.evaluate(() => ({ context: typeof (window.AudioContext || window.webkitAudioContext), worklet: typeof window.AudioWorkletNode }))
await testInfo.attach('asr-media-error-inputs', { contentType: 'application/json', body: JSON.stringify({ core, mode, capabilities, provenance: candidate.provenance, recognizer: 'Controlled pending caller Promise; PCM and media errors are native', errorSource: '/test/manifest.json', mediaAssignment: 'public art.video.src' }) })
if (capabilities.context === 'undefined') {
expect(process.platform).toBe('win32')
expect(testInfo.project.name).toBe('webkit')
expect(capabilities.worklet).toBe('undefined')
test.skip(true, 'Windows WebKit lacks native WebAudio; not Safari/device ASR acceptance')
}
await page.addScriptTag({ content: candidate.code })
await page.evaluate((mode) => {
const Context = window.AudioContext || window.webkitAudioContext
const contexts = []
const events = []
const chunks = []
window.AudioContext = class extends Context {
constructor(options) {
super(options)
contexts.push(this)
}
}
window.art = new window.Artplayer({
container: '.player',
url: '/test/audio-tone.m4a',
volume: 0.6,
plugins: [window.artplayerPluginAsr({
...(mode === 'capture' ? { audioInput: { type: 'capture' } } : {}),
onAudioChunk({ pcm }) {
let peak = 0
for (const sample of new Int16Array(pcm))
peak = Math.max(peak, Math.abs(sample))
chunks.push({ peak, source: window.art.video.currentSrc })
if (!window.completeRecognition) {
if (peak > 0)
return new Promise(resolve => window.completeRecognition = resolve)
return
}
return 'Recovered recognition'
},
})],
})
for (const name of ['play', 'pause', 'restart', 'video:emptied', 'video:error'])
window.art.on(name, () => events.push({ name, at: performance.now(), error: window.art.video.error?.code || null }))
window.mediaErrorState = () => ({
contexts: contexts.map(context => context.state),
events,
chunks,
subtitle: document.querySelector('.art-layer-asr')?.textContent || '',
error: window.art.video.error?.code || null,
source: window.art.video.currentSrc,
paused: window.art.video.paused,
})
document.querySelector('#play').onclick = () => window.art.play()
}, mode)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.mediaErrorState().chunks.some(chunk => chunk.peak > 0))).toBe(true)
await page.evaluate(() => {
window.art.video.src = '/test/manifest.json?asr-media-error=unsupported'
})
await expect.poll(() => page.evaluate(() => window.mediaErrorState().error)).toBe(4)
await page.evaluate(async () => {
window.completeRecognition('Obsolete recognition')
await Promise.resolve()
await Promise.resolve()
})
const failed = await page.evaluate(() => window.mediaErrorState())
await testInfo.attach('asr-native-media-failure', { contentType: 'application/json', body: JSON.stringify(failed) })
expect(failed.subtitle).not.toContain('Obsolete recognition')
if (mode === 'capture')
await expect.poll(() => page.evaluate(() => window.mediaErrorState().contexts.every(state => state === 'closed'))).toBe(true)
await page.evaluate(() => {
window.art.url = '/test/audio-tone.m4a?asr-media-error=recovery'
})
await expect.poll(() => page.evaluate(() => window.art.video.currentSrc.includes('asr-media-error=recovery') && window.art.video.readyState >= 2)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.mediaErrorState().chunks.filter(chunk => chunk.source.includes('asr-media-error=recovery') && chunk.peak > 0).length)).toBeGreaterThanOrEqual(3)
await expect(page.locator('.art-layer-asr')).toHaveText('Recovered recognition')
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.mediaErrorState().contexts.every(state => state === 'closed'))).toBe(true)
})
}
}