feat(asr): [PKG-ASR-09] add explicit capture ownership and cancel stale restarts

This commit is contained in:
Harvey Zhao committed 2026-09-13 16:19:49 +08:00
1 parent 5ab7da6e1f
commit 146b78d576
27 files changed
+12951 -64

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){this.video=t,this.sampleRate=e,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)try{this.source=i.createMediaElementSource(this.video),this.direct=!0}catch(s){console.warn("[artplayerPluginAsr] Direct connection failed:",s);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);return this.pause(),this.graph&&!this.graph.ownsMediaConnection?this.stop().then(()=>t?this.start():void 0):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));await n.prepare(),i()?(n.connect(t=>{if(this.running&&this.graph===n)return this.queue.length+t.length>Math.max(60*e,2*r)?(console.error("[artplayerPluginAsr] Audio callback backlog exceeded its capture limit"),void this.pause()):void this.queue.pushLine 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){this.video=t,this.sampleRate=e,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)try{this.source=s.createMediaElementSource(this.video),this.direct=!0}catch(n){console.warn("[artplayerPluginAsr] Direct connection failed:",n);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);return this.pause(),this.graph&&!this.graph.ownsMediaConnection?this.stop().then(()=>t?this.start():void 0):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,!s())return;const{sampleRate:i,interval:n}=this.options,r=Math.floor(i*n/1e3);if(!Number.isSafeInteger(r)||r<1||!Number.isFinite(n)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
+22 -11
View File
@@ -17,9 +17,10 @@ class RecorderProcessor extends AudioWorkletProcessor {
registerProcessor('recorder-processor', RecorderProcessor);
`;
class AudioGraph {
constructor(video, sampleRate) {
constructor(video, sampleRate, captureOnly = false) {
this.video = video;
this.sampleRate = sampleRate;
this.captureOnly = captureOnly;
this.closed = false;
this.direct = false;
this.workletLoaded = false;
@@ -55,11 +56,15 @@ class AudioGraph {
}
this.assertOpen();
if (!this.source) {
try {
this.source = context.createMediaElementSource(this.video);
this.direct = true;
} catch (error) {
console.warn("[artplayerPluginAsr] Direct connection failed:", error);
if (!this.captureOnly) {
try {
this.source = context.createMediaElementSource(this.video);
this.direct = true;
} catch (error) {
console.warn("[artplayerPluginAsr] Direct connection failed:", error);
}
}
if (!this.source) {
const capture = this.video.captureStream || this.video.mozCaptureStream;
if (!capture)
throw new Error("Could not establish audio source");
@@ -241,8 +246,11 @@ class Capture {
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);
if (this.graph && !this.graph.ownsMediaConnection) {
const stopping = this.stop();
const epoch = this.epoch;
return stopping.then(() => active && !this.terminal && this.epoch === epoch ? this.start() : void 0);
}
return active ? this.start() : Promise.resolve();
}
start() {
@@ -260,7 +268,7 @@ class Capture {
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));
const graph = this.graph ?? (this.graph = new AudioGraph(this.video, sampleRate, this.options.captureOnly));
await graph.prepare();
if (!current()) {
if (!this.running && this.graph === graph)
@@ -352,13 +360,16 @@ function createSubtitles(layer, length, autoHideTimeout) {
};
}
function artplayerPluginAsr(option = {}) {
const { length = 3, interval = 100, sampleRate = 16e3, autoHideTimeout = 1e4, onAudioChunk = () => null } = option;
const { length = 3, interval = 100, sampleRate = 16e3, autoHideTimeout = 1e4, onAudioChunk = () => null, audioInput } = option;
if (audioInput !== void 0 && audioInput.type !== "capture")
throw new TypeError("Unsupported ASR audio input");
const captureOnly = audioInput?.type === "capture";
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 capture = new Capture(art.video, { interval, sampleRate, onAudioChunk, captureOnly }, subtitles.append);
const play = () => capture.start();
const pause = () => capture.pause();
const restart = () => capture.restart();
+2 -2
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/asr-local-example.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/asr-explicit-capture.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,7 +94,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: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": "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-types-package": "node refactor/scripts/asr-package-types.mjs",
"test:asr-demo": "node refactor/scripts/asr-local-demo.mjs"
},
@@ -70,6 +70,18 @@ This lifetime follows the [Web Audio close semantics](https://www.w3.org/TR/weba
and [media-element source behavior](https://www.w3.org/TR/webaudio-1.0/#MediaElementAudioSourceNode).
Captured-stream source changes follow [Media Capture from DOM Elements](https://www.w3.org/TR/mediacapture-fromelement/#html-media-element-media-capture-extensions).
The optional `audioInput: { type: 'capture' }` snapshots a capture-only strategy at
factory creation. It skips direct binding entirely and reuses the silent capture
route, with no fallback to media ownership after capture failure. This supports
external owners on Firefox without pretending that default duplicate ownership
is detected automatically. Captured PCM ignores element mute/volume and does not
include processing done in another graph. The browser's CORS checks still apply.
A captured-source restart snapshots the epoch after stopping its previous graph.
When closing settles, a later pause/stop/restart/destroy must invalidate that
continuation. Otherwise a paused player could silently resume recognition. Keep
the delayed-close regressions when changing this state machine.
Queued audio is limited to the larger of 60 seconds or two configured chunks. A caller that
cannot keep up gets an explicit console error and capture pauses, leaving normal
playback routed. This avoids an unbounded backlog; it does not claim lossless
@@ -112,6 +124,11 @@ From the repository root:
- `yarn test:browser test/browser/asr-fallback.spec.js`: native capture/Worklet
audio with an external media owner, silent ASR output even while muted, stop,
fresh tracks after restart/source change and independent context cleanup.
Explicit capture cases must have zero direct calls and no forced exception on
Firefox; only default fallback coverage uses controlled rejection there.
- `yarn test:browser test/browser/asr-capture-cors.spec.js`: explicit capture
respects native cross-origin restrictions without changing media properties or
binding its playback route, then recovers on same-origin source change.
- `yarn test:browser test/browser/asr-cors.spec.js`: CORS-authorized media,
opaque direct/redirected media, native zero-output or absent-chunk observations,
and restoration after switching to a same-origin source. Default routing keeps
+30
View File
@@ -52,6 +52,36 @@ independent `audio` element is not mixed into recognition. Muting the main video
silences its default ASR input even if a consumer separately unmutes that audio
element. Both plugins keep their existing volume and playback APIs.
## Existing audio graphs
When another library already routes the main video through Web Audio, explicitly
select capture mode to avoid taking ownership of its playback connection:
```ts
import asr from 'artplayer-plugin-asr/runtime'
const plugin = asr({ audioInput: { type: 'capture' }, onAudioChunk: ({ pcm }) => {
// Consume main-media PCM without adding a second speaker route.
void pcm
} })
```
Omitting `audioInput` keeps the existing direct-first behavior. Explicit capture
uses `captureStream`/`mozCaptureStream` and never falls back to direct binding,
including on failure. It owns its captured tracks and Context, not the other
library's playback graph. Pause retains that capture graph; stop and source
changes release it, and subsequent play obtains a new stream. An unsupported
capture source reports an initialization error and releases acquired resources.
Captured PCM is independent of the media element's volume and mute. It is the
main media stream, not the output of another library's effects or an independent
audio-track element. CORS restrictions still apply: Chromium rejects restricted
capture; Firefox may expose a track without delivering PCM. ASR leaves the
original media route alone in either case. This mode does not automatically
detect external owners or repair the default mode's restricted-media silence.
For TypeScript, this additive option is exposed through `/runtime`; historical
root and `/legacy` option declarations remain unchanged.
## Maintenance
See [ARCHITECTURE.md](ARCHITECTURE.md) for the current audio flow, public behavior,
+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){this.video=t,this.sampleRate=e,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)try{this.source=i.createMediaElementSource(this.video),this.direct=!0}catch(s){console.warn("[artplayerPluginAsr] Direct connection failed:",s);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);return this.pause(),this.graph&&!this.graph.ownsMediaConnection?this.stop().then(()=>t?this.start():void 0):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));await n.prepare(),i()?(n.connect(t=>{if(this.running&&this.graph===n)return this.queue.length+t.length>Math.max(60*e,2*r)?(console.error("[artplayerPluginAsr] Audio callback backlog exceeded its capture limit"),void this.pause()):void this.queue.pushLine 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){this.video=t,this.sampleRate=e,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)try{this.source=s.createMediaElementSource(this.video),this.direct=!0}catch(n){console.warn("[artplayerPluginAsr] Direct connection failed:",n);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);return this.pause(),this.graph&&!this.graph.ownsMediaConnection?this.stop().then(()=>t?this.start():void 0):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,!s())return;const{sampleRate:i,interval:n}=this.options,r=Math.floor(i*n/1e3);if(!Number.isSafeInteger(r)||r<1||!Number.isFinite(n)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
+22 -11
View File
@@ -17,9 +17,10 @@ class RecorderProcessor extends AudioWorkletProcessor {
registerProcessor('recorder-processor', RecorderProcessor);
`;
class AudioGraph {
constructor(video, sampleRate) {
constructor(video, sampleRate, captureOnly = false) {
this.video = video;
this.sampleRate = sampleRate;
this.captureOnly = captureOnly;
this.closed = false;
this.direct = false;
this.workletLoaded = false;
@@ -55,11 +56,15 @@ class AudioGraph {
}
this.assertOpen();
if (!this.source) {
try {
this.source = context.createMediaElementSource(this.video);
this.direct = true;
} catch (error) {
console.warn("[artplayerPluginAsr] Direct connection failed:", error);
if (!this.captureOnly) {
try {
this.source = context.createMediaElementSource(this.video);
this.direct = true;
} catch (error) {
console.warn("[artplayerPluginAsr] Direct connection failed:", error);
}
}
if (!this.source) {
const capture = this.video.captureStream || this.video.mozCaptureStream;
if (!capture)
throw new Error("Could not establish audio source");
@@ -241,8 +246,11 @@ class Capture {
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);
if (this.graph && !this.graph.ownsMediaConnection) {
const stopping = this.stop();
const epoch = this.epoch;
return stopping.then(() => active && !this.terminal && this.epoch === epoch ? this.start() : void 0);
}
return active ? this.start() : Promise.resolve();
}
start() {
@@ -260,7 +268,7 @@ class Capture {
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));
const graph = this.graph ?? (this.graph = new AudioGraph(this.video, sampleRate, this.options.captureOnly));
await graph.prepare();
if (!current()) {
if (!this.running && this.graph === graph)
@@ -352,13 +360,16 @@ function createSubtitles(layer, length, autoHideTimeout) {
};
}
function artplayerPluginAsr(option = {}) {
const { length = 3, interval = 100, sampleRate = 16e3, autoHideTimeout = 1e4, onAudioChunk = () => null } = option;
const { length = 3, interval = 100, sampleRate = 16e3, autoHideTimeout = 1e4, onAudioChunk = () => null, audioInput } = option;
if (audioInput !== void 0 && audioInput.type !== "capture")
throw new TypeError("Unsupported ASR audio input");
const captureOnly = audioInput?.type === "capture";
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 capture = new Capture(art.video, { interval, sampleRate, onAudioChunk, captureOnly }, subtitles.append);
const play = () => capture.start();
const pause = () => capture.pause();
const restart = () => capture.restart();
@@ -13,7 +13,7 @@ export class AudioGraph {
private direct = false
private workletLoaded = false
constructor(private readonly video: CaptureVideo, private readonly sampleRate: number) {}
constructor(private readonly video: CaptureVideo, private readonly sampleRate: number, private readonly captureOnly = false) {}
private assertOpen() {
if (this.closed)
@@ -49,12 +49,16 @@ export class AudioGraph {
}
this.assertOpen()
if (!this.source) {
try {
this.source = context.createMediaElementSource(this.video)
this.direct = true
if (!this.captureOnly) {
try {
this.source = context.createMediaElementSource(this.video)
this.direct = true
}
catch (error) {
console.warn('[artplayerPluginAsr] Direct connection failed:', error)
}
}
catch (error) {
console.warn('[artplayerPluginAsr] Direct connection failed:', error)
if (!this.source) {
const capture = this.video.captureStream || this.video.mozCaptureStream
if (!capture)
throw new Error('Could not establish audio source')
+6 -3
View File
@@ -34,8 +34,11 @@ export class Capture {
restart() {
const active = this.running || Boolean(this.starting)
this.pause()
if (this.graph && !this.graph.ownsMediaConnection)
return this.stop().then(() => active ? this.start() : undefined)
if (this.graph && !this.graph.ownsMediaConnection) {
const stopping = this.stop()
const epoch = this.epoch
return stopping.then(() => active && !this.terminal && this.epoch === epoch ? this.start() : undefined)
}
return active ? this.start() : Promise.resolve()
}
@@ -54,7 +57,7 @@ export class Capture {
const count = Math.floor(sampleRate * interval / 1000)
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 ??= new AudioGraph(this.video, sampleRate)
const graph = this.graph ??= new AudioGraph(this.video, sampleRate, this.options.captureOnly)
await graph.prepare()
if (!current()) {
if (!this.running && this.graph === graph)
+5 -2
View File
@@ -5,13 +5,16 @@ import style from './style.less?inline'
import { createSubtitles } from './subtitles'
export default function artplayerPluginAsr(option: AsrOptions = {}) {
const { length = 3, interval = 100, sampleRate = 16000, autoHideTimeout = 10000, onAudioChunk = () => null } = option
const { length = 3, interval = 100, sampleRate = 16000, autoHideTimeout = 10000, onAudioChunk = () => null, audioInput } = option
if (audioInput !== undefined && audioInput.type !== 'capture')
throw new TypeError('Unsupported ASR audio input')
const captureOnly = audioInput?.type === 'capture'
return (art: Artplayer): AsrResult => {
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 capture = new Capture(art.video, { interval, sampleRate, onAudioChunk, captureOnly }, subtitles.append)
const play = () => capture.start()
const pause = () => capture.pause()
const restart = () => capture.restart()
@@ -4,6 +4,7 @@ export interface AudioChunk {
}
export interface AsrOptions {
audioInput?: { type: 'capture' }
length?: number
interval?: number
sampleRate?: number
@@ -24,6 +25,7 @@ export interface CaptureVideo extends HTMLVideoElement {
}
export interface CaptureOptions {
captureOnly?: boolean
interval: number
sampleRate: number
onAudioChunk: (chunk: AudioChunk) => unknown
@@ -25,6 +25,8 @@ export type Factory = (option?: AsrPluginOption) => (art: Artplayer) => AsrPlugi
/** Accurate asynchronous view available through the /runtime entry. */
export interface RuntimeOption extends Omit<AsrPluginOption, 'onAudioChunk'> {
/** Capture the media stream without taking ownership of its playback route. */
audioInput?: { type: 'capture' }
onAudioChunk?: (chunk: AudioChunk) => string | void | null | Promise<string | void | null>
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,45 @@
# PKG-ASR-09 显式捕获归属与可取消重启
原默认 direct-first 在 Firefox 已有外部音频图时仍可第二次绑定。新增可选
`audioInput: { type: 'capture' }`,不猜测浏览器归属,不用 URL 推断 CORS,完全跳过
createMediaElementSource。捕获失败也不回退到直接绑定,不影响原播放图。缺省调用
继续旧路径。新字段放 RuntimeOption,旧根/legacy 类型精确形状与四个命名运行类型
保持;这是公开配置的加法,不改变已有字段、事件或返回对象。
## 生产实现
工厂把策略转为内部 primitive 快照,外部后续修改嵌套描述不会切换模式。AudioGraph
复用 ASR-08 的 recorder -> gain(0) 静音输出,stop/换源释放自己捕获的轨道和上下文,
pause 保留以便恢复,destroy 最终清理。采集不改主媒体属性,不拥有外部播放图。
未知策略在工厂构造时报错;现有媒体不支持捕获则按初始化失败路径释放资源。
子代理复核又复现延迟 Context.close 的旧竞态:restart 等待期间用户 pause/stop,
旧 continuation 仍重启。先红两例后添加 stop 后 epoch 守卫,后来生命周期操作会
取消旧重启,下一次用户 play 仍有效。此修复同样保护自动 fallback。
## 明确边界
捕获 PCM 不跟随 element 的 mute/volume;它既不混入独立 audio-track,也不包含其他
图的音效处理。不得因此把默认路径改成 capture-first。依据见
[媒体捕获语义](https://www.w3.org/TR/mediacapture-fromelement/#html-media-element-media-capture-extensions)。
显式模式对无授权跨源源:Chromium 原生 SecurityError 后释放自己的 Context;Firefox
可返回 live track 但不提供 PCM。两者零直接绑定、媒体属性不变、原时钟继续。随后
同源切换恢复有效 PCM,stop 与 destroy 清理。没有 CORS 绕过、额外下载或代理。
默认模式跨源限制仍见 [Web Audio](https://www.w3.org/TR/webaudio-1.0/#MediaElementAudioSourceNode-security)。
ASR-CORS-01 保持默认限制记录,不以显式模式测试冒充自动修复。物理扬声器未测。
## 验证
见 [最终验证证据](../baselines/asr-explicit-capture-validation.json)。新增候选13项
包括模式选择、失败清理、两种API、静音图、暂停/换源/stop/销毁、嵌套快照和两项
延迟关闭竞态。最初9项策略失败及后来2项竞态失败分开冻结,历史基线不改写。
类型fixture新增6个拒绝用法,安装矩阵的负例数量断言从12同步到18;首次矩阵仅因
这个旧计数断言失败的报告保留,不能写成旧运行已通过。
最终229项定向通过,main/legacy各43项生命周期通过;原生source/main/legacy共106项
通过、53项能力跳过。正常构建、根/分包严格类型、lint和17配置隔离安装通过。
真实外部 owner 用例包含 Firefox 自然执行、零直接绑定、无强制异常;非零采集、
静音ASR输出、stop/restart/换源与独立清理均已验证。Windows WebKit 无WebAudio明确
跳过;ASR-05 的设备、无音轨/其他不支持媒体及ASR-06发行仍待完成。没有推送、
标签或 npm 发布。
+7 -5
View File
@@ -2,9 +2,9 @@
> 由 tasks.json 生成。请修改数据后运行 `node refactor/scripts/plan.mjs --write`,不要手改本表。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 227 项,范围 22 个包及工作区/示例。
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 228 项,范围 22 个包及工作区/示例。
状态:todo 81 / doing 17 / blocked 0 / done 129 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 81 / doing 17 / blocked 0 / done 130 / 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, CORE-25, ENG-12 |
| 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 |
| 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-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 |
| 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 |
@@ -255,10 +255,11 @@
| 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 | 真实 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 | 真实 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 |
## 5 包迁移:artplayer-plugin-ads
@@ -532,7 +533,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) [记录](baselines/asr-core.json) [记录](baselines/asr-local-core-validation.json) [记录](changes/2026-09-13-PKG-ASR-05-local-core-checkpoint.md) [记录](baselines/asr-combinations-validation.json) [记录](changes/2026-09-13-PKG-ASR-05-cors-combinations.md)
- 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) [记录](baselines/asr-combinations-validation.json) [记录](changes/2026-09-13-PKG-ASR-05-cors-combinations.md) [记录](baselines/asr-explicit-capture-validation.json)
- 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)
@@ -573,3 +574,4 @@
- ENG-12: [记录](changes/2026-09-13-ENG-12-library-public.md) [记录](baselines/library-public-validation.json)
- 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)
+12
View File
@@ -1,5 +1,17 @@
# 进度与证据
## PKG-ASR-09 显式捕获归属完成
新增可选capture策略,旧默认与根类型保留;Firefox无需强制异常,显式模式零直接
绑定、非零PCM、零附加输出,外部Context保持独立。无CORS源遵循原生限制且不
接管播放,同源切换恢复。独立审查发现的延迟close后过期重启已用两项红绿测试修复。
229定向、main/legacy各43项通过;最终source/main/legacy原生106过、53能力跳过;
正常构建、strict/lint、隔离安装17配置及每候选18个负例通过。见[变更](changes/2026-09-13-PKG-ASR-09-explicit-capture.md)
与[证据](baselines/asr-explicit-capture-validation.json)。外部归属按显式模式闭环,默认
CORS静音仍open,不声称自动检测或物理设备已完成。228项:130 done、17 doing、
81 todo;210风险。ASR-05继续,完整发行和整仓重构仍有剩余任务;无推送或发布。
## PKG-ASR-05 CORS 与独立音轨检查点
新增跨源允许/不允许/重定向及同源恢复、audio-track组合生命周期与独立音频归属
+2 -1
View File
@@ -211,5 +211,6 @@
| ASR-LIFE-01 | resolved / 已复现 | ASR partial initialization, pause resume and stale callback cleanup need controlled evidence | PKG-ASR-02, PKG-ASR-03, PKG-ASR-05 |
| ASR-VOLUME-01 | resolved / 已复现 | ASR applies media volume twice and squares playback amplitude | PKG-ASR-07 |
| ASR-FALLBACK-01 | resolved / 已复现 | Captured fallback produces a second audible route even when the video is muted | PKG-ASR-08 |
| ASR-FOREIGN-01 | open / 已复现 | Firefox permits parallel native media-element owners instead of selecting capture fallback | PKG-ASR-05 |
| ASR-FOREIGN-01 | resolved / 已复现 | Firefox permits parallel native media-element owners instead of selecting capture fallback | PKG-ASR-05, PKG-ASR-09 |
| ASR-CORS-01 | open / 已复现 | Default WebAudio binding silences CORS-cross-origin media that otherwise advances normally | PKG-ASR-05 |
| ASR-RESTART-01 | resolved / 已复现 | Delayed captured-source restart resumes recognition after a newer pause or stop | PKG-ASR-09 |
+31 -4
View File
@@ -4662,16 +4662,22 @@
"id": "ASR-FOREIGN-01",
"title": "Firefox permits parallel native media-element owners instead of selecting capture fallback",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-ASR-05"
"PKG-ASR-05",
"PKG-ASR-09"
],
"evidence": [
"refactor/baselines/asr-fallback-validation.json",
"refactor/changes/2026-09-13-PKG-ASR-08-capture-output.md"
],
"compatibleResolution": "Establish an explicit compatible ownership contract for external audio graphs without silently changing default direct-path PCM/mute semantics.",
"closureCriteria": "Natural Firefox external-owner integration has no duplicate output or has a documented, validated explicit ownership mode; controlled binding rejection alone cannot close this finding."
"closureCriteria": "Natural Firefox external-owner integration has no duplicate output or has a documented, validated explicit ownership mode; controlled binding rejection alone cannot close this finding.",
"resolutionEvidence": [
"refactor/baselines/asr-explicit-capture-validation.json",
"refactor/changes/2026-09-13-PKG-ASR-09-explicit-capture.md"
],
"resolutionRationale": "The documented explicit capture mode satisfies the ownership-mode closure branch: native Firefox never calls direct binding and keeps the external route independent. This is not automatic default-mode owner detection."
},
{
"id": "ASR-CORS-01",
@@ -4683,10 +4689,31 @@
],
"evidence": [
"test/browser/asr-cors.spec.js",
"refactor/baselines/asr-combinations-validation.json"
"refactor/baselines/asr-combinations-validation.json",
"refactor/baselines/asr-explicit-capture-validation.json"
],
"compatibleResolution": "Document required CORS setup and provide an explicitly chosen capture path that never takes over media playback; do not bypass browser security or silently change default PCM semantics.",
"closureCriteria": "Authorized capture and same-origin recovery remain valid; an explicit integration avoids direct ownership for restricted input, fails or stays silent without rerouting playback, and is documented with native evidence."
},
{
"id": "ASR-RESTART-01",
"title": "Delayed captured-source restart resumes recognition after a newer pause or stop",
"confirmation": "reproduced",
"status": "resolved",
"owners": [
"PKG-ASR-09"
],
"evidence": [
"test/asr-explicit-capture.test.js",
"refactor/baselines/asr-explicit-capture-validation.json"
],
"compatibleResolution": "Invalidate stale restart continuation using the lifecycle epoch while allowing the next explicit play.",
"closureCriteria": "Pause and stop during delayed context close prevent a new graph and timer; later play works normally.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-ASR-09-explicit-capture.md",
"refactor/baselines/asr-explicit-capture-validation.json"
],
"resolutionRationale": "Two delayed-close red tests now pass in source/main/legacy, with subsequent play verified; native restart and default regressions pass."
}
]
}
+1 -1
View File
@@ -154,7 +154,7 @@ Promise.all(['artplayer-plugin-asr', 'artplayer-plugin-asr/runtime', 'artplayer-
if (result.plugin === 'candidate') {
const publishedBaseline = result.mode === 'nodenext-esm' ? expected.cases.find(item => item.plugin === 'published-2.1.0' && item.compiler === result.compiler && item.mode === result.mode).diagnostics : []
assert.deepEqual(result.published, publishedBaseline, `Historical direct consumer: ${result.compiler} ${result.mode}`)
assert.equal(result.invalid.length, 12, 'Installed declarations must reject all 12 invalid uses')
assert.equal(result.invalid.length, 18, 'Installed declarations must reject all 18 invalid uses')
}
}
console.log(`ASR installed matrix verified: ${matrix.length} cases; ${output}`)
+24 -2
View File
@@ -2703,7 +2703,8 @@
"PKG-ASR-04",
"CORE-22",
"PKG-ASR-07",
"PKG-ASR-08"
"PKG-ASR-08",
"PKG-ASR-09"
],
"status": "doing",
"risk": "H",
@@ -2716,7 +2717,8 @@
"baselines/asr-local-core-validation.json",
"changes/2026-09-13-PKG-ASR-05-local-core-checkpoint.md",
"baselines/asr-combinations-validation.json",
"changes/2026-09-13-PKG-ASR-05-cors-combinations.md"
"changes/2026-09-13-PKG-ASR-05-cors-combinations.md",
"baselines/asr-explicit-capture-validation.json"
]
},
{
@@ -4584,6 +4586,26 @@
"changes/2026-09-13-PKG-ASR-08-capture-output.md",
"baselines/asr-fallback-validation.json"
]
},
{
"id": "PKG-ASR-09",
"phase": "5 包迁移:artplayer-plugin-asr",
"title": "提供显式捕获归属并取消过期切源重启",
"scope": [
"artplayer-plugin-asr"
],
"dependsOn": [
"PKG-ASR-04",
"PKG-ASR-08"
],
"status": "done",
"risk": "H",
"deliverable": "保留默认API的capture模式、精确runtime类型、可取消重启和原生外部归属/CORS证据",
"acceptance": "显式模式零直接绑定、不增加输出、拥有资源正确清理;Firefox不强制异常,CORS无绕过;旧工厂类型保持;延迟关闭期间pause/stop取消旧重启",
"evidence": [
"changes/2026-09-13-PKG-ASR-09-explicit-capture.md",
"baselines/asr-explicit-capture-validation.json"
]
}
]
}
+1 -1
View File
@@ -1245,7 +1245,7 @@
"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": "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.",
"validationStatus": "Native PCM/Worklet, volume, CORS and lifecycle verified on Chromium/Firefox with candidate and published cores. Explicit capture has native Firefox zero-binding external ownership; default fallback still uses controlled rejection there. Offline demo remains local audio only. Default CORS silence, Windows WebKit, physical devices and the external recognition service remain unverified or restricted.",
"demoPathsSource": "refactor/baselines/demo-inventory.json"
},
{
+240
View File
@@ -0,0 +1,240 @@
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 -- Explicit ASR capture uses the repository runner.
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'),
}
function create(option = {}, capabilities = {}) {
const chunks = []
const environment = asrAudioEnvironment(implementation, {
sampleRate: 8000,
interval: 1,
audioInput: { type: 'capture' },
onAudioChunk(chunk) { chunks.push(chunk) },
...option,
}, capabilities)
const prototype = environment.context.AudioContext.prototype
const direct = prototype.createMediaElementSource
let directCalls = 0
prototype.createMediaElementSource = function (...args) {
directCalls++
return direct.apply(this, args)
}
return { ...environment, chunks, directCalls: () => directCalls }
}
function assertCapture(environment) {
assert.equal(environment.directCalls(), 0, 'Explicit capture must never attempt to bind the media element')
const source = environment.nodes.filter(node => node.kind === 'stream').at(-1)
const recorder = environment.recorders.at(-1)
assert.ok(source)
assert.deepEqual(source.connections, [recorder])
assert.equal(recorder.connections.length, 1)
const sink = recorder.connections[0]
assert.equal(sink.kind, 'gain')
assert.equal(sink.gain.value, 0)
assert.deepEqual(sink.connections, [environment.contexts.at(-1).destination])
return { source, recorder, sink }
}
async function assertChunk(environment) {
const before = environment.chunks.length
environment.send(new Float32Array(8).fill(0.5))
await environment.tick()
assert.equal(environment.chunks.length, before + 1)
const chunk = environment.chunks.at(-1)
assert.equal(chunk.pcm.byteLength, 16)
assert.equal(new DataView(chunk.pcm).getInt16(0, true), 16383)
assert.equal(new DataView(chunk.wav).getUint32(24, true), 8000)
}
function assertReleased(environment) {
assert.equal(environment.directCalls(), 0)
assert.equal(environment.intervals.size, 0)
assert.equal(environment.blobs.size, 0)
assert.ok(environment.contexts.every(context => context.closes === 1))
assert.ok(environment.nodes.every(node => node.connections.length === 0))
assert.ok(environment.recorders.every(recorder => recorder.port.onmessage === null))
}
for (const moz of [false, true]) {
test(`ASR explicit ${moz ? 'mozCaptureStream' : 'captureStream'} skips a working direct binding`, async () => {
const environment = create({}, { moz })
await environment.emit('play')
assertCapture(environment)
assert.equal(environment.captures(), 1)
await assertChunk(environment)
await environment.emit('destroy')
assertReleased(environment)
assert.equal(environment.tracks[0].stops, 1)
})
}
test('ASR omitted audioInput preserves direct-first acquisition', async () => {
const environment = create({ audioInput: undefined })
await environment.emit('play')
assert.equal(environment.directCalls(), 1)
assert.equal(environment.captures(), 0)
assert.ok(environment.nodes.some(node => node.kind === 'element'))
await assertChunk(environment)
await environment.emit('destroy')
assert.equal(environment.contexts[0].closes, 1)
})
for (const failure of ['unsupported', 'throws']) {
test(`ASR explicit capture ${failure} cleans up without attempting direct fallback`, async () => {
const environment = create({}, { capture: failure !== 'unsupported' })
let attempts = 0
if (failure === 'throws') {
environment.art.video.captureStream = () => {
attempts++
throw new Error('Capture is unavailable for this media')
}
}
await environment.emit('play')
assertReleased(environment)
assert.equal(environment.errors.length, 1)
assert.equal(environment.recorders.length, 0)
assert.equal(environment.tracks[0].stops, 0)
assert.equal(attempts, failure === 'throws' ? 1 : 0)
await environment.emit('destroy')
assertReleased(environment)
})
}
test('ASR explicit capture retains a silent sink through media mute and volume changes', async () => {
const environment = create()
await environment.emit('play')
const route = assertCapture(environment)
for (const [muted, volume] of [[true, 0], [false, 0.25], [false, 1]]) {
environment.art.muted = environment.art.video.muted = muted
environment.art.volume = environment.art.video.volume = volume
await environment.emit('video:volumechange')
assert.equal(assertCapture(environment).sink, route.sink)
assert.equal(environment.art.video.muted, muted)
assert.equal(environment.art.video.volume, volume)
await assertChunk(environment)
}
await environment.emit('destroy')
assertReleased(environment)
})
test('ASR explicit capture pause retains its stream and resumes with a fresh recorder', async () => {
const environment = create()
await environment.emit('play')
const before = assertCapture(environment)
await environment.emit('pause')
assert.equal(environment.intervals.size, 0)
assert.equal(environment.contexts[0].closes, 0)
assert.equal(environment.tracks[0].stops, 0)
assert.equal(before.recorder.port.onmessage, null)
for (const node of Object.values(before))
assert.deepEqual(node.connections, [])
await environment.emit('play')
const after = assertCapture(environment)
assert.equal(after.source, before.source)
assert.notEqual(after.recorder, before.recorder)
assert.equal(environment.captures(), 1)
await assertChunk(environment)
await environment.emit('destroy')
assertReleased(environment)
})
for (const operation of ['stop/play', 'restart']) {
test(`ASR explicit capture ${operation} releases the old graph and acquires another stream`, async () => {
const environment = create()
await environment.emit('play')
const before = assertCapture(environment)
if (operation === 'stop/play') {
await environment.plugin.stop()
assertReleased(environment)
await environment.emit('play')
}
else {
await environment.emit('restart')
}
const after = assertCapture(environment)
assert.notEqual(after.source, before.source)
assert.equal(environment.captures(), 2)
assert.equal(environment.contexts[0].closes, 1)
assert.equal(environment.contexts[1].closes, 0)
assert.equal(environment.tracks[0].stops, 1)
for (const node of Object.values(before))
assert.deepEqual(node.connections, [])
await assertChunk(environment)
await environment.emit('destroy')
assertReleased(environment)
assert.equal(environment.tracks[0].stops, 2)
await environment.emit('play')
assert.equal(environment.captures(), 2)
assert.equal(environment.intervals.size, 0)
})
}
test('ASR explicit capture snapshots the nested input choice before later caller mutation', async () => {
const input = { type: 'capture' }
const environment = create({ audioInput: input })
input.type = 'changed by caller'
await environment.emit('play')
assertCapture(environment)
await environment.plugin.stop()
await environment.emit('play')
assertCapture(environment)
assert.equal(environment.captures(), 2)
await environment.emit('destroy')
assertReleased(environment)
})
test('ASR explicit capture destroy during worklet loading never binds or captures late', async () => {
const moduleGate = deferredAudio()
const moduleEntered = deferredAudio()
const environment = create({}, { moduleGate, moduleEntered })
const starting = environment.emit('play')
await moduleEntered.promise
await environment.emit('destroy')
moduleGate.resolve()
await starting
assertReleased(environment)
assert.equal(environment.captures(), 0)
assert.equal(environment.recorders.length, 0)
assert.equal(environment.tracks[0].stops, 0)
})
for (const action of ['pause', 'stop']) {
test(`ASR explicit capture ${action} cancels a restart waiting for the old context to close`, async () => {
const environment = create()
await environment.emit('play')
assertCapture(environment)
const closeGate = deferredAudio()
const context = environment.contexts[0]
const close = context.close.bind(context)
context.close = async () => {
await close()
await closeGate.promise
}
const restarting = environment.emit('restart')
const cancelling = action === 'pause' ? environment.emit('pause') : environment.plugin.stop()
assert.equal(environment.intervals.size, 0)
closeGate.resolve()
await Promise.all([restarting, cancelling])
assert.equal(environment.captures(), 1, 'A superseded restart must not acquire another stream')
assert.equal(environment.contexts.length, 1)
assert.equal(environment.tracks[0].stops, 1)
assertReleased(environment)
await environment.emit('play')
assertCapture(environment)
assert.equal(environment.captures(), 2, 'A subsequent explicit play may start a new capture')
await assertChunk(environment)
await environment.emit('destroy')
assertReleased(environment)
assert.equal(environment.tracks[0].stops, 2)
})
}
+203
View File
@@ -0,0 +1,203 @@
import fs from 'node:fs'
import process from 'node:process'
import { hash } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let code
test.beforeAll(async () => {
expect(process.env.ARTPLAYER_BROWSER_ARTIFACTS, 'Explicit source/artifact tests do not represent installed packages').toBeFalsy()
code = process.env.ARTPLAYER_ASR_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_ASR_ARTIFACT, 'utf8') : await compilePackage('artplayer-plugin-asr', 'umd')
})
test.afterEach(async ({ page }, testInfo) => {
const observation = await page.evaluate(() => window.captureCorsObservation?.()).catch(error => ({ error: error.message }))
await testInfo.attach('capture-cors-final-observation', { contentType: 'application/json', body: JSON.stringify(observation ?? null) })
await page.evaluate(() => {
if (window.art && !window.art.isDestroy)
window.art.destroy()
})
})
for (const core of ['published', 'candidate']) {
test(`ASR explicit capture + ${core}: opaque media keeps native routing and same-origin capture recovers`, async ({ page }, testInfo) => {
const responses = []
const consoleErrors = []
page.on('response', (response) => {
if (new URL(response.url()).pathname === '/test/audio-tone.m4a')
responses.push({ url: response.url(), status: response.status(), allowOrigin: response.headers()['access-control-allow-origin'] || null })
})
page.on('console', (message) => {
if (message.type() === 'error')
consoleErrors.push(message.text())
})
await page.goto(`/test/player.html?core=${core}`)
const capabilities = await page.evaluate(() => ({ context: typeof (window.AudioContext || window.webkitAudioContext), worklet: typeof window.AudioWorkletNode, capture: typeof HTMLMediaElement.prototype.captureStream, mozCapture: typeof HTMLMediaElement.prototype.mozCaptureStream }))
await testInfo.attach('capture-cors-inputs', { contentType: 'application/json', body: JSON.stringify({ core, capabilities, plugin: process.env.ARTPLAYER_ASR_ARTIFACT || 'workspace source', pluginSha256: hash(code), mediaSha256: hash(fs.readFileSync(new URL('./media/audio-tone.m4a', import.meta.url))), nativeCapture: true, forcedException: false, analyserBinding: false, physicalAudioOutput: false, scope: 'Zero direct bindings and native media clock progression preserve routing; no claim about physical speaker output' }) })
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 AudioContext/AudioWorklet; this is not Safari/device CORS acceptance')
}
expect(capabilities.worklet).toBe('function')
expect([capabilities.capture, capabilities.mozCapture]).toContain('function')
await page.addScriptTag({ content: code })
await page.evaluate(() => {
const NativeContext = window.AudioContext || window.webkitAudioContext
window.captureContexts = []
window.captureStreams = []
window.captureTracks = []
window.captureAttempts = []
window.directBindings = 0
window.captureChunks = []
window.AudioContext = class extends NativeContext {
constructor(options) {
super(options)
window.captureContexts.push(this)
}
createMediaElementSource(element) {
window.directBindings++
return super.createMediaElementSource(element)
}
}
const prototype = HTMLMediaElement.prototype
const method = prototype.captureStream ? 'captureStream' : 'mozCaptureStream'
const nativeCapture = prototype[method]
prototype[method] = function (...args) {
const attempt = { source: this.currentSrc, error: null }
window.captureAttempts.push(attempt)
try {
const stream = nativeCapture.apply(this, args)
window.captureStreams.push(stream)
const recordTrack = (track) => {
if (!window.captureTracks.includes(track))
window.captureTracks.push(track)
}
stream.getTracks().forEach(recordTrack)
stream.addEventListener('addtrack', event => recordTrack(event.track))
return stream
}
catch (error) {
attempt.error = { name: error.name, message: error.message }
throw error
}
}
const opaque = new URL('/test/audio-tone.m4a?capture-cors=opaque', location.href)
opaque.hostname = 'localhost'
window.art = new window.Artplayer({
container: '.player',
url: opaque.href,
volume: 0.6,
muted: false,
loop: true,
plugins: [window.artplayerPluginAsr({
audioInput: { type: 'capture' },
onAudioChunk({ pcm, wav }) {
let peak = 0
for (const sample of new Int16Array(pcm))
peak = Math.max(peak, Math.abs(sample))
window.captureChunks.push({ source: window.art.video.currentSrc, peak, pcmBytes: pcm.byteLength, wavBytes: wav.byteLength })
},
})],
})
window.captureVideoProperties = () => {
const video = window.art.video
return {
src: video.getAttribute('src'),
crossOrigin: video.crossOrigin,
crossOriginAttribute: video.getAttribute('crossorigin'),
muted: video.muted,
defaultMuted: video.defaultMuted,
volume: video.volume,
playbackRate: video.playbackRate,
autoplay: video.autoplay,
controls: video.controls,
loop: video.loop,
preload: video.preload,
playsInline: video.playsInline,
}
}
window.captureCorsObservation = () => ({
attempts: window.captureAttempts,
directBindings: window.directBindings,
contexts: window.captureContexts.map(context => context.state),
streams: window.captureStreams.map(stream => ({ active: stream.active, tracks: stream.getTracks().map(track => ({ kind: track.kind, muted: track.muted, enabled: track.enabled, readyState: track.readyState })) })),
retainedTracks: window.captureTracks.map(track => ({ kind: track.kind, muted: track.muted, enabled: track.enabled, readyState: track.readyState })),
chunks: window.captureChunks,
})
document.querySelector('#play').onclick = () => window.art.play()
document.querySelector('#pause').onclick = () => window.art.pause()
})
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
const initialProperties = await page.evaluate(() => window.captureVideoProperties())
expect(initialProperties.crossOrigin).toBeNull()
expect(initialProperties.crossOriginAttribute).toBeNull()
expect(initialProperties.muted).toBe(false)
expect(new URL(initialProperties.src, page.url()).origin).not.toBe(new URL(page.url()).origin)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.captureAttempts.length)).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(1)
const opaqueStart = await page.evaluate(() => window.art.currentTime)
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(opaqueStart + 0.5)
const opaque = await page.evaluate(() => window.captureCorsObservation())
expect(opaque.directBindings).toBe(0)
expect(opaque.chunks.every(chunk => chunk.peak === 0)).toBe(true)
expect(await page.evaluate(() => window.art.video.error)).toBeNull()
expect(await page.evaluate(() => window.art.video.paused)).toBe(false)
expect(await page.evaluate(() => window.captureVideoProperties())).toEqual(initialProperties)
expect(responses.some(response => new URL(response.url).hostname === 'localhost' && [200, 206].includes(response.status))).toBe(true)
expect(responses.every(response => response.allowOrigin === null)).toBe(true)
expect(opaque.chunks).toEqual([])
expect(opaque.attempts).toHaveLength(1)
if (testInfo.project.name === 'chromium') {
expect(opaque.attempts[0].error.name).toBe('SecurityError')
expect(opaque.contexts).toEqual(['closed'])
expect(opaque.streams).toEqual([])
expect(consoleErrors).toHaveLength(1)
expect(consoleErrors[0]).toContain('[artplayerPluginAsr] Initialization failed: SecurityError:')
}
else {
expect(testInfo.project.name).toBe('firefox')
expect(opaque.attempts[0].error).toBeNull()
expect(opaque.contexts).toEqual(['running'])
expect(opaque.streams).toHaveLength(1)
expect(opaque.retainedTracks).toHaveLength(1)
expect(opaque.retainedTracks[0].kind).toBe('audio')
expect(opaque.retainedTracks[0].readyState).toBe('live')
expect(consoleErrors).toEqual([])
}
await testInfo.attach('opaque-native-capture-observation', { contentType: 'application/json', body: JSON.stringify({ opaque, responses: [...responses], consoleErrors: [...consoleErrors], properties: initialProperties, clockAdvanced: true }) })
await page.evaluate(() => {
window.art.url = '/test/audio-tone.m4a?capture-cors=recovery'
})
await expect.poll(() => page.evaluate(() => window.art.video.currentSrc.includes('capture-cors=recovery') && window.art.video.readyState >= 2)).toBe(true)
const recoveryProperties = await page.evaluate(() => window.captureVideoProperties())
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.captureChunks.filter(chunk => chunk.source.includes('capture-cors=recovery') && chunk.peak > 0).length)).toBeGreaterThanOrEqual(3)
expect(await page.evaluate(() => window.directBindings)).toBe(0)
expect(await page.evaluate(() => window.captureVideoProperties())).toEqual(recoveryProperties)
expect(await page.evaluate(() => window.captureChunks.filter(chunk => chunk.source.includes('capture-cors=opaque')).every(chunk => chunk.peak === 0))).toBe(true)
expect(await page.evaluate(() => window.captureAttempts.length)).toBeGreaterThanOrEqual(2)
await page.evaluate(() => window.art.plugins.artplayerPluginAsr.stop())
await expect.poll(() => page.evaluate(() => window.captureContexts.every(context => context.state === 'closed'))).toBe(true)
expect(await page.evaluate(() => window.captureTracks.every(track => track.readyState === 'ended'))).toBe(true)
const stopped = await page.evaluate(() => ({ chunks: window.captureChunks.length, time: window.art.currentTime }))
await expect.poll(() => page.evaluate(() => window.art.currentTime)).toBeGreaterThan(stopped.time + 0.4)
expect(await page.evaluate(() => window.captureChunks.length)).toBe(stopped.chunks)
expect(await page.evaluate(() => window.art.video.paused)).toBe(false)
expect(await page.evaluate(() => window.captureVideoProperties())).toEqual(recoveryProperties)
await page.locator('#pause').click()
await page.locator('#play').click()
await expect.poll(() => page.evaluate(start => window.captureChunks.slice(start).filter(chunk => chunk.peak > 0).length, stopped.chunks)).toBeGreaterThanOrEqual(3)
expect(await page.evaluate(() => window.directBindings)).toBe(0)
await testInfo.attach('same-origin-capture-recovery', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.captureCorsObservation())) })
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.captureContexts.every(context => context.state === 'closed'))).toBe(true)
expect(await page.evaluate(() => window.captureTracks.every(track => track.readyState === 'ended'))).toBe(true)
expect(await page.evaluate(() => window.directBindings)).toBe(0)
await expect(page.locator('.art-video-player')).toHaveCount(0)
})
}
+14 -9
View File
@@ -12,14 +12,14 @@ test.beforeAll(async () => {
candidateCode = process.env.ARTPLAYER_ASR_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_ASR_ARTIFACT) : await compilePackage('artplayer-plugin-asr', 'umd')
})
for (const version of ['2.1.0', 'candidate']) {
for (const { version, captureOnly } of [{ version: '2.1.0', captureOnly: false }, { version: 'candidate', captureOnly: false }, { version: 'candidate', captureOnly: true }]) {
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) => {
test(`ASR ${version}${captureOnly ? ' capture' : ''} + ${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}`)
const capabilities = await page.evaluate(() => ({ context: typeof AudioContext, worklet: typeof AudioWorkletNode, capture: typeof HTMLMediaElement.prototype.captureStream, mozCapture: typeof HTMLMediaElement.prototype.mozCaptureStream }))
const forceRejection = testInfo.project.name === 'firefox'
await testInfo.attach('asr-fallback-inputs', { contentType: 'application/json', body: JSON.stringify({ version, core, pluginSha256: hash(code), capabilities, forcedException: forceRejection, reason: forceRejection ? 'Firefox permits a second native media source; force only binding rejection to reach real captureStream/Worklet' : 'Native duplicate binding rejection', externalContext: 'Native media-element source remains owned and running', physicalOutput: false }) })
const forceRejection = testInfo.project.name === 'firefox' && !captureOnly
await testInfo.attach('asr-fallback-inputs', { contentType: 'application/json', body: JSON.stringify({ version, core, captureOnly, pluginSha256: hash(code), capabilities, forcedException: forceRejection, reason: captureOnly ? 'Explicit capture mode never binds the element; no forced exceptions' : forceRejection ? 'Firefox permits a second native media source; force only binding rejection to reach real captureStream/Worklet' : 'Native duplicate binding rejection', externalContext: 'Native media-element source remains owned and running', physicalOutput: false }) })
if (capabilities.context !== 'function') {
expect(process.platform).toBe('win32')
expect(testInfo.project.name).toBe('webkit')
@@ -28,13 +28,14 @@ for (const version of ['2.1.0', 'candidate']) {
expect(capabilities.worklet).toBe('function')
expect([capabilities.capture, capabilities.mozCapture]).toContain('function')
await page.addScriptTag({ content: code.toString() })
await page.evaluate((forceRejection) => {
await page.evaluate(({ forceRejection, captureOnly }) => {
const NativeContext = window.AudioContext
window.asrContexts = []
window.asrMeters = []
window.asrStreams = []
window.asrChunks = []
window.asrDirectFailures = []
window.asrDirectCalls = 0
window.AudioContext = class extends NativeContext {
constructor(option) {
super(option)
@@ -42,6 +43,7 @@ for (const version of ['2.1.0', 'candidate']) {
}
createMediaElementSource(video) {
window.asrDirectCalls++
try {
if (forceRejection)
throw new DOMException('Controlled duplicate binding rejection for native capture test', 'InvalidStateError')
@@ -80,7 +82,7 @@ for (const version of ['2.1.0', 'candidate']) {
url: '/test/audio-tone.m4a',
volume: 1,
loop: true,
plugins: [window.artplayerPluginAsr({ onAudioChunk({ pcm }) {
plugins: [window.artplayerPluginAsr({ audioInput: captureOnly ? { type: 'capture' } : undefined, onAudioChunk({ pcm }) {
const samples = new Int16Array(pcm)
const max = samples.reduce((value, sample) => Math.max(value, Math.abs(sample)), 0)
window.asrChunks.push({ max, source: window.art.video.currentSrc, samples: samples.length })
@@ -108,9 +110,9 @@ for (const version of ['2.1.0', 'candidate']) {
analyser.getFloatTimeDomainData(samples)
return Math.sqrt(samples.reduce((sum, value) => sum + value * value, 0) / samples.length)
}
return { external: rms(window.externalMeter), asr: window.asrMeters.map(rms), contexts: window.asrContexts.map(c => c.state), owner: window.externalContext.state, streams: window.asrStreams.map(s => s.tracks.map(t => ({ kind: t.kind, state: t.readyState }))), chunks: window.asrChunks.slice(-3), failures: window.asrDirectFailures }
return { external: rms(window.externalMeter), asr: window.asrMeters.map(rms), contexts: window.asrContexts.map(c => c.state), owner: window.externalContext.state, streams: window.asrStreams.map(s => s.tracks.map(t => ({ kind: t.kind, state: t.readyState }))), chunks: window.asrChunks.slice(-3), failures: window.asrDirectFailures, directCalls: window.asrDirectCalls }
}
}, forceRejection)
}, { forceRejection, captureOnly })
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.asrChunks.filter(c => c.max > 0).length)).toBeGreaterThanOrEqual(3)
@@ -121,7 +123,8 @@ for (const version of ['2.1.0', 'candidate']) {
await page.waitForTimeout(450)
const muted = await page.evaluate(() => window.readFallback())
await testInfo.attach('asr-fallback-output', { contentType: 'application/json', body: JSON.stringify({ playing, muted }) })
expect(playing.failures.length).toBe(1)
expect(playing.failures.length).toBe(captureOnly ? 0 : 1)
expect(playing.directCalls).toBe(captureOnly ? 0 : 1)
expect(playing.external).toBeGreaterThan(0.01)
expect(muted.external).toBe(0)
if (version === '2.1.0') {
@@ -167,6 +170,8 @@ for (const version of ['2.1.0', 'candidate']) {
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.asrContexts.every(context => context.state === 'closed'))).toBe(true)
expect(await page.evaluate(() => window.externalContext.state)).toBe('running')
if (captureOnly)
expect(await page.evaluate(() => window.asrDirectCalls)).toBe(0)
await page.evaluate(() => window.externalContext.close())
})
}
+22 -2
View File
@@ -38,6 +38,8 @@ type PreciseResult = Assert<Equal<ReturnType<ReturnType<typeof runtime>>, Runtim
type PreciseStop = Assert<Equal<ReturnType<RuntimeResult['stop']>, Promise<void>>>
type PreciseFactory = Assert<Equal<typeof runtime, RuntimeFactory>>
type PreciseCallback = Assert<Equal<NonNullable<RuntimeOption['onAudioChunk']>, (chunk: AudioChunk) => string | void | null | Promise<string | void | null>>>
type PreciseAudioInput = Assert<Equal<RuntimeOption['audioInput'], { type: 'capture' } | undefined>>
type RootAudioInputAbsent = Assert<Equal<Extract<keyof AsrPluginOption, 'audioInput'>, never>>
const replacement: HistoricalFactory = (_option?: HistoricalOption) => (_art: Artplayer) => ({
name: 'artplayerPluginAsr',
@@ -72,6 +74,24 @@ runtime({ onAudioChunk: () => undefined })
runtime({ onAudioChunk: async () => {} })
runtime({ onAudioChunk: async () => null })
runtime(option)
const captureOption: RuntimeOption = { audioInput: { type: 'capture' } }
const captureEntryOption: EntryOption = captureOption
runtime(captureOption)
runtime(captureEntryOption)
runtime({ audioInput: { type: 'capture' }, onAudioChunk: async () => 'Captured main media.' })
// @ts-expect-error Only the explicit capture strategy is public.
runtime({ audioInput: { type: 'direct' } })
// @ts-expect-error The strategy discriminator is required when audioInput is provided.
runtime({ audioInput: {} })
// @ts-expect-error Audio input is a descriptor, not a string shorthand.
runtime({ audioInput: 'capture' })
// @ts-expect-error Root options retain their historical fields without the runtime-only strategy.
const rootCaptureOption: AsrPluginOption = { audioInput: { type: 'capture' } }
// @ts-expect-error The historical factory does not expose runtime-only options.
legacy({ audioInput: { type: 'capture' } })
// @ts-expect-error The explicit legacy entry also retains the historical option shape.
legacyEntry({ audioInput: { type: 'capture' } })
// @ts-expect-error Root options preserve the historical void/Promise<void> callback shape.
legacy({ onAudioChunk: () => 'Only the precise entry describes returned subtitles.' })
@@ -97,5 +117,5 @@ runtime()()
correct.append(123)
// @ts-expect-error PCM is an ArrayBuffer rather than encoded text.
const invalidChunk: AudioChunk = { pcm: 'raw audio', wav: new ArrayBuffer(4) }
void [assignToOld, assignFromOld, namespaceFactory, explicitLegacy, oldReturns, stopped, aliasResult, rootPromise, synchronous, wrongStop, invalidChunk]
export type { ChunkShape, PreciseArguments, PreciseCallback, PreciseFactory, PreciseResult, PreciseStop, RootArguments, RootFactory, RootOption, RootResult, RootShape, RootStop, RuntimeEntryTypes }
void [assignToOld, assignFromOld, namespaceFactory, explicitLegacy, oldReturns, stopped, aliasResult, rootCaptureOption, rootPromise, synchronous, wrongStop, invalidChunk]
export type { ChunkShape, PreciseArguments, PreciseAudioInput, PreciseCallback, PreciseFactory, PreciseResult, PreciseStop, RootArguments, RootAudioInputAbsent, RootFactory, RootOption, RootResult, RootShape, RootStop, RuntimeEntryTypes }