fix(asr): [PKG-ASR-10] cancel recognition on native media errors

This commit is contained in:
Harvey Zhao committed 2026-09-15 14:25:59 +08:00
1 parent 3c89b48514
commit 9cf4a58480
21 files changed
+18402 -12

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";class t{constructor(t,e,i=!1){this.video=t,this.sampleRate=e,this.captureOnly=i,this.closed=!1,this.direct=!1,this.workletLoaded=!1}assertOpen(){if(this.closed)throw new Error("Audio capture was closed")}revokeWorklet(){void 0!==this.blobUrl&&(URL.revokeObjectURL(this.blobUrl),this.blobUrl=void 0)}async initialize(){this.assertOpen();const t=window,e=window.AudioContext||t.webkitAudioContext;if(!e)throw new Error("AudioContext is not supported");const i=this.context??(this.context=new e({sampleRate:this.sampleRate}));if("suspended"===i.state&&await i.resume(),this.assertOpen(),!this.workletLoaded){this.blobUrl=URL.createObjectURL(new Blob(["\nclass RecorderProcessor extends AudioWorkletProcessor {\n process(inputs) {\n const input = inputs[0];\n if (input && input[0]) {\n this.port.postMessage(input[0]);\n }\n return true;\n }\n}\nregisterProcessor('recorder-processor', RecorderProcessor);\n"],{type:"application/javascript"}));try{await i.audioWorklet.addModule(this.blobUrl),this.workletLoaded=!0}finally{this.revokeWorklet()}}if(this.assertOpen(),!this.source){if(!this.captureOnly)try{this.source=i.createMediaElementSource(this.video),this.direct=!0}catch(s){console.warn("[artplayerPluginAsr] Direct connection failed:",s)}if(!this.source){const t=this.video.captureStream||this.video.mozCaptureStream;if(!t)throw new Error("Could not establish audio source");this.stream=t.call(this.video),this.source=i.createMediaStreamSource(this.stream)}}}prepare(){return this.preparation??(this.preparation=this.initialize().catch(t=>{throw this.preparation=void 0,t})),this.preparation}get ownsMediaConnection(){return this.direct}idle(){const t=this.gain;this.disconnect(),this.direct&&this.context&&this.source&&(this.gain=t||this.context.createGain(),t||(this.gain.gain.value=1),this.source.connect(this.gain),this.gain.connect(this.context.destination))}connect(t){this.assertOpen();const e=this.context,i=this.source;if(!e||!i)throw new Error("Audio capture is not prepared");const s=this.gain;this.disconnect();const r=this.gain=s||e.createGain();s||(r.gain.value=this.direct?1:0);const n=this.recorder=new AudioWorkletNode(e,"recorder-processor");n.port.onmessage=e=>{this.closed||this.recorder!==n||t(e.data)},i.connect(n),this.direct?i.connect(r):n.connect(r),r.connect(e.destination)}disconnect(){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=!0,this.disconnect(),this.revokeWorklet(),this.stream?.getTracks().forEach(t=>t.stop()),this.stream=void 0;const t=this.context;this.context=void 0,this.source=void 0,t&&"closed"!==t.state&&await t.close()}}class e{constructor(){this.offset=0,this.size=0}get length(){return this.size}push(t){if(0===t.length)return;const e={samples:new Float32Array(t)};this.tail?this.tail.next=e:this.head=e,this.tail=e,this.size+=t.length}take(t){if(!Number.isSafeInteger(t)||t<=0)throw new RangeError("Sample count must be a positive safe integer");if(t>this.size)return null;const e=new Float32Array(t);let i=0;for(;this.head&&i<t;){const s=this.head.samples,r=Math.min(s.length-this.offset,t-i);e.set(s.subarray(this.offset,this.offset+r),i),i+=r,this.offset+=r,this.offset===s.length&&(this.head=this.head.next,this.offset=0)}return this.head||(this.tail=void 0),this.size-=t,e}clear(){this.head=void 0,this.tail=void 0,this.offset=0,this.size=0}}class i{constructor(t,i,s){this.video=t,this.options=i,this.append=s,this.queue=new e,this.closing=Promise.resolve(),this.running=!1,this.terminal=!1,this.epoch=0}reset(){this.epoch++,this.pending=void 0,this.queue.clear()}pause(){this.reset(),this.running=!1,clearInterval(this.timer),this.timer=void 0,this.starting=void 0,this.graph?.idle()}restart(){const t=this.running||Boolean(this.starting);if(this.pause(),this.graph&&!this.graph.ownsMediaConnection){const e=this.stop(),i=this.epoch;return e.then(()=>t&&!this.terminal&&this.epoch===i?this.start():void 0)}return t?this.start():Promise.resolve()}start(){if(this.terminal||this.running)return Promise.resolve();if(this.starting)return this.starting;const e=this.epoch,i=()=>!this.terminal&&this.epoch===e,s=(async()=>{if(await this.closing,!i())return;const{sampleRate:e,interval:s}=this.options,r=Math.floor(e*s/1e3);if(!Number.isSafeInteger(r)||r<1||!Number.isFinite(s)||s<=0)throw new Error("Audio chunk length must be positive and finite");const n=this.graph??(this.graph=new t(this.video,e,this.options.captureOnly));await n.prepare(),i()?(n.connect(t=>{if(this.running&&this.graph===n)return this.queue.length+tLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";class t{constructor(t,e,i=!1){this.video=t,this.sampleRate=e,this.captureOnly=i,this.closed=!1,this.direct=!1,this.workletLoaded=!1}assertOpen(){if(this.closed)throw new Error("Audio capture was closed")}revokeWorklet(){void 0!==this.blobUrl&&(URL.revokeObjectURL(this.blobUrl),this.blobUrl=void 0)}async initialize(){this.assertOpen();const t=window,e=window.AudioContext||t.webkitAudioContext;if(!e)throw new Error("AudioContext is not supported");const i=this.context??(this.context=new e({sampleRate:this.sampleRate}));if("suspended"===i.state&&await i.resume(),this.assertOpen(),!this.workletLoaded){this.blobUrl=URL.createObjectURL(new Blob(["\nclass RecorderProcessor extends AudioWorkletProcessor {\n process(inputs) {\n const input = inputs[0];\n if (input && input[0]) {\n this.port.postMessage(input[0]);\n }\n return true;\n }\n}\nregisterProcessor('recorder-processor', RecorderProcessor);\n"],{type:"application/javascript"}));try{await i.audioWorklet.addModule(this.blobUrl),this.workletLoaded=!0}finally{this.revokeWorklet()}}if(this.assertOpen(),!this.source){if(!this.captureOnly)try{this.source=i.createMediaElementSource(this.video),this.direct=!0}catch(s){console.warn("[artplayerPluginAsr] Direct connection failed:",s)}if(!this.source){const t=this.video.captureStream||this.video.mozCaptureStream;if(!t)throw new Error("Could not establish audio source");this.stream=t.call(this.video),this.source=i.createMediaStreamSource(this.stream)}}}prepare(){return this.preparation??(this.preparation=this.initialize().catch(t=>{throw this.preparation=void 0,t})),this.preparation}get ownsMediaConnection(){return this.direct}idle(){const t=this.gain;this.disconnect(),this.direct&&this.context&&this.source&&(this.gain=t||this.context.createGain(),t||(this.gain.gain.value=1),this.source.connect(this.gain),this.gain.connect(this.context.destination))}connect(t){this.assertOpen();const e=this.context,i=this.source;if(!e||!i)throw new Error("Audio capture is not prepared");const s=this.gain;this.disconnect();const r=this.gain=s||e.createGain();s||(r.gain.value=this.direct?1:0);const n=this.recorder=new AudioWorkletNode(e,"recorder-processor");n.port.onmessage=e=>{this.closed||this.recorder!==n||t(e.data)},i.connect(n),this.direct?i.connect(r):n.connect(r),r.connect(e.destination)}disconnect(){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=!0,this.disconnect(),this.revokeWorklet(),this.stream?.getTracks().forEach(t=>t.stop()),this.stream=void 0;const t=this.context;this.context=void 0,this.source=void 0,t&&"closed"!==t.state&&await t.close()}}class e{constructor(){this.offset=0,this.size=0}get length(){return this.size}push(t){if(0===t.length)return;const e={samples:new Float32Array(t)};this.tail?this.tail.next=e:this.head=e,this.tail=e,this.size+=t.length}take(t){if(!Number.isSafeInteger(t)||t<=0)throw new RangeError("Sample count must be a positive safe integer");if(t>this.size)return null;const e=new Float32Array(t);let i=0;for(;this.head&&i<t;){const s=this.head.samples,r=Math.min(s.length-this.offset,t-i);e.set(s.subarray(this.offset,this.offset+r),i),i+=r,this.offset+=r,this.offset===s.length&&(this.head=this.head.next,this.offset=0)}return this.head||(this.tail=void 0),this.size-=t,e}clear(){this.head=void 0,this.tail=void 0,this.offset=0,this.size=0}}class i{constructor(t,i,s){this.video=t,this.options=i,this.append=s,this.queue=new e,this.closing=Promise.resolve(),this.running=!1,this.terminal=!1,this.epoch=0}reset(){this.epoch++,this.pending=void 0,this.queue.clear()}pause(){this.reset(),this.running=!1,clearInterval(this.timer),this.timer=void 0,this.starting=void 0,this.graph?.idle()}restart(){const t=this.running||Boolean(this.starting);if(this.pause(),this.graph&&!this.graph.ownsMediaConnection){const e=this.stop(),i=this.epoch;return e.then(()=>t&&!this.terminal&&this.epoch===i?this.start():void 0)}return t?this.start():Promise.resolve()}start(){if(this.terminal||this.running)return Promise.resolve();if(this.starting)return this.starting;const e=this.epoch,i=()=>!this.terminal&&this.epoch===e,s=(async()=>{if(await this.closing,!i())return;const{sampleRate:e,interval:s}=this.options,r=Math.floor(e*s/1e3);if(!Number.isSafeInteger(r)||r<1||!Number.isFinite(s)||s<=0)throw new Error("Audio chunk length must be positive and finite");const n=this.graph??(this.graph=new t(this.video,e,this.options.captureOnly));await n.prepare(),i()?(n.connect(t=>{if(this.running&&this.graph===n)return this.queue.length+tLine truncated
+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";var t=(t,e,i)=>new Promise((s,n)=>{var r=t=>{try{a(i.next(t))}catch(e){n(e)}},o=t=>{try{a(i.throw(t))}catch(e){n(e)}},a=t=>t.done?s(t.value):Promise.resolve(t.value).then(r,o);a((i=i.apply(t,e)).next())});class e{constructor(t,e,i=!1){this.video=t,this.sampleRate=e,this.captureOnly=i,this.closed=!1,this.direct=!1,this.workletLoaded=!1}assertOpen(){if(this.closed)throw new Error("Audio capture was closed")}revokeWorklet(){void 0!==this.blobUrl&&(URL.revokeObjectURL(this.blobUrl),this.blobUrl=void 0)}initialize(){return t(this,null,function*(){var t;this.assertOpen();const e=window,i=window.AudioContext||e.webkitAudioContext;if(!i)throw new Error("AudioContext is not supported");const s=null!=(t=this.context)?t:this.context=new i({sampleRate:this.sampleRate});if("suspended"===s.state&&(yield s.resume()),this.assertOpen(),!this.workletLoaded){this.blobUrl=URL.createObjectURL(new Blob(["\nclass RecorderProcessor extends AudioWorkletProcessor {\n process(inputs) {\n const input = inputs[0];\n if (input && input[0]) {\n this.port.postMessage(input[0]);\n }\n return true;\n }\n}\nregisterProcessor('recorder-processor', RecorderProcessor);\n"],{type:"application/javascript"}));try{yield s.audioWorklet.addModule(this.blobUrl),this.workletLoaded=!0}finally{this.revokeWorklet()}}if(this.assertOpen(),!this.source){if(!this.captureOnly)try{this.source=s.createMediaElementSource(this.video),this.direct=!0}catch(n){console.warn("[artplayerPluginAsr] Direct connection failed:",n)}if(!this.source){const t=this.video.captureStream||this.video.mozCaptureStream;if(!t)throw new Error("Could not establish audio source");this.stream=t.call(this.video),this.source=s.createMediaStreamSource(this.stream)}}})}prepare(){return null!=this.preparation||(this.preparation=this.initialize().catch(t=>{throw this.preparation=void 0,t})),this.preparation}get ownsMediaConnection(){return this.direct}idle(){const t=this.gain;this.disconnect(),this.direct&&this.context&&this.source&&(this.gain=t||this.context.createGain(),t||(this.gain.gain.value=1),this.source.connect(this.gain),this.gain.connect(this.context.destination))}connect(t){this.assertOpen();const e=this.context,i=this.source;if(!e||!i)throw new Error("Audio capture is not prepared");const s=this.gain;this.disconnect();const n=this.gain=s||e.createGain();s||(n.gain.value=this.direct?1:0);const r=this.recorder=new AudioWorkletNode(e,"recorder-processor");r.port.onmessage=e=>{this.closed||this.recorder!==r||t(e.data)},i.connect(r),this.direct?i.connect(n):r.connect(n),n.connect(e.destination)}disconnect(){var t,e;this.recorder&&(this.recorder.port.onmessage=null,this.recorder.disconnect(),this.recorder=void 0),null==(t=this.gain)||t.disconnect(),this.gain=void 0,null==(e=this.source)||e.disconnect()}close(){return t(this,null,function*(){var t;if(this.closed)return;this.closed=!0,this.disconnect(),this.revokeWorklet(),null==(t=this.stream)||t.getTracks().forEach(t=>t.stop()),this.stream=void 0;const e=this.context;this.context=void 0,this.source=void 0,e&&"closed"!==e.state&&(yield e.close())})}}class i{constructor(){this.offset=0,this.size=0}get length(){return this.size}push(t){if(0===t.length)return;const e={samples:new Float32Array(t)};this.tail?this.tail.next=e:this.head=e,this.tail=e,this.size+=t.length}take(t){if(!Number.isSafeInteger(t)||t<=0)throw new RangeError("Sample count must be a positive safe integer");if(t>this.size)return null;const e=new Float32Array(t);let i=0;for(;this.head&&i<t;){const s=this.head.samples,n=Math.min(s.length-this.offset,t-i);e.set(s.subarray(this.offset,this.offset+n),i),i+=n,this.offset+=n,this.offset===s.length&&(this.head=this.head.next,this.offset=0)}return this.head||(this.tail=void 0),this.size-=t,e}clear(){this.head=void 0,this.tail=void 0,this.offset=0,this.size=0}}class s{constructor(t,e,s){this.video=t,this.options=e,this.append=s,this.queue=new i,this.closing=Promise.resolve(),this.running=!1,this.terminal=!1,this.epoch=0}reset(){this.epoch++,this.pending=void 0,this.queue.clear()}pause(){var t;this.reset(),this.running=!1,clearInterval(this.timer),this.timer=void 0,this.starting=void 0,null==(t=this.graph)||t.idle()}restart(){const t=this.running||Boolean(this.starting);if(this.pause(),this.graph&&!this.graph.ownsMediaConnection){const e=this.stop(),i=this.epoch;return e.then(()=>t&&!this.terminal&&this.epoch===i?this.start():void 0)}return t?this.start():Promise.resolve()}start(){if(this.terminal||this.running)return Promise.resolve();if(this.starting)return this.starting;const i=this.epoch,s=()=>!this.terminal&&this.epoch===i,n=(()=>t(this,null,function*(){var t;if(yield this.closing,!Line truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";var t=(t,e,i)=>new Promise((s,n)=>{var r=t=>{try{a(i.next(t))}catch(e){n(e)}},o=t=>{try{a(i.throw(t))}catch(e){n(e)}},a=t=>t.done?s(t.value):Promise.resolve(t.value).then(r,o);a((i=i.apply(t,e)).next())});class e{constructor(t,e,i=!1){this.video=t,this.sampleRate=e,this.captureOnly=i,this.closed=!1,this.direct=!1,this.workletLoaded=!1}assertOpen(){if(this.closed)throw new Error("Audio capture was closed")}revokeWorklet(){void 0!==this.blobUrl&&(URL.revokeObjectURL(this.blobUrl),this.blobUrl=void 0)}initialize(){return t(this,null,function*(){var t;this.assertOpen();const e=window,i=window.AudioContext||e.webkitAudioContext;if(!i)throw new Error("AudioContext is not supported");const s=null!=(t=this.context)?t:this.context=new i({sampleRate:this.sampleRate});if("suspended"===s.state&&(yield s.resume()),this.assertOpen(),!this.workletLoaded){this.blobUrl=URL.createObjectURL(new Blob(["\nclass RecorderProcessor extends AudioWorkletProcessor {\n process(inputs) {\n const input = inputs[0];\n if (input && input[0]) {\n this.port.postMessage(input[0]);\n }\n return true;\n }\n}\nregisterProcessor('recorder-processor', RecorderProcessor);\n"],{type:"application/javascript"}));try{yield s.audioWorklet.addModule(this.blobUrl),this.workletLoaded=!0}finally{this.revokeWorklet()}}if(this.assertOpen(),!this.source){if(!this.captureOnly)try{this.source=s.createMediaElementSource(this.video),this.direct=!0}catch(n){console.warn("[artplayerPluginAsr] Direct connection failed:",n)}if(!this.source){const t=this.video.captureStream||this.video.mozCaptureStream;if(!t)throw new Error("Could not establish audio source");this.stream=t.call(this.video),this.source=s.createMediaStreamSource(this.stream)}}})}prepare(){return null!=this.preparation||(this.preparation=this.initialize().catch(t=>{throw this.preparation=void 0,t})),this.preparation}get ownsMediaConnection(){return this.direct}idle(){const t=this.gain;this.disconnect(),this.direct&&this.context&&this.source&&(this.gain=t||this.context.createGain(),t||(this.gain.gain.value=1),this.source.connect(this.gain),this.gain.connect(this.context.destination))}connect(t){this.assertOpen();const e=this.context,i=this.source;if(!e||!i)throw new Error("Audio capture is not prepared");const s=this.gain;this.disconnect();const n=this.gain=s||e.createGain();s||(n.gain.value=this.direct?1:0);const r=this.recorder=new AudioWorkletNode(e,"recorder-processor");r.port.onmessage=e=>{this.closed||this.recorder!==r||t(e.data)},i.connect(r),this.direct?i.connect(n):r.connect(n),n.connect(e.destination)}disconnect(){var t,e;this.recorder&&(this.recorder.port.onmessage=null,this.recorder.disconnect(),this.recorder=void 0),null==(t=this.gain)||t.disconnect(),this.gain=void 0,null==(e=this.source)||e.disconnect()}close(){return t(this,null,function*(){var t;if(this.closed)return;this.closed=!0,this.disconnect(),this.revokeWorklet(),null==(t=this.stream)||t.getTracks().forEach(t=>t.stop()),this.stream=void 0;const e=this.context;this.context=void 0,this.source=void 0,e&&"closed"!==e.state&&(yield e.close())})}}class i{constructor(){this.offset=0,this.size=0}get length(){return this.size}push(t){if(0===t.length)return;const e={samples:new Float32Array(t)};this.tail?this.tail.next=e:this.head=e,this.tail=e,this.size+=t.length}take(t){if(!Number.isSafeInteger(t)||t<=0)throw new RangeError("Sample count must be a positive safe integer");if(t>this.size)return null;const e=new Float32Array(t);let i=0;for(;this.head&&i<t;){const s=this.head.samples,n=Math.min(s.length-this.offset,t-i);e.set(s.subarray(this.offset,this.offset+n),i),i+=n,this.offset+=n,this.offset===s.length&&(this.head=this.head.next,this.offset=0)}return this.head||(this.tail=void 0),this.size-=t,e}clear(){this.head=void 0,this.tail=void 0,this.offset=0,this.size=0}}class s{constructor(t,e,s){this.video=t,this.options=e,this.append=s,this.queue=new i,this.closing=Promise.resolve(),this.running=!1,this.terminal=!1,this.epoch=0}reset(){this.epoch++,this.pending=void 0,this.queue.clear()}pause(){var t;this.reset(),this.running=!1,clearInterval(this.timer),this.timer=void 0,this.starting=void 0,null==(t=this.graph)||t.idle()}restart(){const t=this.running||Boolean(this.starting);if(this.pause(),this.graph&&!this.graph.ownsMediaConnection){const e=this.stop(),i=this.epoch;return e.then(()=>t&&!this.terminal&&this.epoch===i?this.start():void 0)}return t?this.start():Promise.resolve()}start(){if(this.terminal||this.running)return Promise.resolve();if(this.starting)return this.starting;const i=this.epoch,s=()=>!this.terminal&&this.epoch===i,n=(()=>t(this,null,function*(){var t;if(yield this.closing,!Line truncated
+2
View File
@@ -379,12 +379,14 @@ function artplayerPluginAsr(option = {}) {
art.off("play", play);
art.off("pause", pause);
art.off("restart", restart);
art.off("video:error", stop);
art.off("destroy", destroy);
return capture.stop(true);
};
art.on("play", play);
art.on("pause", pause);
art.on("restart", restart);
art.on("video:error", stop);
art.on("destroy", destroy);
return { name: "artplayerPluginAsr", stop, hide: subtitles.hide, append: subtitles.append };
};
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:llm && yarn build:vconsole && yarn build:console && yarn build:site-notices && yarn build:test && 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/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/danmuku-mask-lifecycle.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/jassub-registration.test.js test/jassub-runtime.test.js test/jassub-vendor-lifecycle.test.js test/jassub-frame-polyfill.test.js test/jassub-offscreen.test.js test/multiple-subtitles-ass.test.js test/multiple-subtitles-caption.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-entities.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-exports.test.js test/auto-thumbnail-canvas.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-encoding.test.js test/auto-thumbnail-metadata.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-exports.test.js test/vast.test.js test/vast-lifecycle.test.js test/vast-compatibility.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/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
"test:unit": "node --test test/danmuku-setting.test.js test/danmuku-heatmap.test.js test/danmuku-renderer.test.js test/danmuku-scheduler.test.js test/danmuku-worker-client.test.js test/danmuku-input.test.js test/danmuku-parser.test.js test/danmuku-failures.test.js test/danmuku-mask-failures.test.js test/danmuku-mask-lifecycle.test.js test/asr-encoding.test.js test/asr-lifecycle.test.js test/asr-audio.test.js test/asr.test.js test/jassub.test.js test/jassub-registration.test.js test/jassub-runtime.test.js test/jassub-vendor-lifecycle.test.js test/jassub-frame-polyfill.test.js test/jassub-offscreen.test.js test/multiple-subtitles-ass.test.js test/multiple-subtitles-caption.test.js test/multiple-subtitles-merge.test.js test/multiple-subtitles-entities.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-exports.test.js test/auto-thumbnail-canvas.test.js test/auto-thumbnail-frames.test.js test/auto-thumbnail-encoding.test.js test/auto-thumbnail-metadata.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-exports.test.js test/vast.test.js test/vast-lifecycle.test.js test/vast-compatibility.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/asr-explicit-capture.test.js test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js test/asr-media-error.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",
@@ -98,7 +98,7 @@
"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/pages-artifact.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-local-example.test.js test/asr-explicit-capture.test.js",
"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-explicit-capture.test.js test/asr-media-error.test.js",
"test:asr-types-package": "node refactor/scripts/asr-package-types.mjs",
"test:asr-demo": "node refactor/scripts/asr-local-demo.mjs",
"test:chromecast": "node --test test/chromecast.test.js test/chromecast-failures.test.js test/chromecast-runtime.test.js",
+16 -1
View File
@@ -29,6 +29,13 @@ Rejections are logged and later ticks continue. Pause, source restart, stop and
destroy invalidate old recognizer completions and discard obsolete queued audio.
Each recorder also checks its own identity, rejecting messages from retired ports.
`video:error` uses the same nonterminal stop path. Native source changes or decode
failures can emit an error without a pause event, so pause alone cannot cancel old
recognition. Error handling clears the queue/timer and invalidates pending results,
closes captured graphs, and keeps an existing direct connection available for later
playback. Existing subtitles retain their auto-hide deadline. Destroy also removes
this error listener; a later play can initialize or reconnect normally.
Pause detaches the recorder and cancels the interval. The direct media source
continues through its playback gain to the destination. A source switch reuses the
direct source and replaces the recorder; a captured-stream fallback is closed and
@@ -162,7 +169,7 @@ recognizer service as a prerequisite for local audio verification.
## Shared installed browser validation
Seven browser files select verified installed ASR bytes through browser-candidate.js. The Audio Track combination independently verifies both installed plugins. Published ASR controls retain their frozen archives; attachments distinguish selected published, installed and source inputs. Tests cover native local PCM, direct/capture routing, CORS, video-only input recovery, volume/mute and ownership. Deliberately forced Firefox binding rejection remains labeled; no recognition service or physical speaker output is claimed.
Eight browser files select verified installed ASR bytes through browser-candidate.js. The Audio Track combination independently verifies both installed plugins. Published ASR controls retain their frozen archives; attachments distinguish selected published, installed and source inputs. Tests cover native local PCM, direct/capture routing, CORS, video-only input recovery, volume/mute and ownership. Deliberately forced Firefox binding rejection remains labeled; no recognition service or physical speaker output is claimed.
Use `yarn test:package --browser`, then `yarn test:browser:installed`. The common
roster in scripts/browser-validation/scope.ts drives both preparation and required
@@ -186,3 +193,11 @@ It does not prove speaker output, malformed-media recovery, Safari or device
support. Run `yarn test:browser asr-no-audio.spec.js --workers=1` for source mode;
the same file is included in the installed CI roster. WebAudio capability skips
remain visible and cannot be counted as audio processing passes.
`asr-media-error.spec.js` holds a caller recognition Promise after real nonzero PCM,
assigns non-media HTTP content through public `art.video.src`, waits for the native
MediaError, then completes that old Promise. It verifies that obsolete subtitles
are rejected and normal audio recognition resumes on a valid source. It does not
simulate a network outage or every decoder error. `test/asr-media-error.test.js`
also covers errors during Worklet loading and delayed capture closure. Both are
required when changing capture cancellation or error listener ownership.
+4
View File
@@ -84,6 +84,10 @@ root and `/legacy` option declarations remain unchanged.
## Maintenance
A media error stops recognition and ignores pending results from the failed source.
Existing subtitles keep their auto-hide behavior; playback of a valid source can
resume recognition. ASR does not cancel a caller's own network request.
See [ARCHITECTURE.md](ARCHITECTURE.md) for the current audio flow, public behavior,
known migration gaps and test commands.
+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";class t{constructor(t,e,i=!1){this.video=t,this.sampleRate=e,this.captureOnly=i,this.closed=!1,this.direct=!1,this.workletLoaded=!1}assertOpen(){if(this.closed)throw new Error("Audio capture was closed")}revokeWorklet(){void 0!==this.blobUrl&&(URL.revokeObjectURL(this.blobUrl),this.blobUrl=void 0)}async initialize(){this.assertOpen();const t=window,e=window.AudioContext||t.webkitAudioContext;if(!e)throw new Error("AudioContext is not supported");const i=this.context??(this.context=new e({sampleRate:this.sampleRate}));if("suspended"===i.state&&await i.resume(),this.assertOpen(),!this.workletLoaded){this.blobUrl=URL.createObjectURL(new Blob(["\nclass RecorderProcessor extends AudioWorkletProcessor {\n process(inputs) {\n const input = inputs[0];\n if (input && input[0]) {\n this.port.postMessage(input[0]);\n }\n return true;\n }\n}\nregisterProcessor('recorder-processor', RecorderProcessor);\n"],{type:"application/javascript"}));try{await i.audioWorklet.addModule(this.blobUrl),this.workletLoaded=!0}finally{this.revokeWorklet()}}if(this.assertOpen(),!this.source){if(!this.captureOnly)try{this.source=i.createMediaElementSource(this.video),this.direct=!0}catch(s){console.warn("[artplayerPluginAsr] Direct connection failed:",s)}if(!this.source){const t=this.video.captureStream||this.video.mozCaptureStream;if(!t)throw new Error("Could not establish audio source");this.stream=t.call(this.video),this.source=i.createMediaStreamSource(this.stream)}}}prepare(){return this.preparation??(this.preparation=this.initialize().catch(t=>{throw this.preparation=void 0,t})),this.preparation}get ownsMediaConnection(){return this.direct}idle(){const t=this.gain;this.disconnect(),this.direct&&this.context&&this.source&&(this.gain=t||this.context.createGain(),t||(this.gain.gain.value=1),this.source.connect(this.gain),this.gain.connect(this.context.destination))}connect(t){this.assertOpen();const e=this.context,i=this.source;if(!e||!i)throw new Error("Audio capture is not prepared");const s=this.gain;this.disconnect();const r=this.gain=s||e.createGain();s||(r.gain.value=this.direct?1:0);const n=this.recorder=new AudioWorkletNode(e,"recorder-processor");n.port.onmessage=e=>{this.closed||this.recorder!==n||t(e.data)},i.connect(n),this.direct?i.connect(r):n.connect(r),r.connect(e.destination)}disconnect(){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=!0,this.disconnect(),this.revokeWorklet(),this.stream?.getTracks().forEach(t=>t.stop()),this.stream=void 0;const t=this.context;this.context=void 0,this.source=void 0,t&&"closed"!==t.state&&await t.close()}}class e{constructor(){this.offset=0,this.size=0}get length(){return this.size}push(t){if(0===t.length)return;const e={samples:new Float32Array(t)};this.tail?this.tail.next=e:this.head=e,this.tail=e,this.size+=t.length}take(t){if(!Number.isSafeInteger(t)||t<=0)throw new RangeError("Sample count must be a positive safe integer");if(t>this.size)return null;const e=new Float32Array(t);let i=0;for(;this.head&&i<t;){const s=this.head.samples,r=Math.min(s.length-this.offset,t-i);e.set(s.subarray(this.offset,this.offset+r),i),i+=r,this.offset+=r,this.offset===s.length&&(this.head=this.head.next,this.offset=0)}return this.head||(this.tail=void 0),this.size-=t,e}clear(){this.head=void 0,this.tail=void 0,this.offset=0,this.size=0}}class i{constructor(t,i,s){this.video=t,this.options=i,this.append=s,this.queue=new e,this.closing=Promise.resolve(),this.running=!1,this.terminal=!1,this.epoch=0}reset(){this.epoch++,this.pending=void 0,this.queue.clear()}pause(){this.reset(),this.running=!1,clearInterval(this.timer),this.timer=void 0,this.starting=void 0,this.graph?.idle()}restart(){const t=this.running||Boolean(this.starting);if(this.pause(),this.graph&&!this.graph.ownsMediaConnection){const e=this.stop(),i=this.epoch;return e.then(()=>t&&!this.terminal&&this.epoch===i?this.start():void 0)}return t?this.start():Promise.resolve()}start(){if(this.terminal||this.running)return Promise.resolve();if(this.starting)return this.starting;const e=this.epoch,i=()=>!this.terminal&&this.epoch===e,s=(async()=>{if(await this.closing,!i())return;const{sampleRate:e,interval:s}=this.options,r=Math.floor(e*s/1e3);if(!Number.isSafeInteger(r)||r<1||!Number.isFinite(s)||s<=0)throw new Error("Audio chunk length must be positive and finite");const n=this.graph??(this.graph=new t(this.video,e,this.options.captureOnly));await n.prepare(),i()?(n.connect(t=>{if(this.running&&this.graph===n)return this.queue.length+tLine truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";class t{constructor(t,e,i=!1){this.video=t,this.sampleRate=e,this.captureOnly=i,this.closed=!1,this.direct=!1,this.workletLoaded=!1}assertOpen(){if(this.closed)throw new Error("Audio capture was closed")}revokeWorklet(){void 0!==this.blobUrl&&(URL.revokeObjectURL(this.blobUrl),this.blobUrl=void 0)}async initialize(){this.assertOpen();const t=window,e=window.AudioContext||t.webkitAudioContext;if(!e)throw new Error("AudioContext is not supported");const i=this.context??(this.context=new e({sampleRate:this.sampleRate}));if("suspended"===i.state&&await i.resume(),this.assertOpen(),!this.workletLoaded){this.blobUrl=URL.createObjectURL(new Blob(["\nclass RecorderProcessor extends AudioWorkletProcessor {\n process(inputs) {\n const input = inputs[0];\n if (input && input[0]) {\n this.port.postMessage(input[0]);\n }\n return true;\n }\n}\nregisterProcessor('recorder-processor', RecorderProcessor);\n"],{type:"application/javascript"}));try{await i.audioWorklet.addModule(this.blobUrl),this.workletLoaded=!0}finally{this.revokeWorklet()}}if(this.assertOpen(),!this.source){if(!this.captureOnly)try{this.source=i.createMediaElementSource(this.video),this.direct=!0}catch(s){console.warn("[artplayerPluginAsr] Direct connection failed:",s)}if(!this.source){const t=this.video.captureStream||this.video.mozCaptureStream;if(!t)throw new Error("Could not establish audio source");this.stream=t.call(this.video),this.source=i.createMediaStreamSource(this.stream)}}}prepare(){return this.preparation??(this.preparation=this.initialize().catch(t=>{throw this.preparation=void 0,t})),this.preparation}get ownsMediaConnection(){return this.direct}idle(){const t=this.gain;this.disconnect(),this.direct&&this.context&&this.source&&(this.gain=t||this.context.createGain(),t||(this.gain.gain.value=1),this.source.connect(this.gain),this.gain.connect(this.context.destination))}connect(t){this.assertOpen();const e=this.context,i=this.source;if(!e||!i)throw new Error("Audio capture is not prepared");const s=this.gain;this.disconnect();const r=this.gain=s||e.createGain();s||(r.gain.value=this.direct?1:0);const n=this.recorder=new AudioWorkletNode(e,"recorder-processor");n.port.onmessage=e=>{this.closed||this.recorder!==n||t(e.data)},i.connect(n),this.direct?i.connect(r):n.connect(r),r.connect(e.destination)}disconnect(){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=!0,this.disconnect(),this.revokeWorklet(),this.stream?.getTracks().forEach(t=>t.stop()),this.stream=void 0;const t=this.context;this.context=void 0,this.source=void 0,t&&"closed"!==t.state&&await t.close()}}class e{constructor(){this.offset=0,this.size=0}get length(){return this.size}push(t){if(0===t.length)return;const e={samples:new Float32Array(t)};this.tail?this.tail.next=e:this.head=e,this.tail=e,this.size+=t.length}take(t){if(!Number.isSafeInteger(t)||t<=0)throw new RangeError("Sample count must be a positive safe integer");if(t>this.size)return null;const e=new Float32Array(t);let i=0;for(;this.head&&i<t;){const s=this.head.samples,r=Math.min(s.length-this.offset,t-i);e.set(s.subarray(this.offset,this.offset+r),i),i+=r,this.offset+=r,this.offset===s.length&&(this.head=this.head.next,this.offset=0)}return this.head||(this.tail=void 0),this.size-=t,e}clear(){this.head=void 0,this.tail=void 0,this.offset=0,this.size=0}}class i{constructor(t,i,s){this.video=t,this.options=i,this.append=s,this.queue=new e,this.closing=Promise.resolve(),this.running=!1,this.terminal=!1,this.epoch=0}reset(){this.epoch++,this.pending=void 0,this.queue.clear()}pause(){this.reset(),this.running=!1,clearInterval(this.timer),this.timer=void 0,this.starting=void 0,this.graph?.idle()}restart(){const t=this.running||Boolean(this.starting);if(this.pause(),this.graph&&!this.graph.ownsMediaConnection){const e=this.stop(),i=this.epoch;return e.then(()=>t&&!this.terminal&&this.epoch===i?this.start():void 0)}return t?this.start():Promise.resolve()}start(){if(this.terminal||this.running)return Promise.resolve();if(this.starting)return this.starting;const e=this.epoch,i=()=>!this.terminal&&this.epoch===e,s=(async()=>{if(await this.closing,!i())return;const{sampleRate:e,interval:s}=this.options,r=Math.floor(e*s/1e3);if(!Number.isSafeInteger(r)||r<1||!Number.isFinite(s)||s<=0)throw new Error("Audio chunk length must be positive and finite");const n=this.graph??(this.graph=new t(this.video,e,this.options.captureOnly));await n.prepare(),i()?(n.connect(t=>{if(this.running&&this.graph===n)return this.queue.length+tLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";var t=(t,e,i)=>new Promise((s,n)=>{var r=t=>{try{a(i.next(t))}catch(e){n(e)}},o=t=>{try{a(i.throw(t))}catch(e){n(e)}},a=t=>t.done?s(t.value):Promise.resolve(t.value).then(r,o);a((i=i.apply(t,e)).next())});class e{constructor(t,e,i=!1){this.video=t,this.sampleRate=e,this.captureOnly=i,this.closed=!1,this.direct=!1,this.workletLoaded=!1}assertOpen(){if(this.closed)throw new Error("Audio capture was closed")}revokeWorklet(){void 0!==this.blobUrl&&(URL.revokeObjectURL(this.blobUrl),this.blobUrl=void 0)}initialize(){return t(this,null,function*(){var t;this.assertOpen();const e=window,i=window.AudioContext||e.webkitAudioContext;if(!i)throw new Error("AudioContext is not supported");const s=null!=(t=this.context)?t:this.context=new i({sampleRate:this.sampleRate});if("suspended"===s.state&&(yield s.resume()),this.assertOpen(),!this.workletLoaded){this.blobUrl=URL.createObjectURL(new Blob(["\nclass RecorderProcessor extends AudioWorkletProcessor {\n process(inputs) {\n const input = inputs[0];\n if (input && input[0]) {\n this.port.postMessage(input[0]);\n }\n return true;\n }\n}\nregisterProcessor('recorder-processor', RecorderProcessor);\n"],{type:"application/javascript"}));try{yield s.audioWorklet.addModule(this.blobUrl),this.workletLoaded=!0}finally{this.revokeWorklet()}}if(this.assertOpen(),!this.source){if(!this.captureOnly)try{this.source=s.createMediaElementSource(this.video),this.direct=!0}catch(n){console.warn("[artplayerPluginAsr] Direct connection failed:",n)}if(!this.source){const t=this.video.captureStream||this.video.mozCaptureStream;if(!t)throw new Error("Could not establish audio source");this.stream=t.call(this.video),this.source=s.createMediaStreamSource(this.stream)}}})}prepare(){return null!=this.preparation||(this.preparation=this.initialize().catch(t=>{throw this.preparation=void 0,t})),this.preparation}get ownsMediaConnection(){return this.direct}idle(){const t=this.gain;this.disconnect(),this.direct&&this.context&&this.source&&(this.gain=t||this.context.createGain(),t||(this.gain.gain.value=1),this.source.connect(this.gain),this.gain.connect(this.context.destination))}connect(t){this.assertOpen();const e=this.context,i=this.source;if(!e||!i)throw new Error("Audio capture is not prepared");const s=this.gain;this.disconnect();const n=this.gain=s||e.createGain();s||(n.gain.value=this.direct?1:0);const r=this.recorder=new AudioWorkletNode(e,"recorder-processor");r.port.onmessage=e=>{this.closed||this.recorder!==r||t(e.data)},i.connect(r),this.direct?i.connect(n):r.connect(n),n.connect(e.destination)}disconnect(){var t,e;this.recorder&&(this.recorder.port.onmessage=null,this.recorder.disconnect(),this.recorder=void 0),null==(t=this.gain)||t.disconnect(),this.gain=void 0,null==(e=this.source)||e.disconnect()}close(){return t(this,null,function*(){var t;if(this.closed)return;this.closed=!0,this.disconnect(),this.revokeWorklet(),null==(t=this.stream)||t.getTracks().forEach(t=>t.stop()),this.stream=void 0;const e=this.context;this.context=void 0,this.source=void 0,e&&"closed"!==e.state&&(yield e.close())})}}class i{constructor(){this.offset=0,this.size=0}get length(){return this.size}push(t){if(0===t.length)return;const e={samples:new Float32Array(t)};this.tail?this.tail.next=e:this.head=e,this.tail=e,this.size+=t.length}take(t){if(!Number.isSafeInteger(t)||t<=0)throw new RangeError("Sample count must be a positive safe integer");if(t>this.size)return null;const e=new Float32Array(t);let i=0;for(;this.head&&i<t;){const s=this.head.samples,n=Math.min(s.length-this.offset,t-i);e.set(s.subarray(this.offset,this.offset+n),i),i+=n,this.offset+=n,this.offset===s.length&&(this.head=this.head.next,this.offset=0)}return this.head||(this.tail=void 0),this.size-=t,e}clear(){this.head=void 0,this.tail=void 0,this.offset=0,this.size=0}}class s{constructor(t,e,s){this.video=t,this.options=e,this.append=s,this.queue=new i,this.closing=Promise.resolve(),this.running=!1,this.terminal=!1,this.epoch=0}reset(){this.epoch++,this.pending=void 0,this.queue.clear()}pause(){var t;this.reset(),this.running=!1,clearInterval(this.timer),this.timer=void 0,this.starting=void 0,null==(t=this.graph)||t.idle()}restart(){const t=this.running||Boolean(this.starting);if(this.pause(),this.graph&&!this.graph.ownsMediaConnection){const e=this.stop(),i=this.epoch;return e.then(()=>t&&!this.terminal&&this.epoch===i?this.start():void 0)}return t?this.start():Promise.resolve()}start(){if(this.terminal||this.running)return Promise.resolve();if(this.starting)return this.starting;const i=this.epoch,s=()=>!this.terminal&&this.epoch===i,n=(()=>t(this,null,function*(){var t;if(yield this.closing,!Line truncated
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";var t=(t,e,i)=>new Promise((s,n)=>{var r=t=>{try{a(i.next(t))}catch(e){n(e)}},o=t=>{try{a(i.throw(t))}catch(e){n(e)}},a=t=>t.done?s(t.value):Promise.resolve(t.value).then(r,o);a((i=i.apply(t,e)).next())});class e{constructor(t,e,i=!1){this.video=t,this.sampleRate=e,this.captureOnly=i,this.closed=!1,this.direct=!1,this.workletLoaded=!1}assertOpen(){if(this.closed)throw new Error("Audio capture was closed")}revokeWorklet(){void 0!==this.blobUrl&&(URL.revokeObjectURL(this.blobUrl),this.blobUrl=void 0)}initialize(){return t(this,null,function*(){var t;this.assertOpen();const e=window,i=window.AudioContext||e.webkitAudioContext;if(!i)throw new Error("AudioContext is not supported");const s=null!=(t=this.context)?t:this.context=new i({sampleRate:this.sampleRate});if("suspended"===s.state&&(yield s.resume()),this.assertOpen(),!this.workletLoaded){this.blobUrl=URL.createObjectURL(new Blob(["\nclass RecorderProcessor extends AudioWorkletProcessor {\n process(inputs) {\n const input = inputs[0];\n if (input && input[0]) {\n this.port.postMessage(input[0]);\n }\n return true;\n }\n}\nregisterProcessor('recorder-processor', RecorderProcessor);\n"],{type:"application/javascript"}));try{yield s.audioWorklet.addModule(this.blobUrl),this.workletLoaded=!0}finally{this.revokeWorklet()}}if(this.assertOpen(),!this.source){if(!this.captureOnly)try{this.source=s.createMediaElementSource(this.video),this.direct=!0}catch(n){console.warn("[artplayerPluginAsr] Direct connection failed:",n)}if(!this.source){const t=this.video.captureStream||this.video.mozCaptureStream;if(!t)throw new Error("Could not establish audio source");this.stream=t.call(this.video),this.source=s.createMediaStreamSource(this.stream)}}})}prepare(){return null!=this.preparation||(this.preparation=this.initialize().catch(t=>{throw this.preparation=void 0,t})),this.preparation}get ownsMediaConnection(){return this.direct}idle(){const t=this.gain;this.disconnect(),this.direct&&this.context&&this.source&&(this.gain=t||this.context.createGain(),t||(this.gain.gain.value=1),this.source.connect(this.gain),this.gain.connect(this.context.destination))}connect(t){this.assertOpen();const e=this.context,i=this.source;if(!e||!i)throw new Error("Audio capture is not prepared");const s=this.gain;this.disconnect();const n=this.gain=s||e.createGain();s||(n.gain.value=this.direct?1:0);const r=this.recorder=new AudioWorkletNode(e,"recorder-processor");r.port.onmessage=e=>{this.closed||this.recorder!==r||t(e.data)},i.connect(r),this.direct?i.connect(n):r.connect(n),n.connect(e.destination)}disconnect(){var t,e;this.recorder&&(this.recorder.port.onmessage=null,this.recorder.disconnect(),this.recorder=void 0),null==(t=this.gain)||t.disconnect(),this.gain=void 0,null==(e=this.source)||e.disconnect()}close(){return t(this,null,function*(){var t;if(this.closed)return;this.closed=!0,this.disconnect(),this.revokeWorklet(),null==(t=this.stream)||t.getTracks().forEach(t=>t.stop()),this.stream=void 0;const e=this.context;this.context=void 0,this.source=void 0,e&&"closed"!==e.state&&(yield e.close())})}}class i{constructor(){this.offset=0,this.size=0}get length(){return this.size}push(t){if(0===t.length)return;const e={samples:new Float32Array(t)};this.tail?this.tail.next=e:this.head=e,this.tail=e,this.size+=t.length}take(t){if(!Number.isSafeInteger(t)||t<=0)throw new RangeError("Sample count must be a positive safe integer");if(t>this.size)return null;const e=new Float32Array(t);let i=0;for(;this.head&&i<t;){const s=this.head.samples,n=Math.min(s.length-this.offset,t-i);e.set(s.subarray(this.offset,this.offset+n),i),i+=n,this.offset+=n,this.offset===s.length&&(this.head=this.head.next,this.offset=0)}return this.head||(this.tail=void 0),this.size-=t,e}clear(){this.head=void 0,this.tail=void 0,this.offset=0,this.size=0}}class s{constructor(t,e,s){this.video=t,this.options=e,this.append=s,this.queue=new i,this.closing=Promise.resolve(),this.running=!1,this.terminal=!1,this.epoch=0}reset(){this.epoch++,this.pending=void 0,this.queue.clear()}pause(){var t;this.reset(),this.running=!1,clearInterval(this.timer),this.timer=void 0,this.starting=void 0,null==(t=this.graph)||t.idle()}restart(){const t=this.running||Boolean(this.starting);if(this.pause(),this.graph&&!this.graph.ownsMediaConnection){const e=this.stop(),i=this.epoch;return e.then(()=>t&&!this.terminal&&this.epoch===i?this.start():void 0)}return t?this.start():Promise.resolve()}start(){if(this.terminal||this.running)return Promise.resolve();if(this.starting)return this.starting;const i=this.epoch,s=()=>!this.terminal&&this.epoch===i,n=(()=>t(this,null,function*(){var t;if(yield this.closing,!Line truncated
@@ -379,12 +379,14 @@ function artplayerPluginAsr(option = {}) {
art.off("play", play);
art.off("pause", pause);
art.off("restart", restart);
art.off("video:error", stop);
art.off("destroy", destroy);
return capture.stop(true);
};
art.on("play", play);
art.on("pause", pause);
art.on("restart", restart);
art.on("video:error", stop);
art.on("destroy", destroy);
return { name: "artplayerPluginAsr", stop, hide: subtitles.hide, append: subtitles.append };
};
@@ -24,12 +24,14 @@ export default function artplayerPluginAsr(option: AsrOptions = {}) {
art.off('play', play)
art.off('pause', pause)
art.off('restart', restart)
art.off('video:error', stop)
art.off('destroy', destroy)
return capture.stop(true)
}
art.on('play', play)
art.on('pause', pause)
art.on('restart', restart)
art.on('video:error', stop)
art.on('destroy', destroy)
return { name: 'artplayerPluginAsr', stop, hide: subtitles.hide, append: subtitles.append }
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,64 @@
# PKG-ASR-10 原生媒体错误取消与恢复
来源 HEAD:`3c89b48514e9fe02b4cac64a1630c50380c4c947`。从 PKG-ASR-05 的
不支持媒体验收中拆出本项独立缺陷修复,任务05新增对此项的依赖。
## 问题与修复
真实 PCM 进入调用方识别 Promise 后,通过公开 `art.video.src` 切换到 HTTP
JSON 内容,Chromium 发出 `video:emptied`、`video:error`(MediaError code 4),
但没有 pause。此前 ASR 只处理 play/pause/restart/destroy,仍运行原采集会话。
延迟识别完成会把失败媒体的过期字幕显示出来,显式捕获图也仍保持 running。
两种模式和新旧核心的四项原生复现全部失败于旧字幕断言。
修复在工厂注册处将 `video:error` 连接到既有 `capture.stop()` 路径,并在 destroy
时对称移除该监听。不另建状态机、不改变停止语义:错误会同步清空队列/定时器,
使 pending 识别和迟到 Worklet 消息失效;捕获模式关闭 Context/轨道,默认直接
绑定保留视频所需的原音频连接。已有字幕沿用原 auto-hide 期限,后续合法播放
可以复用或重新创建采集。不会取消调用方自己的识别网络请求。
这不是声称普通 `art.url` 切换必然有同一问题,也不是网络中断/所有解码错误的
完整证明。本轮原生错误来自不可解码 HTTP 内容;受控单测覆盖相同错误事件在
其他异步阶段的取消。正常参数、根及 `/runtime` 类型、返回对象、事件名、分发
入口保持不变;改变的是错误后不应继续显示旧识别结果的行为。
## 测试与构建
新增五项单元回归,旧代码全部失败、修复后全部通过:两种模式的 pending 识别
与迟到 port 消息、两种模式的 Worklet 加载中取消、延迟捕获关闭期间取消重启。
同时检查默认 Context 保留、捕获资源释放、正常再播放、既有字幕期限和销毁解绑。
原生 `asr-media-error.spec.js` 使用真实本地 AAC、AudioContext/Worklet、原生错误;
只有调用方识别 Promise 由测试控制。验证错误后旧结果不显示,随后切回 AAC
恢复至少三块非零 PCM 和正常字幕,销毁关闭所有 Context。安装轮包含真实旧核心
5.3.0、5.4.0 与候选核心,以及两种音频输入模式。
第一轮跨浏览器检查的三个 Firefox 失败发生在触发错误之前:测试挂起了首个
静音块,又等待被该 Promise 阻止的后续非零块。已修正为仅在首个非零 PCM
到达后挂起识别;保留失败报告、原 7 秒等待和非零 PCM 断言,不作为生产回归。
正常 `yarn build artplayer-plugin-asr` 生成 main/legacy/ESM 与 docs/compiled。
三个格式都与隔离安装快照逐字节相同。`run-gTeZ8R` 通过重新构建、Yarn pack、
仓库外安装/冻结复装及文件核验;通用 36 runtime、5 旧类型、8 精确类型仍只
属于 core/chapter,ASR 的运行证据来自本批专项与实际安装浏览器测试。
最终命令、版本、输入指纹、失败与通过结果见
[验证记录](../baselines/asr-media-error-validation.json)。Node 24.21.0、Yarn 1.22.22;
234 项 ASR 专项及 main/legacy 各43项生命周期回归通过,类型/lint/工具链与清单
检查通过。安装回归同时保留正常音量/静音、无音轨和跨域显式捕获用例。
Windows WebKit 无 WebAudio 的项目明确跳过,不以这些跳过关闭设备要求。
最终源码轮为 8 通过/4 能力跳过;加入 5.3.0 错误路径及正常回归的 installed
main/legacy 各 36 通过/18 能力跳过。合计 80 通过、40 跳过、0 失败、0 重试。
这与修复前四项全红及测试等待修正前的 5 通过/3 失败/4 跳过分别保存,不能
合并成一个全部成功的历史结果。Chromium 的媒体取消诊断仍保留在机器证据中。
## 维护与剩余
包内 README/ARCHITECTURE 解释新错误边界、监听所有权、重启和调用方请求职责。
`test:asr`、`test:unit` 与 installed CI 清单均纳入回归;远端 CI 仍待执行。
没有新增依赖或改锁,类型文件未改;最终各包大版本升级由既有发布任务承接。
本项不关闭 PKG-ASR-05 的其他不支持媒体、真实设备或分发门槛,也不改变默认
CORS 限制。回退需同时撤销错误监听/解绑、测试/文档并正常重建产物;这样会
重新引入已证明的过期识别问题。未推送、部署或发布。
+6 -4
View File
@@ -2,9 +2,9 @@
> 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 266 项,范围 22 个包及工作区/示例。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 267 项,范围 22 个包及工作区/示例。
状态:todo 43 / doing 21 / blocked 0 / done 202 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 43 / doing 21 / blocked 0 / done 203 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -17,7 +17,7 @@
| artplayer | 5.4.1 | CORE-01, CORE-02, CORE-03, CORE-04, CORE-05, CORE-06, CORE-07, CORE-08, CORE-09, CORE-10, CORE-11, CORE-12, CORE-13, CORE-14, CORE-15, CORE-16, CORE-17, CORE-18, CORE-19, CORE-20, CORE-21, CORE-23, CORE-22, CORE-24, SITE-BUILD-01, CORE-25, ENG-12, CORE-SUBTITLE-OFFSET-01, PKG-MULTI-SUB-09, ENG-LINT-02 |
| artplayer-plugin-ads | 2.1.0 | PKG-ADS-01, PKG-ADS-02, PKG-ADS-03, PKG-ADS-04, PKG-ADS-05, PKG-ADS-06 |
| artplayer-plugin-ambilight | 1.1.0 | PKG-AMBILIGHT-01, PKG-AMBILIGHT-02, PKG-AMBILIGHT-03, PKG-AMBILIGHT-04, PKG-AMBILIGHT-PROXY-01, PKG-AMBILIGHT-05, PKG-AMBILIGHT-06, PKG-FACTORY-01 |
| artplayer-plugin-asr | 2.1.0 | PKG-ASR-01, PKG-ASR-02, PKG-ASR-03, PKG-ASR-04, PKG-ASR-05, PKG-ASR-06, PKG-ASR-07, PKG-ASR-08, PKG-ASR-09 |
| artplayer-plugin-asr | 2.1.0 | PKG-ASR-01, PKG-ASR-02, PKG-ASR-03, PKG-ASR-04, PKG-ASR-05, PKG-ASR-06, PKG-ASR-07, PKG-ASR-08, PKG-ASR-09, PKG-ASR-10 |
| artplayer-plugin-audio-track | 1.1.0 | PKG-AUDIO-01, PKG-AUDIO-02, PKG-AUDIO-03, PKG-AUDIO-04, PKG-AUDIO-05, PKG-AUDIO-06 |
| artplayer-plugin-auto-thumbnail | 1.1.0 | PKG-AUTO-THUMB-01, PKG-AUTO-THUMB-02, PKG-AUTO-THUMB-03, PKG-AUTO-THUMB-04, PKG-AUTO-THUMB-05, PKG-AUTO-THUMB-06, PKG-AUTO-THUMB-07, PKG-AUTO-THUMB-08, PKG-AUTO-THUMB-09, PKG-AUTO-THUMB-10, PKG-AUTO-THUMB-11, PKG-AUTO-THUMB-12 |
| artplayer-plugin-chapter | 1.1.0 | PILOT-01, PKG-CHAPTER-01, PKG-CHAPTER-02, PKG-CHAPTER-03, PKG-CHAPTER-04, PKG-CHAPTER-05, PKG-CHAPTER-06, PKG-CHAPTER-HOVER-01 |
@@ -275,11 +275,12 @@
| PKG-ASR-02 | artplayer-plugin-asr<br>建立特有行为与错误测试 | PKG-ASR-01, ENG-03, ENG-05 | PCM/WAV、chunk 时序、回调慢/拒绝、重复初始化与停止 | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-ASR-03 | artplayer-plugin-asr<br>整理内部职责与资源 | PKG-ASR-02, CORE-10, CORE-18 | AudioContext/Worklet/Stream 生命周期和背压分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-ASR-04 | artplayer-plugin-asr<br>迁移自有源码和公开类型 | PKG-ASR-03, ENG-04, ENG-06, CORE-07 | 主线程/Worklet/音频 buffer 与异步回调类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | done |
| PKG-ASR-05 | artplayer-plugin-asr<br>验证新旧核心和组合 | PKG-ASR-04, CORE-22, PKG-ASR-07, PKG-ASR-08, PKG-ASR-09 | 真实 WebAudio 分块、播放暂停/切源/销毁,不引入网络 ASR | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | doing |
| PKG-ASR-05 | artplayer-plugin-asr<br>验证新旧核心和组合 | PKG-ASR-04, CORE-22, PKG-ASR-07, PKG-ASR-08, PKG-ASR-09, PKG-ASR-10 | 真实 WebAudio 分块、播放暂停/切源/销毁,不引入网络 ASR | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | doing |
| PKG-ASR-06 | artplayer-plugin-asr<br>验证分发并同步文档 | PKG-ASR-05, ENG-07 | asr.js、Worklet 资源、输出格式与声明证据 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
| PKG-ASR-07 | artplayer-plugin-asr<br>修复原生播放音量二次衰减 | PKG-ASR-04 | 移除重复音量应用;保留新旧原生音频振幅对照及正式产物验收 | Chromium/Firefox 两核心和 source/main/legacy 输出音量线性、静音和 stop/restart 正确;旧版平方衰减单独保留;单元/类型/真实安装通过,不代替设备验收 | H | done |
| PKG-ASR-08 | artplayer-plugin-asr<br>修复捕获回退重复播放与静音漏声 | PKG-ASR-07 | 捕获回退静音输出图、非零PCM与外部播放归属;真实红绿证据及产物验证 | 已进入回退后不产生额外输出、静音不漏声、外部播放图不被关闭、stop/restart/换源清理正确;明确Firefox受控拒绝不代表自然选择回退 | H | done |
| PKG-ASR-09 | artplayer-plugin-asr<br>提供显式捕获归属并取消过期切源重启 | PKG-ASR-04, PKG-ASR-08 | 保留默认API的capture模式、精确runtime类型、可取消重启和原生外部归属/CORS证据 | 显式模式零直接绑定、不增加输出、拥有资源正确清理;Firefox不强制异常,CORS无绕过;旧工厂类型保持;延迟关闭期间pause/stop取消旧重启 | H | done |
| PKG-ASR-10 | artplayer-plugin-asr<br>媒体错误取消过期识别并释放捕获资源 | PKG-ASR-09 | 原生媒体错误取消、延迟加载/关闭回归、安装产物恢复与维护文档 | 旧红新绿证明错误后旧识别不显示、无新采集;捕获图释放,直接图保留合法回放归属;新旧核心实际错误与恢复通过,销毁移除监听 | H | done |
## 5 包迁移:artplayer-plugin-ads
@@ -671,6 +672,7 @@
- PKG-ASR-07: [记录](changes/2026-09-13-PKG-ASR-07-playback-volume.md) [记录](baselines/asr-volume-validation.json)
- PKG-ASR-08: [记录](changes/2026-09-13-PKG-ASR-08-capture-output.md) [记录](baselines/asr-fallback-validation.json)
- PKG-ASR-09: [记录](changes/2026-09-13-PKG-ASR-09-explicit-capture.md) [记录](baselines/asr-explicit-capture-validation.json)
- PKG-ASR-10: [记录](changes/2026-09-15-PKG-ASR-10-media-error.md) [记录](baselines/asr-media-error-validation.json)
- PKG-DANMUKU-10: [记录](changes/2026-09-13-PKG-DANMUKU-10-heatmap-density.md) [记录](baselines/danmuku-heatmap-density-validation.json)
- PKG-DANMUKU-11: [记录](changes/2026-09-13-PKG-DANMUKU-11-visible-lifetime.md) [记录](baselines/danmuku-lifetime-validation.json)
- PKG-DANMUKU-12: [记录](changes/2026-09-13-PKG-DANMUKU-12-frame-sampling.md) [记录](baselines/danmuku-frame-sampling-validation.json)
+16
View File
@@ -1,5 +1,21 @@
# 进度与证据
## PKG-ASR-10 修复原生媒体错误后的过期识别
复现真实 `art.video.src` 不可解码输入产生 error 而没有 pause:旧识别结果仍显示、
捕获图继续运行。新增对 `video:error` 的停止/销毁解绑,复用现有取消、资源归属
与恢复逻辑。五项单测旧红新绿,错误中加载/延迟关闭、旧回调和恢复均覆盖。
234 ASR 专项、main/legacy 各43生命周期及6清单测试通过。源码/两份实际安装
产物共80浏览器通过、40 Windows WebKit WebAudio能力跳过;正常音量/静音、
无音轨、跨域及三种核心的错误恢复纳入回归。三种正式构建字节与独立安装一致。
初版 Firefox 等待首个静音块的测试死锁已修正并保留失败记录。
见[修复记录](changes/2026-09-15-PKG-ASR-10-media-error.md)和
[证据](baselines/asr-media-error-validation.json)。任务10完成,任务05继续doing;
203 done、21 doing、43 todo,共267项。类型/依赖/锁未改,远端CI/设备/发布复盘
仍待完成;未推送、部署或发布。
## PKG-ASR-05 无音轨视频与恢复
新增真实无音轨 H.264 → AAC 的默认/显式捕获测试:播放时钟与解码画面继续,
+1
View File
@@ -280,3 +280,4 @@
| CONSOLE-OWNERSHIP-01 | resolved / 已复现 | 多个控制台的全局 hook 所有权冲突 | SITE-CONSOLE-01, SITE-07 |
| CONSOLE-ERROR-01 | resolved / 已复现 | Firefox/WebKit 控制台丢失原生 Error 消息 | SITE-CONSOLE-01, SITE-07 |
| AUTO-THUMB-METADATA-01 | resolved / 已复现 | Private metadata loading can retain its decoder indefinitely without metadata or media errors | PKG-AUTO-THUMB-12 |
| ASR-MEDIA-01 | resolved / 已复现 | ASR accepts obsolete recognition after native media error without pause | PKG-ASR-10, PKG-ASR-05 |
+22
View File
@@ -6579,6 +6579,28 @@
"refactor/baselines/auto-thumbnail-metadata-validation.json"
],
"resolutionRationale": "Seven regressions fail on prechange main; source 189/189, main and legacy 82/82, native main and legacy 36/36 each. Final pending-response tests observe the HTTP server directly. Existing first-frame risk stays open."
},
{
"id": "ASR-MEDIA-01",
"title": "ASR accepts obsolete recognition after native media error without pause",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"PKG-ASR-10",
"PKG-ASR-05"
],
"evidence": [
"test/asr-media-error.test.js",
"test/browser/asr-media-error.spec.js",
"refactor/baselines/asr-media-error-validation.json"
],
"compatibleResolution": "Use the existing nonterminal stop path on video:error; preserve direct media ownership and later playback, release capture graphs, invalidate old callbacks and remove the listener on destroy.",
"closureCriteria": "Prepatch failures and fixed regressions prove pending-result and initialization/close cancellation, plus native errors and recovery on supported old/candidate core inputs; missing device/WebAudio evidence stays in task05.",
"resolutionEvidence": [
"refactor/changes/2026-09-15-PKG-ASR-10-media-error.md",
"refactor/baselines/asr-media-error-validation.json"
],
"resolutionRationale": "Five prepatch unit failures and four native Chromium failures become passing cancellation/recovery checks. Source and actual installed main/legacy pass, including minimum5.3.0 and5.4.0 core controls; default routing and captured cleanup remain intact. WebKit capability skips and broader package acceptance are not waived."
}
]
}
+21 -1
View File
@@ -2906,7 +2906,8 @@
"CORE-22",
"PKG-ASR-07",
"PKG-ASR-08",
"PKG-ASR-09"
"PKG-ASR-09",
"PKG-ASR-10"
],
"status": "doing",
"risk": "H",
@@ -5170,6 +5171,25 @@
"baselines/asr-explicit-capture-validation.json"
]
},
{
"id": "PKG-ASR-10",
"phase": "5 包迁移:artplayer-plugin-asr",
"title": "媒体错误取消过期识别并释放捕获资源",
"scope": [
"artplayer-plugin-asr"
],
"dependsOn": [
"PKG-ASR-09"
],
"status": "done",
"risk": "H",
"deliverable": "原生媒体错误取消、延迟加载/关闭回归、安装产物恢复与维护文档",
"acceptance": "旧红新绿证明错误后旧识别不显示、无新采集;捕获图释放,直接图保留合法回放归属;新旧核心实际错误与恢复通过,销毁移除监听",
"evidence": [
"changes/2026-09-15-PKG-ASR-10-media-error.md",
"baselines/asr-media-error-validation.json"
]
},
{
"id": "PKG-DANMUKU-10",
"phase": "5 包迁移:artplayer-plugin-danmuku",
+1
View File
@@ -42,6 +42,7 @@ export const installedTests = [
'asr-cors.spec.js',
'asr-capture-cors.spec.js',
'asr-no-audio.spec.js',
'asr-media-error.spec.js',
'asr-audio-track.spec.js',
'chromecast-lifecycle.spec.js',
'mediabunny-audio.spec.js',
+90
View File
@@ -0,0 +1,90 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Exercise cancellation after a native media error without depending on browser timing.
import test from 'node:test'
import { asrAudioEnvironment, deferredAudio } from './helpers/asr-audio.js'
import { compilePackage } from './helpers/load.js'
const artifact = process.env.ARTPLAYER_ASR_ARTIFACT
const implementation = { name: artifact || 'candidate-asr', code: artifact ? fs.readFileSync(artifact, 'utf8') : await compilePackage('artplayer-plugin-asr', 'umd') }
for (const capture of [false, true]) {
const option = capture ? { audioInput: { type: 'capture' } } : {}
test(`ASR media error cancels pending recognition and permits ${capture ? 'capture' : 'direct'} recovery`, async () => {
const pending = deferredAudio()
let calls = 0
const environment = asrAudioEnvironment(implementation, { ...option, sampleRate: 8000, interval: 1, onAudioChunk: () => ++calls === 1 ? pending.promise : 'Recovered' })
await environment.emit('play')
environment.send(new Float32Array(8).fill(0.5))
const recognizing = environment.tick()
assert.equal(calls, 1)
const oldMessage = environment.recorders[0].port.onmessage
await environment.emit('video:error')
assert.equal(environment.intervals.size, 0, 'Media error must stop collection even without a pause event')
assert.equal(environment.recorders[0].port.onmessage, null)
pending.resolve('Obsolete')
await recognizing
assert.equal(environment.layer.innerHTML, '')
assert.equal(environment.timeouts.size, 0)
assert.equal(environment.contexts[0].state, capture ? 'closed' : 'running')
assert.equal(environment.tracks[0].stops, capture ? 1 : 0)
oldMessage({ data: new Float32Array(8).fill(1) })
assert.equal(calls, 1)
await environment.emit('play')
assert.equal(environment.contexts.length, capture ? 2 : 1)
assert.equal(environment.intervals.size, 1)
environment.send(new Float32Array(8).fill(0.25))
await environment.tick()
assert.match(environment.layer.innerHTML, /Recovered/)
await environment.emit('destroy')
assert(environment.contexts.every(context => context.state === 'closed'))
assert.equal(environment.listeners.get('video:error').length, 0)
})
test(`ASR media error during ${capture ? 'capture' : 'direct'} worklet loading cancels late initialization`, async () => {
const moduleGate = deferredAudio()
const moduleEntered = deferredAudio()
const environment = asrAudioEnvironment(implementation, option, { moduleGate, moduleEntered })
const starting = environment.emit('play')
await moduleEntered.promise
await environment.emit('video:error')
moduleGate.resolve()
await starting
assert.equal(environment.contexts[0].state, 'closed')
assert.equal(environment.recorders.length, 0)
assert.equal(environment.nodes.filter(node => ['element', 'stream'].includes(node.kind)).length, 0)
assert.equal(environment.intervals.size, 0)
assert.equal(environment.blobs.size, 0)
assert.equal(environment.errors.length, 0)
await environment.emit('play')
assert.equal(environment.intervals.size, 1)
await environment.emit('destroy')
assert(environment.contexts.every(context => context.state === 'closed'))
})
}
test('ASR media error cancels a capture restart waiting for close without hiding an existing subtitle', async () => {
const environment = asrAudioEnvironment(implementation, { audioInput: { type: 'capture' } })
environment.plugin.append('Existing')
const timers = [...environment.timeouts.keys()]
await environment.emit('play')
const closing = deferredAudio()
const close = environment.contexts[0].close.bind(environment.contexts[0])
environment.contexts[0].close = async () => {
await close()
await closing.promise
}
const restarting = environment.emit('restart')
const failing = environment.emit('video:error')
closing.resolve()
await Promise.all([restarting, failing])
assert.equal(environment.captures(), 1)
assert.equal(environment.intervals.size, 0)
assert.match(environment.layer.innerHTML, /Existing/)
assert.deepEqual([...environment.timeouts.keys()], timers)
await environment.emit('destroy')
await environment.emit('video:error')
assert.equal(environment.timeouts.size, 0)
assert.equal(environment.contexts.length, 1)
})
+1
View File
@@ -21,6 +21,7 @@ test('Source invocation clears inherited artifact selection without changing cal
assert(installedTests.includes('ambilight-proxy.spec.js'))
assert(installedTests.includes('canvas-lifecycle.spec.js'))
assert(installedTests.includes('asr-no-audio.spec.js'))
assert(installedTests.includes('asr-media-error.spec.js'))
for (const file of installedTests)
assert(fs.existsSync(path.resolve('test/browser', file)))
})
+104
View File
@@ -0,0 +1,104 @@
import process from 'node:process'
import { browserCandidate } from '../helpers/browser-candidate.js'
import { expect, test } from './fixtures.js'
let candidate
test.beforeAll(async () => {
candidate = await browserCandidate('artplayer-plugin-asr', process.env.ARTPLAYER_ASR_ARTIFACT)
})
test.afterEach(async ({ page }, testInfo) => {
const state = await page.evaluate(() => window.mediaErrorState?.()).catch(error => ({ error: error.message }))
await testInfo.attach('asr-media-error-final', { contentType: 'application/json', body: JSON.stringify(state ?? null) })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
})
for (const core of ['published-5.3.0', 'published', 'candidate']) {
for (const mode of ['default', 'capture']) {
test(`ASR ${mode} + ${core}: native media error invalidates pending recognition and permits recovery`, async ({ page }, testInfo) => {
await page.goto(`/test/player.html?core=${core}`)
const capabilities = await page.evaluate(() => ({ context: typeof (window.AudioContext || window.webkitAudioContext), worklet: typeof window.AudioWorkletNode }))
await testInfo.attach('asr-media-error-inputs', { contentType: 'application/json', body: JSON.stringify({ core, mode, capabilities, provenance: candidate.provenance, recognizer: 'Controlled pending caller Promise; PCM and media errors are native', errorSource: '/test/manifest.json', mediaAssignment: 'public art.video.src' }) })
if (capabilities.context === 'undefined') {
expect(process.platform).toBe('win32')
expect(testInfo.project.name).toBe('webkit')
expect(capabilities.worklet).toBe('undefined')
test.skip(true, 'Windows WebKit lacks native WebAudio; not Safari/device ASR acceptance')
}
await page.addScriptTag({ content: candidate.code })
await page.evaluate((mode) => {
const Context = window.AudioContext || window.webkitAudioContext
const contexts = []
const events = []
const chunks = []
window.AudioContext = class extends Context {
constructor(options) {
super(options)
contexts.push(this)
}
}
window.art = new window.Artplayer({
container: '.player',
url: '/test/audio-tone.m4a',
volume: 0.6,
plugins: [window.artplayerPluginAsr({
...(mode === 'capture' ? { audioInput: { type: 'capture' } } : {}),
onAudioChunk({ pcm }) {
let peak = 0
for (const sample of new Int16Array(pcm))
peak = Math.max(peak, Math.abs(sample))
chunks.push({ peak, source: window.art.video.currentSrc })
if (!window.completeRecognition) {
if (peak > 0)
return new Promise(resolve => window.completeRecognition = resolve)
return
}
return 'Recovered recognition'
},
})],
})
for (const name of ['play', 'pause', 'restart', 'video:emptied', 'video:error'])
window.art.on(name, () => events.push({ name, at: performance.now(), error: window.art.video.error?.code || null }))
window.mediaErrorState = () => ({
contexts: contexts.map(context => context.state),
events,
chunks,
subtitle: document.querySelector('.art-layer-asr')?.textContent || '',
error: window.art.video.error?.code || null,
source: window.art.video.currentSrc,
paused: window.art.video.paused,
})
document.querySelector('#play').onclick = () => window.art.play()
}, mode)
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.mediaErrorState().chunks.some(chunk => chunk.peak > 0))).toBe(true)
await page.evaluate(() => {
window.art.video.src = '/test/manifest.json?asr-media-error=unsupported'
})
await expect.poll(() => page.evaluate(() => window.mediaErrorState().error)).toBe(4)
await page.evaluate(async () => {
window.completeRecognition('Obsolete recognition')
await Promise.resolve()
await Promise.resolve()
})
const failed = await page.evaluate(() => window.mediaErrorState())
await testInfo.attach('asr-native-media-failure', { contentType: 'application/json', body: JSON.stringify(failed) })
expect(failed.subtitle).not.toContain('Obsolete recognition')
if (mode === 'capture')
await expect.poll(() => page.evaluate(() => window.mediaErrorState().contexts.every(state => state === 'closed'))).toBe(true)
await page.evaluate(() => {
window.art.url = '/test/audio-tone.m4a?asr-media-error=recovery'
})
await expect.poll(() => page.evaluate(() => window.art.video.currentSrc.includes('asr-media-error=recovery') && window.art.video.readyState >= 2)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.mediaErrorState().chunks.filter(chunk => chunk.source.includes('asr-media-error=recovery') && chunk.peak > 0).length)).toBeGreaterThanOrEqual(3)
await expect(page.locator('.art-layer-asr')).toHaveText('Recovered recognition')
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.mediaErrorState().contexts.every(state => state === 'closed'))).toBe(true)
})
}
}