refactor(asr): [PKG-ASR-03] own typed audio capture and playback lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-13 14:44:23 +08:00
1 parent 53c295819d
commit 0cf2af4840
33 files changed
+3492 -809

No files matched your search

+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Actual ASR distribution imports use the repository runner.
import test from 'node:test'
test('ASR built ESM imports without DOM and keeps both factory paths and synchronous registration', async () => {
const module = await import('../packages/artplayer-plugin-asr/dist/artplayer-plugin-asr.mjs')
assert.deepEqual(Object.keys(module), ['default'])
assert.equal(module.default.default, module.default)
assert.deepEqual(Object.keys(module.default), [])
const events = new Map()
const layer = { style: {}, innerHTML: '' }
const result = module.default({ length: 1 })({
video: {},
volume: 0.5,
layers: { add: () => layer },
on: (name, callback) => events.set(name, callback),
off: name => events.delete(name),
})
assert.equal(result.then, undefined)
assert.deepEqual(Object.keys(result), ['name', 'stop', 'hide', 'append'])
result.append('One. <b>Two</b>')
assert.equal(layer.innerHTML, '<div class="art-asr-line"><b>Two</b></div>')
await result.stop()
await events.get('destroy')()
assert.equal(events.size, 0)
})
test('ASR three generated package artifacts exactly match the demo copies', () => {
for (const suffix of ['js', 'legacy.js', 'mjs']) {
const name = `artplayer-plugin-asr.${suffix}`
assert.deepEqual(fs.readFileSync(`packages/artplayer-plugin-asr/dist/${name}`), fs.readFileSync(`docs/compiled/${name}`))
}
})
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
// eslint-disable-next-line test/no-import-node-test -- Pure ASR audio modules use the repository runner.
import test from 'node:test'
import { asrAudioEnvironment, runAsrWorklet } from './helpers/asr-audio.js'
import { asrHistorical } from './helpers/asr.js'
import { loadModules } from './helpers/load.js'
const root = 'packages/artplayer-plugin-asr/src'
const { encodeAudio, SampleQueue, recorderProcessorCode } = await loadModules({
encodeAudio: { file: `${root}/encoding`, name: 'encodeAudio' },
SampleQueue: { file: `${root}/sample-queue`, name: 'SampleQueue' },
recorderProcessorCode: { file: `${root}/worklet`, name: 'recorderProcessorCode' },
})
test('ASR encoder preserves signed PCM16 clamping, truncation, NaN and little endian bytes', () => {
const input = Float32Array.of(-Infinity, -2, -1, -0.5, -0, 0, 0.5, 1, 2, Infinity, Number.NaN)
const encoded = encodeAudio(input, 16000)
assert.deepEqual(Object.keys(encoded), ['pcm', 'wav'])
const expected = [0, 128, 0, 128, 0, 128, 0, 192, 0, 0, 0, 0, 255, 63, 255, 127, 255, 127, 255, 127, 0, 0]
assert.deepEqual([...new Uint8Array(encoded.pcm)], expected)
assert.equal(input[0], -Infinity)
assert.equal(Number.isNaN(input.at(-1)), true)
})
for (const sampleRate of [8000, 16000, 44100, 48000]) {
test(`ASR encoder writes exact mono WAV metadata at ${sampleRate} Hz`, () => {
const { pcm, wav } = encodeAudio(Float32Array.of(-1, 0, 1), sampleRate)
const data = Buffer.from(wav)
assert.equal(pcm.byteLength, 6)
assert.equal(data.length, 50)
assert.equal(data.toString('ascii', 0, 4), 'RIFF')
assert.equal(data.readUInt32LE(4), 42)
assert.equal(data.toString('ascii', 8, 16), 'WAVEfmt ')
assert.equal(data.readUInt32LE(16), 16)
assert.equal(data.readUInt16LE(20), 1)
assert.equal(data.readUInt16LE(22), 1)
assert.equal(data.readUInt32LE(24), sampleRate)
assert.equal(data.readUInt32LE(28), sampleRate * 2)
assert.equal(data.readUInt16LE(32), 2)
assert.equal(data.readUInt16LE(34), 16)
assert.equal(data.toString('ascii', 36, 40), 'data')
assert.equal(data.readUInt32LE(40), 6)
assert.deepEqual([...data.subarray(44)], [0, 128, 0, 0, 255, 127])
new Uint8Array(pcm).fill(0)
assert.deepEqual([...data.subarray(44)], [0, 128, 0, 0, 255, 127], 'The WAV payload owns its bytes')
})
}
test('ASR encoder empty and offset views retain exact payload boundaries', () => {
const empty = encodeAudio(new Float32Array(0), 16000)
assert.equal(empty.pcm.byteLength, 0)
assert.equal(empty.wav.byteLength, 44)
assert.equal(new DataView(empty.wav).getUint32(4, true), 36)
assert.equal(new DataView(empty.wav).getUint32(40, true), 0)
const backing = Float32Array.of(1, -1, 0.5, 1)
const { pcm } = encodeAudio(backing.subarray(1, 3), 16000)
assert.deepEqual([...new Uint8Array(pcm)], [0, 128, 255, 63])
})
for (const implementation of await asrHistorical()) {
test(`ASR encoder byte parity with ${implementation.name}`, async () => {
const chunks = []
const environment = asrAudioEnvironment(implementation, { sampleRate: 8000, interval: 1, onAudioChunk: chunk => chunks.push(chunk) })
await environment.emit('play')
const input = Float32Array.of(-Infinity, -1, -0.5, Number.NaN, 0, 0.5, 1, Infinity)
environment.send(input)
await environment.tick()
assert.equal(chunks.length, 1)
const candidate = encodeAudio(input, 8000)
assert.deepEqual(Buffer.from(candidate.pcm), Buffer.from(chunks[0].pcm))
assert.deepEqual(Buffer.from(candidate.wav), Buffer.from(chunks[0].wav))
await environment.plugin.stop()
})
}
test('ASR sample queue retains insufficient chunks and excess tails', () => {
const queue = new SampleQueue()
assert.equal(queue.take(8), null)
queue.push(Float32Array.of(1, 2, 3, 4))
assert.equal(queue.take(8), null)
assert.equal(queue.length, 4)
queue.push(Float32Array.of(5, 6, 7, 8, 9, 10))
assert.deepEqual([...queue.take(8)], [1, 2, 3, 4, 5, 6, 7, 8])
assert.equal(queue.length, 2)
assert.equal(queue.take(3), null)
queue.push(Float32Array.of(11))
assert.deepEqual([...queue.take(3)], [9, 10, 11])
assert.equal(queue.length, 0)
assert.equal(queue.take(1), null)
})
test('ASR sample queue copies input views and returns independently owned output', () => {
const queue = new SampleQueue()
const input = Float32Array.of(0, 1, 2, 3)
queue.push(input.subarray(1, 3))
input.fill(9)
const first = queue.take(1)
assert.deepEqual([...first], [1])
first[0] = 8
assert.deepEqual([...queue.take(1)], [2])
})
test('ASR sample queue clears partial blocks and can be reused after draining', () => {
const queue = new SampleQueue()
queue.push(Float32Array.of(1, 2, 3))
assert.deepEqual([...queue.take(1)], [1])
queue.clear()
queue.clear()
queue.push(new Float32Array(0))
assert.equal(queue.length, 0)
assert.equal(queue.take(1), null)
queue.push(Float32Array.of(4, 5))
assert.deepEqual([...queue.take(2)], [4, 5])
queue.push(Float32Array.of(6))
assert.deepEqual([...queue.take(1)], [6])
})
test('ASR sample queue rejects invalid count without losing buffered audio', () => {
const queue = new SampleQueue()
queue.push(Float32Array.of(1, 2))
for (const count of [0, -1, 0.5, Number.NaN, Infinity, -Infinity, Number.MAX_SAFE_INTEGER + 1]) {
assert.throws(() => queue.take(count), { name: 'RangeError', message: 'Sample count must be a positive safe integer' })
assert.equal(queue.length, 2)
}
assert.equal(queue.take(Number.MAX_SAFE_INTEGER), null)
assert.deepEqual([...queue.take(2)], [1, 2])
})
test('ASR sample queue handles many worklet blocks without dropping boundary samples', () => {
const queue = new SampleQueue()
for (let index = 0; index < 4096; index++)
queue.push(Float32Array.of(index, -index))
assert.equal(queue.length, 8192)
const first = queue.take(4097)
const second = queue.take(4095)
assert.equal(queue.length, 0)
const output = [...first, ...second]
for (let index = 0; index < 4096; index++) {
assert.equal(output[index * 2], index)
assert.equal(output[index * 2 + 1], -index)
}
})
test('ASR sample queue preserves FIFO through deterministic mixed push, take and clear operations', () => {
const queue = new SampleQueue()
let expected = []
let serial = 0
for (let index = 1; index <= 1000; index++) {
if (index % 113 === 0) {
queue.clear()
expected = []
}
else if (index % 3 !== 0) {
const chunk = Float32Array.from({ length: index % 17 }, () => ++serial)
queue.push(chunk)
expected.push(...chunk)
}
else {
const count = index % 23 + 1
const actual = queue.take(count)
if (expected.length < count)
assert.equal(actual, null)
else
assert.deepEqual([...actual], expected.splice(0, count))
}
assert.equal(queue.length, expected.length)
}
if (expected.length)
assert.deepEqual([...queue.take(expected.length)], expected)
})
test('ASR candidate Worklet keeps registration, first-channel identity and empty-input behavior', async () => {
const { processor, messages } = await runAsrWorklet(new Blob([recorderProcessorCode]))
const first = Float32Array.of(-0.5, 0.5)
const second = Float32Array.of(1, 1)
assert.equal(processor.process([]), true)
assert.equal(processor.process([[]]), true)
assert.equal(messages.length, 0)
assert.equal(processor.process([[first, second]]), true)
assert.equal(messages.length, 1)
assert.equal(messages[0], first)
})
+434
View File
@@ -0,0 +1,434 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Candidate ASR lifecycle uses the repository runner.
import test from 'node:test'
import { asrAudioEnvironment, deferredAudio } from './helpers/asr-audio.js'
import { asrEnvironment } from './helpers/asr.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'),
}
const block = (value = 0, length = 8) => new Float32Array(length).fill(value)
function create(option = {}, capabilities = {}) {
return asrAudioEnvironment(implementation, { sampleRate: 8000, interval: 1, ...option }, capabilities)
}
function pcmSamples(chunk) {
const bytes = new DataView(chunk.pcm)
return Array.from({ length: chunk.pcm.byteLength / 2 }, (_, index) => bytes.getInt16(index * 2, true))
}
test('ASR candidate lazy factory and synchronous registration keep result keys and Promise stop', async () => {
const environment = asrEnvironment(implementation)
assert.equal(environment.exported.default, environment.exported, 'Both historical CJS factory paths remain callable')
assert.deepEqual(Object.keys(environment.exported), [])
const option = { length: 1, autoHideTimeout: 321 }
const register = environment.factory(option)
assert.equal(typeof register, 'function')
assert.equal(environment.layers.length, 0)
assert.equal(environment.listeners.size, 0)
option.length = 3
option.autoHideTimeout = 999
const result = register(environment.art)
assert.equal(result.then, undefined)
assert.deepEqual(Object.keys(result), ['name', 'stop', 'hide', 'append'])
assert.equal(result.name, 'artplayerPluginAsr')
assert.equal(environment.layers.length, 1)
assert.equal(environment.layers[0].name, 'asr')
assert.equal(environment.layers[0].html, '')
result.append('One. Two! <b>Three</b>')
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line"><b>Three</b></div>')
assert.equal([...environment.timeouts.values()][0].delay, 321, 'Factory snapshots its configuration')
const stopping = result.stop()
assert.equal(typeof stopping.then, 'function')
assert.equal(await stopping, undefined)
assert.equal(environment.timeouts.size, 1, 'A displayed subtitle keeps its existing auto-hide deadline after stop')
;[...environment.timeouts.values()][0].callback()
assert.equal(environment.layer.style.display, 'none')
await environment.emit('destroy')
assert.equal(environment.timeouts.size, 0)
})
test('ASR candidate append keeps punctuation, HTML, ignored non-string values and timer replacement', async () => {
const environment = create({ length: 2, autoHideTimeout: 432 })
assert.equal(environment.plugin.append('First. Second! Third?'), undefined)
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">Second!</div><div class="art-asr-line">Third?</div>')
const firstTimer = [...environment.timeouts.keys()][0]
environment.plugin.append(42)
assert.equal([...environment.timeouts.keys()][0], firstTimer)
environment.plugin.append('<b>Raw</b>')
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line"><b>Raw</b></div>')
assert.equal(environment.timeouts.size, 1)
assert.notEqual([...environment.timeouts.keys()][0], firstTimer)
const timer = [...environment.timeouts.values()][0]
assert.equal(timer.delay, 432)
timer.callback()
assert.equal(environment.layer.style.display, 'none')
environment.plugin.append('Visible.')
assert.equal(environment.layer.style.display, '')
assert.equal(environment.plugin.hide(), undefined)
assert.equal(environment.layer.style.display, 'none')
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">Visible.</div>')
await environment.plugin.stop()
})
test('ASR candidate preserves chunk payload, exact PCM/WAV bytes and callback subtitles', async () => {
let chunk
const environment = create({ onAudioChunk(value) {
chunk = value
return 'One. Two!'
} })
await environment.emit('play')
environment.send([-Infinity, -1, -0.5, Number.NaN, 0, 0.5, 1, Infinity])
await environment.tick()
assert.deepEqual(Object.keys(chunk), ['pcm', 'wav'])
assert.deepEqual(pcmSamples(chunk), [-32768, -32768, -16384, 0, 0, 16383, 32767, 32767])
const wav = Buffer.from(chunk.wav)
assert.equal(wav.byteLength, 60)
assert.equal(wav.toString('ascii', 0, 4), 'RIFF')
assert.equal(wav.readUInt32LE(4), 52)
assert.equal(wav.readUInt16LE(22), 1)
assert.equal(wav.readUInt32LE(24), 8000)
assert.equal(wav.readUInt32LE(28), 16000)
assert.equal(wav.readUInt32LE(40), 16)
assert.deepEqual(wav.subarray(44), Buffer.from(chunk.pcm))
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">One.</div><div class="art-asr-line">Two!</div>')
await environment.plugin.stop()
})
test('ASR candidate preserves short chunks across ticks and excess tail in FIFO order', async () => {
const chunks = []
const environment = create({ onAudioChunk: value => chunks.push(value) })
await environment.emit('play')
environment.send(block(0.5, 4))
await environment.tick()
assert.equal(chunks.length, 0)
environment.send(block(-0.5, 8))
await environment.tick()
assert.deepEqual(pcmSamples(chunks[0]), [16383, 16383, 16383, 16383, -16384, -16384, -16384, -16384])
await environment.tick()
assert.equal(chunks.length, 1)
environment.send(block(1, 4))
await environment.tick()
assert.deepEqual(pcmSamples(chunks[1]), [-16384, -16384, -16384, -16384, 32767, 32767, 32767, 32767])
await environment.plugin.stop()
})
test('ASR candidate allows one in-flight recognizer and retains queued audio', async () => {
const pending = deferredAudio()
const calls = []
const environment = create({ onAudioChunk: (chunk) => {
calls.push(chunk)
return calls.length === 1 ? pending.promise : 'Second.'
} })
await environment.emit('play')
environment.send(block(0.5))
const first = environment.tick()
environment.send(block(-0.5))
await environment.tick()
await environment.tick()
assert.equal(calls.length, 1)
pending.resolve('First.')
await first
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">First.</div>')
await environment.tick()
assert.equal(calls.length, 2)
assert.deepEqual(pcmSamples(calls[1]), Array.from({ length: 8 }, () => -16384))
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">Second.</div>')
await environment.plugin.stop()
})
for (const mode of ['throw', 'reject']) {
test(`ASR candidate handles callback ${mode} and continues subsequent audio`, async () => {
const failure = new Error('recognition failed')
let count = 0
const environment = create({ onAudioChunk: () => {
if (++count > 1)
return 'Recovered.'
if (mode === 'throw')
throw failure
return Promise.reject(failure)
} })
await environment.emit('play')
environment.send(block())
await assert.doesNotReject(environment.tick())
assert.equal(environment.errors.length, 1)
assert.equal(environment.errors[0][1], failure)
environment.send(block())
await environment.tick()
assert.equal(count, 2)
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">Recovered.</div>')
await environment.plugin.stop()
})
}
for (const event of ['pause', 'stop', 'destroy', 'restart']) {
test(`ASR candidate invalidates pending callback after ${event}`, async () => {
const pending = deferredAudio()
const environment = create({ onAudioChunk: () => pending.promise })
await environment.emit('play')
environment.send(block())
const recognizing = environment.tick()
await (event === 'stop' ? environment.plugin.stop() : environment.emit(event))
pending.resolve('Obsolete.')
await recognizing
assert.equal(environment.layer.innerHTML, '')
assert.equal(environment.timeouts.size, 0)
assert.equal(environment.errors.length, 0)
assert.equal(environment.intervals.size, event === 'restart' ? 1 : 0)
await environment.plugin.stop()
})
}
test('ASR candidate reset clears old queued audio without dropping the next source', async () => {
const calls = []
const environment = create({ onAudioChunk: value => calls.push(value) })
await environment.emit('play')
environment.send(block(0.5))
environment.art.video.src = 'local-new-source.mp4'
await environment.emit('restart')
await environment.tick()
assert.equal(calls.length, 0)
environment.send(block(-0.5))
await environment.tick()
assert.deepEqual(pcmSamples(calls[0]), Array.from({ length: 8 }, () => -16384))
await environment.plugin.stop()
})
test('ASR candidate old callback completion cannot unlock a new session pending callback', async () => {
const pending = [deferredAudio(), deferredAudio()]
let calls = 0
const environment = create({ onAudioChunk: () => pending[calls++].promise })
await environment.emit('play')
environment.send(block())
const old = environment.tick()
await environment.emit('pause')
await environment.emit('play')
environment.send(block())
const current = environment.tick()
pending[0].resolve('Old.')
await old
environment.send(block())
await environment.tick()
assert.equal(calls, 2)
assert.equal(environment.layer.innerHTML, '')
pending[1].resolve('Current.')
await current
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">Current.</div>')
await environment.plugin.stop()
})
test('ASR candidate concurrent play creates one graph and one interval', async () => {
const moduleGate = deferredAudio()
const moduleEntered = deferredAudio()
const environment = create({}, { moduleGate, moduleEntered })
const starting = environment.emit('play')
const duplicate = environment.emit('play')
await moduleEntered.promise
assert.equal(environment.modules.length, 1)
moduleGate.resolve()
await Promise.all([starting, duplicate])
await environment.emit('play')
assert.equal(environment.contexts.length, 1)
assert.equal(environment.recorders.length, 1)
assert.equal(environment.intervals.size, 1)
await environment.plugin.stop()
assert.equal(environment.intervals.size, 0)
assert.equal(environment.recorders[0].port.onmessage, null)
assert.equal(environment.recorders[0].disconnects, 1)
})
test('ASR candidate pause during module loading prevents late connection and later resumes prepared graph', async () => {
const moduleGate = deferredAudio()
const moduleEntered = deferredAudio()
const environment = create({}, { moduleGate, moduleEntered, capture: false })
const starting = environment.emit('play')
await moduleEntered.promise
await environment.emit('pause')
moduleGate.resolve()
await starting
assert.equal(environment.intervals.size, 0)
assert.equal(environment.recorders.length, 0)
assert.equal(environment.contexts[0].closes, 0)
await environment.emit('play')
assert.equal(environment.contexts.length, 1)
assert.equal(environment.modules.length, 1)
assert.equal(environment.recorders.length, 1)
assert.equal(environment.intervals.size, 1)
assert.equal(environment.captures(), 0)
assert.equal(environment.errors.length, 0)
await environment.plugin.stop()
})
for (const event of ['stop', 'destroy']) {
test(`ASR candidate ${event} during module loading frees URL and context without resurrection`, async () => {
const moduleGate = deferredAudio()
const moduleEntered = deferredAudio()
const environment = create({}, { moduleGate, moduleEntered })
const starting = environment.emit('play')
await moduleEntered.promise
assert.equal(environment.blobs.size, 1)
await (event === 'stop' ? environment.plugin.stop() : environment.emit(event))
assert.equal(environment.blobs.size, 0)
assert.equal(environment.contexts[0].closes, 1)
moduleGate.resolve()
await starting
assert.equal(environment.revoked.length, 1)
assert.equal(environment.intervals.size, 0)
assert.equal(environment.recorders.length, 0)
assert.equal(environment.errors.length, 0)
})
}
test('ASR candidate failed module initialization releases context and URL before binding video', async () => {
const failure = new Error('failed worklet')
const environment = create({}, { moduleError: failure })
await environment.emit('play')
assert.equal(environment.errors.length, 1)
assert.equal(environment.errors[0][1], failure)
assert.equal(environment.contexts[0].closes, 1)
assert.equal(environment.blobs.size, 0)
assert.equal(environment.revoked.length, 1)
assert.equal(environment.nodes.filter(node => node.kind === 'element').length, 0)
assert.equal(environment.intervals.size, 0)
await environment.plugin.stop()
assert.equal(environment.contexts[0].closes, 1)
})
test('ASR candidate pause/resume reuses direct source without captureStream', async () => {
const environment = create({}, { capture: false })
await environment.emit('play')
const source = environment.nodes.find(node => node.kind === 'element')
await environment.emit('pause')
assert.equal(source.connections.length, 1, 'The player-owned audio route remains available while ASR is paused')
assert.equal(source.connections[0].kind, 'gain')
assert.equal(source.connections[0].connections[0], environment.contexts[0].destination)
assert.equal(environment.recorders[0].port.onmessage, null)
await environment.emit('play')
assert.equal(environment.contexts.length, 1)
assert.equal(environment.modules.length, 1)
assert.equal(environment.nodes.filter(node => node.kind === 'element').length, 1)
assert.equal(source.connections.length, 2)
assert.equal(environment.errors.length, 0)
assert.equal(environment.intervals.size, 1)
await environment.plugin.stop()
})
test('ASR candidate ignores old worklet delivery after pause/resume reconnect', async () => {
const calls = []
const environment = create({ onAudioChunk: value => calls.push(value) })
await environment.emit('play')
const staleMessage = environment.recorders[0].port.onmessage
await environment.emit('pause')
await environment.emit('play')
staleMessage({ data: block(0.5) })
environment.send(block(-0.5))
await environment.tick()
assert.equal(calls.length, 1)
assert.deepEqual(pcmSamples(calls[0]), Array.from({ length: 8 }, () => -16384))
await environment.plugin.stop()
})
test('ASR candidate overload explicitly reports and pauses instead of silently discarding samples', async () => {
const environment = create()
await environment.emit('play')
environment.send(block(0, 8000 * 60))
assert.equal(environment.errors.length, 0)
assert.equal(environment.intervals.size, 1)
environment.send(block(0, 1))
assert.equal(environment.errors.length, 1)
assert.deepEqual(environment.errors[0], ['[artplayerPluginAsr] Audio callback backlog exceeded its capture limit'])
assert.equal(environment.intervals.size, 0)
assert.equal(environment.recorders[0].port.onmessage, null)
await environment.plugin.stop()
await environment.emit('destroy')
const longChunk = create({ interval: 120000 })
await longChunk.emit('play')
longChunk.send(block(0, 8000 * 60 + 1))
assert.equal(longChunk.errors.length, 0, 'A configured chunk longer than 60 seconds must still be collectable')
assert.equal(longChunk.intervals.size, 1)
await longChunk.emit('destroy')
})
test('ASR candidate destroy cancels subtitle timers, removes subscriptions and forbids public append resurrection', async () => {
const environment = create()
await environment.emit('play')
environment.plugin.append('Kept.')
assert.equal(environment.timeouts.size, 1)
await environment.emit('destroy')
assert.equal(environment.timeouts.size, 0)
assert.equal([...environment.listeners.values()].flat().length, 0)
const html = environment.layer.innerHTML
const display = environment.layer.style.display
environment.plugin.append('Unexpected.')
environment.plugin.hide()
await environment.emit('play')
await environment.emit('restart')
await environment.emit('video:volumechange')
await environment.plugin.stop()
assert.equal(environment.layer.innerHTML, html)
assert.equal(environment.layer.style.display, display)
assert.equal(environment.timeouts.size, 0)
assert.equal(environment.contexts.length, 1)
assert.equal(environment.contexts[0].closes, 1)
assert.equal(environment.intervals.size, 0)
})
test('ASR candidate public stop retains the current hide deadline and allows append and subsequent play', async () => {
const environment = create({}, { capture: false })
await environment.emit('play')
environment.plugin.append('Before.')
const beforeTimer = [...environment.timeouts.keys()][0]
await environment.plugin.stop()
assert.equal(environment.timeouts.size, 1)
assert.equal([...environment.timeouts.keys()][0], beforeTimer)
assert.equal(environment.contexts[0].closes, 0, 'Direct media ownership lasts until player destruction')
const source = environment.nodes.find(node => node.kind === 'element')
assert.equal(source.connections[0].kind, 'gain')
assert.equal(source.connections[0].connections[0], environment.contexts[0].destination)
environment.plugin.append('After.')
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">After.</div>')
await environment.emit('play')
assert.equal(environment.contexts.length, 1)
assert.equal(environment.captures(), 0)
assert.equal(environment.intervals.size, 1)
await environment.plugin.stop()
await environment.emit('destroy')
assert.equal(environment.contexts[0].closes, 1)
assert.equal(source.connections.length, 0)
})
test('ASR candidate volume route survives stop and resume without resetting gain', async () => {
const environment = create({}, { capture: false })
await environment.emit('play')
environment.art.volume = 0.25
await environment.emit('video:volumechange')
await environment.plugin.stop()
const source = environment.nodes.find(node => node.kind === 'element')
assert.equal(source.connections[0].gain.value, 0.25)
await environment.emit('play')
assert.equal(source.connections.find(node => node.kind === 'gain').gain.value, 0.25)
await environment.emit('destroy')
assert.equal(environment.contexts[0].closes, 1)
})
test('ASR candidate source restart releases captured tracks and obtains a new fallback source', async () => {
const environment = create({}, { directError: new Error('video belongs to another context') })
await environment.emit('play')
assert.equal(environment.captures(), 1)
await environment.emit('restart')
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)
assert.equal(environment.intervals.size, 1)
await environment.plugin.stop()
assert.equal(environment.contexts[1].closes, 1)
assert.equal(environment.tracks[0].stops, 2)
})
+4 -1
View File
@@ -30,7 +30,7 @@ for (const implementation of await asrHistorical()) {
assert.equal(env.styles.size, 1)
})
}
test(`ASR ${implementation.name}: append replaces punctuation lines, permits markup, and hide retains content`, () => {
test(`ASR ${implementation.name}: append replaces punctuation lines, permits markup, and hide retains content`, async () => {
const env = asrEnvironment(implementation)
const result = env.factory()(env.art)
assert.equal(result.append('One. Two! 三。 Four?'), undefined)
@@ -47,6 +47,9 @@ for (const implementation of await asrHistorical()) {
result.append('')
assert.equal(env.layer.innerHTML, '')
assert.equal(env.layer.style.display, '')
const timer = [...env.timeouts.keys()][0]
await result.stop()
assert.equal([...env.timeouts.keys()][0], timer, 'Stopping audio preserves an existing subtitle hide deadline')
;[...env.timeouts.values()][0].callback()
assert.equal(env.layer.style.display, 'none')
})
+36 -7
View File
@@ -2,20 +2,23 @@ import fs from 'node:fs'
import process from 'node:process'
import { verifyAsrContract } from '../../refactor/scripts/asr-contract.mjs'
import { hash, readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let contract
let candidateCode
test.beforeAll(async () => {
contract = await verifyAsrContract()
candidateCode = process.env.ARTPLAYER_ASR_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_ASR_ARTIFACT) : await compilePackage('artplayer-plugin-asr', 'umd')
})
for (const version of ['2.0.0', '2.1.0']) {
for (const version of ['2.0.0', '2.1.0', 'candidate']) {
for (const core of ['published', 'candidate']) {
test(`ASR ${version} + ${core}: native Worklet supplies local PCM and stop closes AudioContext`, async ({ page }, testInfo) => {
test(`ASR ${version} + ${core}: native Worklet supplies local PCM with owned stop and destroy`, async ({ page }, testInfo) => {
const release = [contract.baseline.release, ...contract.baseline.previous].find(release => release.version === version)
const code = readMember(contract.archives.get(version), 'package/dist/artplayer-plugin-asr.js')
const code = version === 'candidate' ? candidateCode : readMember(contract.archives.get(version), 'package/dist/artplayer-plugin-asr.js')
const media = fs.readFileSync(new URL('./media/audio-tone.m4a', import.meta.url))
await testInfo.attach('asr-inputs', { contentType: 'application/json', body: JSON.stringify({ version, core, pluginSha256: hash(code), integrity: release.integrity, mediaSha256: hash(media), media: 'local mono AAC 440 Hz tone', networkService: false }) })
await testInfo.attach('asr-inputs', { contentType: 'application/json', body: JSON.stringify({ version, core, pluginSha256: hash(code), integrity: release?.integrity || null, candidate: process.env.ARTPLAYER_ASR_ARTIFACT || 'workspace source', mediaSha256: hash(media), media: 'local mono AAC 440 Hz tone', networkService: false }) })
await page.goto(`/test/player.html?core=${core}`)
const capabilities = await page.evaluate(() => ({ AudioContext: typeof window.AudioContext, webkitAudioContext: typeof window.webkitAudioContext, AudioWorkletNode: typeof window.AudioWorkletNode }))
await testInfo.attach('asr-audio-capabilities', { contentType: 'application/json', body: JSON.stringify(capabilities) })
@@ -47,7 +50,7 @@ for (const version of ['2.0.0', '2.1.0']) {
const samples = new Int16Array(pcm)
let max = 0
for (const sample of samples) max = Math.max(max, Math.abs(sample))
window.asrChunks.push({ samples: samples.length, max, wavBytes: wav.byteLength, sampleRate: new DataView(wav).getUint32(24, true) })
window.asrChunks.push({ samples: samples.length, max, wavBytes: wav.byteLength, sampleRate: new DataView(wav).getUint32(24, true), source: window.art.video.currentSrc })
return 'Local transcript.'
},
})],
@@ -60,10 +63,36 @@ for (const version of ['2.0.0', '2.1.0']) {
const chunks = await page.evaluate(() => window.asrChunks)
expect(chunks.every(chunk => chunk.samples === 1600 && chunk.wavBytes === 3244 && chunk.sampleRate === 16000)).toBe(true)
await expect(page.locator('.art-layer-asr')).toContainText('Local transcript.')
if (version === 'candidate') {
await page.evaluate(() => window.art.pause())
const before = await page.evaluate(() => window.asrChunks.length)
await page.waitForTimeout(250)
expect(await page.evaluate(() => window.asrChunks.length)).toBe(before)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.asrChunks.length)).toBeGreaterThan(before + 2)
expect(await page.evaluate(() => window.asrContexts.length)).toBe(1)
await page.evaluate(() => {
window.art.url = '/test/audio-tone.m4a?asr=second'
})
await expect.poll(() => page.evaluate(() => window.art.video.currentSrc.includes('asr=second') && window.art.video.readyState >= 2)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.asrChunks.filter(chunk => chunk.source.includes('asr=second') && chunk.max > 0).length)).toBeGreaterThanOrEqual(3)
expect(await page.evaluate(() => window.asrContexts.length)).toBe(1)
}
await page.evaluate(() => window.art.plugins.artplayerPluginAsr.stop())
expect(await page.evaluate(() => window.asrContexts.map(context => context.state))).toEqual(['closed'])
await testInfo.attach('asr-native-chunks', { contentType: 'application/json', body: JSON.stringify(chunks) })
expect(await page.evaluate(() => window.asrContexts.map(context => context.state))).toEqual([version === 'candidate' ? 'running' : 'closed'])
if (version === 'candidate') {
await page.evaluate(() => window.art.pause())
const before = await page.evaluate(() => window.asrChunks.filter(chunk => chunk.max > 0).length)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.asrChunks.filter(chunk => chunk.max > 0).length)).toBeGreaterThan(before + 2)
expect(await page.evaluate(() => window.asrContexts.length)).toBe(1)
await page.evaluate(() => window.art.plugins.artplayerPluginAsr.stop())
expect(await page.evaluate(() => window.asrContexts.map(context => context.state))).toEqual(['running'])
}
await testInfo.attach('asr-native-chunks', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.asrChunks)) })
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.asrContexts.map(context => context.state))).toEqual(['closed'])
})
}
}
+2
View File
@@ -32,6 +32,7 @@ export function asrEnvironment(implementation, { script = false } = {}) {
return id
},
clearTimeout(timer) { timeouts.delete(timer) },
clearInterval() {},
document: {
getElementById: id => styles.get(id),
createElement: () => ({}),
@@ -57,6 +58,7 @@ export function asrEnvironment(implementation, { script = false } = {}) {
return layer
} },
on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) },
off(name, callback) { listeners.set(name, (listeners.get(name) || []).filter(listener => listener !== callback)) },
}
return { context, factory, exported, styles, layer, layers, listeners, timeouts, art, reload: () => vm.runInContext(code, context), emit: name => Promise.all((listeners.get(name) || []).map(callback => callback())) }
}