mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-21 04:31:20 -08:00
refactor(asr): [PKG-ASR-03] own typed audio capture and playback lifetimes
This commit is contained in:
1 parent
53c295819d
commit
0cf2af4840
33 files changed
+3492
-809
No files matched your search
@@ -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}`))
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())) }
|
||||
}
|
||||
Reference in new issue
Block a user