mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 19:06:15 -08:00
test(asr): [PKG-ASR-05] checkpoint local editor and adjacent core coverage
This commit is contained in:
1 parent
6d7b9292f8
commit
2cede66933
18 files changed
+10908
-240
No files matched your search
@@ -0,0 +1,68 @@
|
||||
/* global Artplayer, artplayerPluginAsr */
|
||||
// Local audio capture demo. The subtitles below are simulated, not recognized speech.
|
||||
// No audio is uploaded; only the sample media is loaded from this local site.
|
||||
const statistics = document.createElement('div')
|
||||
statistics.textContent = 'Local ASR demo: press play. No recognition service is used.'
|
||||
let chunks = 0
|
||||
let pcmBytes = 0
|
||||
let wavBytes = 0
|
||||
|
||||
const art = new Artplayer({
|
||||
container: '.artplayer-app',
|
||||
url: '/assets/sample/steve-jobs.mp4',
|
||||
autoSize: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
layers: [{
|
||||
name: 'asr-local-statistics',
|
||||
html: statistics,
|
||||
style: {
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
left: '12px',
|
||||
right: '12px',
|
||||
padding: '8px 12px',
|
||||
background: 'rgba(0, 0, 0, 0.65)',
|
||||
color: '#fff',
|
||||
fontSize: '12px',
|
||||
whiteSpace: 'pre-line',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
}],
|
||||
controls: [{
|
||||
name: 'asr-local-stop',
|
||||
position: 'right',
|
||||
html: 'Stop ASR',
|
||||
tooltip: 'Stop capture; pause and play to restart',
|
||||
async click() {
|
||||
await art.plugins.artplayerPluginAsr.stop()
|
||||
if (!art.isDestroy)
|
||||
statistics.textContent = 'Local capture stopped. Pause and play to restart. Nothing was uploaded.'
|
||||
},
|
||||
}],
|
||||
plugins: [artplayerPluginAsr({
|
||||
length: 2,
|
||||
interval: 250,
|
||||
sampleRate: 16000,
|
||||
autoHideTimeout: 5000,
|
||||
onAudioChunk({ pcm, wav }) {
|
||||
if (art.isDestroy)
|
||||
return
|
||||
chunks++
|
||||
pcmBytes += pcm.byteLength
|
||||
wavBytes += wav.byteLength
|
||||
const sampleRate = new DataView(wav).getUint32(24, true)
|
||||
const samples = new DataView(pcm)
|
||||
let peak = 0
|
||||
for (let offset = 0; offset < pcm.byteLength; offset += 2)
|
||||
peak = Math.max(peak, Math.abs(samples.getInt16(offset, true)))
|
||||
const duration = (pcmBytes / 2 / sampleRate).toFixed(2)
|
||||
statistics.textContent = [
|
||||
'Local capture only - simulated subtitles, no speech recognition',
|
||||
`Chunks: ${chunks} | ${sampleRate} Hz mono PCM16 | ${duration} seconds captured`,
|
||||
`PCM: ${pcmBytes} bytes | WAV: ${wavBytes} bytes | Current peak: ${peak}`,
|
||||
].join('\n')
|
||||
return `Simulated local subtitle: audio chunk ${chunks} received.`
|
||||
},
|
||||
})],
|
||||
})
|
||||
@@ -1,247 +1,385 @@
|
||||
var artplayerPluginAsr = (function() {
|
||||
"use strict";
|
||||
const style = ".art-video-player .art-layer-asr {\n pointer-events: none !important;\n position: absolute;\n z-index: 150;\n inset: 0px;\n display: flex;\n flex-direction: column;\n justify-content: end;\n padding: 0 2%;\n font-size: 1rem;\n gap: var(--art-subtitle-gap);\n padding-bottom: var(--art-subtitle-bottom);\n transition: padding-bottom var(--art-transition-duration) ease;\n text-shadow: var(--art-subtitle-border) 1px 0 1px, var(--art-subtitle-border) 0 1px 1px, var(--art-subtitle-border) -1px 0 1px, var(--art-subtitle-border) 0 -1px 1px, var(--art-subtitle-border) 1px 1px 1px, var(--art-subtitle-border) -1px -1px 1px, var(--art-subtitle-border) 1px -1px 1px, var(--art-subtitle-border) -1px 1px 1px;\n}\n.art-video-player.art-control-show .art-layer-asr {\n padding-bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));\n}\n";
|
||||
function artplayerPluginAsr2(option = {}) {
|
||||
const {
|
||||
length = 3,
|
||||
interval = 100,
|
||||
sampleRate = 16e3,
|
||||
autoHideTimeout = 1e4,
|
||||
onAudioChunk = () => null
|
||||
} = option;
|
||||
return (art) => {
|
||||
let started = false;
|
||||
let audioCtx = null;
|
||||
let sourceNode = null;
|
||||
let recorderNode = null;
|
||||
let gainNode = null;
|
||||
let bufferChunks = [];
|
||||
let timer = null;
|
||||
let workletLoaded = false;
|
||||
let hideTimer = null;
|
||||
let mediaStream = null;
|
||||
let mediaStreamSource = null;
|
||||
const $asr = art.layers.add({
|
||||
name: "asr",
|
||||
html: ""
|
||||
});
|
||||
function splitByPunctuation(text) {
|
||||
return text.split(/(?<=[、。!?!?.])\s*/u).map((s) => s.trim()).filter(Boolean).slice(-length);
|
||||
}
|
||||
function hide() {
|
||||
$asr.style.display = "none";
|
||||
}
|
||||
function append(subtitle) {
|
||||
if (typeof subtitle !== "string")
|
||||
return;
|
||||
clearTimeout(hideTimer);
|
||||
hideTimer = setTimeout(hide, autoHideTimeout);
|
||||
$asr.style.display = "";
|
||||
$asr.innerHTML = splitByPunctuation(subtitle).map((line) => `<div class="art-asr-line">${line}</div>`).join("");
|
||||
}
|
||||
const recorderProcessorCode = `
|
||||
class RecorderProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (input && input[0]) {
|
||||
this.port.postMessage(input[0]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('recorder-processor', RecorderProcessor);
|
||||
`;
|
||||
const createWorkletBlobUrl = () => {
|
||||
const blob = new Blob([recorderProcessorCode], { type: "application/javascript" });
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
function floatTo16BitPCM(float32Array) {
|
||||
const len = float32Array.length;
|
||||
const buffer = new ArrayBuffer(len * 2);
|
||||
const view = new DataView(buffer);
|
||||
for (let i = 0; i < len; i++) {
|
||||
const s = Math.max(-1, Math.min(1, float32Array[i]));
|
||||
view.setInt16(i * 2, s < 0 ? s * 32768 : s * 32767, true);
|
||||
const recorderProcessorCode = `
|
||||
class RecorderProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const input = inputs[0];
|
||||
if (input && input[0]) {
|
||||
this.port.postMessage(input[0]);
|
||||
}
|
||||
return buffer;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('recorder-processor', RecorderProcessor);
|
||||
`;
|
||||
class AudioGraph {
|
||||
constructor(video, sampleRate) {
|
||||
this.video = video;
|
||||
this.sampleRate = sampleRate;
|
||||
this.closed = false;
|
||||
this.direct = false;
|
||||
this.workletLoaded = false;
|
||||
}
|
||||
assertOpen() {
|
||||
if (this.closed)
|
||||
throw new Error("Audio capture was closed");
|
||||
}
|
||||
revokeWorklet() {
|
||||
if (this.blobUrl !== void 0) {
|
||||
URL.revokeObjectURL(this.blobUrl);
|
||||
this.blobUrl = void 0;
|
||||
}
|
||||
function pcmToWav(pcmBuffer, sampleRate2) {
|
||||
const pcmLength = pcmBuffer.byteLength;
|
||||
const wavBuffer = new ArrayBuffer(44 + pcmLength);
|
||||
const view = new DataView(wavBuffer);
|
||||
const writeString = (offset, str) => {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
view.setUint8(offset + i, str.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
writeString(0, "RIFF");
|
||||
view.setUint32(4, 36 + pcmLength, true);
|
||||
writeString(8, "WAVE");
|
||||
writeString(12, "fmt ");
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, 1, true);
|
||||
view.setUint32(24, sampleRate2, true);
|
||||
view.setUint32(28, sampleRate2 * 2, true);
|
||||
view.setUint16(32, 2, true);
|
||||
view.setUint16(34, 16, true);
|
||||
writeString(36, "data");
|
||||
view.setUint32(40, pcmLength, true);
|
||||
new Uint8Array(wavBuffer).set(new Uint8Array(pcmBuffer), 44);
|
||||
return wavBuffer;
|
||||
}
|
||||
async function setupAudioContext() {
|
||||
if (audioCtx)
|
||||
return audioCtx;
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate });
|
||||
if (audioCtx.state === "suspended")
|
||||
await audioCtx.resume();
|
||||
return audioCtx;
|
||||
}
|
||||
async function setupAudioSource() {
|
||||
if (!sourceNode && art.video) {
|
||||
try {
|
||||
sourceNode = audioCtx.createMediaElementSource(art.video);
|
||||
return sourceNode;
|
||||
} catch (err) {
|
||||
console.warn("[artplayerPluginAsr] Direct connection failed:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
async initialize() {
|
||||
this.assertOpen();
|
||||
const platform = window;
|
||||
const Context = window.AudioContext || platform.webkitAudioContext;
|
||||
if (!Context)
|
||||
throw new Error("AudioContext is not supported");
|
||||
const context = this.context ?? (this.context = new Context({ sampleRate: this.sampleRate }));
|
||||
if (context.state === "suspended")
|
||||
await context.resume();
|
||||
this.assertOpen();
|
||||
if (!this.workletLoaded) {
|
||||
this.blobUrl = URL.createObjectURL(new Blob([recorderProcessorCode], { type: "application/javascript" }));
|
||||
try {
|
||||
if (!mediaStream) {
|
||||
mediaStream = await captureAudioFromVideo(art.video);
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStreamSource = audioCtx.createMediaStreamSource(mediaStream);
|
||||
return mediaStreamSource;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[artplayerPluginAsr] MediaStream fallback failed:", err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function captureAudioFromVideo(videoElement) {
|
||||
try {
|
||||
if (videoElement.captureStream)
|
||||
return videoElement.captureStream();
|
||||
if (videoElement.mozCaptureStream)
|
||||
return videoElement.mozCaptureStream();
|
||||
console.warn("[artplayerPluginAsr] captureStream not supported");
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.warn("[artplayerPluginAsr] Error capturing stream:", err);
|
||||
return null;
|
||||
await context.audioWorklet.addModule(this.blobUrl);
|
||||
this.workletLoaded = true;
|
||||
} finally {
|
||||
this.revokeWorklet();
|
||||
}
|
||||
}
|
||||
async function startCapture() {
|
||||
if (started)
|
||||
return;
|
||||
this.assertOpen();
|
||||
if (!this.source) {
|
||||
try {
|
||||
await setupAudioContext();
|
||||
const audioSource = await setupAudioSource();
|
||||
if (!audioSource)
|
||||
this.source = context.createMediaElementSource(this.video);
|
||||
this.direct = true;
|
||||
} catch (error) {
|
||||
console.warn("[artplayerPluginAsr] Direct connection failed:", error);
|
||||
const capture = this.video.captureStream || this.video.mozCaptureStream;
|
||||
if (!capture)
|
||||
throw new Error("Could not establish audio source");
|
||||
if (!workletLoaded) {
|
||||
const blobUrl = createWorkletBlobUrl();
|
||||
await audioCtx.audioWorklet.addModule(blobUrl);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
workletLoaded = true;
|
||||
}
|
||||
gainNode = audioCtx.createGain();
|
||||
gainNode.gain.value = 1;
|
||||
recorderNode = new AudioWorkletNode(audioCtx, "recorder-processor");
|
||||
recorderNode.port.onmessage = (event) => {
|
||||
bufferChunks.push(new Float32Array(event.data));
|
||||
};
|
||||
audioSource.connect(recorderNode);
|
||||
audioSource.connect(gainNode);
|
||||
gainNode.connect(audioCtx.destination);
|
||||
const CHUNK_SAMPLES = sampleRate * (interval / 1e3);
|
||||
timer = setInterval(async () => {
|
||||
if (bufferChunks.length === 0)
|
||||
return;
|
||||
let accumulated = new Float32Array(0);
|
||||
while (accumulated.length < CHUNK_SAMPLES && bufferChunks.length) {
|
||||
const next = bufferChunks.shift();
|
||||
const tmp = new Float32Array(accumulated.length + next.length);
|
||||
tmp.set(accumulated, 0);
|
||||
tmp.set(next, accumulated.length);
|
||||
accumulated = tmp;
|
||||
}
|
||||
if (accumulated.length < CHUNK_SAMPLES)
|
||||
return;
|
||||
const chunkToSend = accumulated.slice(0, CHUNK_SAMPLES);
|
||||
const pcm = floatTo16BitPCM(chunkToSend);
|
||||
const wav = pcmToWav(pcm, sampleRate);
|
||||
const subtitle = await onAudioChunk({ pcm, wav });
|
||||
append(subtitle);
|
||||
}, interval);
|
||||
started = true;
|
||||
} catch (err) {
|
||||
console.error("[artplayerPluginAsr] Initialization failed:", err);
|
||||
await stopCapture();
|
||||
this.stream = capture.call(this.video);
|
||||
this.source = context.createMediaStreamSource(this.stream);
|
||||
}
|
||||
}
|
||||
async function stopCapture() {
|
||||
if (!started)
|
||||
return;
|
||||
started = false;
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
if (recorderNode) {
|
||||
recorderNode.disconnect();
|
||||
recorderNode.port.onmessage = null;
|
||||
recorderNode = null;
|
||||
}
|
||||
if (gainNode) {
|
||||
gainNode.disconnect();
|
||||
gainNode = null;
|
||||
}
|
||||
bufferChunks = [];
|
||||
}
|
||||
prepare() {
|
||||
this.preparation ?? (this.preparation = this.initialize().catch((error) => {
|
||||
this.preparation = void 0;
|
||||
throw error;
|
||||
}));
|
||||
return this.preparation;
|
||||
}
|
||||
get ownsMediaConnection() {
|
||||
return this.direct;
|
||||
}
|
||||
idle() {
|
||||
const gain = this.gain;
|
||||
this.disconnect();
|
||||
if (this.direct && this.context && this.source) {
|
||||
this.gain = gain || this.context.createGain();
|
||||
if (!gain)
|
||||
this.gain.gain.value = 1;
|
||||
this.source.connect(this.gain);
|
||||
this.gain.connect(this.context.destination);
|
||||
}
|
||||
async function destroy() {
|
||||
await stopCapture();
|
||||
if (mediaStreamSource) {
|
||||
mediaStreamSource.disconnect();
|
||||
mediaStreamSource = null;
|
||||
}
|
||||
if (sourceNode) {
|
||||
sourceNode.disconnect();
|
||||
sourceNode = null;
|
||||
}
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach((track) => track.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
if (audioCtx) {
|
||||
await audioCtx.close();
|
||||
audioCtx = null;
|
||||
}
|
||||
workletLoaded = false;
|
||||
}
|
||||
art.on("video:volumechange", () => {
|
||||
if (gainNode) {
|
||||
gainNode.gain.value = art.volume;
|
||||
}
|
||||
});
|
||||
art.on("play", startCapture);
|
||||
art.on("pause", stopCapture);
|
||||
art.on("destroy", destroy);
|
||||
return {
|
||||
name: "artplayerPluginAsr",
|
||||
stop: destroy,
|
||||
hide,
|
||||
append
|
||||
}
|
||||
connect(receive) {
|
||||
this.assertOpen();
|
||||
const context = this.context;
|
||||
const source = this.source;
|
||||
if (!context || !source)
|
||||
throw new Error("Audio capture is not prepared");
|
||||
const previousGain = this.gain;
|
||||
this.disconnect();
|
||||
const gain = this.gain = previousGain || context.createGain();
|
||||
if (!previousGain)
|
||||
gain.gain.value = this.direct ? 1 : 0;
|
||||
const recorder = this.recorder = new AudioWorkletNode(context, "recorder-processor");
|
||||
recorder.port.onmessage = (event) => {
|
||||
if (!this.closed && this.recorder === recorder)
|
||||
receive(event.data);
|
||||
};
|
||||
source.connect(recorder);
|
||||
if (this.direct)
|
||||
source.connect(gain);
|
||||
else recorder.connect(gain);
|
||||
gain.connect(context.destination);
|
||||
}
|
||||
disconnect() {
|
||||
if (this.recorder) {
|
||||
this.recorder.port.onmessage = null;
|
||||
this.recorder.disconnect();
|
||||
this.recorder = void 0;
|
||||
}
|
||||
this.gain?.disconnect();
|
||||
this.gain = void 0;
|
||||
this.source?.disconnect();
|
||||
}
|
||||
async close() {
|
||||
if (this.closed)
|
||||
return;
|
||||
this.closed = true;
|
||||
this.disconnect();
|
||||
this.revokeWorklet();
|
||||
this.stream?.getTracks().forEach((track) => track.stop());
|
||||
this.stream = void 0;
|
||||
const context = this.context;
|
||||
this.context = void 0;
|
||||
this.source = void 0;
|
||||
if (context && context.state !== "closed")
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
function encodeAudio(samples, sampleRate) {
|
||||
const pcm = new ArrayBuffer(samples.length * 2);
|
||||
const pcmView = new DataView(pcm);
|
||||
for (let index = 0; index < samples.length; index++) {
|
||||
const value = Math.max(-1, Math.min(1, samples[index]));
|
||||
pcmView.setInt16(index * 2, value < 0 ? value * 32768 : value * 32767, true);
|
||||
}
|
||||
const wav = new ArrayBuffer(44 + pcm.byteLength);
|
||||
const header = new DataView(wav);
|
||||
function writeString(offset, value) {
|
||||
for (let index = 0; index < value.length; index++)
|
||||
header.setUint8(offset + index, value.charCodeAt(index));
|
||||
}
|
||||
writeString(0, "RIFF");
|
||||
header.setUint32(4, 36 + pcm.byteLength, true);
|
||||
writeString(8, "WAVE");
|
||||
writeString(12, "fmt ");
|
||||
header.setUint32(16, 16, true);
|
||||
header.setUint16(20, 1, true);
|
||||
header.setUint16(22, 1, true);
|
||||
header.setUint32(24, sampleRate, true);
|
||||
header.setUint32(28, sampleRate * 2, true);
|
||||
header.setUint16(32, 2, true);
|
||||
header.setUint16(34, 16, true);
|
||||
writeString(36, "data");
|
||||
header.setUint32(40, pcm.byteLength, true);
|
||||
new Uint8Array(wav).set(new Uint8Array(pcm), 44);
|
||||
return { pcm, wav };
|
||||
}
|
||||
class SampleQueue {
|
||||
constructor() {
|
||||
this.offset = 0;
|
||||
this.size = 0;
|
||||
}
|
||||
get length() {
|
||||
return this.size;
|
||||
}
|
||||
push(input) {
|
||||
if (input.length === 0)
|
||||
return;
|
||||
const block = { samples: new Float32Array(input) };
|
||||
if (this.tail)
|
||||
this.tail.next = block;
|
||||
else
|
||||
this.head = block;
|
||||
this.tail = block;
|
||||
this.size += input.length;
|
||||
}
|
||||
take(count) {
|
||||
if (!Number.isSafeInteger(count) || count <= 0)
|
||||
throw new RangeError("Sample count must be a positive safe integer");
|
||||
if (count > this.size)
|
||||
return null;
|
||||
const output = new Float32Array(count);
|
||||
let written = 0;
|
||||
while (this.head && written < count) {
|
||||
const samples = this.head.samples;
|
||||
const amount = Math.min(samples.length - this.offset, count - written);
|
||||
output.set(samples.subarray(this.offset, this.offset + amount), written);
|
||||
written += amount;
|
||||
this.offset += amount;
|
||||
if (this.offset === samples.length) {
|
||||
this.head = this.head.next;
|
||||
this.offset = 0;
|
||||
}
|
||||
}
|
||||
if (!this.head)
|
||||
this.tail = void 0;
|
||||
this.size -= count;
|
||||
return output;
|
||||
}
|
||||
clear() {
|
||||
this.head = void 0;
|
||||
this.tail = void 0;
|
||||
this.offset = 0;
|
||||
this.size = 0;
|
||||
}
|
||||
}
|
||||
class Capture {
|
||||
constructor(video, options, append) {
|
||||
this.video = video;
|
||||
this.options = options;
|
||||
this.append = append;
|
||||
this.queue = new SampleQueue();
|
||||
this.closing = Promise.resolve();
|
||||
this.running = false;
|
||||
this.terminal = false;
|
||||
this.epoch = 0;
|
||||
}
|
||||
reset() {
|
||||
this.epoch++;
|
||||
this.pending = void 0;
|
||||
this.queue.clear();
|
||||
}
|
||||
pause() {
|
||||
this.reset();
|
||||
this.running = false;
|
||||
clearInterval(this.timer);
|
||||
this.timer = void 0;
|
||||
this.starting = void 0;
|
||||
this.graph?.idle();
|
||||
}
|
||||
restart() {
|
||||
const active = this.running || Boolean(this.starting);
|
||||
this.pause();
|
||||
if (this.graph && !this.graph.ownsMediaConnection)
|
||||
return this.stop().then(() => active ? this.start() : void 0);
|
||||
return active ? this.start() : Promise.resolve();
|
||||
}
|
||||
start() {
|
||||
if (this.terminal || this.running)
|
||||
return Promise.resolve();
|
||||
if (this.starting)
|
||||
return this.starting;
|
||||
const epoch = this.epoch;
|
||||
const current = () => !this.terminal && this.epoch === epoch;
|
||||
const operation = (async () => {
|
||||
await this.closing;
|
||||
if (!current())
|
||||
return;
|
||||
const { sampleRate, interval } = this.options;
|
||||
const count = Math.floor(sampleRate * interval / 1e3);
|
||||
if (!Number.isSafeInteger(count) || count < 1 || !Number.isFinite(interval) || interval <= 0)
|
||||
throw new Error("Audio chunk length must be positive and finite");
|
||||
const graph = this.graph ?? (this.graph = new AudioGraph(this.video, sampleRate));
|
||||
await graph.prepare();
|
||||
if (!current()) {
|
||||
if (!this.running && this.graph === graph)
|
||||
graph.idle();
|
||||
return;
|
||||
}
|
||||
graph.connect((samples) => {
|
||||
if (!this.running || this.graph !== graph)
|
||||
return;
|
||||
if (this.queue.length + samples.length > Math.max(sampleRate * 60, count * 2)) {
|
||||
console.error("[artplayerPluginAsr] Audio callback backlog exceeded its capture limit");
|
||||
this.pause();
|
||||
return;
|
||||
}
|
||||
this.queue.push(samples);
|
||||
});
|
||||
this.running = true;
|
||||
this.timer = setInterval(() => this.tick(count), interval);
|
||||
})().catch(async (error) => {
|
||||
if (current()) {
|
||||
console.error("[artplayerPluginAsr] Initialization failed:", error);
|
||||
await this.stop();
|
||||
}
|
||||
}).finally(() => {
|
||||
if (this.starting === operation)
|
||||
this.starting = void 0;
|
||||
});
|
||||
this.starting = operation;
|
||||
return operation;
|
||||
}
|
||||
async tick(count) {
|
||||
if (!this.running || this.pending)
|
||||
return;
|
||||
const samples = this.queue.take(count);
|
||||
if (!samples)
|
||||
return;
|
||||
const epoch = this.epoch;
|
||||
const pending = this.pending = {};
|
||||
try {
|
||||
const text = await this.options.onAudioChunk(encodeAudio(samples, this.options.sampleRate));
|
||||
if (this.running && !this.terminal && this.epoch === epoch)
|
||||
this.append(text);
|
||||
} catch (error) {
|
||||
if (this.epoch === epoch && !this.terminal)
|
||||
console.error("[artplayerPluginAsr] Audio callback failed:", error);
|
||||
} finally {
|
||||
if (this.pending === pending)
|
||||
this.pending = void 0;
|
||||
}
|
||||
}
|
||||
stop(terminal = false) {
|
||||
this.terminal || (this.terminal = terminal);
|
||||
this.pause();
|
||||
const graph = this.graph;
|
||||
if (terminal || !graph?.ownsMediaConnection) {
|
||||
this.graph = void 0;
|
||||
this.closing = Promise.all([this.closing, graph?.close()]).then(() => void 0);
|
||||
}
|
||||
return this.closing;
|
||||
}
|
||||
}
|
||||
const style = ".art-video-player .art-layer-asr {\n pointer-events: none !important;\n position: absolute;\n z-index: 150;\n inset: 0px;\n display: flex;\n flex-direction: column;\n justify-content: end;\n padding: 0 2%;\n font-size: 1rem;\n gap: var(--art-subtitle-gap);\n padding-bottom: var(--art-subtitle-bottom);\n transition: padding-bottom var(--art-transition-duration) ease;\n text-shadow: var(--art-subtitle-border) 1px 0 1px, var(--art-subtitle-border) 0 1px 1px, var(--art-subtitle-border) -1px 0 1px, var(--art-subtitle-border) 0 -1px 1px, var(--art-subtitle-border) 1px 1px 1px, var(--art-subtitle-border) -1px -1px 1px, var(--art-subtitle-border) 1px -1px 1px, var(--art-subtitle-border) -1px 1px 1px;\n}\n.art-video-player.art-control-show .art-layer-asr {\n padding-bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));\n}\n";
|
||||
function createSubtitles(layer, length, autoHideTimeout) {
|
||||
let timer;
|
||||
let destroyed = false;
|
||||
function cancelTimer() {
|
||||
clearTimeout(timer);
|
||||
timer = void 0;
|
||||
}
|
||||
function hide() {
|
||||
if (!destroyed)
|
||||
layer.style.display = "none";
|
||||
}
|
||||
function append(subtitle) {
|
||||
if (destroyed || typeof subtitle !== "string")
|
||||
return;
|
||||
cancelTimer();
|
||||
timer = setTimeout(hide, autoHideTimeout);
|
||||
layer.style.display = "";
|
||||
layer.innerHTML = subtitle.split(/(?<=[、。!?!?.])\s*/u).map((line) => line.trim()).filter(Boolean).slice(-length).map((line) => `<div class="art-asr-line">${line}</div>`).join("");
|
||||
}
|
||||
return {
|
||||
append,
|
||||
hide,
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
cancelTimer();
|
||||
}
|
||||
};
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
if (!document.getElementById("artplayer-plugin-asr")) {
|
||||
const $style = document.createElement("style");
|
||||
$style.id = "artplayer-plugin-asr";
|
||||
$style.textContent = style;
|
||||
document.head.appendChild($style);
|
||||
}
|
||||
function artplayerPluginAsr2(option = {}) {
|
||||
const { length = 3, interval = 100, sampleRate = 16e3, autoHideTimeout = 1e4, onAudioChunk = () => null } = option;
|
||||
return (art) => {
|
||||
const layer = art.layers.add({ name: "asr", html: "" });
|
||||
if (!layer)
|
||||
throw new Error("Could not create ASR subtitle layer");
|
||||
const subtitles = createSubtitles(layer, length, autoHideTimeout);
|
||||
const capture = new Capture(art.video, { interval, sampleRate, onAudioChunk }, subtitles.append);
|
||||
const play = () => capture.start();
|
||||
const pause = () => capture.pause();
|
||||
const restart = () => capture.restart();
|
||||
const stop = () => capture.stop();
|
||||
const destroy = () => {
|
||||
subtitles.destroy();
|
||||
art.off("play", play);
|
||||
art.off("pause", pause);
|
||||
art.off("restart", restart);
|
||||
art.off("destroy", destroy);
|
||||
return capture.stop(true);
|
||||
};
|
||||
art.on("play", play);
|
||||
art.on("pause", pause);
|
||||
art.on("restart", restart);
|
||||
art.on("destroy", destroy);
|
||||
return { name: "artplayerPluginAsr", stop, hide: subtitles.hide, append: subtitles.append };
|
||||
};
|
||||
}
|
||||
Object.defineProperty(artplayerPluginAsr2, "default", { value: artplayerPluginAsr2 });
|
||||
if (typeof document !== "undefined" && !document.getElementById("artplayer-plugin-asr")) {
|
||||
const styleElement = document.createElement("style");
|
||||
styleElement.id = "artplayer-plugin-asr";
|
||||
styleElement.textContent = style;
|
||||
document.head.appendChild(styleElement);
|
||||
}
|
||||
return artplayerPluginAsr2;
|
||||
})();
|
||||
+4
-3
@@ -51,7 +51,7 @@
|
||||
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
|
||||
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js test/asr-distribution.test.js",
|
||||
"typecheck": "node scripts/typecheck.mjs",
|
||||
"test:unit": "node --test test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js",
|
||||
"test:unit": "node --test test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-lifecycle.test.js test/multiple-subtitles-failures.test.js test/multiple-subtitles.test.js test/multiple-subtitles-vendor.test.js test/vtt-thumbnail-parser.test.js test/vtt-thumbnail-lifecycle.test.js test/vtt-thumbnail.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-lifecycle.test.js test/auto-thumbnail.test.js test/thumbnail-emitter.test.js test/thumbnail-runtime.test.js test/thumbnail-vendor.test.js test/thumbnail-lifecycle.test.js test/thumbnail-input.test.js test/thumbnail.test.js test/iframe-navigation.test.js test/iframe-boundaries.test.js test/iframe-lifecycle.test.js test/iframe.test.js test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-video.test.js test/mediabunny-audio.test.js test/mediabunny-hls.test.js test/mediabunny-entry.test.js test/mediabunny-capability.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js",
|
||||
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
|
||||
"test": "yarn test:node && yarn test:baseline",
|
||||
"test:browser": "playwright test",
|
||||
@@ -94,8 +94,9 @@
|
||||
"check:ci": "node refactor/scripts/ci-workflow.mjs",
|
||||
"test:ci": "node --test test/ci-summary.test.js refactor/scripts/ci-workflow.test.mjs refactor/scripts/impact.test.mjs test/package-runtime.test.js",
|
||||
"test:package:runtime": "node scripts/package-runtime.mjs --canonical",
|
||||
"test:asr": "node --test test/asr.test.js test/asr-audio.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-distribution.test.js test/asr-fallback-routing.test.js",
|
||||
"test:asr-types-package": "node refactor/scripts/asr-package-types.mjs"
|
||||
"test:asr": "node --test test/asr.test.js test/asr-audio.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-distribution.test.js test/asr-fallback-routing.test.js test/asr-local-example.test.js",
|
||||
"test:asr-types-package": "node refactor/scripts/asr-package-types.mjs",
|
||||
"test:asr-demo": "node refactor/scripts/asr-local-demo.mjs"
|
||||
},
|
||||
"browserslist": "last 1 Chrome version",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -118,6 +118,10 @@ From the repository root:
|
||||
modes, verifies offline frozen reinstall and file hashes, exercises actual
|
||||
import/require exports, and rejects invalid uses. Historical declaration errors
|
||||
are explicit expectations and never counted as candidate success.
|
||||
- `yarn test:asr-demo`: actual localhost:8082 docs/Monaco/Run flow in Chromium
|
||||
and Firefox, served script hashes, local PCM statistics, stop/play and destroy.
|
||||
Requires a free port 8082; owns only its temporary dev server. The exact AdSense
|
||||
request is isolated in this test; recognition requests must remain absent.
|
||||
- `yarn dev artplayer-plugin-asr` and `yarn build artplayer-plugin-asr`: normal
|
||||
repository development and three-format production builds.
|
||||
|
||||
|
||||
@@ -46,6 +46,13 @@ known migration gaps and test commands.
|
||||
|
||||
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-plugin-asr/index.js&example=asr)
|
||||
|
||||
For local verification without a recognition service, run
|
||||
`yarn dev artplayer-plugin-asr --no-open` in the repository and open the
|
||||
[local capture demo](http://localhost:8082/?libs=./uncompiled/artplayer-plugin-asr/index.js&example=asr.local).
|
||||
It displays PCM/WAV statistics and clearly labelled simulated subtitles, using
|
||||
the site's sample video. Stop ASR leaves playback running; pause/play resumes
|
||||
capture. The existing `asr` example remains the external-service example.
|
||||
|
||||
## License
|
||||
|
||||
MIT © Harvey Zhao
|
||||
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"task": "PKG-ASR-05",
|
||||
"sourceCommit": "93208e59e14e3384d0a5f5ffa49f55ee95c218c4",
|
||||
"capturedAt": "2026-09-13T07:20:45.919Z",
|
||||
"association": {
|
||||
"commit": "daf133b22630b4a0eecfa3336bbddab0e9119d96",
|
||||
"file": "packages/artplayer/package.json",
|
||||
"version": "5.3.1",
|
||||
"sha256LF": "862f3697876fe915dd0ada178da766ce823aae7563ed30b3e5b11b811202929c"
|
||||
},
|
||||
"associationScope": "ASR 2.1.0 source commit records core workspace version 5.3.1, which is absent from npm at capture time. The frozen 5.3.0 archive is an adjacent published stable comparison point; it is not that source-associated version, a minimum supported core, or completed browser validation.",
|
||||
"metadata": {
|
||||
"requestedVersion": "5.3.1",
|
||||
"selectedVersion": "5.3.0",
|
||||
"selectionReason": "Expand actual native compatibility coverage with an available adjacent stable npm release; existing 5.4.0 and candidate checks remain separate.",
|
||||
"checks": [
|
||||
{
|
||||
"checkedAt": "2026-09-13T07:20:42.645Z",
|
||||
"url": "https://registry.npmjs.org/artplayer/5.3.1",
|
||||
"method": "GET",
|
||||
"status": 404,
|
||||
"body": "\"version not found: 5.3.1\""
|
||||
},
|
||||
{
|
||||
"checkedAt": "2026-09-13T07:20:43.810Z",
|
||||
"url": "https://registry.npmjs.org/artplayer",
|
||||
"method": "GET",
|
||||
"status": 200,
|
||||
"bodySha256": "560fa27d689284c6e1743835b765e790613b0b11d7255ea0390eb0017a7e9748",
|
||||
"containsRequestedVersion": false,
|
||||
"requestedVersion": "5.3.1",
|
||||
"nearbyVersions": [
|
||||
"5.2.0",
|
||||
"5.2.1",
|
||||
"5.2.2",
|
||||
"5.2.3",
|
||||
"5.2.4",
|
||||
"5.2.5",
|
||||
"5.3.0-beta.0",
|
||||
"5.3.0-beta.1",
|
||||
"5.3.0-beta.2",
|
||||
"5.3.0-beta.3",
|
||||
"5.3.0-beta.4",
|
||||
"5.3.0",
|
||||
"5.3.1-beta.0",
|
||||
"5.3.1-beta.1",
|
||||
"5.4.0"
|
||||
],
|
||||
"distTags": {
|
||||
"latest": "5.4.0",
|
||||
"beta": "5.3.1-beta.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"checkedAt": "2026-09-13T07:20:44.012Z",
|
||||
"url": "https://registry.npmjs.org/artplayer/-/artplayer-5.3.1.tgz",
|
||||
"method": "HEAD",
|
||||
"status": 404,
|
||||
"contentType": "application/json"
|
||||
}
|
||||
],
|
||||
"replayCommand": "@'\nconst urls = ['https://registry.npmjs.org/artplayer/5.3.1', 'https://registry.npmjs.org/artplayer', 'https://registry.npmjs.org/artplayer/-/artplayer-5.3.1.tgz'];\nfor (const url of urls) { const method = url.endsWith('.tgz') ? 'HEAD' : 'GET'; const response = await fetch(url, { method, redirect: 'error', signal: AbortSignal.timeout(30000) }); const body = method === 'HEAD' ? null : await response.text(); console.log({ url, method, status: response.status, body: url.endsWith('/artplayer') && response.ok ? { contains531: Object.hasOwn(JSON.parse(body).versions, '5.3.1'), nearbyVersions: Object.keys(JSON.parse(body).versions).filter(version => /^5\\.[234]\\./.test(version)) } : body }); }\n'@ | & refactor/.cache/toolchains/node-v24.21.0-win-x64/node.exe --input-type=module",
|
||||
"archiveVerificationCommand": "@'\nimport assert from 'node:assert/strict';\nimport fs from 'node:fs';\nimport {execFileSync} from 'node:child_process';\nimport {archiveFiles,ensureArchive,hash,readMember} from './refactor/scripts/releases.mjs';\nconst baseline=JSON.parse(fs.readFileSync('refactor/baselines/asr-core.json','utf8'));\nconst {release,association}=baseline;\nconst archive=await ensureArchive(release);\nassert.deepEqual(archiveFiles(archive),Object.keys(release.files).sort());\nfor(const [member,expected] of Object.entries(release.files)) assert.equal(hash(readMember(archive,member)),expected,member);\nassert.deepEqual(JSON.parse(readMember(archive,'package/package.json')),release.manifest);\nassert.equal(hash(fs.readFileSync(archive),'sha1'),release.registryShasum);\nfor(const field of ['main','module','types','legacy']) assert(Object.hasOwn(release.files,'package/'+release.manifest[field].replace(/^\\.\\//,'')));\nconst source=execFileSync('git',['show',association.commit+':'+association.file],{encoding:'utf8'}).replaceAll('\\r\\n','\\n');\nassert.equal(hash(source),association.sha256LF);\nassert.equal(JSON.parse(source).version,'5.3.1');\nassert.equal(release.manifest.version,'5.3.0');\nconsole.log('Verified ASR adjacent stable core 5.3.0: '+Object.keys(release.files).length+' archive members and distinct 5.3.1 source association');\n'@ | & refactor/.cache/toolchains/node-v24.21.0-win-x64/node.exe --input-type=module"
|
||||
},
|
||||
"release": {
|
||||
"name": "artplayer",
|
||||
"version": "5.3.0",
|
||||
"metadataUrl": "https://registry.npmjs.org/artplayer/5.3.0",
|
||||
"tarball": "https://registry.npmjs.org/artplayer/-/artplayer-5.3.0.tgz",
|
||||
"integrity": "sha512-yExO39MpEg4P+bxgChxx1eJfiUPE4q1QQRLCmqGhlsj+ANuaoEkR8hF93LdI5ZyrAcIbJkuEndxEiUoKobifDw==",
|
||||
"sha256": "8649d22785381d2f7729efeb73f384e5810507031e9b47ebe6df79ae5bea309b",
|
||||
"registryShasum": "ecb011d1eed008ee806e2a1850026ee0030fd85f",
|
||||
"registryGitHead": "eb015c7facd42cde2f1af6b79dd19e4662182fdf",
|
||||
"files": {
|
||||
"package/README.md": "2070b94087839a80dc01616a3114cbe2d3a4950018188f433752880daf7843f8",
|
||||
"package/dist/artplayer.js": "54ffaf7598b910d5a88d2d2eec1aa6faa806583915b99b892dd32129c53e421a",
|
||||
"package/dist/artplayer.legacy.js": "16cbdfbae82f91e16aa7654885e82be00647b57652ce81d050bb42ae1e58ed52",
|
||||
"package/dist/artplayer.mjs": "9282f24a395f6ebc8122fddce6faaac2c58f22a05f150b538b954c3ace8b0712",
|
||||
"package/dist/i18n/ar.js": "44b94758cfc7c45179ee1a90ca730b91717fccb5a79d27afd691b61fdfdf51e0",
|
||||
"package/dist/i18n/ar.mjs": "5dd3a7183c81350786e95a5f0431de9c64937a16627c2a44042af7cb33d3d646",
|
||||
"package/dist/i18n/cs.js": "76353dbd1b97f52f3bcd7e71aa9117ca7d76d2b2cbf86426cf3dac64afd7abf2",
|
||||
"package/dist/i18n/cs.mjs": "aa6cb5eaa1ce78fd7d4033affb4f48cff4fedd773bf55f88ccaf3d76dd822cb1",
|
||||
"package/dist/i18n/es.js": "7e43252ba479d87c1edd5244a7c870126aae07c8791c365888fe0ba82dd6e85f",
|
||||
"package/dist/i18n/es.mjs": "7a0aa9698ddcb191fc4f62d1fcd07ee004dbb00053b2920cfcf5fa81f71ea71e",
|
||||
"package/dist/i18n/fa.js": "4b60d3d4f5d5e87686a3af179c9b671b4f432888c6f77ae92fb7025386a9910b",
|
||||
"package/dist/i18n/fa.mjs": "261a81e46d40a1bcad64419d4954036fb49e9fea86682a89aaba277bd52d3c54",
|
||||
"package/dist/i18n/fr.js": "410d9552ad387c191d168b6fff6d21538c57e74d07908ebab2c0dedc3577ed90",
|
||||
"package/dist/i18n/fr.mjs": "d8adfe0ba5eb2208a5241675a084dbd1c4d20d8ac2979fcbcb72bef18827fbfd",
|
||||
"package/dist/i18n/id.js": "4a8fc5d2393a5a19a8ba68429545073035c8803d9d3101b696fbc895d20b22b2",
|
||||
"package/dist/i18n/id.mjs": "b3ac8f8c203a7a64374ac64931d7eb3d912c2e71c4a604ddc4651229cca64c07",
|
||||
"package/dist/i18n/pl.js": "a7c421f0264cd054b8d289e553e77018c547417d8d437f24157ccb1a2cd8c4f6",
|
||||
"package/dist/i18n/pl.mjs": "c58ce3f06411e837c28dbea3d2a68f38045da58de3b73ae5457468a01d526884",
|
||||
"package/dist/i18n/ru.js": "5406ee8662a259af703a930c4e241874e12a811304f3e1d40dd00033e7ff0b52",
|
||||
"package/dist/i18n/ru.mjs": "c17c830da2189d4584c816907b6e8626d8606c7a20565b383c4b768be00e6600",
|
||||
"package/dist/i18n/tr.js": "f82cdbf5feefceff68f78466ca261e1ea1c4975c853543947fc91ffd8107ec46",
|
||||
"package/dist/i18n/tr.mjs": "683b768eeea905793f63bf483d9870af10183a718aba84d8ee794934a8f07b22",
|
||||
"package/dist/i18n/vi.js": "c4f0c224404ce73a807758661c9eaa46fb548eb4519a2ee11304396f268a6a0e",
|
||||
"package/dist/i18n/vi.mjs": "68d10c0bb6e7fe4d82817bca883f04f514d73301ff5dfddf02a0a1e3b2bfbc0a",
|
||||
"package/dist/i18n/zh-tw.js": "1241a6749c088192d1118f91ae36a894ecc198875d4b9bdfe2bbf617803e3806",
|
||||
"package/dist/i18n/zh-tw.mjs": "b28d13355759e2b47d0553617a01ad6b3af76d975681b569bad2e99e48d095ff",
|
||||
"package/package.json": "da526c13939b7a55b08c5c518608fa59c7185a0bab184186d5a1af80d1ea5078",
|
||||
"package/types/artplayer.d.ts": "28cb09ddffb6591372ab01cbde8ff3c3c0db903eea58851dc6561375e740cc3c",
|
||||
"package/types/component.d.ts": "0118ec5dbb9aa48d735169c74bd65149b72c9f99db29dd635cd41ff69707b68d",
|
||||
"package/types/config.d.ts": "bb40a0ca9d200c15bba7ecba0103d4ce8216e428d05c17ec43288d298223bc68",
|
||||
"package/types/cssVar.d.ts": "dd4ff10f9b4d55b3e438abf1ec6239baa3692a110e05f49d883285ce069c3687",
|
||||
"package/types/events.d.ts": "afd56b6939bedecc070eecd2b7440d0903ef8d3107fe41d376bfdb9e0ae3362d",
|
||||
"package/types/i18n.d.ts": "01e7d7e7f775166eed6dbc6b86feb26306302fcbfe33fa01b7e363a14b3ef2b0",
|
||||
"package/types/icons.d.ts": "61ce1b0cb9bc7488263b644419b5fd5a13e72de1493c8113eee81d5174f1234b",
|
||||
"package/types/option.d.ts": "d689b55e3fbc0a9ca9c554b5c9f6c1ae5f1d81aed07c174a557c95633a18a276",
|
||||
"package/types/player.d.ts": "8eb3e3b5822139379ad09f6aad8a0da360fb049bd0ebf4540d128cdd1fb5e994",
|
||||
"package/types/quality.d.ts": "dcca8c0f5b5c25373f85825a274fc3ee8beeffd7c8b2a978468e039ff4946a9e",
|
||||
"package/types/setting.d.ts": "cc87ff5ccb87901e12d2f367ec2e872afd5c029b9f6d0a22d01d7d44a63dfb33",
|
||||
"package/types/subtitle.d.ts": "92f711ad0f7924e609ad4425cd26d19c73eb027cccddc6d89fcf9b8e334db065",
|
||||
"package/types/template.d.ts": "22d64dc2c68f8bd90247755a12bcd233343bb67537591ab08e34900917e1723d",
|
||||
"package/types/utils.d.ts": "65cbb66ab2f5d38085e0a1e5ebea57c320d16f566fb6dc8f8e2dac6cf096c004"
|
||||
},
|
||||
"manifest": {
|
||||
"name": "artplayer",
|
||||
"version": "5.3.0",
|
||||
"description": "ArtPlayer.js is a modern and full featured HTML5 video player",
|
||||
"author": "Harvey Zack <laozhaochaguan@gmail.com>",
|
||||
"license": "MIT",
|
||||
"homepage": "https://artplayer.org",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/zhw2590582/ArtPlayer.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/zhw2590582/ArtPlayer/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"html5",
|
||||
"video",
|
||||
"player"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./types/artplayer.d.ts",
|
||||
"import": "./dist/artplayer.mjs",
|
||||
"require": "./dist/artplayer.js"
|
||||
},
|
||||
"./legacy": {
|
||||
"types": "./types/artplayer.d.ts",
|
||||
"import": "./dist/artplayer.legacy.js",
|
||||
"require": "./dist/artplayer.legacy.js"
|
||||
},
|
||||
"./i18n/*": {
|
||||
"types": "./types/i18n.d.ts",
|
||||
"import": "./dist/i18n/*.mjs",
|
||||
"require": "./dist/i18n/*.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/artplayer.js",
|
||||
"module": "./dist/artplayer.mjs",
|
||||
"types": "./types/artplayer.d.ts",
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"i18n/*": [
|
||||
"types/i18n.d.ts"
|
||||
],
|
||||
"legacy": [
|
||||
"types/artplayer.d.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"legacy": "./dist/artplayer.legacy.js",
|
||||
"browserslist": "last 1 Chrome version",
|
||||
"dependencies": {
|
||||
"option-validator": "^2.0.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,49 @@
|
||||
# PKG-ASR-05 检查点:相邻稳定核心与本地编辑器
|
||||
|
||||
本记录是 ASR-05 的局部进展,任务保持 doing。ASR-07/08 已独立修复原生音量和
|
||||
捕获回退漏声;本检查点扩大核心对照并建立无需识别服务的真实演示页验证。
|
||||
|
||||
## 核心来源与实测范围
|
||||
|
||||
ASR 来源提交的核心 manifest 写作 5.3.1,但 npm 精确版本接口和 tarball 均 404,
|
||||
完整版本目录也没有 5.3.1。`asr-core.json` 记录准确请求、UTC 时间与响应摘要;
|
||||
来源提交关联不能被当成正式发布,也不能把缺失的版本列为测试通过。
|
||||
|
||||
选择真实、相邻稳定发布 5.3.0 作为额外对照,冻结 registry integrity、SHA256、
|
||||
41 个成员、入口和来源说明。它不是最低支持承诺,也不是那个未发布的 5.3.1。
|
||||
浏览器服务器核验实际 tarball 后新增 `/published-5.3.0/artplayer.js`,三个 ASR
|
||||
原生测试组均纳入此核心。
|
||||
|
||||
正式 main 产物下的 5.3.0 组合:14 通过/7 能力跳过,含 2.0/2.1 历史观察及候选。
|
||||
正式 legacy 候选另 6 通过/3 能力跳过。覆盖 PCM/WAV、字幕、暂停/切源/stop恢复、
|
||||
最终清理、线性音量及回退静音输出。Firefox 回退仍明确使用受控绑定拒绝,不能
|
||||
视为自然解决 ASR-FOREIGN-01。全部候选产物哈希对应 ASR-08。
|
||||
|
||||
## 无服务演示与真实 Run
|
||||
|
||||
新增 `docs/assets/example/asr.local.js`,沿用已有同源 steve-jobs.mp4。每个 PCM/WAV
|
||||
分块显示计数、字节、采样率、时长和峰值,字幕明确标注 Simulated,不冒充语音识别。
|
||||
Stop ASR 等待公开 stop;播放继续,pause/play 恢复采集;销毁后的晚回调不再写 UI。
|
||||
原 asr 示例保留其 URL 与外部服务逻辑,未在本项改写。
|
||||
|
||||
三个受控示例测试执行实际示例文本,禁用 fetch/WS/XHR/EventSource/sendBeacon
|
||||
识别路径,并检查统计、异步 stop 及销毁期间返回。与既有 ASR 组合为 216 项通过。
|
||||
|
||||
`yarn test:asr-demo` 自启真实 repo dev 服务,访问 localhost:8082 的 docs/Monaco,
|
||||
不替换插件脚本。在 Chromium/Firefox 用真实播放控件和 Run Code 验证非零 PCM、
|
||||
模拟字幕、stop 时视频时间继续前进、暂停/恢复、重复 Run 销毁旧实例/context,以及
|
||||
最终所有 context 关闭。实际响应与磁盘脚本哈希比较,并保存源代码/构建配置指纹、
|
||||
请求、trace 和截图。只隔离准确的 AdSense 请求,不允许外部识别请求。
|
||||
|
||||
此脚本针对当前 Windows 环境验收,8082 被占用时退出;只停止自己启动的进程树,
|
||||
检查释放端口。子代理独立实现和运行,主代理复核脚本、报告与相关输入后整合。
|
||||
具体核心/编辑器报告摘要见 [检查点证据](../baselines/asr-local-core-validation.json)。
|
||||
|
||||
## 未完成范围
|
||||
|
||||
ASR-05 继续保留跨源/CORS、audio-track 组合、Firefox 外部双 owner、macOS/iOS及
|
||||
物理设备门槛;SDK-11 仍不宣称真实识别服务已验证。新增本地示例不等于服务验收。
|
||||
ASR-06 的完整发行文档/导出及后续主版本准备仍独立执行。本项没有生产源码改动,
|
||||
正式 dist/compiled 沿用 ASR-08;真实 dev 命令正常生成并更新 uncompiled 演示产物。
|
||||
没有整仓 CI、推送或发布。台账与第三方记录区分已有证据
|
||||
和这些未完成项,不因单个演示通过而把整包标 done。
|
||||
+1
-1
@@ -532,7 +532,7 @@
|
||||
- PKG-ASR-02: [记录](changes/2026-09-13-PKG-ASR-02-audio-baseline.md) [记录](baselines/asr-audio-validation.json)
|
||||
- PKG-ASR-03: [记录](changes/2026-09-13-PKG-ASR-03-audio-ownership.md) [记录](baselines/asr-ownership-validation.json)
|
||||
- PKG-ASR-04: [记录](changes/2026-09-13-PKG-ASR-04-public-types.md) [记录](baselines/asr-types-validation.json) [记录](baselines/asr-type-diagnostics.json)
|
||||
- PKG-ASR-05: [记录](baselines/asr-volume-validation.json) [记录](baselines/asr-fallback-validation.json)
|
||||
- PKG-ASR-05: [记录](baselines/asr-volume-validation.json) [记录](baselines/asr-fallback-validation.json) [记录](baselines/asr-core.json) [记录](baselines/asr-local-core-validation.json) [记录](changes/2026-09-13-PKG-ASR-05-local-core-checkpoint.md)
|
||||
- PKG-ADS-01: [记录](baselines/ads-release.json) [记录](baselines/ads-contract.md) [记录](baselines/ads-contract-validation.json) [记录](changes/2026-09-12-PKG-ADS-01-contract.md) [记录](scripts/ads-contract.test.mjs)
|
||||
- PKG-ADS-02: [记录](changes/2026-09-12-PKG-ADS-02-tests.md) [记录](ads-validation.md) [记录](baselines/ads-validation.json)
|
||||
- PKG-ADS-03: [记录](changes/2026-09-12-PKG-ADS-03-lifecycle.md) [记录](ads-validation.md) [记录](baselines/ads-lifecycle-validation.json)
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# 进度与证据
|
||||
|
||||
## PKG-ASR-05 本地编辑器与相邻核心检查点
|
||||
|
||||
真实 npm 5.3.0 核心扩大对照:main 14过/7能力跳过,legacy 6过/3能力跳过。
|
||||
实际 localhost:8082 docs/Monaco 在 Chromium、Firefox 均通过非零 PCM、stop保留
|
||||
播放、暂停恢复、Run销毁旧实例并重采集、最终清理;216项ASR测试通过。新增可
|
||||
重放的 yarn test:asr-demo,保存真实响应哈希、trace、截图及自有进程清理证据。
|
||||
见[检查点](changes/2026-09-13-PKG-ASR-05-local-core-checkpoint.md)与
|
||||
[证据](baselines/asr-local-core-validation.json)。源关联5.3.1未发布,5.3.0不作最低
|
||||
支持承诺。ASR-05保持doing,CORS、audio-track、Firefox外部归属和设备范围待完成。
|
||||
227项:129 done、17 doing、81 todo;208风险。子代理成果经主代理复核整合,
|
||||
本检查点单独提交;没有推送或发布。
|
||||
|
||||
|
||||
## PKG-ASR-08 回退漏声修复完成,ASR-05 继续
|
||||
|
||||
独立审查指出 captureStream 回退额外接扬声器;Chromium 原生两个候选核心确实在
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync, spawn } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import fs from 'node:fs'
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
import process from 'node:process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, expect, firefox } from '@playwright/test'
|
||||
|
||||
// Windows verification only: owns its Yarn process tree and a separate report directory.
|
||||
assert.equal(process.platform, 'win32', 'This replay uses Windows taskkill process-tree cleanup')
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url))
|
||||
const evidenceRoot = path.join(root, 'refactor/.cache/asr05-editor')
|
||||
fs.mkdirSync(evidenceRoot, { recursive: true })
|
||||
const output = fs.mkdtempSync(path.join(evidenceRoot, 'run-'))
|
||||
const report = { startedAt: new Date().toISOString(), node: process.version, platform: process.platform, output, browsers: [] }
|
||||
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex')
|
||||
const lfHash = file => sha256(fs.readFileSync(path.join(root, file), 'utf8').replace(/\r\n/gu, '\n'))
|
||||
function sourceSnapshot() {
|
||||
const directory = path.join(root, 'packages/artplayer-plugin-asr/src')
|
||||
const files = fs.readdirSync(directory).sort().filter(name => name.endsWith('.ts')).map(name => `packages/artplayer-plugin-asr/src/${name}`)
|
||||
files.push('scripts/dev.js', 'scripts/utils.js', 'scripts/projects.js', 'scripts/rebuild.js', 'package.json', 'yarn.lock', 'packages/artplayer-plugin-asr/package.json')
|
||||
return Object.fromEntries(files.map(file => [file, lfHash(file)]))
|
||||
}
|
||||
report.head = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8', windowsHide: true }).trim()
|
||||
report.sourceAndBuildLfSha256 = sourceSnapshot()
|
||||
report.coreProd = { file: 'docs/compiled/artplayer.js', sha256: sha256(fs.readFileSync(path.join(root, 'docs/compiled/artplayer.js'))), lfSha256: lfHash('docs/compiled/artplayer.js') }
|
||||
report.scriptSha256 = sha256(fs.readFileSync(fileURLToPath(import.meta.url)))
|
||||
report.mediaSha256 = sha256(fs.readFileSync(path.join(root, 'docs/assets/sample/steve-jobs.mp4')))
|
||||
const pageUrl = 'http://localhost:8082/?libs=./uncompiled/artplayer-plugin-asr/index.js&example=asr.local'
|
||||
const scriptFiles = {
|
||||
'/compiled/artplayer.js': 'docs/compiled/artplayer.js',
|
||||
'/uncompiled/artplayer-plugin-asr/index.js': 'docs/uncompiled/artplayer-plugin-asr/index.js',
|
||||
'/assets/example/asr.local.js': 'docs/assets/example/asr.local.js',
|
||||
}
|
||||
|
||||
async function verifyFreePort() {
|
||||
const probe = net.createServer()
|
||||
await new Promise((resolve, reject) => {
|
||||
probe.once('error', reject)
|
||||
probe.listen(8082, resolve)
|
||||
})
|
||||
await new Promise((resolve, reject) => probe.close(error => error ? reject(error) : resolve()))
|
||||
}
|
||||
|
||||
async function validate(browserType) {
|
||||
const result = { engine: browserType.name(), navigations: [], requests: [], requestFailures: [], errors: [], consoleErrors: [], blockedAdvertising: [], responses: [], checks: [] }
|
||||
report.browsers.push(result)
|
||||
const browser = await browserType.launch({ headless: true })
|
||||
result.version = browser.version()
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
await context.tracing.start({ screenshots: true, snapshots: true, sources: true })
|
||||
const page = await context.newPage()
|
||||
let phase = 'initial-load'
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame === page.mainFrame())
|
||||
result.navigations.push({ url: frame.url(), phase })
|
||||
})
|
||||
const responseReads = []
|
||||
page.on('pageerror', error => result.errors.push(error.message))
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error')
|
||||
result.consoleErrors.push({ text: message.text(), location: message.location() })
|
||||
})
|
||||
page.on('request', request => result.requests.push({ url: request.url(), method: request.method(), type: request.resourceType() }))
|
||||
page.on('requestfailed', request => result.requestFailures.push({ url: request.url(), error: request.failure()?.errorText, phase }))
|
||||
page.on('websocket', socket => result.requests.push({ url: socket.url(), type: 'websocket' }))
|
||||
page.on('response', (response) => {
|
||||
const pathname = new URL(response.url()).pathname
|
||||
if (!scriptFiles[pathname])
|
||||
return
|
||||
responseReads.push((async () => {
|
||||
const bytes = await response.body()
|
||||
const disk = fs.readFileSync(path.join(root, scriptFiles[pathname]))
|
||||
const captured = { url: response.url(), status: response.status(), sha256: sha256(bytes), diskSha256: sha256(disk), bytes: bytes.length }
|
||||
result.responses.push(captured)
|
||||
fs.writeFileSync(path.join(output, `${result.engine}-${path.basename(scriptFiles[pathname])}`), bytes)
|
||||
assert.equal(captured.sha256, captured.diskSha256, `Served file drift: ${pathname}`)
|
||||
assert.equal(captured.status, 200)
|
||||
})())
|
||||
})
|
||||
await page.route('https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js*', async (route) => {
|
||||
result.blockedAdvertising.push(route.request().url())
|
||||
await route.abort('blockedbyclient')
|
||||
})
|
||||
await context.addInitScript(() => {
|
||||
localStorage.setItem('prod', 'true')
|
||||
window.asrContexts = []
|
||||
const NativeContext = window.AudioContext || window.webkitAudioContext
|
||||
window.AudioContext = class extends NativeContext {
|
||||
constructor(options) {
|
||||
super(options)
|
||||
window.asrContexts.push(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
const statistics = page.locator('.art-layer-asr-local-statistics')
|
||||
const playControl = page.locator('.art-control-playAndPause')
|
||||
const chunks = () => statistics.textContent().then(text => Number(/Chunks: (\d+)/u.exec(text)?.[1] || 0))
|
||||
const nonzero = async () => {
|
||||
await expect.poll(async () => Number(/Current peak: (\d+)/u.exec(await statistics.textContent())?.[1] || 0), { timeout: 30000 }).toBeGreaterThan(0)
|
||||
await expect(page.locator('.art-layer-asr')).toContainText('Simulated local subtitle: audio chunk')
|
||||
}
|
||||
try {
|
||||
await page.goto(pageUrl)
|
||||
await expect.poll(() => page.evaluate(() => Boolean(window.art?.isReady && window.art?.plugins.artplayerPluginAsr)), { timeout: 30000 }).toBe(true)
|
||||
const editor = await page.evaluate(() => ({
|
||||
models: window.monaco.editor.getModels().filter(model => model.getLanguageId() === 'javascript').map(model => model.getValue()),
|
||||
coreVersion: window.Artplayer.version,
|
||||
pluginVersion: window.artplayerPluginAsr.version,
|
||||
source: window.art.video.currentSrc,
|
||||
userAgent: navigator.userAgent,
|
||||
}))
|
||||
result.editor = editor
|
||||
assert(editor.models.includes(fs.readFileSync(path.join(root, scriptFiles['/assets/example/asr.local.js']), 'utf8')))
|
||||
assert.equal(editor.source, 'http://localhost:8082/assets/sample/steve-jobs.mp4')
|
||||
await playControl.press('Enter')
|
||||
await nonzero()
|
||||
phase = 'capture'
|
||||
result.firstStatistics = await statistics.textContent()
|
||||
result.checks.push('Actual Monaco example and native playback produce nonzero PCM and simulated subtitle')
|
||||
await page.screenshot({ path: path.join(output, `${result.engine}-playing.png`), fullPage: true })
|
||||
|
||||
await page.locator('.art-video-player').hover()
|
||||
await page.locator('.art-control-asr-local-stop').click()
|
||||
await expect(statistics).toContainText('Local capture stopped.')
|
||||
const stoppedAt = await page.evaluate(() => window.art.currentTime)
|
||||
await expect.poll(() => page.evaluate(() => window.art.currentTime), { timeout: 10000 }).toBeGreaterThan(stoppedAt + 0.8)
|
||||
await expect(statistics).toContainText('Local capture stopped.')
|
||||
assert.equal(await page.evaluate(() => window.art.playing), true)
|
||||
assert.deepEqual(await page.evaluate(() => window.asrContexts.map(context => context.state)), ['running'])
|
||||
result.checks.push('Stop ASR settles while native playback advances and capture statistics remain stopped')
|
||||
|
||||
await playControl.press('Enter')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(false)
|
||||
await playControl.press('Enter')
|
||||
await nonzero()
|
||||
const beforePause = await chunks()
|
||||
await playControl.press('Enter')
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing)).toBe(false)
|
||||
await page.waitForTimeout(600)
|
||||
assert.equal(await chunks(), beforePause)
|
||||
await playControl.press('Enter')
|
||||
await expect.poll(chunks).toBeGreaterThan(beforePause)
|
||||
assert.equal(await page.evaluate(() => window.asrContexts.length), 1)
|
||||
result.checks.push('Pause/play resumes after stop and ordinary pause preserves one native context')
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.asrOldArt = window.art
|
||||
})
|
||||
phase = 'run-cleanup'
|
||||
await page.locator('.run').click()
|
||||
await expect.poll(() => page.evaluate(() => Boolean(window.art !== window.asrOldArt && window.art?.isReady)), { timeout: 30000 }).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.asrContexts[0].state)).toBe('closed')
|
||||
assert.equal(await page.evaluate(() => window.asrOldArt.isDestroy), true)
|
||||
assert.equal(await page.evaluate(() => window.Artplayer.instances.length), 1)
|
||||
await expect(page.locator('.art-video-player')).toHaveCount(1)
|
||||
await playControl.press('Enter')
|
||||
await nonzero()
|
||||
phase = 'rerun-capture'
|
||||
assert.deepEqual(await page.evaluate(() => window.asrContexts.map(context => context.state)), ['closed', 'running'])
|
||||
result.checks.push('Actual Run Code destroys old player/context, installs one new player, and native sampling resumes')
|
||||
await page.screenshot({ path: path.join(output, `${result.engine}-rerun.png`), fullPage: true })
|
||||
phase = 'destroy-cleanup'
|
||||
await page.evaluate(() => window.art.destroy(true))
|
||||
await expect.poll(() => page.evaluate(() => window.asrContexts.every(context => context.state === 'closed'))).toBe(true)
|
||||
assert.equal(await page.evaluate(() => window.Artplayer.instances.length), 0)
|
||||
result.checks.push('Final destroy closes every observed native AudioContext and empties instance registry')
|
||||
await Promise.all(responseReads)
|
||||
for (const pathname of Object.keys(scriptFiles))
|
||||
assert(result.responses.some(response => new URL(response.url).pathname === pathname), `Missing actual served script: ${pathname}`)
|
||||
const unexpectedNetwork = result.requests.filter(({ url }) => {
|
||||
const parsed = new URL(url)
|
||||
return !['localhost', '127.0.0.1'].includes(parsed.hostname) && !['blob:', 'data:'].includes(parsed.protocol) && !result.blockedAdvertising.includes(url)
|
||||
})
|
||||
assert.deepEqual(unexpectedNetwork, [], 'Only local media/code and the explicitly aborted advertising request are permitted')
|
||||
const mediaUrl = 'http://localhost:8082/assets/sample/steve-jobs.mp4'
|
||||
result.expectedMediaCancellations = result.requestFailures.filter(({ url, error, phase }) => url === mediaUrl && ['net::ERR_ABORTED', 'NS_BINDING_ABORTED'].includes(error) && ['run-cleanup', 'destroy-cleanup'].includes(phase))
|
||||
assert.deepEqual(result.requestFailures.filter(failure => !result.blockedAdvertising.includes(failure.url) && !result.expectedMediaCancellations.includes(failure)), [], 'Unexpected failed local requests')
|
||||
assert.deepEqual(result.consoleErrors.filter(({ text, location }) => {
|
||||
const chromiumAdvertising = /^Failed to load resource: net::ERR_BLOCKED_BY_CLIENT(?:\.Inspector)?$/u.test(text) && result.blockedAdvertising.includes(location.url)
|
||||
const firefoxAdvertising = text.includes('Cross-Origin Request Blocked:') && result.blockedAdvertising.some(url => text.includes(url))
|
||||
return !chromiumAdvertising && !firefoxAdvertising
|
||||
}), [], 'Console errors must be attributable to the explicitly blocked advertising URL')
|
||||
assert.deepEqual(result.errors, [])
|
||||
assert.equal(result.navigations.filter(({ phase }) => phase === 'capture' || phase === 'rerun-capture').length, 0, 'The dev server reloaded the page during sampling')
|
||||
result.status = 'passed'
|
||||
}
|
||||
catch (error) {
|
||||
result.status = 'failed'
|
||||
result.error = error.stack
|
||||
await page.screenshot({ path: path.join(output, `${result.engine}-failure.png`), fullPage: true }).catch(() => {})
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
await Promise.allSettled(responseReads)
|
||||
await context.tracing.stop({ path: path.join(output, `${result.engine}-trace.zip`) })
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
|
||||
let server
|
||||
let serverLog = ''
|
||||
let docsWatcher
|
||||
report.docsChanges = []
|
||||
try {
|
||||
await verifyFreePort()
|
||||
docsWatcher = fs.watch(path.join(root, 'docs'), { recursive: true }, (event, filename) => report.docsChanges.push({ at: new Date().toISOString(), event, filename }))
|
||||
const yarn = path.join(root, 'refactor/.cache/toolchains/yarn-1.22.22/package/bin/yarn.js')
|
||||
server = spawn(process.execPath, [yarn, 'dev', 'artplayer-plugin-asr', '--no-open'], {
|
||||
cwd: root,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH}` },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
report.serverPid = server.pid
|
||||
report.serverCommand = [process.execPath, yarn, 'dev', 'artplayer-plugin-asr', '--no-open']
|
||||
server.stdout.on('data', (data) => {
|
||||
serverLog += data
|
||||
})
|
||||
server.stderr.on('data', (data) => {
|
||||
serverLog += data
|
||||
})
|
||||
await expect.poll(() => {
|
||||
assert.equal(server.exitCode, null, serverLog)
|
||||
return serverLog.includes('Watching') && serverLog.includes('Built in')
|
||||
}, { timeout: 30000 }).toBe(true)
|
||||
for (const browserType of [chromium, firefox]) {
|
||||
try {
|
||||
await validate(browserType)
|
||||
}
|
||||
catch (error) {
|
||||
process.exitCode = 1
|
||||
console.error(`${browserType.name()}: ${error.message}`)
|
||||
}
|
||||
}
|
||||
assert.deepEqual(sourceSnapshot(), report.sourceAndBuildLfSha256, 'ASR source/build configuration changed during validation; rerun for a stable candidate')
|
||||
}
|
||||
catch (error) {
|
||||
report.error = error.stack
|
||||
process.exitCode = 1
|
||||
}
|
||||
finally {
|
||||
docsWatcher?.close()
|
||||
if (server?.pid && server.exitCode === null) {
|
||||
report.serverCleanupExitCode = await new Promise((resolve) => {
|
||||
const kill = spawn('taskkill', ['/PID', String(server.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' })
|
||||
kill.once('exit', resolve)
|
||||
})
|
||||
try {
|
||||
assert.equal(report.serverCleanupExitCode, 0, 'Owned Yarn process tree cleanup failed')
|
||||
await verifyFreePort()
|
||||
report.port8082Released = true
|
||||
}
|
||||
catch (error) {
|
||||
report.cleanupError = error.stack
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
report.finishedAt = new Date().toISOString()
|
||||
fs.writeFileSync(path.join(output, 'server.log'), serverLog)
|
||||
fs.writeFileSync(path.join(output, 'report.json'), `${JSON.stringify(report, null, 2)}\n`)
|
||||
console.log(JSON.stringify({ output, browsers: report.browsers.map(({ engine, status }) => ({ engine, status })), error: report.error }, null, 2))
|
||||
}
|
||||
+4
-1
@@ -2711,7 +2711,10 @@
|
||||
"acceptance": "最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成",
|
||||
"evidence": [
|
||||
"baselines/asr-volume-validation.json",
|
||||
"baselines/asr-fallback-validation.json"
|
||||
"baselines/asr-fallback-validation.json",
|
||||
"baselines/asr-core.json",
|
||||
"baselines/asr-local-core-validation.json",
|
||||
"changes/2026-09-13-PKG-ASR-05-local-core-checkpoint.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1241,10 +1241,11 @@
|
||||
],
|
||||
"sourceFiles": [
|
||||
"packages/artplayer-plugin-asr/src/index.ts",
|
||||
"docs/assets/example/asr.js"
|
||||
"docs/assets/example/asr.js",
|
||||
"docs/assets/example/asr.local.js"
|
||||
],
|
||||
"contract": "Core plugin supplies PCM to caller onAudioChunk. Example fetches an external ASR endpoint and opens its returned WebSocket; not an embedded ASR SDK or a verified service.",
|
||||
"validationStatus": "Source integration observed; complete real SDK/service/device matrix not yet verified.",
|
||||
"validationStatus": "Native PCM/Worklet, volume and lifecycle verified on Chromium/Firefox with candidate, 5.4.0 and adjacent stable 5.3.0 cores; fallback Firefox selection is explicitly controlled, with foreign ownership still open. Offline docs example verifies local audio only. Windows WebKit, physical devices and actual external recognition service remain unverified.",
|
||||
"demoPathsSource": "refactor/baselines/demo-inventory.json"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Demo lifecycle uses the repository runner.
|
||||
import test from 'node:test'
|
||||
import vm from 'node:vm'
|
||||
import { deferredAudio } from './helpers/asr-audio.js'
|
||||
|
||||
const code = fs.readFileSync(new URL('../docs/assets/example/asr.local.js', import.meta.url), 'utf8')
|
||||
|
||||
function example() {
|
||||
const requests = []
|
||||
const statistics = { textContent: '' }
|
||||
let instance
|
||||
let option
|
||||
let asrOption
|
||||
const stop = deferredAudio()
|
||||
function forbidden(...args) {
|
||||
requests.push(args)
|
||||
throw new Error('The local ASR example must not open a recognition connection')
|
||||
}
|
||||
const context = vm.createContext({
|
||||
document: { createElement: () => statistics },
|
||||
Artplayer: class {
|
||||
isDestroy = false
|
||||
plugins = { artplayerPluginAsr: { stop: () => stop.promise } }
|
||||
constructor(value) {
|
||||
option = value
|
||||
instance = this
|
||||
}
|
||||
},
|
||||
artplayerPluginAsr(value) {
|
||||
asrOption = value
|
||||
return () => ({ name: 'artplayerPluginAsr' })
|
||||
},
|
||||
fetch: forbidden,
|
||||
WebSocket: forbidden,
|
||||
XMLHttpRequest: forbidden,
|
||||
EventSource: forbidden,
|
||||
navigator: { sendBeacon: forbidden },
|
||||
})
|
||||
vm.runInContext(code, context, { timeout: 1000 })
|
||||
return { statistics, instance, option, asrOption, requests, stop }
|
||||
}
|
||||
|
||||
function chunk() {
|
||||
const pcm = new ArrayBuffer(8000)
|
||||
const wav = new ArrayBuffer(8044)
|
||||
const samples = new DataView(pcm)
|
||||
samples.setInt16(0, -32768, true)
|
||||
samples.setInt16(2, 32767, true)
|
||||
new DataView(wav).setUint32(24, 16000, true)
|
||||
return { pcm, wav }
|
||||
}
|
||||
|
||||
test('ASR local example uses a real same-site sample and reports PCM/WAV without a recognition connection', () => {
|
||||
const environment = example()
|
||||
assert.equal(environment.option.url, '/assets/sample/steve-jobs.mp4')
|
||||
assert.ok(fs.statSync(new URL(`../docs${environment.option.url}`, import.meta.url)).size > 0)
|
||||
assert.equal(environment.option.layers[0].html, environment.statistics)
|
||||
assert.equal(environment.asrOption.interval, 250)
|
||||
assert.equal(environment.asrOption.onAudioChunk(chunk()), 'Simulated local subtitle: audio chunk 1 received.')
|
||||
assert.match(environment.statistics.textContent, /Chunks: 1 \| 16000 Hz mono PCM16 \| 0\.25 seconds captured/u)
|
||||
assert.match(environment.statistics.textContent, /PCM: 8000 bytes \| WAV: 8044 bytes \| Current peak: 32768/u)
|
||||
environment.asrOption.onAudioChunk(chunk())
|
||||
assert.match(environment.statistics.textContent, /Chunks: 2.*0\.50 seconds captured/u)
|
||||
assert.match(environment.statistics.textContent, /simulated subtitles, no speech recognition/u)
|
||||
assert.deepEqual(environment.requests, [])
|
||||
})
|
||||
|
||||
test('ASR local example waits for stop and reports how to resume capture', async () => {
|
||||
const environment = example()
|
||||
environment.asrOption.onAudioChunk(chunk())
|
||||
const before = environment.statistics.textContent
|
||||
const stopping = environment.option.controls[0].click()
|
||||
assert.equal(environment.statistics.textContent, before)
|
||||
environment.stop.resolve()
|
||||
await stopping
|
||||
assert.equal(environment.statistics.textContent, 'Local capture stopped. Pause and play to restart. Nothing was uploaded.')
|
||||
assert.deepEqual(environment.requests, [])
|
||||
})
|
||||
|
||||
test('ASR local example ignores late audio and stop completion after player destruction', async () => {
|
||||
const environment = example()
|
||||
const stopping = environment.option.controls[0].click()
|
||||
const before = environment.statistics.textContent
|
||||
environment.instance.isDestroy = true
|
||||
assert.equal(environment.asrOption.onAudioChunk(chunk()), undefined)
|
||||
environment.stop.resolve()
|
||||
await stopping
|
||||
assert.equal(environment.statistics.textContent, before)
|
||||
assert.deepEqual(environment.requests, [])
|
||||
})
|
||||
@@ -13,7 +13,7 @@ test.beforeAll(async () => {
|
||||
})
|
||||
|
||||
for (const version of ['2.0.0', '2.1.0', 'candidate']) {
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const core of ['published-5.3.0', 'published', 'candidate']) {
|
||||
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 = version === 'candidate' ? candidateCode : readMember(contract.archives.get(version), 'package/dist/artplayer-plugin-asr.js')
|
||||
|
||||
@@ -13,7 +13,7 @@ test.beforeAll(async () => {
|
||||
})
|
||||
|
||||
for (const version of ['2.1.0', 'candidate']) {
|
||||
for (const core of ['published', 'candidate']) {
|
||||
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) => {
|
||||
const code = version === 'candidate' ? candidateCode : readMember(contract.archives.get(version), 'package/dist/artplayer-plugin-asr.js')
|
||||
await page.goto(`/test/player.html?core=${core}`)
|
||||
|
||||
@@ -13,7 +13,7 @@ test.beforeAll(async () => {
|
||||
})
|
||||
|
||||
for (const version of ['2.1.0', 'candidate']) {
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const core of ['published-5.3.0', 'published', 'candidate']) {
|
||||
test(`ASR ${version} + ${core}: native playback volume and mute`, 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}`)
|
||||
|
||||
@@ -74,6 +74,11 @@ const iframeCoreMember = `package/${iframeCore.manifest.main}`
|
||||
const iframeCoreBytes = readMember(await ensureArchive(iframeCore), iframeCoreMember)
|
||||
assert.equal(hash(iframeCoreBytes), iframeCore.files[iframeCoreMember], 'Historical Iframe core differs from frozen release')
|
||||
add('/published-4.5.9/artplayer.js', iframeCoreBytes, { kind: 'npm-release', version: iframeCore.version, integrity: iframeCore.integrity, member: iframeCoreMember })
|
||||
const asrCore = JSON.parse(fs.readFileSync(path.join(workspace, 'refactor/baselines/asr-core.json'), 'utf8')).release
|
||||
const asrCoreMember = `package/${asrCore.manifest.main.replace(/^\.\//, '')}`
|
||||
const asrCoreBytes = readMember(await ensureArchive(asrCore), asrCoreMember)
|
||||
assert.equal(hash(asrCoreBytes), asrCore.files[asrCoreMember], 'Adjacent stable ASR core differs from frozen release')
|
||||
add('/published-5.3.0/artplayer.js', asrCoreBytes, { kind: 'npm-release', version: asrCore.version, integrity: asrCore.integrity, member: asrCoreMember })
|
||||
add('/test/audio-tone.m4a', fs.readFileSync(path.join(workspace, 'test/browser/media/audio-tone.m4a')), { kind: 'generated-media', file: 'test/browser/media/audio-tone.m4a' })
|
||||
add('/test/thumbnail-pattern.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/thumbnail-pattern.mp4')), { kind: 'generated-media', file: 'test/browser/media/thumbnail-pattern.mp4' })
|
||||
add('/test/auto-thumbnail-timeline.mp4', fs.readFileSync(path.join(workspace, 'test/browser/media/auto-thumbnail-timeline.mp4')), { kind: 'generated-media', file: 'test/browser/media/auto-thumbnail-timeline.mp4' })
|
||||
@@ -145,7 +150,7 @@ const server = http.createServer((req, res) => {
|
||||
if (url.pathname === '/test/player.html') {
|
||||
const core = url.searchParams.get('core') || 'candidate'
|
||||
const chapter = url.searchParams.get('chapter') || 'candidate'
|
||||
assert(['candidate', 'published', 'published-4.5.5', 'published-4.5.9', 'published-5.1.7'].includes(core) && ['candidate', 'published'].includes(chapter), 'Invalid test combination')
|
||||
assert(['candidate', 'published', 'published-4.5.5', 'published-4.5.9', 'published-5.1.7', 'published-5.3.0'].includes(core) && ['candidate', 'published'].includes(chapter), 'Invalid test combination')
|
||||
const html = fs.readFileSync(path.join(workspace, 'test/browser/player.html'), 'utf8')
|
||||
.replace('__CORE__', core)
|
||||
.replace('__CHAPTER__', chapter)
|
||||
|
||||
Reference in new issue
Block a user