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

+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"]
}