refactor(asr): [PKG-ASR-03] own typed audio capture and playback lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-13 14:44:23 +08:00
1 parent 53c295819d
commit 0cf2af4840
33 files changed
+3492 -809

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";if("undefined"!==typeof document&&!document.getElementById("artplayer-plugin-asr")){const t=document.createElement("style");t.id="artplayer-plugin-asr",t.textContent=".art-video-player .art-layer-asr{pointer-events:none!important;position:absolute;z-index:150;inset:0;display:flex;flex-direction:column;justify-content:end;padding:0 2%;font-size:1rem;gap:var(--art-subtitle-gap);padding-bottom:var(--art-subtitle-bottom);transition:padding-bottom var(--art-transition-duration) ease;text-shadow:var(--art-subtitle-border) 1px 0 1px,var(--art-subtitle-border) 0 1px 1px,var(--art-subtitle-border) -1px 0 1px,var(--art-subtitle-border) 0 -1px 1px,var(--art-subtitle-border) 1px 1px 1px,var(--art-subtitle-border) -1px -1px 1px,var(--art-subtitle-border) 1px -1px 1px,var(--art-subtitle-border) -1px 1px 1px}.art-video-player.art-control-show .art-layer-asr{padding-bottom:calc(var(--art-control-height) + var(--art-subtitle-bottom))}",document.head.appendChild(t)}return function(t={}){const{length:e=3,interval:n=100,sampleRate:r=16e3,autoHideTimeout:a=1e4,onAudioChunk:o=()=>null}=t;return t=>{let i=!1,l=null,s=null,u=null,c=null,d=[],p=null,f=!1,y=null,m=null,g=null;const h=t.layers.add({name:"asr",html:""});function w(){h.style.display="none"}function b(t){var n;"string"===typeof t&&(clearTimeout(y),y=setTimeout(w,a),h.style.display="",h.innerHTML=(n=t,n.split(/(?<=[\u3001\u3002\uff01\uff1f!?.])\s*/u).map(t=>t.trim()).filter(Boolean).slice(-e)).map(t=>`<div class="art-asr-line">${t}</div>`).join(""))}async function v(){if(!s&&t.video)try{return s=l.createMediaElementSource(t.video),s}catch(e){console.warn("[artplayerPluginAsr] Direct connection failed:",e)}try{if(m||(m=await async function(t){try{return t.captureStream?t.captureStream():t.mozCaptureStream?t.mozCaptureStream():(console.warn("[artplayerPluginAsr] captureStream not supported"),null)}catch(e){return console.warn("[artplayerPluginAsr] Error capturing stream:",e),null}}(t.video)),m)return g=l.createMediaStreamSource(m),g}catch(e){console.warn("[artplayerPluginAsr] MediaStream fallback failed:",e)}return null}async function x(){i&&(i=!1,clearInterval(p),p=null,u&&(u.disconnect(),u.port.onmessage=null,u=null),c&&(c.disconnect(),c=null),d=[])}async function A(){await x(),g&&(g.disconnect(),g=null),s&&(s.disconnect(),s=null),m&&(m.getTracks().forEach(t=>t.stop()),m=null),l&&(await l.close(),l=null),f=!1}return t.on("video:volumechange",()=>{c&&(c.gain.value=t.volume)}),t.on("play",async function(){if(!i)try{await async function(){return l||(l=new(window.AudioContext||window.webkitAudioContext)({sampleRate:r}),"suspended"===l.state&&await l.resume(),l)}();const t=await v();if(!t)throw new Error("Could not establish audio source");if(!f){const t=(()=>{const t=new Blob(["\n class 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 }\n registerProcessor('recorder-processor', RecorderProcessor);\n "],{type:"application/javascript"});return URL.createObjectURL(t)})();await l.audioWorklet.addModule(t),URL.revokeObjectURL(t),f=!0}c=l.createGain(),c.gain.value=1,u=new AudioWorkletNode(l,"recorder-processor"),u.port.onmessage=t=>{d.push(new Float32Array(t.data))},t.connect(u),t.connect(c),c.connect(l.destination);const e=r*(n/1e3);p=setInterval(async()=>{if(0===d.length)return;let t=new Float32Array(0);for(;t.length<e&&d.length;){const e=d.shift(),n=new Float32Array(t.length+e.length);n.set(t,0),n.set(e,t.length),t=n}if(t.length<e)return;const n=function(t){const e=t.length,n=new ArrayBuffer(2*e),r=new DataView(n);for(let a=0;a<e;a++){const e=Math.max(-1,Math.min(1,t[a]));r.setInt16(2*a,e<0?32768*e:32767*e,!0)}return n}(t.slice(0,e)),a=function(t,e){const n=t.byteLength,r=new ArrayBuffer(44+n),a=new DataView(r),o=(t,e)=>{for(let n=0;n<e.length;n++)a.setUint8(t+n,e.charCodeAt(n))};return o(0,"RIFF"),a.setUint32(4,36+n,!0),o(8,"WAVE"),o(12,"fmt "),a.setUint32(16,16,!0),a.setUint16(20,1,!0),a.setUint16(22,1,!0),a.setUint32(24,e,!0),a.setUint32(28,2*e,!0),a.setUint16(32,2,!0),a.setUint16(34,16,!0),o(36,"data"),a.setUint32(40,n,!0),new Uint8Array(r).set(new Uint8Array(t),44),r}(n,r);b(await o({pcm:n,wav:a}))},n),i=!0}catch(t){console.error("[artplayerPluginAsr] Initialization failed:",t),await x()}}),t.on("pause",x),t.on("destroy",A),{name:"artplayerPluginAsr",stop:A,hide:w,append:b}}}});
!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 n=this.gain=s||e.createGain();s||(n.gain.value=1);const r=this.recorder=new AudioWorkletNode(e,"recorder-processor");r.port.onmessage=e=>{this.closed||this.recorder!==r||t(e.data)},i.connect(r),i.connect(n),n.connect(e.destination)}volume(t){this.gain&&(this.gain.gain.value=t)}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,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 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,n=Math.floor(e*s/1e3);if(!Number.isSafeInteger(n)||n<1||!Number.isFinite(s)||s<=0)throw new Error("Audio chunk length must be positive and finite");const r=this.graph??(this.graph=new t(this.video,e));await r.prepare(),i()?(r.connect(t=>{if(this.running&&this.graph===r)return this.queue.length+t.length>Math.max(60*e,2*n)?(console.error("[artplayerPluginAsr] Audio callback backlog exceeded its capture limit"),void this.pause()):void this.queLine truncated
+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";var t=(t,e,n)=>new Promise((r,o)=>{var a=t=>{try{l(n.next(t))}catch(e){o(e)}},i=t=>{try{l(n.throw(t))}catch(e){o(e)}},l=t=>t.done?r(t.value):Promise.resolve(t.value).then(a,i);l((n=n.apply(t,e)).next())});if("undefined"!==typeof document&&!document.getElementById("artplayer-plugin-asr")){const t=document.createElement("style");t.id="artplayer-plugin-asr",t.textContent=".art-video-player .art-layer-asr{pointer-events:none!important;position:absolute;z-index:150;inset:0;display:flex;flex-direction:column;justify-content:end;padding:0 2%;font-size:1rem;gap:var(--art-subtitle-gap);padding-bottom:var(--art-subtitle-bottom);transition:padding-bottom var(--art-transition-duration) ease;text-shadow:var(--art-subtitle-border) 1px 0 1px,var(--art-subtitle-border) 0 1px 1px,var(--art-subtitle-border) -1px 0 1px,var(--art-subtitle-border) 0 -1px 1px,var(--art-subtitle-border) 1px 1px 1px,var(--art-subtitle-border) -1px -1px 1px,var(--art-subtitle-border) 1px -1px 1px,var(--art-subtitle-border) -1px 1px 1px}.art-video-player.art-control-show .art-layer-asr{padding-bottom:calc(var(--art-control-height) + var(--art-subtitle-bottom))}",document.head.appendChild(t)}return function(e={}){const{length:n=3,interval:r=100,sampleRate:o=16e3,autoHideTimeout:a=1e4,onAudioChunk:i=()=>null}=e;return e=>{let l=!1,s=null,u=null,c=null,d=null,p=[],f=null,y=!1,m=null,h=null,g=null;const v=e.layers.add({name:"asr",html:""});function b(){v.style.display="none"}function x(t){var e;"string"===typeof t&&(clearTimeout(m),m=setTimeout(b,a),v.style.display="",v.innerHTML=(e=t,e.split(new RegExp("(?<=[\u3001\u3002\uff01\uff1f!?.])\\s*","u")).map(t=>t.trim()).filter(Boolean).slice(-n)).map(t=>`<div class="art-asr-line">${t}</div>`).join(""))}function w(){return t(this,null,function*(){if(!u&&e.video)try{return u=s.createMediaElementSource(e.video),u}catch(n){console.warn("[artplayerPluginAsr] Direct connection failed:",n)}try{if(h||(h=yield function(e){return t(this,null,function*(){try{return e.captureStream?e.captureStream():e.mozCaptureStream?e.mozCaptureStream():(console.warn("[artplayerPluginAsr] captureStream not supported"),null)}catch(n){return console.warn("[artplayerPluginAsr] Error capturing stream:",n),null}})}(e.video)),h)return g=s.createMediaStreamSource(h),g}catch(n){console.warn("[artplayerPluginAsr] MediaStream fallback failed:",n)}return null})}function A(){return t(this,null,function*(){l&&(l=!1,clearInterval(f),f=null,c&&(c.disconnect(),c.port.onmessage=null,c=null),d&&(d.disconnect(),d=null),p=[])})}function U(){return t(this,null,function*(){yield A(),g&&(g.disconnect(),g=null),u&&(u.disconnect(),u=null),h&&(h.getTracks().forEach(t=>t.stop()),h=null),s&&(yield s.close(),s=null),y=!1})}return e.on("video:volumechange",()=>{d&&(d.gain.value=e.volume)}),e.on("play",function(){return t(this,null,function*(){if(!l)try{yield function(){return t(this,null,function*(){return s||(s=new(window.AudioContext||window.webkitAudioContext)({sampleRate:o}),"suspended"===s.state&&(yield s.resume()),s)})}();const e=yield w();if(!e)throw new Error("Could not establish audio source");if(!y){const t=(()=>{const t=new Blob(["\n class 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 }\n registerProcessor('recorder-processor', RecorderProcessor);\n "],{type:"application/javascript"});return URL.createObjectURL(t)})();yield s.audioWorklet.addModule(t),URL.revokeObjectURL(t),y=!0}d=s.createGain(),d.gain.value=1,c=new AudioWorkletNode(s,"recorder-processor"),c.port.onmessage=t=>{p.push(new Float32Array(t.data))},e.connect(c),e.connect(d),d.connect(s.destination);const n=o*(r/1e3);f=setInterval(()=>t(null,null,function*(){if(0===p.length)return;let t=new Float32Array(0);for(;t.length<n&&p.length;){const e=p.shift(),n=new Float32Array(t.length+e.length);n.set(t,0),n.set(e,t.length),t=n}if(t.length<n)return;const e=function(t){const e=t.length,n=new ArrayBuffer(2*e),r=new DataView(n);for(let o=0;o<e;o++){const e=Math.max(-1,Math.min(1,t[o]));r.setInt16(2*o,e<0?32768*e:32767*e,!0)}return n}(t.slice(0,n)),r=function(t,e){const n=t.byteLength,r=new ArrayBuffer(44+n),o=new DataView(r),a=(t,e)=>{for(let n=0;n<e.length;n++)o.setUint8(t+n,e.charCodeAt(n))};return a(0,"RIFF"),o.setUint32(4,36+n,!0),a(8,"WAVE"),a(12,"fmt "),o.setUint32(16,16,!0),o.setUint16(20,1,!0),o.setUint16(22,1,!0),o.setUint32(24,e,!0),o.setUint32(28,2*e,!0),o.setUint16(32,2,!0),o.setUintLine 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){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=1);const r=this.recorder=new AudioWorkletNode(e,"recorder-processor");r.port.onmessage=e=>{this.closed||this.recorder!==r||t(e.data)},i.connect(r),i.connect(n),n.connect(e.destination)}volume(t){this.gain&&(this.gain.gain.value=t)}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.isFiLine truncated
+374 -228
View File
@@ -4,248 +4,394 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
const style = ".art-video-player .art-layer-asr {\n pointer-events: none !important;\n position: absolute;\n z-index: 150;\n inset: 0px;\n display: flex;\n flex-direction: column;\n justify-content: end;\n padding: 0 2%;\n font-size: 1rem;\n gap: var(--art-subtitle-gap);\n padding-bottom: var(--art-subtitle-bottom);\n transition: padding-bottom var(--art-transition-duration) ease;\n text-shadow: var(--art-subtitle-border) 1px 0 1px, var(--art-subtitle-border) 0 1px 1px, var(--art-subtitle-border) -1px 0 1px, var(--art-subtitle-border) 0 -1px 1px, var(--art-subtitle-border) 1px 1px 1px, var(--art-subtitle-border) -1px -1px 1px, var(--art-subtitle-border) 1px -1px 1px, var(--art-subtitle-border) -1px 1px 1px;\n}\n.art-video-player.art-control-show .art-layer-asr {\n padding-bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));\n}\n";
function artplayerPluginAsr(option = {}) {
const {
length = 3,
interval = 100,
sampleRate = 16e3,
autoHideTimeout = 1e4,
onAudioChunk = () => null
} = option;
return (art) => {
let started = false;
let audioCtx = null;
let sourceNode = null;
let recorderNode = null;
let gainNode = null;
let bufferChunks = [];
let timer = null;
let workletLoaded = false;
let hideTimer = null;
let mediaStream = null;
let mediaStreamSource = null;
const $asr = art.layers.add({
name: "asr",
html: ""
});
function splitByPunctuation(text) {
return text.split(/(?<=[、。!?!?.])\s*/u).map((s) => s.trim()).filter(Boolean).slice(-length);
}
function hide() {
$asr.style.display = "none";
}
function append(subtitle) {
if (typeof subtitle !== "string")
return;
clearTimeout(hideTimer);
hideTimer = setTimeout(hide, autoHideTimeout);
$asr.style.display = "";
$asr.innerHTML = splitByPunctuation(subtitle).map((line) => `<div class="art-asr-line">${line}</div>`).join("");
}
const recorderProcessorCode = `
class RecorderProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (input && input[0]) {
this.port.postMessage(input[0]);
}
return true;
}
}
registerProcessor('recorder-processor', RecorderProcessor);
`;
const createWorkletBlobUrl = () => {
const blob = new Blob([recorderProcessorCode], { type: "application/javascript" });
return URL.createObjectURL(blob);
};
function floatTo16BitPCM(float32Array) {
const len = float32Array.length;
const buffer = new ArrayBuffer(len * 2);
const view = new DataView(buffer);
for (let i = 0; i < len; i++) {
const s = Math.max(-1, Math.min(1, float32Array[i]));
view.setInt16(i * 2, s < 0 ? s * 32768 : s * 32767, true);
}
return buffer;
}
function pcmToWav(pcmBuffer, sampleRate2) {
const pcmLength = pcmBuffer.byteLength;
const wavBuffer = new ArrayBuffer(44 + pcmLength);
const view = new DataView(wavBuffer);
const writeString = (offset, str) => {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
const recorderProcessorCode = `
class RecorderProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (input && input[0]) {
this.port.postMessage(input[0]);
}
};
writeString(0, "RIFF");
view.setUint32(4, 36 + pcmLength, true);
writeString(8, "WAVE");
writeString(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate2, true);
view.setUint32(28, sampleRate2 * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeString(36, "data");
view.setUint32(40, pcmLength, true);
new Uint8Array(wavBuffer).set(new Uint8Array(pcmBuffer), 44);
return wavBuffer;
return true;
}
async function setupAudioContext() {
if (audioCtx)
return audioCtx;
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate });
if (audioCtx.state === "suspended")
await audioCtx.resume();
return audioCtx;
}
registerProcessor('recorder-processor', RecorderProcessor);
`;
class AudioGraph {
constructor(video, sampleRate) {
this.video = video;
this.sampleRate = sampleRate;
this.closed = false;
this.direct = false;
this.workletLoaded = false;
}
assertOpen() {
if (this.closed)
throw new Error("Audio capture was closed");
}
revokeWorklet() {
if (this.blobUrl !== void 0) {
URL.revokeObjectURL(this.blobUrl);
this.blobUrl = void 0;
}
async function setupAudioSource() {
if (!sourceNode && art.video) {
try {
sourceNode = audioCtx.createMediaElementSource(art.video);
return sourceNode;
} catch (err) {
console.warn("[artplayerPluginAsr] Direct connection failed:", err);
}
}
}
async initialize() {
this.assertOpen();
const platform = window;
const Context = window.AudioContext || platform.webkitAudioContext;
if (!Context)
throw new Error("AudioContext is not supported");
const context = this.context ?? (this.context = new Context({ sampleRate: this.sampleRate }));
if (context.state === "suspended")
await context.resume();
this.assertOpen();
if (!this.workletLoaded) {
this.blobUrl = URL.createObjectURL(new Blob([recorderProcessorCode], { type: "application/javascript" }));
try {
if (!mediaStream) {
mediaStream = await captureAudioFromVideo(art.video);
}
if (mediaStream) {
mediaStreamSource = audioCtx.createMediaStreamSource(mediaStream);
return mediaStreamSource;
}
} catch (err) {
console.warn("[artplayerPluginAsr] MediaStream fallback failed:", err);
}
return null;
}
async function captureAudioFromVideo(videoElement) {
try {
if (videoElement.captureStream)
return videoElement.captureStream();
if (videoElement.mozCaptureStream)
return videoElement.mozCaptureStream();
console.warn("[artplayerPluginAsr] captureStream not supported");
return null;
} catch (err) {
console.warn("[artplayerPluginAsr] Error capturing stream:", err);
return null;
await context.audioWorklet.addModule(this.blobUrl);
this.workletLoaded = true;
} finally {
this.revokeWorklet();
}
}
async function startCapture() {
if (started)
return;
this.assertOpen();
if (!this.source) {
try {
await setupAudioContext();
const audioSource = await setupAudioSource();
if (!audioSource)
this.source = context.createMediaElementSource(this.video);
this.direct = true;
} catch (error) {
console.warn("[artplayerPluginAsr] Direct connection failed:", error);
const capture = this.video.captureStream || this.video.mozCaptureStream;
if (!capture)
throw new Error("Could not establish audio source");
if (!workletLoaded) {
const blobUrl = createWorkletBlobUrl();
await audioCtx.audioWorklet.addModule(blobUrl);
URL.revokeObjectURL(blobUrl);
workletLoaded = true;
}
gainNode = audioCtx.createGain();
gainNode.gain.value = 1;
recorderNode = new AudioWorkletNode(audioCtx, "recorder-processor");
recorderNode.port.onmessage = (event) => {
bufferChunks.push(new Float32Array(event.data));
};
audioSource.connect(recorderNode);
audioSource.connect(gainNode);
gainNode.connect(audioCtx.destination);
const CHUNK_SAMPLES = sampleRate * (interval / 1e3);
timer = setInterval(async () => {
if (bufferChunks.length === 0)
return;
let accumulated = new Float32Array(0);
while (accumulated.length < CHUNK_SAMPLES && bufferChunks.length) {
const next = bufferChunks.shift();
const tmp = new Float32Array(accumulated.length + next.length);
tmp.set(accumulated, 0);
tmp.set(next, accumulated.length);
accumulated = tmp;
}
if (accumulated.length < CHUNK_SAMPLES)
return;
const chunkToSend = accumulated.slice(0, CHUNK_SAMPLES);
const pcm = floatTo16BitPCM(chunkToSend);
const wav = pcmToWav(pcm, sampleRate);
const subtitle = await onAudioChunk({ pcm, wav });
append(subtitle);
}, interval);
started = true;
} catch (err) {
console.error("[artplayerPluginAsr] Initialization failed:", err);
await stopCapture();
this.stream = capture.call(this.video);
this.source = context.createMediaStreamSource(this.stream);
}
}
async function stopCapture() {
if (!started)
return;
started = false;
clearInterval(timer);
timer = null;
if (recorderNode) {
recorderNode.disconnect();
recorderNode.port.onmessage = null;
recorderNode = null;
}
if (gainNode) {
gainNode.disconnect();
gainNode = null;
}
bufferChunks = [];
}
prepare() {
this.preparation ?? (this.preparation = this.initialize().catch((error) => {
this.preparation = void 0;
throw error;
}));
return this.preparation;
}
get ownsMediaConnection() {
return this.direct;
}
idle() {
const gain = this.gain;
this.disconnect();
if (this.direct && this.context && this.source) {
this.gain = gain || this.context.createGain();
if (!gain)
this.gain.gain.value = 1;
this.source.connect(this.gain);
this.gain.connect(this.context.destination);
}
async function destroy() {
await stopCapture();
if (mediaStreamSource) {
mediaStreamSource.disconnect();
mediaStreamSource = null;
}
if (sourceNode) {
sourceNode.disconnect();
sourceNode = null;
}
if (mediaStream) {
mediaStream.getTracks().forEach((track) => track.stop());
mediaStream = null;
}
if (audioCtx) {
await audioCtx.close();
audioCtx = null;
}
workletLoaded = false;
}
art.on("video:volumechange", () => {
if (gainNode) {
gainNode.gain.value = art.volume;
}
});
art.on("play", startCapture);
art.on("pause", stopCapture);
art.on("destroy", destroy);
return {
name: "artplayerPluginAsr",
stop: destroy,
hide,
append
}
connect(receive) {
this.assertOpen();
const context = this.context;
const source = this.source;
if (!context || !source)
throw new Error("Audio capture is not prepared");
const previousGain = this.gain;
this.disconnect();
const gain = this.gain = previousGain || context.createGain();
if (!previousGain)
gain.gain.value = 1;
const recorder = this.recorder = new AudioWorkletNode(context, "recorder-processor");
recorder.port.onmessage = (event) => {
if (!this.closed && this.recorder === recorder)
receive(event.data);
};
source.connect(recorder);
source.connect(gain);
gain.connect(context.destination);
}
volume(value) {
if (this.gain)
this.gain.gain.value = value;
}
disconnect() {
if (this.recorder) {
this.recorder.port.onmessage = null;
this.recorder.disconnect();
this.recorder = void 0;
}
this.gain?.disconnect();
this.gain = void 0;
this.source?.disconnect();
}
async close() {
if (this.closed)
return;
this.closed = true;
this.disconnect();
this.revokeWorklet();
this.stream?.getTracks().forEach((track) => track.stop());
this.stream = void 0;
const context = this.context;
this.context = void 0;
this.source = void 0;
if (context && context.state !== "closed")
await context.close();
}
}
function encodeAudio(samples, sampleRate) {
const pcm = new ArrayBuffer(samples.length * 2);
const pcmView = new DataView(pcm);
for (let index = 0; index < samples.length; index++) {
const value = Math.max(-1, Math.min(1, samples[index]));
pcmView.setInt16(index * 2, value < 0 ? value * 32768 : value * 32767, true);
}
const wav = new ArrayBuffer(44 + pcm.byteLength);
const header = new DataView(wav);
function writeString(offset, value) {
for (let index = 0; index < value.length; index++)
header.setUint8(offset + index, value.charCodeAt(index));
}
writeString(0, "RIFF");
header.setUint32(4, 36 + pcm.byteLength, true);
writeString(8, "WAVE");
writeString(12, "fmt ");
header.setUint32(16, 16, true);
header.setUint16(20, 1, true);
header.setUint16(22, 1, true);
header.setUint32(24, sampleRate, true);
header.setUint32(28, sampleRate * 2, true);
header.setUint16(32, 2, true);
header.setUint16(34, 16, true);
writeString(36, "data");
header.setUint32(40, pcm.byteLength, true);
new Uint8Array(wav).set(new Uint8Array(pcm), 44);
return { pcm, wav };
}
class SampleQueue {
constructor() {
this.offset = 0;
this.size = 0;
}
get length() {
return this.size;
}
push(input) {
if (input.length === 0)
return;
const block = { samples: new Float32Array(input) };
if (this.tail)
this.tail.next = block;
else
this.head = block;
this.tail = block;
this.size += input.length;
}
take(count) {
if (!Number.isSafeInteger(count) || count <= 0)
throw new RangeError("Sample count must be a positive safe integer");
if (count > this.size)
return null;
const output = new Float32Array(count);
let written = 0;
while (this.head && written < count) {
const samples = this.head.samples;
const amount = Math.min(samples.length - this.offset, count - written);
output.set(samples.subarray(this.offset, this.offset + amount), written);
written += amount;
this.offset += amount;
if (this.offset === samples.length) {
this.head = this.head.next;
this.offset = 0;
}
}
if (!this.head)
this.tail = void 0;
this.size -= count;
return output;
}
clear() {
this.head = void 0;
this.tail = void 0;
this.offset = 0;
this.size = 0;
}
}
class Capture {
constructor(video, options, append) {
this.video = video;
this.options = options;
this.append = append;
this.queue = new SampleQueue();
this.closing = Promise.resolve();
this.running = false;
this.terminal = false;
this.epoch = 0;
}
reset() {
this.epoch++;
this.pending = void 0;
this.queue.clear();
}
pause() {
this.reset();
this.running = false;
clearInterval(this.timer);
this.timer = void 0;
this.starting = void 0;
this.graph?.idle();
}
restart() {
const active = this.running || Boolean(this.starting);
this.pause();
if (this.graph && !this.graph.ownsMediaConnection)
return this.stop().then(() => active ? this.start() : void 0);
return active ? this.start() : Promise.resolve();
}
start() {
if (this.terminal || this.running)
return Promise.resolve();
if (this.starting)
return this.starting;
const epoch = this.epoch;
const current = () => !this.terminal && this.epoch === epoch;
const operation = (async () => {
await this.closing;
if (!current())
return;
const { sampleRate, interval } = this.options;
const count = Math.floor(sampleRate * interval / 1e3);
if (!Number.isSafeInteger(count) || count < 1 || !Number.isFinite(interval) || interval <= 0)
throw new Error("Audio chunk length must be positive and finite");
const graph = this.graph ?? (this.graph = new AudioGraph(this.video, sampleRate));
await graph.prepare();
if (!current()) {
if (!this.running && this.graph === graph)
graph.idle();
return;
}
graph.connect((samples) => {
if (!this.running || this.graph !== graph)
return;
if (this.queue.length + samples.length > Math.max(sampleRate * 60, count * 2)) {
console.error("[artplayerPluginAsr] Audio callback backlog exceeded its capture limit");
this.pause();
return;
}
this.queue.push(samples);
});
this.running = true;
this.timer = setInterval(() => this.tick(count), interval);
})().catch(async (error) => {
if (current()) {
console.error("[artplayerPluginAsr] Initialization failed:", error);
await this.stop();
}
}).finally(() => {
if (this.starting === operation)
this.starting = void 0;
});
this.starting = operation;
return operation;
}
async tick(count) {
if (!this.running || this.pending)
return;
const samples = this.queue.take(count);
if (!samples)
return;
const epoch = this.epoch;
const pending = this.pending = {};
try {
const text = await this.options.onAudioChunk(encodeAudio(samples, this.options.sampleRate));
if (this.running && !this.terminal && this.epoch === epoch)
this.append(text);
} catch (error) {
if (this.epoch === epoch && !this.terminal)
console.error("[artplayerPluginAsr] Audio callback failed:", error);
} finally {
if (this.pending === pending)
this.pending = void 0;
}
}
volume(value) {
this.graph?.volume(value);
}
stop(terminal = false) {
this.terminal || (this.terminal = terminal);
this.pause();
const graph = this.graph;
if (terminal || !graph?.ownsMediaConnection) {
this.graph = void 0;
this.closing = Promise.all([this.closing, graph?.close()]).then(() => void 0);
}
return this.closing;
}
}
const style = ".art-video-player .art-layer-asr {\n pointer-events: none !important;\n position: absolute;\n z-index: 150;\n inset: 0px;\n display: flex;\n flex-direction: column;\n justify-content: end;\n padding: 0 2%;\n font-size: 1rem;\n gap: var(--art-subtitle-gap);\n padding-bottom: var(--art-subtitle-bottom);\n transition: padding-bottom var(--art-transition-duration) ease;\n text-shadow: var(--art-subtitle-border) 1px 0 1px, var(--art-subtitle-border) 0 1px 1px, var(--art-subtitle-border) -1px 0 1px, var(--art-subtitle-border) 0 -1px 1px, var(--art-subtitle-border) 1px 1px 1px, var(--art-subtitle-border) -1px -1px 1px, var(--art-subtitle-border) 1px -1px 1px, var(--art-subtitle-border) -1px 1px 1px;\n}\n.art-video-player.art-control-show .art-layer-asr {\n padding-bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));\n}\n";
function createSubtitles(layer, length, autoHideTimeout) {
let timer;
let destroyed = false;
function cancelTimer() {
clearTimeout(timer);
timer = void 0;
}
function hide() {
if (!destroyed)
layer.style.display = "none";
}
function append(subtitle) {
if (destroyed || typeof subtitle !== "string")
return;
cancelTimer();
timer = setTimeout(hide, autoHideTimeout);
layer.style.display = "";
layer.innerHTML = subtitle.split(/(?<=[、。!?!?.])\s*/u).map((line) => line.trim()).filter(Boolean).slice(-length).map((line) => `<div class="art-asr-line">${line}</div>`).join("");
}
return {
append,
hide,
destroy() {
destroyed = true;
cancelTimer();
}
};
}
if (typeof document !== "undefined") {
if (!document.getElementById("artplayer-plugin-asr")) {
const $style = document.createElement("style");
$style.id = "artplayer-plugin-asr";
$style.textContent = style;
document.head.appendChild($style);
}
function artplayerPluginAsr(option = {}) {
const { length = 3, interval = 100, sampleRate = 16e3, autoHideTimeout = 1e4, onAudioChunk = () => null } = option;
return (art) => {
const layer = art.layers.add({ name: "asr", html: "" });
if (!layer)
throw new Error("Could not create ASR subtitle layer");
const subtitles = createSubtitles(layer, length, autoHideTimeout);
const capture = new Capture(art.video, { interval, sampleRate, onAudioChunk }, subtitles.append);
const volume = () => capture.volume(art.volume);
const play = () => capture.start();
const pause = () => capture.pause();
const restart = () => capture.restart();
const stop = () => capture.stop();
const destroy = () => {
subtitles.destroy();
art.off("video:volumechange", volume);
art.off("play", play);
art.off("pause", pause);
art.off("restart", restart);
art.off("destroy", destroy);
return capture.stop(true);
};
art.on("video:volumechange", volume);
art.on("play", play);
art.on("pause", pause);
art.on("restart", restart);
art.on("destroy", destroy);
return { name: "artplayerPluginAsr", stop, hide: subtitles.hide, append: subtitles.append };
};
}
Object.defineProperty(artplayerPluginAsr, "default", { value: artplayerPluginAsr });
if (typeof document !== "undefined" && !document.getElementById("artplayer-plugin-asr")) {
const styleElement = document.createElement("style");
styleElement.id = "artplayer-plugin-asr";
styleElement.textContent = style;
document.head.appendChild(styleElement);
}
export {
artplayerPluginAsr as default
+3 -3
View File
@@ -49,9 +49,9 @@
"test:baseline": "node --test refactor/scripts/*.test.mjs",
"ci:check": "yarn check:toolchain --strict && yarn check:commits --report && yarn check:impact --report && yarn check:ci && yarn test:contracts && yarn check:contracts --report && yarn check:plan && yarn lint && yarn check:types && yarn typecheck && yarn test",
"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: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-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: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: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": "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"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
+80 -27
View File
@@ -1,33 +1,86 @@
# ASR maintenance
The current implementation is still in `src/index.js`. TypeScript migration and
resource ownership changes are tracked in the repository refactor plan as
PKG-ASR-02 through PKG-ASR-06. Do not interpret the baseline tests as completion.
The player captures first-channel audio and hands PCM/WAV to a caller-provided
recognizer. There is no network request, microphone permission or embedded model.
- The default factory snapshots options. Registration creates the `asr` layer
and subscribes to player play, pause, volumechange and destroy events.
- Play creates an AudioContext and tries a media element source, then a captured
MediaStream source. An inline Blob contains the `recorder-processor` Worklet.
- Worklet messages supply first-channel Float32 samples. The interval callback
encodes little-endian mono PCM16 and a 44-byte-header WAV and invokes the
caller's `onAudioChunk({ pcm, wav })`. A returned string replaces subtitles.
- `append` splits punctuation and sets HTML in `div.art-asr-line`; `hide` changes
display only. `src/style.less` owns layout and existing player CSS variables.
- `stop()` is actually asynchronous and releases audio resources, but retains
playback subscriptions. It is not a permanent disable API. There is no public
`start()` method. Public declarations currently describe stop as returning void.
| File | Responsibility |
| --- | --- |
| src/index.ts | Snapshot options, register player events, return the compatible facade and install the stylesheet |
| src/types.ts | Internal options, result and browser capability types; public declarations remain in types/ |
| src/subtitles.ts | Punctuation/HTML rendering and the owned auto-hide timer |
| src/capture.ts | Initialization deduplication, active generation, FIFO consumption and one pending recognizer call |
| src/audio-graph.ts | AudioContext, element/stream source, Worklet loading, playback routing and recorder ownership |
| src/sample-queue.ts | Copied sample blocks with a linked queue and head offset; preserves partial blocks and tails |
| src/encoding.ts | Pure PCM16LE/WAV encoding, including historical clamping and the 44-byte mono WAV header |
| src/worklet.ts | Inline processor code; first-channel messages and the existing recorder-processor registration name |
| src/style.less | Existing art-layer-asr layout, art-asr-line content and player CSS variables |
This package does not implement recognition or open an ASR network connection.
The demo service and its WebSocket belong to the caller. Do not run that service
to validate audio capture; use local media and a local callback.
Dependencies flow from the facade into subtitles/capture, then from capture into
the graph, queue and pure encoder. Audio modules do not receive the whole player.
The Worklet is loaded from a temporary Blob before binding the media element, so
a failed module load can close its context without taking over video playback.
The initial implementation has suspected chunk loss, partial initialization
cleanup and stale callback issues. Preserve the frozen historical tests while
adding candidate regression tests when fixing these. Keep callback/stop typing
compatible with historical assignments, and preserve public entrypoints, HTML
rendering behavior, layer names and stylesheet identity.
## States and ownership
From the repository root, `yarn test:asr` verifies historical public behavior;
`yarn test:baseline` includes archive provenance. `yarn dev artplayer-plugin-asr`
and `yarn build artplayer-plugin-asr` use repository build tools. Real browser
audio and lifecycle validation remains a separate required migration step.
Play shares one pending initialization. Successful setup attaches one recorder
and starts one interval. Each tick takes exactly one complete block; an incomplete
block stays queued. Only one callback is pending within the active generation.
Rejections are logged and later ticks continue. Pause, source restart, stop and
destroy invalidate old recognizer completions and discard obsolete queued audio.
Each recorder also checks its own identity, rejecting messages from retired ports.
Pause detaches the recorder and cancels the interval. The direct media source
continues through its playback gain to the destination. A source switch reuses the
direct source and replaces the recorder; a captured-stream fallback is closed and
reacquired because captured tracks can end on source changes.
`stop()` returns `Promise<void>` and releases ASR work. An already displayed
subtitle retains its original auto-hide deadline; obsolete callbacks cannot add timers.
It retains playback subscriptions and allows explicit append or a later play.
For a directly bound video, the player keeps its original source/context and audio
route until `art.destroy()`. Closing that context at every stop caused Chromium
to lose audio on restart; a media element cannot simply bind a replacement source.
For a captured fallback, stop closes its disposable context and stops its tracks.
Before direct binding, failed/cancelled initialization closes its context and
revokes the Worklet URL. Destroy closes all contexts, disconnects nodes, stops
captured tracks, cancels subtitle timers and removes subscriptions.
This lifetime follows the [Web Audio close semantics](https://www.w3.org/TR/webaudio-1.0/#dom-audiocontext-close)
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).
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
recognition under overload. A later play can restart capture. The caller owns
cancellation of its own network requests; obsolete completions cannot repaint.
## Compatibility and verification
The factory still snapshots options and registers synchronously, returning
`name`, `stop`, `hide`, `append`. There is no public start method. Append preserves
HTML, punctuation splitting, replacement, ignored non-string values and the
historical `length=0` behavior. The CJS function has a non-enumerable self `default`
alias for consumers of the 2.0 namespace, while preserving the callable 2.1 form.
Root public declarations have not yet migrated: they describe stop as void and
the callback as void/Promise<void>. PKG-ASR-04 owns exact asynchronous public types,
old assignment consumers, module forms and installed tarball acceptance. Internal
strict TypeScript does not prove that public type migration complete.
From the repository root:
- `yarn test:asr`: frozen public/audio observations, queue/encoding parity and
candidate lifecycle regressions. Historical defects remain separate tests.
- `yarn test:browser test/browser/asr-audio.spec.js`: local AAC, actual Worklet,
published/current core, pause/resume, source switch, stop/restart and destroy.
Set `ARTPLAYER_ASR_ARTIFACT` to an actual main/legacy file to test that artifact.
- `yarn typecheck`: includes the package strict/noUncheckedIndexedAccess project.
- `yarn dev artplayer-plugin-asr` and `yarn build artplayer-plugin-asr`: normal
repository development and three-format production builds.
Windows Playwright WebKit 26.6 lacks AudioContext and AudioWorkletNode. Its native
ASR cases remain explicit skips and do not establish Safari/device compatibility.
Real fallback track replacement, native volume/mute, CORS, unsupported media and
physical devices remain PKG-ASR-05 acceptance work. Do not use the demo's external
recognizer service as a prerequisite for local audio verification.
+5
View File
@@ -3,6 +3,11 @@
Audio capture and subtitle display plugin for ArtPlayer. Supplies PCM/WAV chunks
to a caller-provided recognition service.
`stop()` stops recognition and returns a Promise. It permits later playback to
restart capture; the player's direct Web Audio connection remains alive until
the player is destroyed so that stopping recognition does not silence playback.
Call `art.destroy()` when disposing the player.
## Maintenance
See [ARCHITECTURE.md](ARCHITECTURE.md) for the current audio flow, public behavior,
+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(t,e){"object"===typeof exports&&"undefined"!==typeof module?module.exports=e():"function"==typeof define&&define.amd?(t.artplayerPluginAsr=e(),define(function(){return t.artplayerPluginAsr})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAsr=e()}(this,function(){"use strict";if("undefined"!==typeof document&&!document.getElementById("artplayer-plugin-asr")){const t=document.createElement("style");t.id="artplayer-plugin-asr",t.textContent=".art-video-player .art-layer-asr{pointer-events:none!important;position:absolute;z-index:150;inset:0;display:flex;flex-direction:column;justify-content:end;padding:0 2%;font-size:1rem;gap:var(--art-subtitle-gap);padding-bottom:var(--art-subtitle-bottom);transition:padding-bottom var(--art-transition-duration) ease;text-shadow:var(--art-subtitle-border) 1px 0 1px,var(--art-subtitle-border) 0 1px 1px,var(--art-subtitle-border) -1px 0 1px,var(--art-subtitle-border) 0 -1px 1px,var(--art-subtitle-border) 1px 1px 1px,var(--art-subtitle-border) -1px -1px 1px,var(--art-subtitle-border) 1px -1px 1px,var(--art-subtitle-border) -1px 1px 1px}.art-video-player.art-control-show .art-layer-asr{padding-bottom:calc(var(--art-control-height) + var(--art-subtitle-bottom))}",document.head.appendChild(t)}return function(t={}){const{length:e=3,interval:n=100,sampleRate:r=16e3,autoHideTimeout:a=1e4,onAudioChunk:o=()=>null}=t;return t=>{let i=!1,l=null,s=null,u=null,c=null,d=[],p=null,f=!1,y=null,m=null,g=null;const h=t.layers.add({name:"asr",html:""});function w(){h.style.display="none"}function b(t){var n;"string"===typeof t&&(clearTimeout(y),y=setTimeout(w,a),h.style.display="",h.innerHTML=(n=t,n.split(/(?<=[\u3001\u3002\uff01\uff1f!?.])\s*/u).map(t=>t.trim()).filter(Boolean).slice(-e)).map(t=>`<div class="art-asr-line">${t}</div>`).join(""))}async function v(){if(!s&&t.video)try{return s=l.createMediaElementSource(t.video),s}catch(e){console.warn("[artplayerPluginAsr] Direct connection failed:",e)}try{if(m||(m=await async function(t){try{return t.captureStream?t.captureStream():t.mozCaptureStream?t.mozCaptureStream():(console.warn("[artplayerPluginAsr] captureStream not supported"),null)}catch(e){return console.warn("[artplayerPluginAsr] Error capturing stream:",e),null}}(t.video)),m)return g=l.createMediaStreamSource(m),g}catch(e){console.warn("[artplayerPluginAsr] MediaStream fallback failed:",e)}return null}async function x(){i&&(i=!1,clearInterval(p),p=null,u&&(u.disconnect(),u.port.onmessage=null,u=null),c&&(c.disconnect(),c=null),d=[])}async function A(){await x(),g&&(g.disconnect(),g=null),s&&(s.disconnect(),s=null),m&&(m.getTracks().forEach(t=>t.stop()),m=null),l&&(await l.close(),l=null),f=!1}return t.on("video:volumechange",()=>{c&&(c.gain.value=t.volume)}),t.on("play",async function(){if(!i)try{await async function(){return l||(l=new(window.AudioContext||window.webkitAudioContext)({sampleRate:r}),"suspended"===l.state&&await l.resume(),l)}();const t=await v();if(!t)throw new Error("Could not establish audio source");if(!f){const t=(()=>{const t=new Blob(["\n class 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 }\n registerProcessor('recorder-processor', RecorderProcessor);\n "],{type:"application/javascript"});return URL.createObjectURL(t)})();await l.audioWorklet.addModule(t),URL.revokeObjectURL(t),f=!0}c=l.createGain(),c.gain.value=1,u=new AudioWorkletNode(l,"recorder-processor"),u.port.onmessage=t=>{d.push(new Float32Array(t.data))},t.connect(u),t.connect(c),c.connect(l.destination);const e=r*(n/1e3);p=setInterval(async()=>{if(0===d.length)return;let t=new Float32Array(0);for(;t.length<e&&d.length;){const e=d.shift(),n=new Float32Array(t.length+e.length);n.set(t,0),n.set(e,t.length),t=n}if(t.length<e)return;const n=function(t){const e=t.length,n=new ArrayBuffer(2*e),r=new DataView(n);for(let a=0;a<e;a++){const e=Math.max(-1,Math.min(1,t[a]));r.setInt16(2*a,e<0?32768*e:32767*e,!0)}return n}(t.slice(0,e)),a=function(t,e){const n=t.byteLength,r=new ArrayBuffer(44+n),a=new DataView(r),o=(t,e)=>{for(let n=0;n<e.length;n++)a.setUint8(t+n,e.charCodeAt(n))};return o(0,"RIFF"),a.setUint32(4,36+n,!0),o(8,"WAVE"),o(12,"fmt "),a.setUint32(16,16,!0),a.setUint16(20,1,!0),a.setUint16(22,1,!0),a.setUint32(24,e,!0),a.setUint32(28,2*e,!0),a.setUint16(32,2,!0),a.setUint16(34,16,!0),o(36,"data"),a.setUint32(40,n,!0),new Uint8Array(r).set(new Uint8Array(t),44),r}(n,r);b(await o({pcm:n,wav:a}))},n),i=!0}catch(t){console.error("[artplayerPluginAsr] Initialization failed:",t),await x()}}),t.on("pause",x),t.on("destroy",A),{name:"artplayerPluginAsr",stop:A,hide:w,append:b}}}});
!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 n=this.gain=s||e.createGain();s||(n.gain.value=1);const r=this.recorder=new AudioWorkletNode(e,"recorder-processor");r.port.onmessage=e=>{this.closed||this.recorder!==r||t(e.data)},i.connect(r),i.connect(n),n.connect(e.destination)}volume(t){this.gain&&(this.gain.gain.value=t)}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,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 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,n=Math.floor(e*s/1e3);if(!Number.isSafeInteger(n)||n<1||!Number.isFinite(s)||s<=0)throw new Error("Audio chunk length must be positive and finite");const r=this.graph??(this.graph=new t(this.video,e));await r.prepare(),i()?(r.connect(t=>{if(this.running&&this.graph===r)return this.queue.length+t.length>Math.max(60*e,2*n)?(console.error("[artplayerPluginAsr] Audio callback backlog exceeded its capture limit"),void this.pause()):void this.queLine 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,n)=>new Promise((r,o)=>{var a=t=>{try{l(n.next(t))}catch(e){o(e)}},i=t=>{try{l(n.throw(t))}catch(e){o(e)}},l=t=>t.done?r(t.value):Promise.resolve(t.value).then(a,i);l((n=n.apply(t,e)).next())});if("undefined"!==typeof document&&!document.getElementById("artplayer-plugin-asr")){const t=document.createElement("style");t.id="artplayer-plugin-asr",t.textContent=".art-video-player .art-layer-asr{pointer-events:none!important;position:absolute;z-index:150;inset:0;display:flex;flex-direction:column;justify-content:end;padding:0 2%;font-size:1rem;gap:var(--art-subtitle-gap);padding-bottom:var(--art-subtitle-bottom);transition:padding-bottom var(--art-transition-duration) ease;text-shadow:var(--art-subtitle-border) 1px 0 1px,var(--art-subtitle-border) 0 1px 1px,var(--art-subtitle-border) -1px 0 1px,var(--art-subtitle-border) 0 -1px 1px,var(--art-subtitle-border) 1px 1px 1px,var(--art-subtitle-border) -1px -1px 1px,var(--art-subtitle-border) 1px -1px 1px,var(--art-subtitle-border) -1px 1px 1px}.art-video-player.art-control-show .art-layer-asr{padding-bottom:calc(var(--art-control-height) + var(--art-subtitle-bottom))}",document.head.appendChild(t)}return function(e={}){const{length:n=3,interval:r=100,sampleRate:o=16e3,autoHideTimeout:a=1e4,onAudioChunk:i=()=>null}=e;return e=>{let l=!1,s=null,u=null,c=null,d=null,p=[],f=null,y=!1,m=null,h=null,g=null;const v=e.layers.add({name:"asr",html:""});function b(){v.style.display="none"}function x(t){var e;"string"===typeof t&&(clearTimeout(m),m=setTimeout(b,a),v.style.display="",v.innerHTML=(e=t,e.split(new RegExp("(?<=[\u3001\u3002\uff01\uff1f!?.])\\s*","u")).map(t=>t.trim()).filter(Boolean).slice(-n)).map(t=>`<div class="art-asr-line">${t}</div>`).join(""))}function w(){return t(this,null,function*(){if(!u&&e.video)try{return u=s.createMediaElementSource(e.video),u}catch(n){console.warn("[artplayerPluginAsr] Direct connection failed:",n)}try{if(h||(h=yield function(e){return t(this,null,function*(){try{return e.captureStream?e.captureStream():e.mozCaptureStream?e.mozCaptureStream():(console.warn("[artplayerPluginAsr] captureStream not supported"),null)}catch(n){return console.warn("[artplayerPluginAsr] Error capturing stream:",n),null}})}(e.video)),h)return g=s.createMediaStreamSource(h),g}catch(n){console.warn("[artplayerPluginAsr] MediaStream fallback failed:",n)}return null})}function A(){return t(this,null,function*(){l&&(l=!1,clearInterval(f),f=null,c&&(c.disconnect(),c.port.onmessage=null,c=null),d&&(d.disconnect(),d=null),p=[])})}function U(){return t(this,null,function*(){yield A(),g&&(g.disconnect(),g=null),u&&(u.disconnect(),u=null),h&&(h.getTracks().forEach(t=>t.stop()),h=null),s&&(yield s.close(),s=null),y=!1})}return e.on("video:volumechange",()=>{d&&(d.gain.value=e.volume)}),e.on("play",function(){return t(this,null,function*(){if(!l)try{yield function(){return t(this,null,function*(){return s||(s=new(window.AudioContext||window.webkitAudioContext)({sampleRate:o}),"suspended"===s.state&&(yield s.resume()),s)})}();const e=yield w();if(!e)throw new Error("Could not establish audio source");if(!y){const t=(()=>{const t=new Blob(["\n class 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 }\n registerProcessor('recorder-processor', RecorderProcessor);\n "],{type:"application/javascript"});return URL.createObjectURL(t)})();yield s.audioWorklet.addModule(t),URL.revokeObjectURL(t),y=!0}d=s.createGain(),d.gain.value=1,c=new AudioWorkletNode(s,"recorder-processor"),c.port.onmessage=t=>{p.push(new Float32Array(t.data))},e.connect(c),e.connect(d),d.connect(s.destination);const n=o*(r/1e3);f=setInterval(()=>t(null,null,function*(){if(0===p.length)return;let t=new Float32Array(0);for(;t.length<n&&p.length;){const e=p.shift(),n=new Float32Array(t.length+e.length);n.set(t,0),n.set(e,t.length),t=n}if(t.length<n)return;const e=function(t){const e=t.length,n=new ArrayBuffer(2*e),r=new DataView(n);for(let o=0;o<e;o++){const e=Math.max(-1,Math.min(1,t[o]));r.setInt16(2*o,e<0?32768*e:32767*e,!0)}return n}(t.slice(0,n)),r=function(t,e){const n=t.byteLength,r=new ArrayBuffer(44+n),o=new DataView(r),a=(t,e)=>{for(let n=0;n<e.length;n++)o.setUint8(t+n,e.charCodeAt(n))};return a(0,"RIFF"),o.setUint32(4,36+n,!0),a(8,"WAVE"),a(12,"fmt "),o.setUint32(16,16,!0),o.setUint16(20,1,!0),o.setUint16(22,1,!0),o.setUint32(24,e,!0),o.setUint32(28,2*e,!0),o.setUint16(32,2,!0),o.setUintLine 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){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=1);const r=this.recorder=new AudioWorkletNode(e,"recorder-processor");r.port.onmessage=e=>{this.closed||this.recorder!==r||t(e.data)},i.connect(r),i.connect(n),n.connect(e.destination)}volume(t){this.gain&&(this.gain.gain.value=t)}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.isFiLine truncated
+374 -228
View File
@@ -4,248 +4,394 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
const style = ".art-video-player .art-layer-asr {\n pointer-events: none !important;\n position: absolute;\n z-index: 150;\n inset: 0px;\n display: flex;\n flex-direction: column;\n justify-content: end;\n padding: 0 2%;\n font-size: 1rem;\n gap: var(--art-subtitle-gap);\n padding-bottom: var(--art-subtitle-bottom);\n transition: padding-bottom var(--art-transition-duration) ease;\n text-shadow: var(--art-subtitle-border) 1px 0 1px, var(--art-subtitle-border) 0 1px 1px, var(--art-subtitle-border) -1px 0 1px, var(--art-subtitle-border) 0 -1px 1px, var(--art-subtitle-border) 1px 1px 1px, var(--art-subtitle-border) -1px -1px 1px, var(--art-subtitle-border) 1px -1px 1px, var(--art-subtitle-border) -1px 1px 1px;\n}\n.art-video-player.art-control-show .art-layer-asr {\n padding-bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));\n}\n";
function artplayerPluginAsr(option = {}) {
const {
length = 3,
interval = 100,
sampleRate = 16e3,
autoHideTimeout = 1e4,
onAudioChunk = () => null
} = option;
return (art) => {
let started = false;
let audioCtx = null;
let sourceNode = null;
let recorderNode = null;
let gainNode = null;
let bufferChunks = [];
let timer = null;
let workletLoaded = false;
let hideTimer = null;
let mediaStream = null;
let mediaStreamSource = null;
const $asr = art.layers.add({
name: "asr",
html: ""
});
function splitByPunctuation(text) {
return text.split(/(?<=[、。!?!?.])\s*/u).map((s) => s.trim()).filter(Boolean).slice(-length);
}
function hide() {
$asr.style.display = "none";
}
function append(subtitle) {
if (typeof subtitle !== "string")
return;
clearTimeout(hideTimer);
hideTimer = setTimeout(hide, autoHideTimeout);
$asr.style.display = "";
$asr.innerHTML = splitByPunctuation(subtitle).map((line) => `<div class="art-asr-line">${line}</div>`).join("");
}
const recorderProcessorCode = `
class RecorderProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (input && input[0]) {
this.port.postMessage(input[0]);
}
return true;
}
}
registerProcessor('recorder-processor', RecorderProcessor);
`;
const createWorkletBlobUrl = () => {
const blob = new Blob([recorderProcessorCode], { type: "application/javascript" });
return URL.createObjectURL(blob);
};
function floatTo16BitPCM(float32Array) {
const len = float32Array.length;
const buffer = new ArrayBuffer(len * 2);
const view = new DataView(buffer);
for (let i = 0; i < len; i++) {
const s = Math.max(-1, Math.min(1, float32Array[i]));
view.setInt16(i * 2, s < 0 ? s * 32768 : s * 32767, true);
}
return buffer;
}
function pcmToWav(pcmBuffer, sampleRate2) {
const pcmLength = pcmBuffer.byteLength;
const wavBuffer = new ArrayBuffer(44 + pcmLength);
const view = new DataView(wavBuffer);
const writeString = (offset, str) => {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
const recorderProcessorCode = `
class RecorderProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (input && input[0]) {
this.port.postMessage(input[0]);
}
};
writeString(0, "RIFF");
view.setUint32(4, 36 + pcmLength, true);
writeString(8, "WAVE");
writeString(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate2, true);
view.setUint32(28, sampleRate2 * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
writeString(36, "data");
view.setUint32(40, pcmLength, true);
new Uint8Array(wavBuffer).set(new Uint8Array(pcmBuffer), 44);
return wavBuffer;
return true;
}
async function setupAudioContext() {
if (audioCtx)
return audioCtx;
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate });
if (audioCtx.state === "suspended")
await audioCtx.resume();
return audioCtx;
}
registerProcessor('recorder-processor', RecorderProcessor);
`;
class AudioGraph {
constructor(video, sampleRate) {
this.video = video;
this.sampleRate = sampleRate;
this.closed = false;
this.direct = false;
this.workletLoaded = false;
}
assertOpen() {
if (this.closed)
throw new Error("Audio capture was closed");
}
revokeWorklet() {
if (this.blobUrl !== void 0) {
URL.revokeObjectURL(this.blobUrl);
this.blobUrl = void 0;
}
async function setupAudioSource() {
if (!sourceNode && art.video) {
try {
sourceNode = audioCtx.createMediaElementSource(art.video);
return sourceNode;
} catch (err) {
console.warn("[artplayerPluginAsr] Direct connection failed:", err);
}
}
}
async initialize() {
this.assertOpen();
const platform = window;
const Context = window.AudioContext || platform.webkitAudioContext;
if (!Context)
throw new Error("AudioContext is not supported");
const context = this.context ?? (this.context = new Context({ sampleRate: this.sampleRate }));
if (context.state === "suspended")
await context.resume();
this.assertOpen();
if (!this.workletLoaded) {
this.blobUrl = URL.createObjectURL(new Blob([recorderProcessorCode], { type: "application/javascript" }));
try {
if (!mediaStream) {
mediaStream = await captureAudioFromVideo(art.video);
}
if (mediaStream) {
mediaStreamSource = audioCtx.createMediaStreamSource(mediaStream);
return mediaStreamSource;
}
} catch (err) {
console.warn("[artplayerPluginAsr] MediaStream fallback failed:", err);
}
return null;
}
async function captureAudioFromVideo(videoElement) {
try {
if (videoElement.captureStream)
return videoElement.captureStream();
if (videoElement.mozCaptureStream)
return videoElement.mozCaptureStream();
console.warn("[artplayerPluginAsr] captureStream not supported");
return null;
} catch (err) {
console.warn("[artplayerPluginAsr] Error capturing stream:", err);
return null;
await context.audioWorklet.addModule(this.blobUrl);
this.workletLoaded = true;
} finally {
this.revokeWorklet();
}
}
async function startCapture() {
if (started)
return;
this.assertOpen();
if (!this.source) {
try {
await setupAudioContext();
const audioSource = await setupAudioSource();
if (!audioSource)
this.source = context.createMediaElementSource(this.video);
this.direct = true;
} catch (error) {
console.warn("[artplayerPluginAsr] Direct connection failed:", error);
const capture = this.video.captureStream || this.video.mozCaptureStream;
if (!capture)
throw new Error("Could not establish audio source");
if (!workletLoaded) {
const blobUrl = createWorkletBlobUrl();
await audioCtx.audioWorklet.addModule(blobUrl);
URL.revokeObjectURL(blobUrl);
workletLoaded = true;
}
gainNode = audioCtx.createGain();
gainNode.gain.value = 1;
recorderNode = new AudioWorkletNode(audioCtx, "recorder-processor");
recorderNode.port.onmessage = (event) => {
bufferChunks.push(new Float32Array(event.data));
};
audioSource.connect(recorderNode);
audioSource.connect(gainNode);
gainNode.connect(audioCtx.destination);
const CHUNK_SAMPLES = sampleRate * (interval / 1e3);
timer = setInterval(async () => {
if (bufferChunks.length === 0)
return;
let accumulated = new Float32Array(0);
while (accumulated.length < CHUNK_SAMPLES && bufferChunks.length) {
const next = bufferChunks.shift();
const tmp = new Float32Array(accumulated.length + next.length);
tmp.set(accumulated, 0);
tmp.set(next, accumulated.length);
accumulated = tmp;
}
if (accumulated.length < CHUNK_SAMPLES)
return;
const chunkToSend = accumulated.slice(0, CHUNK_SAMPLES);
const pcm = floatTo16BitPCM(chunkToSend);
const wav = pcmToWav(pcm, sampleRate);
const subtitle = await onAudioChunk({ pcm, wav });
append(subtitle);
}, interval);
started = true;
} catch (err) {
console.error("[artplayerPluginAsr] Initialization failed:", err);
await stopCapture();
this.stream = capture.call(this.video);
this.source = context.createMediaStreamSource(this.stream);
}
}
async function stopCapture() {
if (!started)
return;
started = false;
clearInterval(timer);
timer = null;
if (recorderNode) {
recorderNode.disconnect();
recorderNode.port.onmessage = null;
recorderNode = null;
}
if (gainNode) {
gainNode.disconnect();
gainNode = null;
}
bufferChunks = [];
}
prepare() {
this.preparation ?? (this.preparation = this.initialize().catch((error) => {
this.preparation = void 0;
throw error;
}));
return this.preparation;
}
get ownsMediaConnection() {
return this.direct;
}
idle() {
const gain = this.gain;
this.disconnect();
if (this.direct && this.context && this.source) {
this.gain = gain || this.context.createGain();
if (!gain)
this.gain.gain.value = 1;
this.source.connect(this.gain);
this.gain.connect(this.context.destination);
}
async function destroy() {
await stopCapture();
if (mediaStreamSource) {
mediaStreamSource.disconnect();
mediaStreamSource = null;
}
if (sourceNode) {
sourceNode.disconnect();
sourceNode = null;
}
if (mediaStream) {
mediaStream.getTracks().forEach((track) => track.stop());
mediaStream = null;
}
if (audioCtx) {
await audioCtx.close();
audioCtx = null;
}
workletLoaded = false;
}
art.on("video:volumechange", () => {
if (gainNode) {
gainNode.gain.value = art.volume;
}
});
art.on("play", startCapture);
art.on("pause", stopCapture);
art.on("destroy", destroy);
return {
name: "artplayerPluginAsr",
stop: destroy,
hide,
append
}
connect(receive) {
this.assertOpen();
const context = this.context;
const source = this.source;
if (!context || !source)
throw new Error("Audio capture is not prepared");
const previousGain = this.gain;
this.disconnect();
const gain = this.gain = previousGain || context.createGain();
if (!previousGain)
gain.gain.value = 1;
const recorder = this.recorder = new AudioWorkletNode(context, "recorder-processor");
recorder.port.onmessage = (event) => {
if (!this.closed && this.recorder === recorder)
receive(event.data);
};
source.connect(recorder);
source.connect(gain);
gain.connect(context.destination);
}
volume(value) {
if (this.gain)
this.gain.gain.value = value;
}
disconnect() {
if (this.recorder) {
this.recorder.port.onmessage = null;
this.recorder.disconnect();
this.recorder = void 0;
}
this.gain?.disconnect();
this.gain = void 0;
this.source?.disconnect();
}
async close() {
if (this.closed)
return;
this.closed = true;
this.disconnect();
this.revokeWorklet();
this.stream?.getTracks().forEach((track) => track.stop());
this.stream = void 0;
const context = this.context;
this.context = void 0;
this.source = void 0;
if (context && context.state !== "closed")
await context.close();
}
}
function encodeAudio(samples, sampleRate) {
const pcm = new ArrayBuffer(samples.length * 2);
const pcmView = new DataView(pcm);
for (let index = 0; index < samples.length; index++) {
const value = Math.max(-1, Math.min(1, samples[index]));
pcmView.setInt16(index * 2, value < 0 ? value * 32768 : value * 32767, true);
}
const wav = new ArrayBuffer(44 + pcm.byteLength);
const header = new DataView(wav);
function writeString(offset, value) {
for (let index = 0; index < value.length; index++)
header.setUint8(offset + index, value.charCodeAt(index));
}
writeString(0, "RIFF");
header.setUint32(4, 36 + pcm.byteLength, true);
writeString(8, "WAVE");
writeString(12, "fmt ");
header.setUint32(16, 16, true);
header.setUint16(20, 1, true);
header.setUint16(22, 1, true);
header.setUint32(24, sampleRate, true);
header.setUint32(28, sampleRate * 2, true);
header.setUint16(32, 2, true);
header.setUint16(34, 16, true);
writeString(36, "data");
header.setUint32(40, pcm.byteLength, true);
new Uint8Array(wav).set(new Uint8Array(pcm), 44);
return { pcm, wav };
}
class SampleQueue {
constructor() {
this.offset = 0;
this.size = 0;
}
get length() {
return this.size;
}
push(input) {
if (input.length === 0)
return;
const block = { samples: new Float32Array(input) };
if (this.tail)
this.tail.next = block;
else
this.head = block;
this.tail = block;
this.size += input.length;
}
take(count) {
if (!Number.isSafeInteger(count) || count <= 0)
throw new RangeError("Sample count must be a positive safe integer");
if (count > this.size)
return null;
const output = new Float32Array(count);
let written = 0;
while (this.head && written < count) {
const samples = this.head.samples;
const amount = Math.min(samples.length - this.offset, count - written);
output.set(samples.subarray(this.offset, this.offset + amount), written);
written += amount;
this.offset += amount;
if (this.offset === samples.length) {
this.head = this.head.next;
this.offset = 0;
}
}
if (!this.head)
this.tail = void 0;
this.size -= count;
return output;
}
clear() {
this.head = void 0;
this.tail = void 0;
this.offset = 0;
this.size = 0;
}
}
class Capture {
constructor(video, options, append) {
this.video = video;
this.options = options;
this.append = append;
this.queue = new SampleQueue();
this.closing = Promise.resolve();
this.running = false;
this.terminal = false;
this.epoch = 0;
}
reset() {
this.epoch++;
this.pending = void 0;
this.queue.clear();
}
pause() {
this.reset();
this.running = false;
clearInterval(this.timer);
this.timer = void 0;
this.starting = void 0;
this.graph?.idle();
}
restart() {
const active = this.running || Boolean(this.starting);
this.pause();
if (this.graph && !this.graph.ownsMediaConnection)
return this.stop().then(() => active ? this.start() : void 0);
return active ? this.start() : Promise.resolve();
}
start() {
if (this.terminal || this.running)
return Promise.resolve();
if (this.starting)
return this.starting;
const epoch = this.epoch;
const current = () => !this.terminal && this.epoch === epoch;
const operation = (async () => {
await this.closing;
if (!current())
return;
const { sampleRate, interval } = this.options;
const count = Math.floor(sampleRate * interval / 1e3);
if (!Number.isSafeInteger(count) || count < 1 || !Number.isFinite(interval) || interval <= 0)
throw new Error("Audio chunk length must be positive and finite");
const graph = this.graph ?? (this.graph = new AudioGraph(this.video, sampleRate));
await graph.prepare();
if (!current()) {
if (!this.running && this.graph === graph)
graph.idle();
return;
}
graph.connect((samples) => {
if (!this.running || this.graph !== graph)
return;
if (this.queue.length + samples.length > Math.max(sampleRate * 60, count * 2)) {
console.error("[artplayerPluginAsr] Audio callback backlog exceeded its capture limit");
this.pause();
return;
}
this.queue.push(samples);
});
this.running = true;
this.timer = setInterval(() => this.tick(count), interval);
})().catch(async (error) => {
if (current()) {
console.error("[artplayerPluginAsr] Initialization failed:", error);
await this.stop();
}
}).finally(() => {
if (this.starting === operation)
this.starting = void 0;
});
this.starting = operation;
return operation;
}
async tick(count) {
if (!this.running || this.pending)
return;
const samples = this.queue.take(count);
if (!samples)
return;
const epoch = this.epoch;
const pending = this.pending = {};
try {
const text = await this.options.onAudioChunk(encodeAudio(samples, this.options.sampleRate));
if (this.running && !this.terminal && this.epoch === epoch)
this.append(text);
} catch (error) {
if (this.epoch === epoch && !this.terminal)
console.error("[artplayerPluginAsr] Audio callback failed:", error);
} finally {
if (this.pending === pending)
this.pending = void 0;
}
}
volume(value) {
this.graph?.volume(value);
}
stop(terminal = false) {
this.terminal || (this.terminal = terminal);
this.pause();
const graph = this.graph;
if (terminal || !graph?.ownsMediaConnection) {
this.graph = void 0;
this.closing = Promise.all([this.closing, graph?.close()]).then(() => void 0);
}
return this.closing;
}
}
const style = ".art-video-player .art-layer-asr {\n pointer-events: none !important;\n position: absolute;\n z-index: 150;\n inset: 0px;\n display: flex;\n flex-direction: column;\n justify-content: end;\n padding: 0 2%;\n font-size: 1rem;\n gap: var(--art-subtitle-gap);\n padding-bottom: var(--art-subtitle-bottom);\n transition: padding-bottom var(--art-transition-duration) ease;\n text-shadow: var(--art-subtitle-border) 1px 0 1px, var(--art-subtitle-border) 0 1px 1px, var(--art-subtitle-border) -1px 0 1px, var(--art-subtitle-border) 0 -1px 1px, var(--art-subtitle-border) 1px 1px 1px, var(--art-subtitle-border) -1px -1px 1px, var(--art-subtitle-border) 1px -1px 1px, var(--art-subtitle-border) -1px 1px 1px;\n}\n.art-video-player.art-control-show .art-layer-asr {\n padding-bottom: calc(var(--art-control-height) + var(--art-subtitle-bottom));\n}\n";
function createSubtitles(layer, length, autoHideTimeout) {
let timer;
let destroyed = false;
function cancelTimer() {
clearTimeout(timer);
timer = void 0;
}
function hide() {
if (!destroyed)
layer.style.display = "none";
}
function append(subtitle) {
if (destroyed || typeof subtitle !== "string")
return;
cancelTimer();
timer = setTimeout(hide, autoHideTimeout);
layer.style.display = "";
layer.innerHTML = subtitle.split(/(?<=[、。!?!?.])\s*/u).map((line) => line.trim()).filter(Boolean).slice(-length).map((line) => `<div class="art-asr-line">${line}</div>`).join("");
}
return {
append,
hide,
destroy() {
destroyed = true;
cancelTimer();
}
};
}
if (typeof document !== "undefined") {
if (!document.getElementById("artplayer-plugin-asr")) {
const $style = document.createElement("style");
$style.id = "artplayer-plugin-asr";
$style.textContent = style;
document.head.appendChild($style);
}
function artplayerPluginAsr(option = {}) {
const { length = 3, interval = 100, sampleRate = 16e3, autoHideTimeout = 1e4, onAudioChunk = () => null } = option;
return (art) => {
const layer = art.layers.add({ name: "asr", html: "" });
if (!layer)
throw new Error("Could not create ASR subtitle layer");
const subtitles = createSubtitles(layer, length, autoHideTimeout);
const capture = new Capture(art.video, { interval, sampleRate, onAudioChunk }, subtitles.append);
const volume = () => capture.volume(art.volume);
const play = () => capture.start();
const pause = () => capture.pause();
const restart = () => capture.restart();
const stop = () => capture.stop();
const destroy = () => {
subtitles.destroy();
art.off("video:volumechange", volume);
art.off("play", play);
art.off("pause", pause);
art.off("restart", restart);
art.off("destroy", destroy);
return capture.stop(true);
};
art.on("video:volumechange", volume);
art.on("play", play);
art.on("pause", pause);
art.on("restart", restart);
art.on("destroy", destroy);
return { name: "artplayerPluginAsr", stop, hide: subtitles.hide, append: subtitles.append };
};
}
Object.defineProperty(artplayerPluginAsr, "default", { value: artplayerPluginAsr });
if (typeof document !== "undefined" && !document.getElementById("artplayer-plugin-asr")) {
const styleElement = document.createElement("style");
styleElement.id = "artplayer-plugin-asr";
styleElement.textContent = style;
document.head.appendChild(styleElement);
}
export {
artplayerPluginAsr as default
@@ -0,0 +1,144 @@
import type { CaptureVideo } from './types'
import { recorderProcessorCode } from './worklet'
export class AudioGraph {
private context: AudioContext | undefined
private source: AudioNode | undefined
private stream: MediaStream | undefined
private recorder: AudioWorkletNode | undefined
private gain: GainNode | undefined
private preparation: Promise<void> | undefined
private blobUrl: string | undefined
private closed = false
private direct = false
private workletLoaded = false
constructor(private readonly video: CaptureVideo, private readonly sampleRate: number) {}
private assertOpen() {
if (this.closed)
throw new Error('Audio capture was closed')
}
private revokeWorklet() {
if (this.blobUrl !== undefined) {
URL.revokeObjectURL(this.blobUrl)
this.blobUrl = undefined
}
}
private async initialize() {
this.assertOpen()
const platform = window as Window & { webkitAudioContext?: typeof AudioContext }
const Context = window.AudioContext || platform.webkitAudioContext
if (!Context)
throw new Error('AudioContext is not supported')
const context = this.context ??= new Context({ sampleRate: this.sampleRate })
if (context.state === 'suspended')
await context.resume()
this.assertOpen()
if (!this.workletLoaded) {
this.blobUrl = URL.createObjectURL(new Blob([recorderProcessorCode], { type: 'application/javascript' }))
try {
await context.audioWorklet.addModule(this.blobUrl)
this.workletLoaded = true
}
finally {
this.revokeWorklet()
}
}
this.assertOpen()
if (!this.source) {
try {
this.source = context.createMediaElementSource(this.video)
this.direct = true
}
catch (error) {
console.warn('[artplayerPluginAsr] Direct connection failed:', error)
const capture = this.video.captureStream || this.video.mozCaptureStream
if (!capture)
throw new Error('Could not establish audio source')
this.stream = capture.call(this.video)
this.source = context.createMediaStreamSource(this.stream)
}
}
}
prepare() {
this.preparation ??= this.initialize().catch((error: unknown) => {
this.preparation = undefined
throw error
})
return this.preparation
}
get ownsMediaConnection() {
return this.direct
}
idle() {
const gain = this.gain
this.disconnect()
// A media element cannot be reattached after its original context is closed.
// Keep its audio route alive until the player releases the element itself.
if (this.direct && this.context && this.source) {
this.gain = gain || this.context.createGain()
if (!gain)
this.gain.gain.value = 1
this.source.connect(this.gain)
this.gain.connect(this.context.destination)
}
}
connect(receive: (samples: Float32Array) => void) {
this.assertOpen()
const context = this.context
const source = this.source
if (!context || !source)
throw new Error('Audio capture is not prepared')
const previousGain = this.gain
this.disconnect()
const gain = this.gain = previousGain || context.createGain()
if (!previousGain)
gain.gain.value = 1
const recorder = this.recorder = new AudioWorkletNode(context, 'recorder-processor')
recorder.port.onmessage = (event: MessageEvent<Float32Array>) => {
if (!this.closed && this.recorder === recorder)
receive(event.data)
}
source.connect(recorder)
source.connect(gain)
gain.connect(context.destination)
}
volume(value: number) {
if (this.gain)
this.gain.gain.value = value
}
disconnect() {
if (this.recorder) {
this.recorder.port.onmessage = null
this.recorder.disconnect()
this.recorder = undefined
}
this.gain?.disconnect()
this.gain = undefined
this.source?.disconnect()
}
async close() {
if (this.closed)
return
this.closed = true
this.disconnect()
this.revokeWorklet()
this.stream?.getTracks().forEach(track => track.stop())
this.stream = undefined
const context = this.context
this.context = undefined
this.source = undefined
if (context && context.state !== 'closed')
await context.close()
}
}
@@ -0,0 +1,127 @@
import type { CaptureOptions, CaptureVideo } from './types'
import { AudioGraph } from './audio-graph'
import { encodeAudio } from './encoding'
import { SampleQueue } from './sample-queue'
export class Capture {
private graph: AudioGraph | undefined
private readonly queue = new SampleQueue()
private timer: ReturnType<typeof setInterval> | undefined
private starting: Promise<void> | undefined
private closing: Promise<void> = Promise.resolve()
private running = false
private terminal = false
private epoch = 0
private pending: object | undefined
constructor(private readonly video: CaptureVideo, private readonly options: CaptureOptions, private readonly append: (text: unknown) => void) {}
private reset() {
this.epoch++
this.pending = undefined
this.queue.clear()
}
pause() {
this.reset()
this.running = false
clearInterval(this.timer)
this.timer = undefined
this.starting = undefined
this.graph?.idle()
}
restart() {
const active = this.running || Boolean(this.starting)
this.pause()
if (this.graph && !this.graph.ownsMediaConnection)
return this.stop().then(() => active ? this.start() : undefined)
return active ? this.start() : Promise.resolve()
}
start(): Promise<void> {
if (this.terminal || this.running)
return Promise.resolve()
if (this.starting)
return this.starting
const epoch = this.epoch
const current = () => !this.terminal && this.epoch === epoch
const operation = (async () => {
await this.closing
if (!current())
return
const { sampleRate, interval } = this.options
const count = Math.floor(sampleRate * interval / 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)
await graph.prepare()
if (!current()) {
if (!this.running && this.graph === graph)
graph.idle()
return
}
graph.connect((samples) => {
if (!this.running || this.graph !== graph)
return
// Stop overload explicitly instead of retaining an unbounded recognition backlog.
if (this.queue.length + samples.length > Math.max(sampleRate * 60, count * 2)) {
console.error('[artplayerPluginAsr] Audio callback backlog exceeded its capture limit')
this.pause()
return
}
this.queue.push(samples)
})
this.running = true
this.timer = setInterval(() => this.tick(count), interval)
})().catch(async (error: unknown) => {
if (current()) {
console.error('[artplayerPluginAsr] Initialization failed:', error)
await this.stop()
}
}).finally(() => {
if (this.starting === operation)
this.starting = undefined
})
this.starting = operation
return operation
}
private async tick(count: number) {
if (!this.running || this.pending)
return
const samples = this.queue.take(count)
if (!samples)
return
const epoch = this.epoch
const pending = this.pending = {}
try {
const text = await this.options.onAudioChunk(encodeAudio(samples, this.options.sampleRate))
if (this.running && !this.terminal && this.epoch === epoch)
this.append(text)
}
catch (error) {
if (this.epoch === epoch && !this.terminal)
console.error('[artplayerPluginAsr] Audio callback failed:', error)
}
finally {
if (this.pending === pending)
this.pending = undefined
}
}
volume(value: number) {
this.graph?.volume(value)
}
stop(terminal = false) {
this.terminal ||= terminal
this.pause()
const graph = this.graph
if (terminal || !graph?.ownsMediaConnection) {
this.graph = undefined
this.closing = Promise.all([this.closing, graph?.close()]).then(() => undefined)
}
return this.closing
}
}
@@ -0,0 +1,31 @@
export function encodeAudio(samples: Float32Array, sampleRate: number): { pcm: ArrayBuffer, wav: ArrayBuffer } {
const pcm = new ArrayBuffer(samples.length * 2)
const pcmView = new DataView(pcm)
for (let index = 0; index < samples.length; index++) {
const value = Math.max(-1, Math.min(1, samples[index]!))
pcmView.setInt16(index * 2, value < 0 ? value * 0x8000 : value * 0x7FFF, true)
}
const wav = new ArrayBuffer(44 + pcm.byteLength)
const header = new DataView(wav)
function writeString(offset: number, value: string): void {
for (let index = 0; index < value.length; index++)
header.setUint8(offset + index, value.charCodeAt(index))
}
writeString(0, 'RIFF')
header.setUint32(4, 36 + pcm.byteLength, true)
writeString(8, 'WAVE')
writeString(12, 'fmt ')
header.setUint32(16, 16, true)
header.setUint16(20, 1, true)
header.setUint16(22, 1, true)
header.setUint32(24, sampleRate, true)
header.setUint32(28, sampleRate * 2, true)
header.setUint16(32, 2, true)
header.setUint16(34, 16, true)
writeString(36, 'data')
header.setUint32(40, pcm.byteLength, true)
new Uint8Array(wav).set(new Uint8Array(pcm), 44)
return { pcm, wav }
}
-299
View File
@@ -1,299 +0,0 @@
import style from './style.less?inline'
export default function artplayerPluginAsr(option = {}) {
const {
length = 3,
interval = 100,
sampleRate = 16000,
autoHideTimeout = 10000,
onAudioChunk = () => null,
} = option
return (art) => {
let started = false
let audioCtx = null
let sourceNode = null
let recorderNode = null
let gainNode = null
let bufferChunks = []
let timer = null
let workletLoaded = false
let hideTimer = null
let mediaStream = null
let mediaStreamSource = null
const $asr = art.layers.add({
name: 'asr',
html: '',
})
function splitByPunctuation(text) {
return (
text
.split(/(?<=[、。!?!?.])\s*/u)
.map(s => s.trim())
.filter(Boolean)
.slice(-length)
)
}
function hide() {
$asr.style.display = 'none'
}
function append(subtitle) {
if (typeof subtitle !== 'string')
return
clearTimeout(hideTimer)
hideTimer = setTimeout(hide, autoHideTimeout)
$asr.style.display = ''
$asr.innerHTML = splitByPunctuation(subtitle)
.map(line => `<div class="art-asr-line">${line}</div>`)
.join('')
}
const recorderProcessorCode = `
class RecorderProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (input && input[0]) {
this.port.postMessage(input[0]);
}
return true;
}
}
registerProcessor('recorder-processor', RecorderProcessor);
`
const createWorkletBlobUrl = () => {
const blob = new Blob([recorderProcessorCode], { type: 'application/javascript' })
return URL.createObjectURL(blob)
}
function floatTo16BitPCM(float32Array) {
const len = float32Array.length
const buffer = new ArrayBuffer(len * 2)
const view = new DataView(buffer)
for (let i = 0; i < len; i++) {
const s = Math.max(-1, Math.min(1, float32Array[i]))
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
}
return buffer
}
function pcmToWav(pcmBuffer, sampleRate) {
const pcmLength = pcmBuffer.byteLength
const wavBuffer = new ArrayBuffer(44 + pcmLength)
const view = new DataView(wavBuffer)
const writeString = (offset, str) => {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i))
}
}
writeString(0, 'RIFF')
view.setUint32(4, 36 + pcmLength, true)
writeString(8, 'WAVE')
writeString(12, 'fmt ')
view.setUint32(16, 16, true)
view.setUint16(20, 1, true)
view.setUint16(22, 1, true)
view.setUint32(24, sampleRate, true)
view.setUint32(28, sampleRate * 2, true)
view.setUint16(32, 2, true)
view.setUint16(34, 16, true)
writeString(36, 'data')
view.setUint32(40, pcmLength, true)
new Uint8Array(wavBuffer).set(new Uint8Array(pcmBuffer), 44)
return wavBuffer
}
async function setupAudioContext() {
if (audioCtx)
return audioCtx
audioCtx = new (window.AudioContext || window.webkitAudioContext)({ sampleRate })
if (audioCtx.state === 'suspended')
await audioCtx.resume()
return audioCtx
}
async function setupAudioSource() {
if (!sourceNode && art.video) {
try {
sourceNode = audioCtx.createMediaElementSource(art.video)
return sourceNode
}
catch (err) {
console.warn('[artplayerPluginAsr] Direct connection failed:', err)
}
}
try {
if (!mediaStream) {
mediaStream = await captureAudioFromVideo(art.video)
}
if (mediaStream) {
mediaStreamSource = audioCtx.createMediaStreamSource(mediaStream)
return mediaStreamSource
}
}
catch (err) {
console.warn('[artplayerPluginAsr] MediaStream fallback failed:', err)
}
return null
}
async function captureAudioFromVideo(videoElement) {
try {
if (videoElement.captureStream)
return videoElement.captureStream()
if (videoElement.mozCaptureStream)
return videoElement.mozCaptureStream()
console.warn('[artplayerPluginAsr] captureStream not supported')
return null
}
catch (err) {
console.warn('[artplayerPluginAsr] Error capturing stream:', err)
return null
}
}
async function startCapture() {
if (started)
return
try {
await setupAudioContext()
const audioSource = await setupAudioSource()
if (!audioSource)
throw new Error('Could not establish audio source')
if (!workletLoaded) {
const blobUrl = createWorkletBlobUrl()
await audioCtx.audioWorklet.addModule(blobUrl)
URL.revokeObjectURL(blobUrl)
workletLoaded = true
}
gainNode = audioCtx.createGain()
gainNode.gain.value = 1
recorderNode = new AudioWorkletNode(audioCtx, 'recorder-processor')
recorderNode.port.onmessage = (event) => {
bufferChunks.push(new Float32Array(event.data))
}
audioSource.connect(recorderNode)
audioSource.connect(gainNode)
gainNode.connect(audioCtx.destination)
const CHUNK_SAMPLES = sampleRate * (interval / 1000)
timer = setInterval(async () => {
if (bufferChunks.length === 0)
return
let accumulated = new Float32Array(0)
while (accumulated.length < CHUNK_SAMPLES && bufferChunks.length) {
const next = bufferChunks.shift()
const tmp = new Float32Array(accumulated.length + next.length)
tmp.set(accumulated, 0)
tmp.set(next, accumulated.length)
accumulated = tmp
}
if (accumulated.length < CHUNK_SAMPLES)
return
const chunkToSend = accumulated.slice(0, CHUNK_SAMPLES)
const pcm = floatTo16BitPCM(chunkToSend)
const wav = pcmToWav(pcm, sampleRate)
const subtitle = await onAudioChunk({ pcm, wav })
append(subtitle)
}, interval)
started = true
}
catch (err) {
console.error('[artplayerPluginAsr] Initialization failed:', err)
await stopCapture()
}
}
async function stopCapture() {
if (!started)
return
started = false
clearInterval(timer)
timer = null
if (recorderNode) {
recorderNode.disconnect()
recorderNode.port.onmessage = null
recorderNode = null
}
if (gainNode) {
gainNode.disconnect()
gainNode = null
}
bufferChunks = []
}
async function destroy() {
await stopCapture()
if (mediaStreamSource) {
mediaStreamSource.disconnect()
mediaStreamSource = null
}
if (sourceNode) {
sourceNode.disconnect()
sourceNode = null
}
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop())
mediaStream = null
}
if (audioCtx) {
await audioCtx.close()
audioCtx = null
}
workletLoaded = false
}
art.on('video:volumechange', () => {
if (gainNode) {
gainNode.gain.value = art.volume
}
})
art.on('play', startCapture)
art.on('pause', stopCapture)
art.on('destroy', destroy)
return {
name: 'artplayerPluginAsr',
stop: destroy,
hide,
append,
}
}
}
if (typeof document !== 'undefined') {
if (!document.getElementById('artplayer-plugin-asr')) {
const $style = document.createElement('style')
$style.id = 'artplayer-plugin-asr'
$style.textContent = style
document.head.appendChild($style)
}
}
@@ -0,0 +1,46 @@
import type Artplayer from 'artplayer'
import type { AsrOptions, AsrResult } from './types'
import { Capture } from './capture'
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
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 volume = () => capture.volume(art.volume)
const play = () => capture.start()
const pause = () => capture.pause()
const restart = () => capture.restart()
const stop = () => capture.stop()
const destroy = () => {
subtitles.destroy()
art.off('video:volumechange', volume)
art.off('play', play)
art.off('pause', pause)
art.off('restart', restart)
art.off('destroy', destroy)
return capture.stop(true)
}
art.on('video:volumechange', volume)
art.on('play', play)
art.on('pause', pause)
art.on('restart', restart)
art.on('destroy', destroy)
return { name: 'artplayerPluginAsr', stop, hide: subtitles.hide, append: subtitles.append }
}
}
// Published 2.0 CJS consumers used require(...).default; 2.1 exported the function.
Object.defineProperty(artplayerPluginAsr, 'default', { value: artplayerPluginAsr })
if (typeof document !== 'undefined' && !document.getElementById('artplayer-plugin-asr')) {
const styleElement = document.createElement('style')
styleElement.id = 'artplayer-plugin-asr'
styleElement.textContent = style
document.head.appendChild(styleElement)
}
@@ -0,0 +1,59 @@
interface SampleBlock {
samples: Float32Array
next?: SampleBlock
}
export class SampleQueue {
private head?: SampleBlock
private tail?: SampleBlock
private offset = 0
private size = 0
get length(): number {
return this.size
}
push(input: Float32Array): void {
if (input.length === 0)
return
const block: SampleBlock = { samples: new Float32Array(input) }
if (this.tail)
this.tail.next = block
else
this.head = block
this.tail = block
this.size += input.length
}
take(count: number): Float32Array | null {
if (!Number.isSafeInteger(count) || count <= 0)
throw new RangeError('Sample count must be a positive safe integer')
if (count > this.size)
return null
const output = new Float32Array(count)
let written = 0
while (this.head && written < count) {
const samples = this.head.samples
const amount = Math.min(samples.length - this.offset, count - written)
output.set(samples.subarray(this.offset, this.offset + amount), written)
written += amount
this.offset += amount
if (this.offset === samples.length) {
this.head = this.head.next
this.offset = 0
}
}
if (!this.head)
this.tail = undefined
this.size -= count
return output
}
clear(): void {
this.head = undefined
this.tail = undefined
this.offset = 0
this.size = 0
}
}
@@ -0,0 +1,37 @@
export function createSubtitles(layer: HTMLElement, length: number, autoHideTimeout: number) {
let timer: ReturnType<typeof setTimeout> | undefined
let destroyed = false
function cancelTimer() {
clearTimeout(timer)
timer = undefined
}
function hide() {
if (!destroyed)
layer.style.display = 'none'
}
function append(subtitle: unknown) {
if (destroyed || typeof subtitle !== 'string')
return
cancelTimer()
timer = setTimeout(hide, autoHideTimeout)
layer.style.display = ''
layer.innerHTML = subtitle.split(/(?<=[、。!?!?.])\s*/u)
.map(line => line.trim())
.filter(Boolean)
.slice(-length)
.map(line => `<div class="art-asr-line">${line}</div>`)
.join('')
}
return {
append,
hide,
destroy() {
destroyed = true
cancelTimer()
},
}
}
@@ -0,0 +1,30 @@
export interface AudioChunk {
pcm: ArrayBuffer
wav: ArrayBuffer
}
export interface AsrOptions {
length?: number
interval?: number
sampleRate?: number
autoHideTimeout?: number
onAudioChunk?: (chunk: AudioChunk) => unknown
}
export interface AsrResult {
name: 'artplayerPluginAsr'
stop: () => Promise<void>
hide: () => void
append: (subtitle: string) => void
}
export interface CaptureVideo extends HTMLVideoElement {
captureStream?: () => MediaStream
mozCaptureStream?: () => MediaStream
}
export interface CaptureOptions {
interval: number
sampleRate: number
onAudioChunk: (chunk: AudioChunk) => unknown
}
@@ -0,0 +1,12 @@
export const recorderProcessorCode = `
class RecorderProcessor extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (input && input[0]) {
this.port.postMessage(input[0]);
}
return true;
}
}
registerProcessor('recorder-processor', RecorderProcessor);
`
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "types/**/*.d.ts", "../../types/assets.d.ts"]
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,62 @@
# PKG-ASR-03:拆分 ASR 并修复音频归属
从 53c295819db004e507b35689a7bdec1955e08396 开始,将单个生产 JS 文件拆成
8 个严格 TypeScript 模块。公共声明不在本任务改写,ASYNC/旧工厂类型与实际安装
消费者仍由 PKG-ASR-04 验收。无新依赖,版本不变,正常构建生成三格式及 docs 副本。
## 结构改动
index 只承担工厂、事件和兼容门面;subtitles 管字幕和隐藏定时器;capture 管代际、
初始化、节拍和回调;audio-graph 管浏览器节点;encoding/sample-queue/worklet
分离纯数据与处理器边界。完整维护地图在包内 ARCHITECTURE.md。
## 独立列明的缺陷修正
- 链式块队列保留不足块及尾部,复制输入且避免重复 concat/shift。编码与 7 个
历史实现逐字节匹配。每个活动代际只允许一个识别回调,拒绝被捕获且后续可继续。
- 暂停/切源/停止/终止使旧结果失效;旧结果不会解锁新回调。退休 recorder 的
消息即使已经被外部保留也被身份检查拒绝。首次候选回归发现此消息竞态后修复。
- 初始化去重,取消后不晚建录音图;Worklet URL 在成功、失败和终止路径释放。
Worklet 模块先准备再绑定 video,失败不会先接管并关闭视频的播放通路。
- stop 清录音/识别任务,保持可再次播放;已有字幕保留原自动隐藏期限,与历史版一致,
并拒绝旧识别结果新建隐藏任务。直接绑定视频的 context/source/
播放 gain 由播放器持有,直到 art.destroy 才关闭;pause/stop 保持播放路由。
捕获流 fallback 属于可释放的采集资源,stop 关闭;切源时重新取得流与音轨。
- destroy 清监听器、节点、流、timer,并禁止通过保留的 append 引用复活。
- 慢回调超过队列上限时明确报错并暂停采集,播放路由保留。上限取 60 秒与两个配置块
长度的较大值,以支持原本合法的长 interval;有超过 60 秒配置块的回归。不会无限缓存,
也不声称过载时音频无损;调用者网络请求仍由调用者负责取消。
- 工厂补不可枚举的自引用 default,兼容 2.0 的 require(...).default 及 2.1 的
require(...) 函数路径;保留键顺序、同步注册、选项快照、字幕 HTML/CSS 行为。
## 为什么 stop 不再关闭 direct context
实际 Chromium 在 close 后经 capture fallback 重启时无法得到非零 PCM;两个核心
组合失败,Firefox 两组合通过。原生探测报告保留在证据文件中,没有把能力失败
抹成通过。Web Audio 规定绑定后的媒体声音走节点图,close 会忽略关联媒体输出;
关闭后不能简单重新绑定节点来恢复。用户公开 stop/play 用法需要可靠恢复,因此
修正内部生命周期,而不删除重启测试或更改公开方法。
子代理独立复核了 [Web Audio close](https://www.w3.org/TR/webaudio-1.0/#dom-audiocontext-close)、
[媒体源节点](https://www.w3.org/TR/webaudio-1.0/#MediaElementAudioSourceNode)及
[捕获流切源](https://www.w3.org/TR/mediacapture-fromelement/#html-media-element-media-capture-extensions)。
保留 direct-first,避免把采样语义和兼容范围改成尚未全面支持的 capture-first。
旧版 close 行为在 ASR-02 历史测试中保留;候选测试要求停止后有播放路由、同一
context 恢复非零 PCM,最终销毁才关闭。初始化尚未绑定 video 时仍立即完整关闭。
## 验证与剩余范围
源代码分包 strict/noUncheckedIndexedAccess、定向 ESLint、正常 main/legacy/ESM
构建通过。历史/纯模块/候选共 210 项,真实 ESM 与副本校验另 2 项;main 与 legacy
各重跑 25 项候选生命周期。浏览器源码全组 12 通过/6 Windows WebKit 能力跳过,
main/legacy 候选各 4 通过/2 能力跳过;没有 failed/flaky。
具体输入哈希、报告、构建与浏览器版本见 [验证记录](../baselines/asr-ownership-validation.json)。
浏览器实际验证:两核心、非零 PCM/WAV、本地回调字幕、暂停恢复、同视频切源、
stop 后恢复及最终 context 关闭。Windows WebKit 无 WebAudio,不能代表 macOS Safari;
真实 fallback 音轨拓扑、音量/静音、跨源媒体、设备仍归 ASR-05。ASR-04 的公共类型和
实际包安装、ASR-06 的示例及发行文档仍未完成,本记录不表示整包已可发布。
仅运行本包、类型、构建和台账检查,未重复整仓全量 CI。两次子代理承担纯音频模块/
回归与规范审查,主代理负责图与控制器集成、浏览器、文档及提交。每项职责已核对。
回退本任务提交可恢复旧源码及构建产物,但会同时恢复已复现丢样本和生命周期缺陷。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 225 项,范围 22 个包及工作区/示例。
状态:todo 84 / doing 16 / blocked 0 / done 125 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 83 / doing 16 / blocked 0 / done 126 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -253,7 +253,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| PKG-ASR-01 | artplayer-plugin-asr<br>核对包契约与历史用法 | BASE-05 | length/interval/sampleRate/onAudioChunk、append/hide/stop 的真实能力 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
| 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 | todo |
| 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 | todo |
| PKG-ASR-05 | artplayer-plugin-asr<br>验证新旧核心和组合 | PKG-ASR-04, CORE-22 | 真实 WebAudio 分块、播放暂停/切源/销毁,不引入网络 ASR | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-ASR-06 | artplayer-plugin-asr<br>验证分发并同步文档 | PKG-ASR-05, ENG-07 | asr.js、Worklet 资源、输出格式与声明证据 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
@@ -528,6 +528,7 @@
- PKG-JASSUB-01: [记录](baselines/jassub-release.json) [记录](baselines/jassub-vendor.json) [记录](baselines/jassub-font-metadata.json) [记录](baselines/jassub-contract.md) [记录](baselines/jassub-contract-validation.json) [记录](changes/2026-09-13-PKG-JASSUB-01-baseline.md)
- PKG-ASR-01: [记录](changes/2026-09-13-PKG-ASR-01-contracts.md) [记录](baselines/asr-contract.md) [记录](baselines/asr-release.json)
- 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-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)
+15
View File
@@ -1,5 +1,20 @@
# 进度与证据
## PKG-ASR-03 TS 拆分与音频生命周期完成
生产源码拆为 8 个严格 TS 模块;队列保留半块/尾部,识别回调背压、拒绝恢复、
初始化去重和代际清理完成。退休 Worklet 消息反例由失败转通过。
Chromium stop 后不能恢复的原生问题已修复:直接媒体路由由播放器持有到 destroy,
stop 只停 ASR 并允许重新播放;先准备 Worklet 后绑定视频,失败可真正释放未绑定 context。
定向 212 项通过,main/legacy 各重跑 25 候选项;严格分包 tsc、lint、正常三格式构建通过。
最终浏览器源码 12 过/6 能力跳过,main/legacy 各 4 过/2 能力跳过;共 20 过、10 明确
Windows WebKit 无 WebAudio 跳过。非零 PCM、暂停、切源、stop 重启与 destroy 已实测。
见[变更](changes/2026-09-13-PKG-ASR-03-audio-ownership.md)和[证据](baselines/asr-ownership-validation.json)。
225 项:126 done、16 doing、83 todo;205 风险,两项 ASR 音频风险闭环。
下一步 ASR-04 公共异步类型与安装消费者;完整设备/fallback/音量和发行仍未完成。
没有重复全量 CI、没有推送或发布。子代理承担独立模块/测试和规范审查,主代理集成验收。
## PKG-ASR-02 音频与错误基线完成
子代理交付并由主代理审查的 133 项音频历史测试通过,连同公开契约/来源共 165 项。
+2 -2
View File
@@ -207,5 +207,5 @@
| CORE-DEFAULTS-SSR-01 | resolved / 已复现 | Reading static defaults throws when navigator is absent | CORE-25 |
| ENG-DIST-PUBLIC-01 | resolved / 已复现 | Vite copied authored declarations into library distribution | ENG-12 |
| ASR-TYPE-01 | open / 源码/产物事实 | ASR declarations omit asynchronous stop and callback text results | PKG-ASR-04 |
| ASR-CHUNK-01 | open / 已复现 | ASR tick discards underfilled chunks and overrun samples in source | PKG-ASR-02, PKG-ASR-03 |
| ASR-LIFE-01 | open / 已复现 | ASR partial initialization, pause resume and stale callback cleanup need controlled evidence | PKG-ASR-02, PKG-ASR-03, PKG-ASR-05 |
| ASR-CHUNK-01 | resolved / 已复现 | ASR tick discards underfilled chunks and overrun samples in source | PKG-ASR-02, PKG-ASR-03 |
| 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 |
+19 -5
View File
@@ -798,7 +798,7 @@
"EX-03"
],
"evidence": [
"packages/artplayer-plugin-asr/src/index.js",
"packages/artplayer-plugin-asr/src/index.ts",
"docs/assets/example/asr.js"
],
"compatibleResolution": "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.",
@@ -4561,7 +4561,7 @@
"id": "ASR-CHUNK-01",
"title": "ASR tick discards underfilled chunks and overrun samples in source",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-ASR-02",
"PKG-ASR-03"
@@ -4574,13 +4574,20 @@
"refactor/baselines/asr-audio-validation.json"
],
"compatibleResolution": "Reproduce exact sample loss and retain queue remainders with bounded, ordered callback delivery.",
"closureCriteria": "Exact PCM/WAV byte sequence and slow/rejected callback regression tests pass without losing or reordering accepted input."
"closureCriteria": "Exact PCM/WAV byte sequence and slow/rejected callback regression tests pass without losing or reordering accepted input.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-ASR-03-audio-ownership.md",
"refactor/baselines/asr-ownership-validation.json",
"test/asr-lifecycle.test.js",
"test/asr-encoding.test.js"
],
"resolutionRationale": "Copied linked blocks retain underfilled input and tails; exact byte parity and FIFO tests pass. One callback per generation prevents overlap; rejection recovery and obsolete completions are covered. Exceeding the larger of 60 seconds or two configured chunks pauses capture with an explicit error; long configured intervals remain collectable."
},
{
"id": "ASR-LIFE-01",
"title": "ASR partial initialization, pause resume and stale callback cleanup need controlled evidence",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"owners": [
"PKG-ASR-02",
"PKG-ASR-03",
@@ -4594,7 +4601,14 @@
"refactor/baselines/asr-audio-validation.json"
],
"compatibleResolution": "Own pending setup, URLs, source nodes and callbacks across pause, stop, restart and terminal destruction.",
"closureCriteria": "Failure/race regression tests plus native WebAudio playback and cleanup evidence prove resource ownership."
"closureCriteria": "Failure/race regression tests plus native WebAudio playback and cleanup evidence prove resource ownership.",
"resolutionEvidence": [
"refactor/changes/2026-09-13-PKG-ASR-03-audio-ownership.md",
"refactor/baselines/asr-ownership-validation.json",
"test/asr-lifecycle.test.js",
"test/asr-encoding.test.js"
],
"resolutionRationale": "Candidate ownership/race regression tests pass, including retired-port delivery and module failure before binding video. Actual Chromium/Firefox source/main/legacy with both cores verify pause, source change, explicit stop/restart and final close. Direct media contexts deliberately remain player-owned until destroy; disposable stream contexts close on stop. General native fallback/volume/device acceptance remains ASR-05 and SDK-11, not claimed by this specific fix."
}
]
}
+5 -2
View File
@@ -2660,11 +2660,14 @@
"CORE-10",
"CORE-18"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "AudioContext/Worklet/Stream 生命周期和背压分离",
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
"evidence": []
"evidence": [
"changes/2026-09-13-PKG-ASR-03-audio-ownership.md",
"baselines/asr-ownership-validation.json"
]
},
{
"id": "PKG-ASR-04",
+1 -1
View File
@@ -1240,7 +1240,7 @@
"EX-03"
],
"sourceFiles": [
"packages/artplayer-plugin-asr/src/index.js",
"packages/artplayer-plugin-asr/src/index.ts",
"docs/assets/example/asr.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.",
+34
View File
@@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Actual ASR distribution imports use the repository runner.
import test from 'node:test'
test('ASR built ESM imports without DOM and keeps both factory paths and synchronous registration', async () => {
const module = await import('../packages/artplayer-plugin-asr/dist/artplayer-plugin-asr.mjs')
assert.deepEqual(Object.keys(module), ['default'])
assert.equal(module.default.default, module.default)
assert.deepEqual(Object.keys(module.default), [])
const events = new Map()
const layer = { style: {}, innerHTML: '' }
const result = module.default({ length: 1 })({
video: {},
volume: 0.5,
layers: { add: () => layer },
on: (name, callback) => events.set(name, callback),
off: name => events.delete(name),
})
assert.equal(result.then, undefined)
assert.deepEqual(Object.keys(result), ['name', 'stop', 'hide', 'append'])
result.append('One. <b>Two</b>')
assert.equal(layer.innerHTML, '<div class="art-asr-line"><b>Two</b></div>')
await result.stop()
await events.get('destroy')()
assert.equal(events.size, 0)
})
test('ASR three generated package artifacts exactly match the demo copies', () => {
for (const suffix of ['js', 'legacy.js', 'mjs']) {
const name = `artplayer-plugin-asr.${suffix}`
assert.deepEqual(fs.readFileSync(`packages/artplayer-plugin-asr/dist/${name}`), fs.readFileSync(`docs/compiled/${name}`))
}
})
+183
View File
@@ -0,0 +1,183 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
// eslint-disable-next-line test/no-import-node-test -- Pure ASR audio modules use the repository runner.
import test from 'node:test'
import { asrAudioEnvironment, runAsrWorklet } from './helpers/asr-audio.js'
import { asrHistorical } from './helpers/asr.js'
import { loadModules } from './helpers/load.js'
const root = 'packages/artplayer-plugin-asr/src'
const { encodeAudio, SampleQueue, recorderProcessorCode } = await loadModules({
encodeAudio: { file: `${root}/encoding`, name: 'encodeAudio' },
SampleQueue: { file: `${root}/sample-queue`, name: 'SampleQueue' },
recorderProcessorCode: { file: `${root}/worklet`, name: 'recorderProcessorCode' },
})
test('ASR encoder preserves signed PCM16 clamping, truncation, NaN and little endian bytes', () => {
const input = Float32Array.of(-Infinity, -2, -1, -0.5, -0, 0, 0.5, 1, 2, Infinity, Number.NaN)
const encoded = encodeAudio(input, 16000)
assert.deepEqual(Object.keys(encoded), ['pcm', 'wav'])
const expected = [0, 128, 0, 128, 0, 128, 0, 192, 0, 0, 0, 0, 255, 63, 255, 127, 255, 127, 255, 127, 0, 0]
assert.deepEqual([...new Uint8Array(encoded.pcm)], expected)
assert.equal(input[0], -Infinity)
assert.equal(Number.isNaN(input.at(-1)), true)
})
for (const sampleRate of [8000, 16000, 44100, 48000]) {
test(`ASR encoder writes exact mono WAV metadata at ${sampleRate} Hz`, () => {
const { pcm, wav } = encodeAudio(Float32Array.of(-1, 0, 1), sampleRate)
const data = Buffer.from(wav)
assert.equal(pcm.byteLength, 6)
assert.equal(data.length, 50)
assert.equal(data.toString('ascii', 0, 4), 'RIFF')
assert.equal(data.readUInt32LE(4), 42)
assert.equal(data.toString('ascii', 8, 16), 'WAVEfmt ')
assert.equal(data.readUInt32LE(16), 16)
assert.equal(data.readUInt16LE(20), 1)
assert.equal(data.readUInt16LE(22), 1)
assert.equal(data.readUInt32LE(24), sampleRate)
assert.equal(data.readUInt32LE(28), sampleRate * 2)
assert.equal(data.readUInt16LE(32), 2)
assert.equal(data.readUInt16LE(34), 16)
assert.equal(data.toString('ascii', 36, 40), 'data')
assert.equal(data.readUInt32LE(40), 6)
assert.deepEqual([...data.subarray(44)], [0, 128, 0, 0, 255, 127])
new Uint8Array(pcm).fill(0)
assert.deepEqual([...data.subarray(44)], [0, 128, 0, 0, 255, 127], 'The WAV payload owns its bytes')
})
}
test('ASR encoder empty and offset views retain exact payload boundaries', () => {
const empty = encodeAudio(new Float32Array(0), 16000)
assert.equal(empty.pcm.byteLength, 0)
assert.equal(empty.wav.byteLength, 44)
assert.equal(new DataView(empty.wav).getUint32(4, true), 36)
assert.equal(new DataView(empty.wav).getUint32(40, true), 0)
const backing = Float32Array.of(1, -1, 0.5, 1)
const { pcm } = encodeAudio(backing.subarray(1, 3), 16000)
assert.deepEqual([...new Uint8Array(pcm)], [0, 128, 255, 63])
})
for (const implementation of await asrHistorical()) {
test(`ASR encoder byte parity with ${implementation.name}`, async () => {
const chunks = []
const environment = asrAudioEnvironment(implementation, { sampleRate: 8000, interval: 1, onAudioChunk: chunk => chunks.push(chunk) })
await environment.emit('play')
const input = Float32Array.of(-Infinity, -1, -0.5, Number.NaN, 0, 0.5, 1, Infinity)
environment.send(input)
await environment.tick()
assert.equal(chunks.length, 1)
const candidate = encodeAudio(input, 8000)
assert.deepEqual(Buffer.from(candidate.pcm), Buffer.from(chunks[0].pcm))
assert.deepEqual(Buffer.from(candidate.wav), Buffer.from(chunks[0].wav))
await environment.plugin.stop()
})
}
test('ASR sample queue retains insufficient chunks and excess tails', () => {
const queue = new SampleQueue()
assert.equal(queue.take(8), null)
queue.push(Float32Array.of(1, 2, 3, 4))
assert.equal(queue.take(8), null)
assert.equal(queue.length, 4)
queue.push(Float32Array.of(5, 6, 7, 8, 9, 10))
assert.deepEqual([...queue.take(8)], [1, 2, 3, 4, 5, 6, 7, 8])
assert.equal(queue.length, 2)
assert.equal(queue.take(3), null)
queue.push(Float32Array.of(11))
assert.deepEqual([...queue.take(3)], [9, 10, 11])
assert.equal(queue.length, 0)
assert.equal(queue.take(1), null)
})
test('ASR sample queue copies input views and returns independently owned output', () => {
const queue = new SampleQueue()
const input = Float32Array.of(0, 1, 2, 3)
queue.push(input.subarray(1, 3))
input.fill(9)
const first = queue.take(1)
assert.deepEqual([...first], [1])
first[0] = 8
assert.deepEqual([...queue.take(1)], [2])
})
test('ASR sample queue clears partial blocks and can be reused after draining', () => {
const queue = new SampleQueue()
queue.push(Float32Array.of(1, 2, 3))
assert.deepEqual([...queue.take(1)], [1])
queue.clear()
queue.clear()
queue.push(new Float32Array(0))
assert.equal(queue.length, 0)
assert.equal(queue.take(1), null)
queue.push(Float32Array.of(4, 5))
assert.deepEqual([...queue.take(2)], [4, 5])
queue.push(Float32Array.of(6))
assert.deepEqual([...queue.take(1)], [6])
})
test('ASR sample queue rejects invalid count without losing buffered audio', () => {
const queue = new SampleQueue()
queue.push(Float32Array.of(1, 2))
for (const count of [0, -1, 0.5, Number.NaN, Infinity, -Infinity, Number.MAX_SAFE_INTEGER + 1]) {
assert.throws(() => queue.take(count), { name: 'RangeError', message: 'Sample count must be a positive safe integer' })
assert.equal(queue.length, 2)
}
assert.equal(queue.take(Number.MAX_SAFE_INTEGER), null)
assert.deepEqual([...queue.take(2)], [1, 2])
})
test('ASR sample queue handles many worklet blocks without dropping boundary samples', () => {
const queue = new SampleQueue()
for (let index = 0; index < 4096; index++)
queue.push(Float32Array.of(index, -index))
assert.equal(queue.length, 8192)
const first = queue.take(4097)
const second = queue.take(4095)
assert.equal(queue.length, 0)
const output = [...first, ...second]
for (let index = 0; index < 4096; index++) {
assert.equal(output[index * 2], index)
assert.equal(output[index * 2 + 1], -index)
}
})
test('ASR sample queue preserves FIFO through deterministic mixed push, take and clear operations', () => {
const queue = new SampleQueue()
let expected = []
let serial = 0
for (let index = 1; index <= 1000; index++) {
if (index % 113 === 0) {
queue.clear()
expected = []
}
else if (index % 3 !== 0) {
const chunk = Float32Array.from({ length: index % 17 }, () => ++serial)
queue.push(chunk)
expected.push(...chunk)
}
else {
const count = index % 23 + 1
const actual = queue.take(count)
if (expected.length < count)
assert.equal(actual, null)
else
assert.deepEqual([...actual], expected.splice(0, count))
}
assert.equal(queue.length, expected.length)
}
if (expected.length)
assert.deepEqual([...queue.take(expected.length)], expected)
})
test('ASR candidate Worklet keeps registration, first-channel identity and empty-input behavior', async () => {
const { processor, messages } = await runAsrWorklet(new Blob([recorderProcessorCode]))
const first = Float32Array.of(-0.5, 0.5)
const second = Float32Array.of(1, 1)
assert.equal(processor.process([]), true)
assert.equal(processor.process([[]]), true)
assert.equal(messages.length, 0)
assert.equal(processor.process([[first, second]]), true)
assert.equal(messages.length, 1)
assert.equal(messages[0], first)
})
+434
View File
@@ -0,0 +1,434 @@
import assert from 'node:assert/strict'
import { Buffer } from 'node:buffer'
import fs from 'node:fs'
import process from 'node:process'
// eslint-disable-next-line test/no-import-node-test -- Candidate ASR lifecycle uses the repository runner.
import test from 'node:test'
import { asrAudioEnvironment, deferredAudio } from './helpers/asr-audio.js'
import { asrEnvironment } from './helpers/asr.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'),
}
const block = (value = 0, length = 8) => new Float32Array(length).fill(value)
function create(option = {}, capabilities = {}) {
return asrAudioEnvironment(implementation, { sampleRate: 8000, interval: 1, ...option }, capabilities)
}
function pcmSamples(chunk) {
const bytes = new DataView(chunk.pcm)
return Array.from({ length: chunk.pcm.byteLength / 2 }, (_, index) => bytes.getInt16(index * 2, true))
}
test('ASR candidate lazy factory and synchronous registration keep result keys and Promise stop', async () => {
const environment = asrEnvironment(implementation)
assert.equal(environment.exported.default, environment.exported, 'Both historical CJS factory paths remain callable')
assert.deepEqual(Object.keys(environment.exported), [])
const option = { length: 1, autoHideTimeout: 321 }
const register = environment.factory(option)
assert.equal(typeof register, 'function')
assert.equal(environment.layers.length, 0)
assert.equal(environment.listeners.size, 0)
option.length = 3
option.autoHideTimeout = 999
const result = register(environment.art)
assert.equal(result.then, undefined)
assert.deepEqual(Object.keys(result), ['name', 'stop', 'hide', 'append'])
assert.equal(result.name, 'artplayerPluginAsr')
assert.equal(environment.layers.length, 1)
assert.equal(environment.layers[0].name, 'asr')
assert.equal(environment.layers[0].html, '')
result.append('One. Two! <b>Three</b>')
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line"><b>Three</b></div>')
assert.equal([...environment.timeouts.values()][0].delay, 321, 'Factory snapshots its configuration')
const stopping = result.stop()
assert.equal(typeof stopping.then, 'function')
assert.equal(await stopping, undefined)
assert.equal(environment.timeouts.size, 1, 'A displayed subtitle keeps its existing auto-hide deadline after stop')
;[...environment.timeouts.values()][0].callback()
assert.equal(environment.layer.style.display, 'none')
await environment.emit('destroy')
assert.equal(environment.timeouts.size, 0)
})
test('ASR candidate append keeps punctuation, HTML, ignored non-string values and timer replacement', async () => {
const environment = create({ length: 2, autoHideTimeout: 432 })
assert.equal(environment.plugin.append('First. Second! Third?'), undefined)
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">Second!</div><div class="art-asr-line">Third?</div>')
const firstTimer = [...environment.timeouts.keys()][0]
environment.plugin.append(42)
assert.equal([...environment.timeouts.keys()][0], firstTimer)
environment.plugin.append('<b>Raw</b>')
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line"><b>Raw</b></div>')
assert.equal(environment.timeouts.size, 1)
assert.notEqual([...environment.timeouts.keys()][0], firstTimer)
const timer = [...environment.timeouts.values()][0]
assert.equal(timer.delay, 432)
timer.callback()
assert.equal(environment.layer.style.display, 'none')
environment.plugin.append('Visible.')
assert.equal(environment.layer.style.display, '')
assert.equal(environment.plugin.hide(), undefined)
assert.equal(environment.layer.style.display, 'none')
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">Visible.</div>')
await environment.plugin.stop()
})
test('ASR candidate preserves chunk payload, exact PCM/WAV bytes and callback subtitles', async () => {
let chunk
const environment = create({ onAudioChunk(value) {
chunk = value
return 'One. Two!'
} })
await environment.emit('play')
environment.send([-Infinity, -1, -0.5, Number.NaN, 0, 0.5, 1, Infinity])
await environment.tick()
assert.deepEqual(Object.keys(chunk), ['pcm', 'wav'])
assert.deepEqual(pcmSamples(chunk), [-32768, -32768, -16384, 0, 0, 16383, 32767, 32767])
const wav = Buffer.from(chunk.wav)
assert.equal(wav.byteLength, 60)
assert.equal(wav.toString('ascii', 0, 4), 'RIFF')
assert.equal(wav.readUInt32LE(4), 52)
assert.equal(wav.readUInt16LE(22), 1)
assert.equal(wav.readUInt32LE(24), 8000)
assert.equal(wav.readUInt32LE(28), 16000)
assert.equal(wav.readUInt32LE(40), 16)
assert.deepEqual(wav.subarray(44), Buffer.from(chunk.pcm))
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">One.</div><div class="art-asr-line">Two!</div>')
await environment.plugin.stop()
})
test('ASR candidate preserves short chunks across ticks and excess tail in FIFO order', async () => {
const chunks = []
const environment = create({ onAudioChunk: value => chunks.push(value) })
await environment.emit('play')
environment.send(block(0.5, 4))
await environment.tick()
assert.equal(chunks.length, 0)
environment.send(block(-0.5, 8))
await environment.tick()
assert.deepEqual(pcmSamples(chunks[0]), [16383, 16383, 16383, 16383, -16384, -16384, -16384, -16384])
await environment.tick()
assert.equal(chunks.length, 1)
environment.send(block(1, 4))
await environment.tick()
assert.deepEqual(pcmSamples(chunks[1]), [-16384, -16384, -16384, -16384, 32767, 32767, 32767, 32767])
await environment.plugin.stop()
})
test('ASR candidate allows one in-flight recognizer and retains queued audio', async () => {
const pending = deferredAudio()
const calls = []
const environment = create({ onAudioChunk: (chunk) => {
calls.push(chunk)
return calls.length === 1 ? pending.promise : 'Second.'
} })
await environment.emit('play')
environment.send(block(0.5))
const first = environment.tick()
environment.send(block(-0.5))
await environment.tick()
await environment.tick()
assert.equal(calls.length, 1)
pending.resolve('First.')
await first
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">First.</div>')
await environment.tick()
assert.equal(calls.length, 2)
assert.deepEqual(pcmSamples(calls[1]), Array.from({ length: 8 }, () => -16384))
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">Second.</div>')
await environment.plugin.stop()
})
for (const mode of ['throw', 'reject']) {
test(`ASR candidate handles callback ${mode} and continues subsequent audio`, async () => {
const failure = new Error('recognition failed')
let count = 0
const environment = create({ onAudioChunk: () => {
if (++count > 1)
return 'Recovered.'
if (mode === 'throw')
throw failure
return Promise.reject(failure)
} })
await environment.emit('play')
environment.send(block())
await assert.doesNotReject(environment.tick())
assert.equal(environment.errors.length, 1)
assert.equal(environment.errors[0][1], failure)
environment.send(block())
await environment.tick()
assert.equal(count, 2)
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">Recovered.</div>')
await environment.plugin.stop()
})
}
for (const event of ['pause', 'stop', 'destroy', 'restart']) {
test(`ASR candidate invalidates pending callback after ${event}`, async () => {
const pending = deferredAudio()
const environment = create({ onAudioChunk: () => pending.promise })
await environment.emit('play')
environment.send(block())
const recognizing = environment.tick()
await (event === 'stop' ? environment.plugin.stop() : environment.emit(event))
pending.resolve('Obsolete.')
await recognizing
assert.equal(environment.layer.innerHTML, '')
assert.equal(environment.timeouts.size, 0)
assert.equal(environment.errors.length, 0)
assert.equal(environment.intervals.size, event === 'restart' ? 1 : 0)
await environment.plugin.stop()
})
}
test('ASR candidate reset clears old queued audio without dropping the next source', async () => {
const calls = []
const environment = create({ onAudioChunk: value => calls.push(value) })
await environment.emit('play')
environment.send(block(0.5))
environment.art.video.src = 'local-new-source.mp4'
await environment.emit('restart')
await environment.tick()
assert.equal(calls.length, 0)
environment.send(block(-0.5))
await environment.tick()
assert.deepEqual(pcmSamples(calls[0]), Array.from({ length: 8 }, () => -16384))
await environment.plugin.stop()
})
test('ASR candidate old callback completion cannot unlock a new session pending callback', async () => {
const pending = [deferredAudio(), deferredAudio()]
let calls = 0
const environment = create({ onAudioChunk: () => pending[calls++].promise })
await environment.emit('play')
environment.send(block())
const old = environment.tick()
await environment.emit('pause')
await environment.emit('play')
environment.send(block())
const current = environment.tick()
pending[0].resolve('Old.')
await old
environment.send(block())
await environment.tick()
assert.equal(calls, 2)
assert.equal(environment.layer.innerHTML, '')
pending[1].resolve('Current.')
await current
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">Current.</div>')
await environment.plugin.stop()
})
test('ASR candidate concurrent play creates one graph and one interval', async () => {
const moduleGate = deferredAudio()
const moduleEntered = deferredAudio()
const environment = create({}, { moduleGate, moduleEntered })
const starting = environment.emit('play')
const duplicate = environment.emit('play')
await moduleEntered.promise
assert.equal(environment.modules.length, 1)
moduleGate.resolve()
await Promise.all([starting, duplicate])
await environment.emit('play')
assert.equal(environment.contexts.length, 1)
assert.equal(environment.recorders.length, 1)
assert.equal(environment.intervals.size, 1)
await environment.plugin.stop()
assert.equal(environment.intervals.size, 0)
assert.equal(environment.recorders[0].port.onmessage, null)
assert.equal(environment.recorders[0].disconnects, 1)
})
test('ASR candidate pause during module loading prevents late connection and later resumes prepared graph', async () => {
const moduleGate = deferredAudio()
const moduleEntered = deferredAudio()
const environment = create({}, { moduleGate, moduleEntered, capture: false })
const starting = environment.emit('play')
await moduleEntered.promise
await environment.emit('pause')
moduleGate.resolve()
await starting
assert.equal(environment.intervals.size, 0)
assert.equal(environment.recorders.length, 0)
assert.equal(environment.contexts[0].closes, 0)
await environment.emit('play')
assert.equal(environment.contexts.length, 1)
assert.equal(environment.modules.length, 1)
assert.equal(environment.recorders.length, 1)
assert.equal(environment.intervals.size, 1)
assert.equal(environment.captures(), 0)
assert.equal(environment.errors.length, 0)
await environment.plugin.stop()
})
for (const event of ['stop', 'destroy']) {
test(`ASR candidate ${event} during module loading frees URL and context without resurrection`, async () => {
const moduleGate = deferredAudio()
const moduleEntered = deferredAudio()
const environment = create({}, { moduleGate, moduleEntered })
const starting = environment.emit('play')
await moduleEntered.promise
assert.equal(environment.blobs.size, 1)
await (event === 'stop' ? environment.plugin.stop() : environment.emit(event))
assert.equal(environment.blobs.size, 0)
assert.equal(environment.contexts[0].closes, 1)
moduleGate.resolve()
await starting
assert.equal(environment.revoked.length, 1)
assert.equal(environment.intervals.size, 0)
assert.equal(environment.recorders.length, 0)
assert.equal(environment.errors.length, 0)
})
}
test('ASR candidate failed module initialization releases context and URL before binding video', async () => {
const failure = new Error('failed worklet')
const environment = create({}, { moduleError: failure })
await environment.emit('play')
assert.equal(environment.errors.length, 1)
assert.equal(environment.errors[0][1], failure)
assert.equal(environment.contexts[0].closes, 1)
assert.equal(environment.blobs.size, 0)
assert.equal(environment.revoked.length, 1)
assert.equal(environment.nodes.filter(node => node.kind === 'element').length, 0)
assert.equal(environment.intervals.size, 0)
await environment.plugin.stop()
assert.equal(environment.contexts[0].closes, 1)
})
test('ASR candidate pause/resume reuses direct source without captureStream', async () => {
const environment = create({}, { capture: false })
await environment.emit('play')
const source = environment.nodes.find(node => node.kind === 'element')
await environment.emit('pause')
assert.equal(source.connections.length, 1, 'The player-owned audio route remains available while ASR is paused')
assert.equal(source.connections[0].kind, 'gain')
assert.equal(source.connections[0].connections[0], environment.contexts[0].destination)
assert.equal(environment.recorders[0].port.onmessage, null)
await environment.emit('play')
assert.equal(environment.contexts.length, 1)
assert.equal(environment.modules.length, 1)
assert.equal(environment.nodes.filter(node => node.kind === 'element').length, 1)
assert.equal(source.connections.length, 2)
assert.equal(environment.errors.length, 0)
assert.equal(environment.intervals.size, 1)
await environment.plugin.stop()
})
test('ASR candidate ignores old worklet delivery after pause/resume reconnect', async () => {
const calls = []
const environment = create({ onAudioChunk: value => calls.push(value) })
await environment.emit('play')
const staleMessage = environment.recorders[0].port.onmessage
await environment.emit('pause')
await environment.emit('play')
staleMessage({ data: block(0.5) })
environment.send(block(-0.5))
await environment.tick()
assert.equal(calls.length, 1)
assert.deepEqual(pcmSamples(calls[0]), Array.from({ length: 8 }, () => -16384))
await environment.plugin.stop()
})
test('ASR candidate overload explicitly reports and pauses instead of silently discarding samples', async () => {
const environment = create()
await environment.emit('play')
environment.send(block(0, 8000 * 60))
assert.equal(environment.errors.length, 0)
assert.equal(environment.intervals.size, 1)
environment.send(block(0, 1))
assert.equal(environment.errors.length, 1)
assert.deepEqual(environment.errors[0], ['[artplayerPluginAsr] Audio callback backlog exceeded its capture limit'])
assert.equal(environment.intervals.size, 0)
assert.equal(environment.recorders[0].port.onmessage, null)
await environment.plugin.stop()
await environment.emit('destroy')
const longChunk = create({ interval: 120000 })
await longChunk.emit('play')
longChunk.send(block(0, 8000 * 60 + 1))
assert.equal(longChunk.errors.length, 0, 'A configured chunk longer than 60 seconds must still be collectable')
assert.equal(longChunk.intervals.size, 1)
await longChunk.emit('destroy')
})
test('ASR candidate destroy cancels subtitle timers, removes subscriptions and forbids public append resurrection', async () => {
const environment = create()
await environment.emit('play')
environment.plugin.append('Kept.')
assert.equal(environment.timeouts.size, 1)
await environment.emit('destroy')
assert.equal(environment.timeouts.size, 0)
assert.equal([...environment.listeners.values()].flat().length, 0)
const html = environment.layer.innerHTML
const display = environment.layer.style.display
environment.plugin.append('Unexpected.')
environment.plugin.hide()
await environment.emit('play')
await environment.emit('restart')
await environment.emit('video:volumechange')
await environment.plugin.stop()
assert.equal(environment.layer.innerHTML, html)
assert.equal(environment.layer.style.display, display)
assert.equal(environment.timeouts.size, 0)
assert.equal(environment.contexts.length, 1)
assert.equal(environment.contexts[0].closes, 1)
assert.equal(environment.intervals.size, 0)
})
test('ASR candidate public stop retains the current hide deadline and allows append and subsequent play', async () => {
const environment = create({}, { capture: false })
await environment.emit('play')
environment.plugin.append('Before.')
const beforeTimer = [...environment.timeouts.keys()][0]
await environment.plugin.stop()
assert.equal(environment.timeouts.size, 1)
assert.equal([...environment.timeouts.keys()][0], beforeTimer)
assert.equal(environment.contexts[0].closes, 0, 'Direct media ownership lasts until player destruction')
const source = environment.nodes.find(node => node.kind === 'element')
assert.equal(source.connections[0].kind, 'gain')
assert.equal(source.connections[0].connections[0], environment.contexts[0].destination)
environment.plugin.append('After.')
assert.equal(environment.layer.innerHTML, '<div class="art-asr-line">After.</div>')
await environment.emit('play')
assert.equal(environment.contexts.length, 1)
assert.equal(environment.captures(), 0)
assert.equal(environment.intervals.size, 1)
await environment.plugin.stop()
await environment.emit('destroy')
assert.equal(environment.contexts[0].closes, 1)
assert.equal(source.connections.length, 0)
})
test('ASR candidate volume route survives stop and resume without resetting gain', async () => {
const environment = create({}, { capture: false })
await environment.emit('play')
environment.art.volume = 0.25
await environment.emit('video:volumechange')
await environment.plugin.stop()
const source = environment.nodes.find(node => node.kind === 'element')
assert.equal(source.connections[0].gain.value, 0.25)
await environment.emit('play')
assert.equal(source.connections.find(node => node.kind === 'gain').gain.value, 0.25)
await environment.emit('destroy')
assert.equal(environment.contexts[0].closes, 1)
})
test('ASR candidate source restart releases captured tracks and obtains a new fallback source', async () => {
const environment = create({}, { directError: new Error('video belongs to another context') })
await environment.emit('play')
assert.equal(environment.captures(), 1)
await environment.emit('restart')
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)
assert.equal(environment.intervals.size, 1)
await environment.plugin.stop()
assert.equal(environment.contexts[1].closes, 1)
assert.equal(environment.tracks[0].stops, 2)
})
+4 -1
View File
@@ -30,7 +30,7 @@ for (const implementation of await asrHistorical()) {
assert.equal(env.styles.size, 1)
})
}
test(`ASR ${implementation.name}: append replaces punctuation lines, permits markup, and hide retains content`, () => {
test(`ASR ${implementation.name}: append replaces punctuation lines, permits markup, and hide retains content`, async () => {
const env = asrEnvironment(implementation)
const result = env.factory()(env.art)
assert.equal(result.append('One. Two! 三。 Four?'), undefined)
@@ -47,6 +47,9 @@ for (const implementation of await asrHistorical()) {
result.append('')
assert.equal(env.layer.innerHTML, '')
assert.equal(env.layer.style.display, '')
const timer = [...env.timeouts.keys()][0]
await result.stop()
assert.equal([...env.timeouts.keys()][0], timer, 'Stopping audio preserves an existing subtitle hide deadline')
;[...env.timeouts.values()][0].callback()
assert.equal(env.layer.style.display, 'none')
})
+36 -7
View File
@@ -2,20 +2,23 @@ import fs from 'node:fs'
import process from 'node:process'
import { verifyAsrContract } from '../../refactor/scripts/asr-contract.mjs'
import { hash, readMember } from '../../refactor/scripts/releases.mjs'
import { compilePackage } from '../helpers/load.js'
import { expect, test } from './fixtures.js'
let contract
let candidateCode
test.beforeAll(async () => {
contract = await verifyAsrContract()
candidateCode = process.env.ARTPLAYER_ASR_ARTIFACT ? fs.readFileSync(process.env.ARTPLAYER_ASR_ARTIFACT) : await compilePackage('artplayer-plugin-asr', 'umd')
})
for (const version of ['2.0.0', '2.1.0']) {
for (const version of ['2.0.0', '2.1.0', 'candidate']) {
for (const core of ['published', 'candidate']) {
test(`ASR ${version} + ${core}: native Worklet supplies local PCM and stop closes AudioContext`, async ({ page }, testInfo) => {
test(`ASR ${version} + ${core}: native Worklet supplies local PCM with owned stop and destroy`, async ({ page }, testInfo) => {
const release = [contract.baseline.release, ...contract.baseline.previous].find(release => release.version === version)
const code = readMember(contract.archives.get(version), 'package/dist/artplayer-plugin-asr.js')
const code = version === 'candidate' ? candidateCode : readMember(contract.archives.get(version), 'package/dist/artplayer-plugin-asr.js')
const media = fs.readFileSync(new URL('./media/audio-tone.m4a', import.meta.url))
await testInfo.attach('asr-inputs', { contentType: 'application/json', body: JSON.stringify({ version, core, pluginSha256: hash(code), integrity: release.integrity, mediaSha256: hash(media), media: 'local mono AAC 440 Hz tone', networkService: false }) })
await testInfo.attach('asr-inputs', { contentType: 'application/json', body: JSON.stringify({ version, core, pluginSha256: hash(code), integrity: release?.integrity || null, candidate: process.env.ARTPLAYER_ASR_ARTIFACT || 'workspace source', mediaSha256: hash(media), media: 'local mono AAC 440 Hz tone', networkService: false }) })
await page.goto(`/test/player.html?core=${core}`)
const capabilities = await page.evaluate(() => ({ AudioContext: typeof window.AudioContext, webkitAudioContext: typeof window.webkitAudioContext, AudioWorkletNode: typeof window.AudioWorkletNode }))
await testInfo.attach('asr-audio-capabilities', { contentType: 'application/json', body: JSON.stringify(capabilities) })
@@ -47,7 +50,7 @@ for (const version of ['2.0.0', '2.1.0']) {
const samples = new Int16Array(pcm)
let max = 0
for (const sample of samples) max = Math.max(max, Math.abs(sample))
window.asrChunks.push({ samples: samples.length, max, wavBytes: wav.byteLength, sampleRate: new DataView(wav).getUint32(24, true) })
window.asrChunks.push({ samples: samples.length, max, wavBytes: wav.byteLength, sampleRate: new DataView(wav).getUint32(24, true), source: window.art.video.currentSrc })
return 'Local transcript.'
},
})],
@@ -60,10 +63,36 @@ for (const version of ['2.0.0', '2.1.0']) {
const chunks = await page.evaluate(() => window.asrChunks)
expect(chunks.every(chunk => chunk.samples === 1600 && chunk.wavBytes === 3244 && chunk.sampleRate === 16000)).toBe(true)
await expect(page.locator('.art-layer-asr')).toContainText('Local transcript.')
if (version === 'candidate') {
await page.evaluate(() => window.art.pause())
const before = await page.evaluate(() => window.asrChunks.length)
await page.waitForTimeout(250)
expect(await page.evaluate(() => window.asrChunks.length)).toBe(before)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.asrChunks.length)).toBeGreaterThan(before + 2)
expect(await page.evaluate(() => window.asrContexts.length)).toBe(1)
await page.evaluate(() => {
window.art.url = '/test/audio-tone.m4a?asr=second'
})
await expect.poll(() => page.evaluate(() => window.art.video.currentSrc.includes('asr=second') && window.art.video.readyState >= 2)).toBe(true)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.asrChunks.filter(chunk => chunk.source.includes('asr=second') && chunk.max > 0).length)).toBeGreaterThanOrEqual(3)
expect(await page.evaluate(() => window.asrContexts.length)).toBe(1)
}
await page.evaluate(() => window.art.plugins.artplayerPluginAsr.stop())
expect(await page.evaluate(() => window.asrContexts.map(context => context.state))).toEqual(['closed'])
await testInfo.attach('asr-native-chunks', { contentType: 'application/json', body: JSON.stringify(chunks) })
expect(await page.evaluate(() => window.asrContexts.map(context => context.state))).toEqual([version === 'candidate' ? 'running' : 'closed'])
if (version === 'candidate') {
await page.evaluate(() => window.art.pause())
const before = await page.evaluate(() => window.asrChunks.filter(chunk => chunk.max > 0).length)
await page.locator('#play').click()
await expect.poll(() => page.evaluate(() => window.asrChunks.filter(chunk => chunk.max > 0).length)).toBeGreaterThan(before + 2)
expect(await page.evaluate(() => window.asrContexts.length)).toBe(1)
await page.evaluate(() => window.art.plugins.artplayerPluginAsr.stop())
expect(await page.evaluate(() => window.asrContexts.map(context => context.state))).toEqual(['running'])
}
await testInfo.attach('asr-native-chunks', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => window.asrChunks)) })
await page.evaluate(() => window.art.destroy())
await expect.poll(() => page.evaluate(() => window.asrContexts.map(context => context.state))).toEqual(['closed'])
})
}
}
+2
View File
@@ -32,6 +32,7 @@ export function asrEnvironment(implementation, { script = false } = {}) {
return id
},
clearTimeout(timer) { timeouts.delete(timer) },
clearInterval() {},
document: {
getElementById: id => styles.get(id),
createElement: () => ({}),
@@ -57,6 +58,7 @@ export function asrEnvironment(implementation, { script = false } = {}) {
return layer
} },
on(name, callback) { listeners.set(name, [...listeners.get(name) || [], callback]) },
off(name, callback) { listeners.set(name, (listeners.get(name) || []).filter(listener => listener !== callback)) },
}
return { context, factory, exported, styles, layer, layers, listeners, timeouts, art, reload: () => vm.runInContext(code, context), emit: name => Promise.all((listeners.get(name) || []).map(callback => callback())) }
}