test(asr): [PKG-ASR-05] checkpoint local editor and adjacent core coverage

This commit is contained in:
Harvey Zhao committed 2026-09-13 15:55:57 +08:00
1 parent 6d7b9292f8
commit 2cede66933
18 files changed
+10908 -240

No files matched your search

+68
View File
@@ -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.`
},
})],
})
+367 -229
View File
@@ -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
View File
@@ -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.
+7
View File
@@ -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
+174
View File
@@ -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
View File
@@ -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)
+13
View File
@@ -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 原生两个候选核心确实在
+265
View File
@@ -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
View File
@@ -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"
]
},
{
+3 -2
View File
@@ -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"
},
{
+92
View File
@@ -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, [])
})
+1 -1
View File
@@ -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')
+1 -1
View File
@@ -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}`)
+1 -1
View File
@@ -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}`)
+6 -1
View File
@@ -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)