mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-27 09:41:20 -08:00
feat(asr): [PKG-ASR-09] add explicit capture ownership and cancel stale restarts
This commit is contained in:
1 parent
5ab7da6e1f
commit
146b78d576
27 files changed
+12951
-64
No files matched your search
@@ -0,0 +1,240 @@
|
||||
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 -- Explicit ASR capture uses the repository runner.
|
||||
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'),
|
||||
}
|
||||
|
||||
function create(option = {}, capabilities = {}) {
|
||||
const chunks = []
|
||||
const environment = asrAudioEnvironment(implementation, {
|
||||
sampleRate: 8000,
|
||||
interval: 1,
|
||||
audioInput: { type: 'capture' },
|
||||
onAudioChunk(chunk) { chunks.push(chunk) },
|
||||
...option,
|
||||
}, capabilities)
|
||||
const prototype = environment.context.AudioContext.prototype
|
||||
const direct = prototype.createMediaElementSource
|
||||
let directCalls = 0
|
||||
prototype.createMediaElementSource = function (...args) {
|
||||
directCalls++
|
||||
return direct.apply(this, args)
|
||||
}
|
||||
return { ...environment, chunks, directCalls: () => directCalls }
|
||||
}
|
||||
|
||||
function assertCapture(environment) {
|
||||
assert.equal(environment.directCalls(), 0, 'Explicit capture must never attempt to bind the media element')
|
||||
const source = environment.nodes.filter(node => node.kind === 'stream').at(-1)
|
||||
const recorder = environment.recorders.at(-1)
|
||||
assert.ok(source)
|
||||
assert.deepEqual(source.connections, [recorder])
|
||||
assert.equal(recorder.connections.length, 1)
|
||||
const sink = recorder.connections[0]
|
||||
assert.equal(sink.kind, 'gain')
|
||||
assert.equal(sink.gain.value, 0)
|
||||
assert.deepEqual(sink.connections, [environment.contexts.at(-1).destination])
|
||||
return { source, recorder, sink }
|
||||
}
|
||||
|
||||
async function assertChunk(environment) {
|
||||
const before = environment.chunks.length
|
||||
environment.send(new Float32Array(8).fill(0.5))
|
||||
await environment.tick()
|
||||
assert.equal(environment.chunks.length, before + 1)
|
||||
const chunk = environment.chunks.at(-1)
|
||||
assert.equal(chunk.pcm.byteLength, 16)
|
||||
assert.equal(new DataView(chunk.pcm).getInt16(0, true), 16383)
|
||||
assert.equal(new DataView(chunk.wav).getUint32(24, true), 8000)
|
||||
}
|
||||
|
||||
function assertReleased(environment) {
|
||||
assert.equal(environment.directCalls(), 0)
|
||||
assert.equal(environment.intervals.size, 0)
|
||||
assert.equal(environment.blobs.size, 0)
|
||||
assert.ok(environment.contexts.every(context => context.closes === 1))
|
||||
assert.ok(environment.nodes.every(node => node.connections.length === 0))
|
||||
assert.ok(environment.recorders.every(recorder => recorder.port.onmessage === null))
|
||||
}
|
||||
|
||||
for (const moz of [false, true]) {
|
||||
test(`ASR explicit ${moz ? 'mozCaptureStream' : 'captureStream'} skips a working direct binding`, async () => {
|
||||
const environment = create({}, { moz })
|
||||
await environment.emit('play')
|
||||
assertCapture(environment)
|
||||
assert.equal(environment.captures(), 1)
|
||||
await assertChunk(environment)
|
||||
await environment.emit('destroy')
|
||||
assertReleased(environment)
|
||||
assert.equal(environment.tracks[0].stops, 1)
|
||||
})
|
||||
}
|
||||
|
||||
test('ASR omitted audioInput preserves direct-first acquisition', async () => {
|
||||
const environment = create({ audioInput: undefined })
|
||||
await environment.emit('play')
|
||||
assert.equal(environment.directCalls(), 1)
|
||||
assert.equal(environment.captures(), 0)
|
||||
assert.ok(environment.nodes.some(node => node.kind === 'element'))
|
||||
await assertChunk(environment)
|
||||
await environment.emit('destroy')
|
||||
assert.equal(environment.contexts[0].closes, 1)
|
||||
})
|
||||
|
||||
for (const failure of ['unsupported', 'throws']) {
|
||||
test(`ASR explicit capture ${failure} cleans up without attempting direct fallback`, async () => {
|
||||
const environment = create({}, { capture: failure !== 'unsupported' })
|
||||
let attempts = 0
|
||||
if (failure === 'throws') {
|
||||
environment.art.video.captureStream = () => {
|
||||
attempts++
|
||||
throw new Error('Capture is unavailable for this media')
|
||||
}
|
||||
}
|
||||
await environment.emit('play')
|
||||
assertReleased(environment)
|
||||
assert.equal(environment.errors.length, 1)
|
||||
assert.equal(environment.recorders.length, 0)
|
||||
assert.equal(environment.tracks[0].stops, 0)
|
||||
assert.equal(attempts, failure === 'throws' ? 1 : 0)
|
||||
await environment.emit('destroy')
|
||||
assertReleased(environment)
|
||||
})
|
||||
}
|
||||
|
||||
test('ASR explicit capture retains a silent sink through media mute and volume changes', async () => {
|
||||
const environment = create()
|
||||
await environment.emit('play')
|
||||
const route = assertCapture(environment)
|
||||
for (const [muted, volume] of [[true, 0], [false, 0.25], [false, 1]]) {
|
||||
environment.art.muted = environment.art.video.muted = muted
|
||||
environment.art.volume = environment.art.video.volume = volume
|
||||
await environment.emit('video:volumechange')
|
||||
assert.equal(assertCapture(environment).sink, route.sink)
|
||||
assert.equal(environment.art.video.muted, muted)
|
||||
assert.equal(environment.art.video.volume, volume)
|
||||
await assertChunk(environment)
|
||||
}
|
||||
await environment.emit('destroy')
|
||||
assertReleased(environment)
|
||||
})
|
||||
|
||||
test('ASR explicit capture pause retains its stream and resumes with a fresh recorder', async () => {
|
||||
const environment = create()
|
||||
await environment.emit('play')
|
||||
const before = assertCapture(environment)
|
||||
await environment.emit('pause')
|
||||
assert.equal(environment.intervals.size, 0)
|
||||
assert.equal(environment.contexts[0].closes, 0)
|
||||
assert.equal(environment.tracks[0].stops, 0)
|
||||
assert.equal(before.recorder.port.onmessage, null)
|
||||
for (const node of Object.values(before))
|
||||
assert.deepEqual(node.connections, [])
|
||||
await environment.emit('play')
|
||||
const after = assertCapture(environment)
|
||||
assert.equal(after.source, before.source)
|
||||
assert.notEqual(after.recorder, before.recorder)
|
||||
assert.equal(environment.captures(), 1)
|
||||
await assertChunk(environment)
|
||||
await environment.emit('destroy')
|
||||
assertReleased(environment)
|
||||
})
|
||||
|
||||
for (const operation of ['stop/play', 'restart']) {
|
||||
test(`ASR explicit capture ${operation} releases the old graph and acquires another stream`, async () => {
|
||||
const environment = create()
|
||||
await environment.emit('play')
|
||||
const before = assertCapture(environment)
|
||||
if (operation === 'stop/play') {
|
||||
await environment.plugin.stop()
|
||||
assertReleased(environment)
|
||||
await environment.emit('play')
|
||||
}
|
||||
else {
|
||||
await environment.emit('restart')
|
||||
}
|
||||
const after = assertCapture(environment)
|
||||
assert.notEqual(after.source, before.source)
|
||||
assert.equal(environment.captures(), 2)
|
||||
assert.equal(environment.contexts[0].closes, 1)
|
||||
assert.equal(environment.contexts[1].closes, 0)
|
||||
assert.equal(environment.tracks[0].stops, 1)
|
||||
for (const node of Object.values(before))
|
||||
assert.deepEqual(node.connections, [])
|
||||
await assertChunk(environment)
|
||||
await environment.emit('destroy')
|
||||
assertReleased(environment)
|
||||
assert.equal(environment.tracks[0].stops, 2)
|
||||
await environment.emit('play')
|
||||
assert.equal(environment.captures(), 2)
|
||||
assert.equal(environment.intervals.size, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('ASR explicit capture snapshots the nested input choice before later caller mutation', async () => {
|
||||
const input = { type: 'capture' }
|
||||
const environment = create({ audioInput: input })
|
||||
input.type = 'changed by caller'
|
||||
await environment.emit('play')
|
||||
assertCapture(environment)
|
||||
await environment.plugin.stop()
|
||||
await environment.emit('play')
|
||||
assertCapture(environment)
|
||||
assert.equal(environment.captures(), 2)
|
||||
await environment.emit('destroy')
|
||||
assertReleased(environment)
|
||||
})
|
||||
|
||||
test('ASR explicit capture destroy during worklet loading never binds or captures late', async () => {
|
||||
const moduleGate = deferredAudio()
|
||||
const moduleEntered = deferredAudio()
|
||||
const environment = create({}, { moduleGate, moduleEntered })
|
||||
const starting = environment.emit('play')
|
||||
await moduleEntered.promise
|
||||
await environment.emit('destroy')
|
||||
moduleGate.resolve()
|
||||
await starting
|
||||
assertReleased(environment)
|
||||
assert.equal(environment.captures(), 0)
|
||||
assert.equal(environment.recorders.length, 0)
|
||||
assert.equal(environment.tracks[0].stops, 0)
|
||||
})
|
||||
|
||||
for (const action of ['pause', 'stop']) {
|
||||
test(`ASR explicit capture ${action} cancels a restart waiting for the old context to close`, async () => {
|
||||
const environment = create()
|
||||
await environment.emit('play')
|
||||
assertCapture(environment)
|
||||
const closeGate = deferredAudio()
|
||||
const context = environment.contexts[0]
|
||||
const close = context.close.bind(context)
|
||||
context.close = async () => {
|
||||
await close()
|
||||
await closeGate.promise
|
||||
}
|
||||
const restarting = environment.emit('restart')
|
||||
const cancelling = action === 'pause' ? environment.emit('pause') : environment.plugin.stop()
|
||||
assert.equal(environment.intervals.size, 0)
|
||||
closeGate.resolve()
|
||||
await Promise.all([restarting, cancelling])
|
||||
assert.equal(environment.captures(), 1, 'A superseded restart must not acquire another stream')
|
||||
assert.equal(environment.contexts.length, 1)
|
||||
assert.equal(environment.tracks[0].stops, 1)
|
||||
assertReleased(environment)
|
||||
await environment.emit('play')
|
||||
assertCapture(environment)
|
||||
assert.equal(environment.captures(), 2, 'A subsequent explicit play may start a new capture')
|
||||
await assertChunk(environment)
|
||||
await environment.emit('destroy')
|
||||
assertReleased(environment)
|
||||
assert.equal(environment.tracks[0].stops, 2)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import fs from 'node:fs'
|
||||
import process from 'node:process'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { compilePackage } from '../helpers/load.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
let code
|
||||
test.beforeAll(async () => {
|
||||
expect(process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'Explicit source/artifact tests do not represent installed packages').toBeFalsy()
|
||||
code = process.env.ARTPLAYER_ASR_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_ASR_ARTIFACT, 'utf8') : await compilePackage('artplayer-plugin-asr', 'umd')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ page }, testInfo) => {
|
||||
const observation = await page.evaluate(() => window.captureCorsObservation?.()).catch(error => ({ error: error.message }))
|
||||
await testInfo.attach('capture-cors-final-observation', { contentType: 'application/json', body: JSON.stringify(observation ?? null) })
|
||||
await page.evaluate(() => {
|
||||
if (window.art && !window.art.isDestroy)
|
||||
window.art.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`ASR explicit capture + ${core}: opaque media keeps native routing and same-origin capture recovers`, async ({ page }, testInfo) => {
|
||||
const responses = []
|
||||
const consoleErrors = []
|
||||
page.on('response', (response) => {
|
||||
if (new URL(response.url()).pathname === '/test/audio-tone.m4a')
|
||||
responses.push({ url: response.url(), status: response.status(), allowOrigin: response.headers()['access-control-allow-origin'] || null })
|
||||
})
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error')
|
||||
consoleErrors.push(message.text())
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
const capabilities = await page.evaluate(() => ({ context: typeof (window.AudioContext || window.webkitAudioContext), worklet: typeof window.AudioWorkletNode, capture: typeof HTMLMediaElement.prototype.captureStream, mozCapture: typeof HTMLMediaElement.prototype.mozCaptureStream }))
|
||||
await testInfo.attach('capture-cors-inputs', { contentType: 'application/json', body: JSON.stringify({ core, capabilities, plugin: process.env.ARTPLAYER_ASR_ARTIFACT || 'workspace source', pluginSha256: hash(code), mediaSha256: hash(fs.readFileSync(new URL('./media/audio-tone.m4a', import.meta.url))), nativeCapture: true, forcedException: false, analyserBinding: false, physicalAudioOutput: false, scope: 'Zero direct bindings and native media clock progression preserve routing; no claim about physical speaker output' }) })
|
||||
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 AudioContext/AudioWorklet; this is not Safari/device CORS acceptance')
|
||||
}
|
||||
expect(capabilities.worklet).toBe('function')
|
||||
expect([capabilities.capture, capabilities.mozCapture]).toContain('function')
|
||||
await page.addScriptTag({ content: code })
|
||||
await page.evaluate(() => {
|
||||
const NativeContext = window.AudioContext || window.webkitAudioContext
|
||||
window.captureContexts = []
|
||||
window.captureStreams = []
|
||||
window.captureTracks = []
|
||||
window.captureAttempts = []
|
||||
window.directBindings = 0
|
||||
window.captureChunks = []
|
||||
window.AudioContext = class extends NativeContext {
|
||||
constructor(options) {
|
||||
super(options)
|
||||
window.captureContexts.push(this)
|
||||
}
|
||||
|
||||
createMediaElementSource(element) {
|
||||
window.directBindings++
|
||||
return super.createMediaElementSource(element)
|
||||
}
|
||||
}
|
||||
const prototype = HTMLMediaElement.prototype
|
||||
const method = prototype.captureStream ? 'captureStream' : 'mozCaptureStream'
|
||||
const nativeCapture = prototype[method]
|
||||
prototype[method] = function (...args) {
|
||||
const attempt = { source: this.currentSrc, error: null }
|
||||
window.captureAttempts.push(attempt)
|
||||
try {
|
||||
const stream = nativeCapture.apply(this, args)
|
||||
window.captureStreams.push(stream)
|
||||
const recordTrack = (track) => {
|
||||
if (!window.captureTracks.includes(track))
|
||||
window.captureTracks.push(track)
|
||||
}
|
||||
stream.getTracks().forEach(recordTrack)
|
||||
stream.addEventListener('addtrack', event => recordTrack(event.track))
|
||||
return stream
|
||||
}
|
||||
catch (error) {
|
||||
attempt.error = { name: error.name, message: error.message }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const opaque = new URL('/test/audio-tone.m4a?capture-cors=opaque', location.href)
|
||||
opaque.hostname = 'localhost'
|
||||
window.art = new window.Artplayer({
|
||||
container: '.player',
|
||||
url: opaque.href,
|
||||
volume: 0.6,
|
||||
muted: false,
|
||||
loop: true,
|
||||
plugins: [window.artplayerPluginAsr({
|
||||
audioInput: { type: 'capture' },
|
||||
onAudioChunk({ pcm, wav }) {
|
||||
let peak = 0
|
||||
for (const sample of new Int16Array(pcm))
|
||||
peak = Math.max(peak, Math.abs(sample))
|
||||
window.captureChunks.push({ source: window.art.video.currentSrc, peak, pcmBytes: pcm.byteLength, wavBytes: wav.byteLength })
|
||||
},
|
||||
})],
|
||||
})
|
||||
window.captureVideoProperties = () => {
|
||||
const video = window.art.video
|
||||
return {
|
||||
src: video.getAttribute('src'),
|
||||
crossOrigin: video.crossOrigin,
|
||||
crossOriginAttribute: video.getAttribute('crossorigin'),
|
||||
muted: video.muted,
|
||||
defaultMuted: video.defaultMuted,
|
||||
volume: video.volume,
|
||||
playbackRate: video.playbackRate,
|
||||
autoplay: video.autoplay,
|
||||
controls: video.controls,
|
||||
loop: video.loop,
|
||||
preload: video.preload,
|
||||
playsInline: video.playsInline,
|
||||
}
|
||||
}
|
||||
window.captureCorsObservation = () => ({
|
||||
attempts: window.captureAttempts,
|
||||
directBindings: window.directBindings,
|
||||
contexts: window.captureContexts.map(context => context.state),
|
||||
streams: window.captureStreams.map(stream => ({ active: stream.active, tracks: stream.getTracks().map(track => ({ kind: track.kind, muted: track.muted, enabled: track.enabled, readyState: track.readyState })) })),
|
||||
retainedTracks: window.captureTracks.map(track => ({ kind: track.kind, muted: track.muted, enabled: track.enabled, readyState: track.readyState })),
|
||||
chunks: window.captureChunks,
|
||||
})
|
||||
document.querySelector('#play').onclick = () => window.art.play()
|
||||
document.querySelector('#pause').onclick = () => window.art.pause()
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
const initialProperties = await page.evaluate(() => window.captureVideoProperties())
|
||||
expect(initialProperties.crossOrigin).toBeNull()
|
||||
expect(initialProperties.crossOriginAttribute).toBeNull()
|
||||
expect(initialProperties.muted).toBe(false)
|
||||
expect(new URL(initialProperties.src, page.url()).origin).not.toBe(new URL(page.url()).origin)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.captureAttempts.length)).toBeGreaterThanOrEqual(1)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(1)
|
||||
const opaqueStart = await page.evaluate(() => window.art.currentTime)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(opaqueStart + 0.5)
|
||||
const opaque = await page.evaluate(() => window.captureCorsObservation())
|
||||
expect(opaque.directBindings).toBe(0)
|
||||
expect(opaque.chunks.every(chunk => chunk.peak === 0)).toBe(true)
|
||||
expect(await page.evaluate(() => window.art.video.error)).toBeNull()
|
||||
expect(await page.evaluate(() => window.art.video.paused)).toBe(false)
|
||||
expect(await page.evaluate(() => window.captureVideoProperties())).toEqual(initialProperties)
|
||||
expect(responses.some(response => new URL(response.url).hostname === 'localhost' && [200, 206].includes(response.status))).toBe(true)
|
||||
expect(responses.every(response => response.allowOrigin === null)).toBe(true)
|
||||
expect(opaque.chunks).toEqual([])
|
||||
expect(opaque.attempts).toHaveLength(1)
|
||||
if (testInfo.project.name === 'chromium') {
|
||||
expect(opaque.attempts[0].error.name).toBe('SecurityError')
|
||||
expect(opaque.contexts).toEqual(['closed'])
|
||||
expect(opaque.streams).toEqual([])
|
||||
expect(consoleErrors).toHaveLength(1)
|
||||
expect(consoleErrors[0]).toContain('[artplayerPluginAsr] Initialization failed: SecurityError:')
|
||||
}
|
||||
else {
|
||||
expect(testInfo.project.name).toBe('firefox')
|
||||
expect(opaque.attempts[0].error).toBeNull()
|
||||
expect(opaque.contexts).toEqual(['running'])
|
||||
expect(opaque.streams).toHaveLength(1)
|
||||
expect(opaque.retainedTracks).toHaveLength(1)
|
||||
expect(opaque.retainedTracks[0].kind).toBe('audio')
|
||||
expect(opaque.retainedTracks[0].readyState).toBe('live')
|
||||
expect(consoleErrors).toEqual([])
|
||||
}
|
||||
await testInfo.attach('opaque-native-capture-observation', { contentType: 'application/json', body: JSON.stringify({ opaque, responses: [...responses], consoleErrors: [...consoleErrors], properties: initialProperties, clockAdvanced: true }) })
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.art.url = '/test/audio-tone.m4a?capture-cors=recovery'
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.video.currentSrc.includes('capture-cors=recovery') && window.art.video.readyState >= 2)).toBe(true)
|
||||
const recoveryProperties = await page.evaluate(() => window.captureVideoProperties())
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.captureChunks.filter(chunk => chunk.source.includes('capture-cors=recovery') && chunk.peak > 0).length)).toBeGreaterThanOrEqual(3)
|
||||
expect(await page.evaluate(() => window.directBindings)).toBe(0)
|
||||
expect(await page.evaluate(() => window.captureVideoProperties())).toEqual(recoveryProperties)
|
||||
expect(await page.evaluate(() => window.captureChunks.filter(chunk => chunk.source.includes('capture-cors=opaque')).every(chunk => chunk.peak === 0))).toBe(true)
|
||||
expect(await page.evaluate(() => window.captureAttempts.length)).toBeGreaterThanOrEqual(2)
|
||||
await page.evaluate(() => window.art.plugins.artplayerPluginAsr.stop())
|
||||
await expect.poll(() => page.evaluate(() => window.captureContexts.every(context => context.state === 'closed'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.captureTracks.every(track => track.readyState === 'ended'))).toBe(true)
|
||||
const stopped = await page.evaluate(() => ({ chunks: window.captureChunks.length, time: window.art.currentTime }))
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stopped.time + 0.4)
|
||||
expect(await page.evaluate(() => window.captureChunks.length)).toBe(stopped.chunks)
|
||||
expect(await page.evaluate(() => window.art.video.paused)).toBe(false)
|
||||
expect(await page.evaluate(() => window.captureVideoProperties())).toEqual(recoveryProperties)
|
||||
await page.locator('#pause').click()
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(start => window.captureChunks.slice(start).filter(chunk => chunk.peak > 0).length, stopped.chunks)).toBeGreaterThanOrEqual(3)
|
||||
expect(await page.evaluate(() => window.directBindings)).toBe(0)
|
||||
await testInfo.attach('same-origin-capture-recovery', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.captureCorsObservation())) })
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
await expect.poll(() => page.evaluate(() => window.captureContexts.every(context => context.state === 'closed'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.captureTracks.every(track => track.readyState === 'ended'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.directBindings)).toBe(0)
|
||||
await expect(page.locator('.art-video-player')).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
@@ -12,14 +12,14 @@ test.beforeAll(async () => {
|
||||
candidateCode = process.env.ARTPLAYER_ASR_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_ASR_ARTIFACT) : await compilePackage('artplayer-plugin-asr', 'umd')
|
||||
})
|
||||
|
||||
for (const version of ['2.1.0', 'candidate']) {
|
||||
for (const { version, captureOnly } of [{ version: '2.1.0', captureOnly: false }, { version: 'candidate', captureOnly: false }, { version: 'candidate', captureOnly: true }]) {
|
||||
for (const core of ['published-5.3.0', 'published', 'candidate']) {
|
||||
test(`ASR ${version} + ${core}: native capture fallback does not create a second audible route`, async ({ page }, testInfo) => {
|
||||
test(`ASR ${version}${captureOnly ? ' capture' : ''} + ${core}: native capture fallback does not create a second audible route`, async ({ page }, testInfo) => {
|
||||
const code = version === 'candidate' ? candidateCode : readMember(contract.archives.get(version), 'package/dist/artplayer-plugin-asr.js')
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
const capabilities = await page.evaluate(() => ({ context: typeof AudioContext, worklet: typeof AudioWorkletNode, capture: typeof HTMLMediaElement.prototype.captureStream, mozCapture: typeof HTMLMediaElement.prototype.mozCaptureStream }))
|
||||
const forceRejection = testInfo.project.name === 'firefox'
|
||||
await testInfo.attach('asr-fallback-inputs', { contentType: 'application/json', body: JSON.stringify({ version, core, pluginSha256: hash(code), capabilities, forcedException: forceRejection, reason: forceRejection ? 'Firefox permits a second native media source; force only binding rejection to reach real captureStream/Worklet' : 'Native duplicate binding rejection', externalContext: 'Native media-element source remains owned and running', physicalOutput: false }) })
|
||||
const forceRejection = testInfo.project.name === 'firefox' && !captureOnly
|
||||
await testInfo.attach('asr-fallback-inputs', { contentType: 'application/json', body: JSON.stringify({ version, core, captureOnly, pluginSha256: hash(code), capabilities, forcedException: forceRejection, reason: captureOnly ? 'Explicit capture mode never binds the element; no forced exceptions' : forceRejection ? 'Firefox permits a second native media source; force only binding rejection to reach real captureStream/Worklet' : 'Native duplicate binding rejection', externalContext: 'Native media-element source remains owned and running', physicalOutput: false }) })
|
||||
if (capabilities.context !== 'function') {
|
||||
expect(process.platform).toBe('win32')
|
||||
expect(testInfo.project.name).toBe('webkit')
|
||||
@@ -28,13 +28,14 @@ for (const version of ['2.1.0', 'candidate']) {
|
||||
expect(capabilities.worklet).toBe('function')
|
||||
expect([capabilities.capture, capabilities.mozCapture]).toContain('function')
|
||||
await page.addScriptTag({ content: code.toString() })
|
||||
await page.evaluate((forceRejection) => {
|
||||
await page.evaluate(({ forceRejection, captureOnly }) => {
|
||||
const NativeContext = window.AudioContext
|
||||
window.asrContexts = []
|
||||
window.asrMeters = []
|
||||
window.asrStreams = []
|
||||
window.asrChunks = []
|
||||
window.asrDirectFailures = []
|
||||
window.asrDirectCalls = 0
|
||||
window.AudioContext = class extends NativeContext {
|
||||
constructor(option) {
|
||||
super(option)
|
||||
@@ -42,6 +43,7 @@ for (const version of ['2.1.0', 'candidate']) {
|
||||
}
|
||||
|
||||
createMediaElementSource(video) {
|
||||
window.asrDirectCalls++
|
||||
try {
|
||||
if (forceRejection)
|
||||
throw new DOMException('Controlled duplicate binding rejection for native capture test', 'InvalidStateError')
|
||||
@@ -80,7 +82,7 @@ for (const version of ['2.1.0', 'candidate']) {
|
||||
url: '/test/audio-tone.m4a',
|
||||
volume: 1,
|
||||
loop: true,
|
||||
plugins: [window.artplayerPluginAsr({ onAudioChunk({ pcm }) {
|
||||
plugins: [window.artplayerPluginAsr({ audioInput: captureOnly ? { type: 'capture' } : undefined, onAudioChunk({ pcm }) {
|
||||
const samples = new Int16Array(pcm)
|
||||
const max = samples.reduce((value, sample) => Math.max(value, Math.abs(sample)), 0)
|
||||
window.asrChunks.push({ max, source: window.art.video.currentSrc, samples: samples.length })
|
||||
@@ -108,9 +110,9 @@ for (const version of ['2.1.0', 'candidate']) {
|
||||
analyser.getFloatTimeDomainData(samples)
|
||||
return Math.sqrt(samples.reduce((sum, value) => sum + value * value, 0) / samples.length)
|
||||
}
|
||||
return { external: rms(window.externalMeter), asr: window.asrMeters.map(rms), contexts: window.asrContexts.map(c => c.state), owner: window.externalContext.state, streams: window.asrStreams.map(s => s.tracks.map(t => ({ kind: t.kind, state: t.readyState }))), chunks: window.asrChunks.slice(-3), failures: window.asrDirectFailures }
|
||||
return { external: rms(window.externalMeter), asr: window.asrMeters.map(rms), contexts: window.asrContexts.map(c => c.state), owner: window.externalContext.state, streams: window.asrStreams.map(s => s.tracks.map(t => ({ kind: t.kind, state: t.readyState }))), chunks: window.asrChunks.slice(-3), failures: window.asrDirectFailures, directCalls: window.asrDirectCalls }
|
||||
}
|
||||
}, forceRejection)
|
||||
}, { forceRejection, captureOnly })
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
await page.locator('#play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.asrChunks.filter(c => c.max > 0).length)).toBeGreaterThanOrEqual(3)
|
||||
@@ -121,7 +123,8 @@ for (const version of ['2.1.0', 'candidate']) {
|
||||
await page.waitForTimeout(450)
|
||||
const muted = await page.evaluate(() => window.readFallback())
|
||||
await testInfo.attach('asr-fallback-output', { contentType: 'application/json', body: JSON.stringify({ playing, muted }) })
|
||||
expect(playing.failures.length).toBe(1)
|
||||
expect(playing.failures.length).toBe(captureOnly ? 0 : 1)
|
||||
expect(playing.directCalls).toBe(captureOnly ? 0 : 1)
|
||||
expect(playing.external).toBeGreaterThan(0.01)
|
||||
expect(muted.external).toBe(0)
|
||||
if (version === '2.1.0') {
|
||||
@@ -167,6 +170,8 @@ for (const version of ['2.1.0', 'candidate']) {
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
await expect.poll(() => page.evaluate(() => window.asrContexts.every(context => context.state === 'closed'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.externalContext.state)).toBe('running')
|
||||
if (captureOnly)
|
||||
expect(await page.evaluate(() => window.asrDirectCalls)).toBe(0)
|
||||
await page.evaluate(() => window.externalContext.close())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ type PreciseResult = Assert<Equal<ReturnType<ReturnType<typeof runtime>>, Runtim
|
||||
type PreciseStop = Assert<Equal<ReturnType<RuntimeResult['stop']>, Promise<void>>>
|
||||
type PreciseFactory = Assert<Equal<typeof runtime, RuntimeFactory>>
|
||||
type PreciseCallback = Assert<Equal<NonNullable<RuntimeOption['onAudioChunk']>, (chunk: AudioChunk) => string | void | null | Promise<string | void | null>>>
|
||||
type PreciseAudioInput = Assert<Equal<RuntimeOption['audioInput'], { type: 'capture' } | undefined>>
|
||||
type RootAudioInputAbsent = Assert<Equal<Extract<keyof AsrPluginOption, 'audioInput'>, never>>
|
||||
|
||||
const replacement: HistoricalFactory = (_option?: HistoricalOption) => (_art: Artplayer) => ({
|
||||
name: 'artplayerPluginAsr',
|
||||
@@ -72,6 +74,24 @@ runtime({ onAudioChunk: () => undefined })
|
||||
runtime({ onAudioChunk: async () => {} })
|
||||
runtime({ onAudioChunk: async () => null })
|
||||
runtime(option)
|
||||
const captureOption: RuntimeOption = { audioInput: { type: 'capture' } }
|
||||
const captureEntryOption: EntryOption = captureOption
|
||||
runtime(captureOption)
|
||||
runtime(captureEntryOption)
|
||||
runtime({ audioInput: { type: 'capture' }, onAudioChunk: async () => 'Captured main media.' })
|
||||
|
||||
// @ts-expect-error Only the explicit capture strategy is public.
|
||||
runtime({ audioInput: { type: 'direct' } })
|
||||
// @ts-expect-error The strategy discriminator is required when audioInput is provided.
|
||||
runtime({ audioInput: {} })
|
||||
// @ts-expect-error Audio input is a descriptor, not a string shorthand.
|
||||
runtime({ audioInput: 'capture' })
|
||||
// @ts-expect-error Root options retain their historical fields without the runtime-only strategy.
|
||||
const rootCaptureOption: AsrPluginOption = { audioInput: { type: 'capture' } }
|
||||
// @ts-expect-error The historical factory does not expose runtime-only options.
|
||||
legacy({ audioInput: { type: 'capture' } })
|
||||
// @ts-expect-error The explicit legacy entry also retains the historical option shape.
|
||||
legacyEntry({ audioInput: { type: 'capture' } })
|
||||
|
||||
// @ts-expect-error Root options preserve the historical void/Promise<void> callback shape.
|
||||
legacy({ onAudioChunk: () => 'Only the precise entry describes returned subtitles.' })
|
||||
@@ -97,5 +117,5 @@ runtime()()
|
||||
correct.append(123)
|
||||
// @ts-expect-error PCM is an ArrayBuffer rather than encoded text.
|
||||
const invalidChunk: AudioChunk = { pcm: 'raw audio', wav: new ArrayBuffer(4) }
|
||||
void [assignToOld, assignFromOld, namespaceFactory, explicitLegacy, oldReturns, stopped, aliasResult, rootPromise, synchronous, wrongStop, invalidChunk]
|
||||
export type { ChunkShape, PreciseArguments, PreciseCallback, PreciseFactory, PreciseResult, PreciseStop, RootArguments, RootFactory, RootOption, RootResult, RootShape, RootStop, RuntimeEntryTypes }
|
||||
void [assignToOld, assignFromOld, namespaceFactory, explicitLegacy, oldReturns, stopped, aliasResult, rootCaptureOption, rootPromise, synchronous, wrongStop, invalidChunk]
|
||||
export type { ChunkShape, PreciseArguments, PreciseAudioInput, PreciseCallback, PreciseFactory, PreciseResult, PreciseStop, RootArguments, RootAudioInputAbsent, RootFactory, RootOption, RootResult, RootShape, RootStop, RuntimeEntryTypes }
|
||||
Reference in new issue
Block a user