mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
feat(asr): [PKG-ASR-09] add explicit capture ownership and cancel stale restarts
This commit is contained in:
1 parent
5ab7da6e1f
commit
146b78d576
27 files changed
+12951
-64
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
@@ -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组合生命周期与独立音频归属
|
||||
|
||||
@@ -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
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user