refactor(mediabunny): [PKG-MB-06] own audio clocks, buffers and context lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 20:21:25 +08:00
1 parent 9d037dcbf9
commit ccee9157a7
30 files changed
+16182 -736

No files matched your search

@@ -16,25 +16,29 @@ declarations, the factory signature, canvas forwarding, or package entrypoints.
| `preflight.ts` | Optional HEAD Range check, historical warning/error policy, cancellation and stale-result guard |
| `load-session.ts` | Ownership of the pending/active SDK Input, HEAD AbortController, deferred load events and deadline |
| `MediaBunnyEngine.ts` | Load/readiness coordinator, source identity, errors and terminal teardown |
| `playback.ts` | Playback intent, cancellable play/seek/replacement operations and shared pending audio resume |
| `playback.ts` | Playback intent and cancellable play/seek/replacement operations |
| `readiness.ts` | One metadata publication after both participants, guarded event sequences |
| `hls-selection.ts` | Source-bound track queries, pairing and active replacement errors |
| `VideoEngine.ts` | Video capability/setup, source and seek ownership, first frames and terminal cleanup |
| `video-frames.ts` | Shared pending iterator reads, idempotent iterator release and existing frame timing policy |
| `video-renderer.ts` | Owned RAF, stale frame rejection, clock-driven draw/stall/end events |
| `video-poster.ts` | Per-request image callbacks, source guards and handler cleanup |
| `AudioEngine.js` | Remaining Web Audio clock, audio decoding and scheduling implementation |
| `AudioEngine.ts` | Audio load/play generation, clock anchors and decoder-error forwarding |
| `audio-clock.ts` | Existing media-clock and future/partially-late buffer scheduling formulas |
| `audio-context.ts` | Context/gain setup, sample-rate fallback, shared native resume and terminal close |
| `audio-nodes.ts` | Scheduled source nodes, rate, ended handlers and complete stop/disconnect |
| `audio-pump.ts` | SDK buffer iterator, bounded batches, backpressure and stale-result guards |
| `audio-task.ts` | Cancelable waits and owned batch/backpressure timers |
| `VideoShim.ts` / `EventTarget.ts` | Video-like descriptors, synchronous/Promise forwarding and owned event lifecycle |
| `shim-values.ts` | Existing volume coercion and synthetic TimeRanges values |
| `shim-frames.ts` | Synthetic frame callbacks, per-instance RAF ownership and terminal cleanup |
| `engine-types.ts` / `engine-ports.ts` | Internal shim, coordinator and decoder interfaces |
| `AudioEngine.d.ts` | Temporary boundary for the remaining JS audio engine, removed in MB-06 |
| `m3u8.js` | Current ArtPlayer control/setting integration |
The twenty production TypeScript modules use strict checking with `skipLibCheck: false`
and no ambient Node types. The coordinator and video engine are checked implementations.
The adjacent audio declaration is a temporary boundary, not checking of its JavaScript
implementation. Remaining JavaScript is scheduled in PKG-MB-06/07/08.
The twenty-six production TypeScript modules use strict checking with `skipLibCheck: false`
and no ambient Node types. The coordinator and both decoder engines are checked
implementations; there are no remaining adjacent JavaScript declaration bridges.
The public factory and HLS integration remain JavaScript, scheduled in PKG-MB-07/08.
Do not describe the whole package as migrated yet.
The shim keeps its existing own-property order and direct prototype surface because
@@ -86,7 +90,10 @@ for the seek completion signal, resolved before optional resume to avoid a cycle
A pending AudioContext resume is shared across play-pause-play; only the current operation
starts video and emits play/playing. Source change and destroy invalidate both operations.
Cancellation settles the public operation while observing the underlying late result;
video frame ownership is detailed below; remaining audio work belongs to MB-06.
video and audio ownership are detailed below. Native resume coalescing belongs to the
context owner: each new playback intent calls audio.play(), but shares the same pending
native resume. Sharing a canceled upper-level play Promise would incorrectly start video
before the latest audio intent is active.
Normal play, pause, seek/resume and HLS replacement event order is retained. Before each
event the coordinator rechecks ownership, since a listener may synchronously pause, seek,
@@ -140,6 +147,39 @@ stopIterator still rejects. Missing 2D context fails at use with a descriptive e
than becoming falsely ready. Capability handling for existing undecodable tracks remains
under MB-CAP-01/MB-READY-01; the current no-video fallback is preserved for that separate review.
## Audio clock and resource ownership
AudioEngine keeps source generation separate from pump IDs and cancellable play intent.
Pause invalidates a suspended resume even when the audio clock has not started; source
replacement invalidates audio before track parsing. Late resume, capability checks and
buffer reads cannot install a sink, schedule nodes or resurrect a destroyed instance.
Native AudioContext.resume is coalesced per context, while canceled callers settle early
and late rejections stay observed. A valid no-audio-track source retains the Web Audio
clock fallback; this is distinct from rejecting a container with neither track.
The clock retains the existing anchor/rate formula and squared gain with mute. Pause
captures media time; seek/rate changes cancel the old iterator and stop/disconnect its
scheduled nodes before rebuilding the queue at the new anchor. The renderer's ended
event freezes the audio clock and releases remaining nodes. Source load resets position.
Fully expired buffers do not allocate nodes; partially late buffers retain the old offset
calculation. Audio playbackRate changes still change pitch through native source playbackRate;
no new pitch-preservation or acoustic synchronization guarantee is introduced.
AudioPump owns 16-buffer batches, a zero-delay yield and the existing one-second/50ms
backpressure policy. Its timers include ID zero and are canceled with its task. Iterator
detachment is synchronous and return is observed once; obsolete cleanup cannot clear a
new iterator. Native AudioBuffers have no close() contract: the SDK closes its AudioSamples,
while the proxy releases buffer/node/iterator references. Every scheduled source is owned
before start, cleared on ended, stopped/disconnected on cancellation, and has its callback
removed. Cleanup attempts all nodes and context resources before throwing the first error;
background cleanup failures are observed, and do not replace an active decoder failure.
ContextOwner installs context/gain references only after successful setup. Setup failure
releases partial resources, destroy nulls references and observes the asynchronous close,
and repeated destroy is inert. Real-browser teardown assertions poll the native closed state.
Tests deliberately delay native resume completion or decoded buffer delivery, without
substituting fake audio decoding for native acceptance.
## Types and dependencies
The locked runtime SDK remains MediaBunny 1.56.1. Its pinned WebCodecs declaration
@@ -160,6 +200,7 @@ yarn build artplayer-proxy-mediabunny
yarn test:browser test/browser/mediabunny-inputs.spec.js test/browser/mediabunny.spec.js test/browser/mediabunny-load.spec.js
yarn test:browser test/browser/mediabunny-shim.spec.js
yarn test:browser test/browser/mediabunny-video.spec.js
yarn test:browser test/browser/mediabunny-audio.spec.js
```
Node tests use actual SDK parsing and controlled lifecycle interleavings. Browser tests
@@ -174,9 +215,9 @@ inputs, not a rebuilt dist. Package-specific evidence and remaining tasks are in
`refactor/mb-validation.md`. The full refactor plan is at the repository root.
PKG-MB-04 covers duplicate/trackless readiness, reentrant event sequences and
pending play/seek coordination. PKG-MB-05/06 own late frame and audio callbacks,
including operations already inside decoders at cancellation; releasing Input alone
does not prove those resources are fully handled. PKG-MB-07 owns HLS UI/selection
pending play/seek coordination. PKG-MB-05/06 cover late frame and audio callbacks,
including operations already inside decoders at cancellation. These bounded cases do not
replace long-run synchronization and combination validation. PKG-MB-07 owns HLS UI/selection
races. PKG-MB-09 owns long-run AV sync, full core/proxy/plugin combinations and device
validation. PKG-MB-10 owns the demo, installed package and MPL/source notice gate.
Windows WebKit without WebCodecs/Web Audio is recorded as a capability failure,
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i,r,a,s,n,o,c,l,u,d,h,f,m,p,g,k,y,b,w=e=>{throw TypeError(e)},T=(e,t,i)=>t.has(e)||w("Cannot "+i),S=(e,t,i)=>(T(e,t,"read from private field"),i?i.call(e):t.get(e)),v=(e,t,i)=>t.has(e)?w("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),P=(e,t,i,r)=>(T(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),I=(e,t,i)=>(T(e,t,"access private method"),i),x=(e,t,i,r)=>({set _(r){P(e,t,r,i)},get _(){return S(e,t,r)}});function C(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class B{constructor(){v(this,e,!1),this.listeners=new Map}addEventListener(t,i){S(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(S(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{S(this,e)||t(r)})}destroy(){P(this,e,!0),this.listeners.clear()}}function E(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const _=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},R=e=>e&&e[e.length-1],F=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},A=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},D=e=>{const t=F(e);return 0===(1&t)?-(t>>1):t+1>>1},M=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),N=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),O=new TextDecoder,z=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),L={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},U=z(L),q={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},W=z(q),H={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},V=z(H),j=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,K={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},X=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ${constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const G=/^[0-9a-fA-F]+$/,Q=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),Y=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,J=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},Z=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},ee=(e,t,i)=>{const r=Z(e,i(t),i);e.splice(r+1,0,t)},te=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},ie=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},re=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ae=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},se=e=>{throw new Error(`Unexpected value: ${e}`)},ne=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},oe=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},ce=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),le=(e,t,i)=>Math.max(t,Math.min(i,e)),ue="und",de=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},he=(e,t)=>Math.round(e/t)*t,fe=(e,t)=>Math.round(e*t)/t,me=(e,t)=>Math.floor(e/t)*t,pe=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},ge=/^[a-z]{3}$/,ke=e=>ge.test(e),ye=1e6*(1+Number.EPSILON),be=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?we(e.headers):{},a=t.headers?we(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},we=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},Te=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catcLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i,r,a,s,n,o,c,l,u,h,d,f,m,p,g,k,y,b,w,T,S,v,P,I,x,C,B,E,_,R,F,A=e=>{throw TypeError(e)},M=(e,t,i)=>t.has(e)||A("Cannot "+i),D=(e,t,i)=>(M(e,t,"read from private field"),i?i.call(e):t.get(e)),N=(e,t,i)=>t.has(e)?A("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),O=(e,t,i,r)=>(M(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),z=(e,t,i)=>(M(e,t,"access private method"),i),L=(e,t,i,r)=>({set _(r){O(e,t,r,i)},get _(){return D(e,t,r)}});function U(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class q{constructor(){N(this,e,!1),this.listeners=new Map}addEventListener(t,i){D(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(D(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{D(this,e)||t(r)})}destroy(){O(this,e,!0),this.listeners.clear()}}function W(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const H=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},V=e=>e&&e[e.length-1],j=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},K=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},X=e=>{const t=j(e);return 0===(1&t)?-(t>>1):t+1>>1},$=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),G=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),Q=new TextDecoder,Y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),J={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},Z=Y(J),ee={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},te=Y(ee),ie={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},re=Y(ie),ae=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,se={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ne=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class oe{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const ce=/^[0-9a-fA-F]+$/,le=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),ue=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,he=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},de=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},fe=(e,t,i)=>{const r=de(e,i(t),i);e.splice(r+1,0,t)},me=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},pe=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},ge=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ke=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},ye=e=>{throw new Error(`Unexpected value: ${e}`)},be=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},we=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},Te=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),Se=(e,t,i)=>Math.max(t,Math.min(i,e)),ve="und",Pe=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},Ie=(e,t)=>Math.round(e/t)*t,xe=(e,t)=>Math.round(e*t)/t,Ce=(e,t)=>Math.floor(e/t)*t,Be=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Ee=/^[a-z]{3}$/,_e=e=>Ee.test(e),Re=1e6*(1+Number.EPSILON),Fe=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?Ae(e.headers):{},a=t.headers?Ae(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},Ae=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},Me=async(e,t,i,r,a)=>{lLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i,r,n,s,a,o,l,c,u,d,h,f,m,p,g,k,y,b,T=Object.defineProperty,v=Object.defineProperties,S=Object.getOwnPropertyDescriptors,w=Object.getOwnPropertySymbols,P=Object.prototype.hasOwnProperty,I=Object.prototype.propertyIsEnumerable,x=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),C=e=>{throw TypeError(e)},B=Math.pow,E=(e,t,i)=>t in e?T(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,_=(e,t)=>{for(var i in t||(t={}))P.call(t,i)&&E(e,i,t[i]);if(w)for(var i of w(t))I.call(t,i)&&E(e,i,t[i]);return e},R=(e,t)=>v(e,S(t)),F=(e,t,i)=>t.has(e)||C("Cannot "+i),A=(e,t,i)=>(F(e,t,"read from private field"),i?i.call(e):t.get(e)),D=(e,t,i)=>t.has(e)?C("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),M=(e,t,i,r)=>(F(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),N=(e,t,i)=>(F(e,t,"access private method"),i),O=(e,t,i,r)=>({set _(r){M(e,t,r,i)},get _(){return A(e,t,r)}}),z=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),L=function(e,t){this[0]=e,this[1]=t},U=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof L,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[x("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},q=e=>{var t,i=e[x("asyncIterator")],r=!1,n={};return null==i?(i=e[x("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new L(new Promise(r=>{var n=i[e](t);n instanceof Object||C("Object expected"),r(n)}),1)}}),n[x("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},W=(e,t,i)=>(t=e[x("asyncIterator")])?t.call(e):(e=e[x("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function H(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class V{constructor(){D(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;A(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(A(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{A(this,e)||t(n)})}destroy(){M(this,e,!0),this.listeners.clear()}}function j(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const K=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},X=e=>e&&e[e.length-1],$=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},G=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-B(2,r))},Q=e=>{const t=$(e);return 0===(1&t)?-(t>>1):t+1>>1},Y=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),J=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),Z=new TextDecoder,ee=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),te={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},ie=ee(te),re={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ne=ee(re),se={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},ae=ee(se),oe=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,le={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ce=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ue{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return z(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const de=/^[0-9a-fA-F]+$/,he=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),fe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&167Line truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i,r,n,s,a,o,l,c,u,d,h,f,m,p,g,k,y,b,T,v,w,S,P,I,x,C,B,E,_,R,F,A=Object.defineProperty,M=Object.defineProperties,D=Object.getOwnPropertyDescriptors,N=Object.getOwnPropertySymbols,O=Object.prototype.hasOwnProperty,z=Object.prototype.propertyIsEnumerable,L=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),U=e=>{throw TypeError(e)},q=Math.pow,W=(e,t,i)=>t in e?A(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,H=(e,t)=>{for(var i in t||(t={}))O.call(t,i)&&W(e,i,t[i]);if(N)for(var i of N(t))z.call(t,i)&&W(e,i,t[i]);return e},V=(e,t)=>M(e,D(t)),j=(e,t,i)=>t.has(e)||U("Cannot "+i),K=(e,t,i)=>(j(e,t,"read from private field"),i?i.call(e):t.get(e)),X=(e,t,i)=>t.has(e)?U("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),$=(e,t,i,r)=>(j(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),G=(e,t,i)=>(j(e,t,"access private method"),i),Q=(e,t,i,r)=>({set _(r){$(e,t,r,i)},get _(){return K(e,t,r)}}),Y=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),J=function(e,t){this[0]=e,this[1]=t},Z=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof J,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[L("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},ee=e=>{var t,i=e[L("asyncIterator")],r=!1,n={};return null==i?(i=e[L("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new J(new Promise(r=>{var n=i[e](t);n instanceof Object||U("Object expected"),r(n)}),1)}}),n[L("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},te=(e,t,i)=>(t=e[L("asyncIterator")])?t.call(e):(e=e[L("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function ie(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class re{constructor(){X(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;K(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(K(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{K(this,e)||t(n)})}destroy(){$(this,e,!0),this.listeners.clear()}}function ne(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const se=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},ae=e=>e&&e[e.length-1],oe=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},le=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-q(2,r))},ce=e=>{const t=oe(e);return 0===(1&t)?-(t>>1):t+1>>1},ue=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),de=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),he=new TextDecoder,fe=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),me={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},pe=fe(me),ge={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ke=fe(ge),ye={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},be=fe(ye),Te=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,ve={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},we=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class Se{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return Y(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const Pe=/^[0-9a-fA-F]+$/,Ie=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),xe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<Line truncated
@@ -20,7 +20,7 @@ var __privateWrapper = (obj, member, setter, getter) => ({
return __privateGet(obj, member, getter);
}
});
var _closed, _generation, _iteration, _loading, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn, reset_fn, _playback;
var _closed, _closed2, _generation, _contexts, _nodes, _pump, _playTask, _playing, _loading, _onError, _AudioEngine_instances, load_fn, startPump_fn, play_fn, _generation2, _iteration, _loading2, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError2, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn2, reset_fn, _playback;
const $audio = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>';
const $quality = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>';
function uniqBy(array, property) {
@@ -5317,13 +5317,13 @@ class IsobmffDemuxer extends Demuxer {
const entryCount = readU32Be(slice);
for (let i = 0; i < entryCount; i++) {
const segmentDuration = version === 1 ? readU64Be(slice) : readU32Be(slice);
const mediaTime = version === 1 ? readI64Be(slice) : readI32Be(slice);
const mediaTime2 = version === 1 ? readI64Be(slice) : readI32Be(slice);
const mediaRate = readFixed_16_16(slice);
if (relevantEntryFound) {
Logging._warn("Unsupported edit list: multiple edits are not currently supported. Only using first edit.");
break;
}
if (mediaTime === -1) {
if (mediaTime2 === -1) {
previousSegmentDurations += segmentDuration;
continue;
}
@@ -5332,7 +5332,7 @@ class IsobmffDemuxer extends Demuxer {
break;
}
track.editListPreviousSegmentDurations = previousSegmentDurations;
track.editListOffset = mediaTime;
track.editListOffset = mediaTime2;
relevantEntryFound = true;
}
}
@@ -24076,8 +24076,344 @@ class Id3V2Reader {
return this.readId3V2Text(encoding, until);
}
}
function mediaTime(clock) {
if (clock.paused || !clock.audioContext)
return clock.playbackTimeAtStart;
return (clock.audioContext.currentTime - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart;
}
function bufferTiming(clock, timestamp, duration, now) {
const startAt = clock.audioContextStartTime + (timestamp - clock.playbackTimeAtStart) / clock.playbackRate;
const endAt = startAt + duration / clock.playbackRate;
const endMediaTime = (endAt - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart;
return { startAt: Math.max(startAt, now), offset: startAt < now ? (now - startAt) * clock.playbackRate : null, endMediaTime };
}
class ContextOwner {
constructor(host) {
this.host = host;
this.closed = false;
this.resumes = /* @__PURE__ */ new WeakMap();
}
ensure(sampleRate) {
if (this.closed)
return null;
if (this.host.audioContext)
return this.host.audioContext;
const Context = window.AudioContext || window.webkitAudioContext;
let context;
try {
context = new Context({ sampleRate });
} catch {
context = new Context();
}
if (this.closed) {
void context.close().catch((error) => console.warn("MediaBunny obsolete context cleanup:", error));
return null;
}
let gain = null;
try {
gain = context.createGain();
gain.connect(context.destination);
const volume = this.host.muted ? 0 : this.host.volume;
gain.gain.value = volume * volume;
if (this.closed) {
gain.disconnect();
void context.close().catch((error) => console.warn("MediaBunny obsolete context cleanup:", error));
return null;
}
this.host.audioContext = context;
this.host.gainNode = gain;
return context;
} catch (error) {
try {
gain?.disconnect();
} catch (failure) {
console.warn("MediaBunny gain setup cleanup:", failure);
}
void context.close().catch((failure) => console.warn("MediaBunny context setup cleanup:", failure));
throw error;
}
}
updateGain() {
if (!this.host.gainNode)
return;
const volume = this.host.muted ? 0 : this.host.volume;
this.host.gainNode.gain.value = volume * volume;
}
resume(context) {
const previous = this.resumes.get(context);
if (previous)
return previous;
const pending = context.resume();
this.resumes.set(context, pending);
const clear = () => {
if (this.resumes.get(context) === pending)
this.resumes.delete(context);
};
pending.then(clear, clear);
return pending;
}
destroy() {
if (this.closed)
return;
this.closed = true;
const { audioContext: context, gainNode: gain } = this.host;
this.host.audioContext = null;
this.host.gainNode = null;
try {
gain?.disconnect();
} finally {
if (context)
void context.close().catch((error) => console.warn("MediaBunny context close error:", error));
}
}
}
class NodeQueue {
constructor(host) {
this.host = host;
}
release(node, stop) {
this.host.queuedNodes.delete(node);
node.onended = null;
let failed = false;
let failure;
try {
if (stop)
node.stop();
} catch (error) {
failed = true;
failure = error;
}
try {
node.disconnect();
} catch (error) {
if (!failed) {
failed = true;
failure = error;
}
}
if (failed)
throw failure;
}
stop() {
const failures = [];
for (const node of [...this.host.queuedNodes]) {
try {
this.release(node, true);
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
schedule({ buffer, timestamp }) {
const context = this.host.audioContext;
const gain = this.host.gainNode;
if (!context || !gain)
return;
const timing = bufferTiming(this.host, timestamp, buffer.duration, context.currentTime);
if (timing.offset !== null && timing.offset >= buffer.duration)
return;
const node = context.createBufferSource();
this.host.queuedNodes.add(node);
try {
node.buffer = buffer;
node.connect(gain);
node.playbackRate.value = this.host.playbackRate;
node.onended = () => {
if (!this.host.queuedNodes.has(node))
return;
try {
this.release(node, false);
} catch (error) {
console.warn("MediaBunny ended node cleanup:", error);
}
};
if (timing.offset === null)
node.start(timing.startAt);
else
node.start(timing.startAt, timing.offset);
this.host.latestScheduledEndTime = Math.max(this.host.latestScheduledEndTime, timing.endMediaTime);
} catch (error) {
try {
this.release(node, true);
} catch (failure) {
console.warn("MediaBunny audio node cleanup:", failure);
}
throw error;
}
}
}
const CANCELLED = /* @__PURE__ */ Symbol("cancelled");
class AudioTask {
constructor() {
this.cancelled = false;
this.waits = /* @__PURE__ */ new Set();
this.timers = /* @__PURE__ */ new Map();
}
wait(pending) {
return new Promise((resolve, reject) => {
const cancel = () => resolve(CANCELLED);
if (this.cancelled)
cancel();
else
this.waits.add(cancel);
pending.then((value) => {
this.waits.delete(cancel);
resolve(value);
}, (error) => {
this.waits.delete(cancel);
reject(error);
});
});
}
delay(ms) {
if (this.cancelled)
return Promise.resolve(false);
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.timers.delete(timer);
resolve(!this.cancelled);
}, ms);
this.timers.set(timer, resolve);
});
}
cancel() {
if (this.cancelled)
return;
this.cancelled = true;
for (const cancel of this.waits)
cancel();
this.waits.clear();
for (const [timer, resolve] of this.timers) {
clearTimeout(timer);
resolve(false);
}
this.timers.clear();
}
}
function publish(events, current, names) {
for (const name of names) {
if (!current())
return;
events.emit(name);
}
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
published = true;
ready();
}
}
return { video: () => complete("video"), audio: () => complete("audio") };
}
class AudioPump {
constructor(host, contexts, nodes) {
this.host = host;
this.contexts = contexts;
this.nodes = nodes;
this.task = null;
this.closing = /* @__PURE__ */ new WeakMap();
}
async stopIterator() {
const iterator = this.host.audioIterator;
this.host.audioIterator = null;
if (!iterator)
return;
let pending = this.closing.get(iterator);
if (!pending) {
pending = Promise.resolve().then(() => iterator.return()).then(() => {
});
this.closing.set(iterator, pending);
}
await pending;
}
cancel() {
this.task?.cancel();
this.task = null;
void this.stopIterator().catch((error) => console.warn("MediaBunny audio iterator cleanup:", error));
}
async run(id) {
if (this.host.asyncId !== id)
return;
this.cancel();
const sink = this.host.audioSink;
const context = this.host.audioContext;
if (!sink || !context || this.host.paused || this.host.asyncId !== id)
return;
const task = new AudioTask();
this.task = task;
let iterator = null;
const current = () => !task.cancelled && this.task === task && this.host.asyncId === id && !this.host.paused && this.host.audioContext === context && (!iterator || this.host.audioIterator === iterator);
try {
iterator = sink.buffers(this.host.currentTime);
this.host.audioIterator = iterator;
while (current()) {
const batch = [];
let done = false;
for (let index = 0; index < 16; index++) {
const result = await task.wait(iterator.next());
if (result === CANCELLED || !current())
return;
if (result.done) {
done = true;
break;
}
batch.push(result.value);
}
if (batch.length && context.state === "suspended") {
const resumed = await task.wait(this.contexts.resume(context));
if (resumed === CANCELLED || !current())
return;
publish(this.host.events, current, ["canplay", "playing"]);
}
for (const buffer of batch) {
if (!current())
return;
this.nodes.schedule(buffer);
}
if (done)
return;
if (!await task.delay(0) || !current())
return;
while (this.host.latestScheduledEndTime - this.host.currentTime > 1) {
if (!await task.delay(50) || !current())
return;
}
}
} catch (error) {
if (current())
throw error;
} finally {
task.cancel();
if (this.task === task) {
this.task = null;
void this.stopIterator().catch((error) => console.warn("MediaBunny audio iterator cleanup:", error));
}
}
}
}
class AudioEngine {
constructor(events) {
constructor(events, onError) {
__privateAdd(this, _AudioEngine_instances);
__privateAdd(this, _closed2, false);
__privateAdd(this, _generation, 0);
__privateAdd(this, _contexts);
__privateAdd(this, _nodes);
__privateAdd(this, _pump);
__privateAdd(this, _playTask, null);
__privateAdd(this, _playing, null);
__privateAdd(this, _loading, null);
__privateAdd(this, _onError);
this.events = events;
this.input = null;
this.audioSink = null;
@@ -24094,164 +24430,108 @@ class AudioEngine {
this.playbackRate = 1;
this.asyncId = 0;
this.queuedNodes = /* @__PURE__ */ new Set();
__privateSet(this, _contexts, new ContextOwner(this));
__privateSet(this, _nodes, new NodeQueue(this));
__privateSet(this, _pump, new AudioPump(this, __privateGet(this, _contexts), __privateGet(this, _nodes)));
__privateSet(this, _onError, onError);
}
get currentTime() {
if (this.paused)
return this.playbackTimeAtStart;
return (this.audioContext.currentTime - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart;
return mediaTime(this);
}
ensureAudioContext(sampleRate) {
if (this.audioContext)
return;
const AudioContext = window.AudioContext || window.webkitAudioContext;
try {
this.audioContext = new AudioContext({ sampleRate });
} catch {
this.audioContext = new AudioContext();
}
this.gainNode = this.audioContext.createGain();
this.gainNode.connect(this.audioContext.destination);
this.updateGain();
__privateGet(this, _contexts).ensure(sampleRate);
}
updateGain() {
if (!this.gainNode)
return;
const v = this.muted ? 0 : this.volume;
this.gainNode.gain.value = v * v;
__privateGet(this, _contexts).updateGain();
}
stopQueuedNodes() {
this.queuedNodes.forEach((node) => node.stop());
this.queuedNodes.clear();
__privateGet(this, _nodes).stop();
}
async stopIterator() {
await this.audioIterator?.return();
this.audioIterator = null;
await __privateGet(this, _pump).stopIterator();
}
handleNoAudioTrack() {
if (__privateGet(this, _closed2))
return;
this.audioSink = null;
this.ensureAudioContext();
}
cancelPending() {
__privateWrapper(this, _generation)._++;
try {
this.pause();
} finally {
__privateSet(this, _loading, null);
this.input = null;
this.audioSink = null;
}
}
async load(media, onMetadata) {
++this.asyncId;
await this.stopIterator();
this.stopQueuedNodes();
this.paused = true;
if (__privateGet(this, _closed2))
return;
this.cancelPending();
const generation = __privateGet(this, _generation);
this.playbackTimeAtStart = 0;
this.audioContextStartTime = 0;
const { input, audioTrack, duration } = media;
this.input = input;
this.duration = duration;
if (!audioTrack) {
this.handleNoAudioTrack();
onMetadata?.();
return;
this.latestScheduledEndTime = 0;
const pending = Promise.resolve().then(() => __privateMethod(this, _AudioEngine_instances, load_fn).call(this, media, generation, onMetadata));
__privateSet(this, _loading, pending);
try {
await pending;
} finally {
if (__privateGet(this, _loading) === pending)
__privateSet(this, _loading, null);
}
if (audioTrack.codec === null || !await audioTrack.canDecode()) {
this.audioSink = null;
this.ensureAudioContext();
onMetadata?.();
return;
}
this.ensureAudioContext(audioTrack.sampleRate);
this.audioSink = new AudioBufferSink(audioTrack);
onMetadata?.();
}
async runIterator(localId) {
if (!this.audioSink)
if (__privateGet(this, _closed2))
return;
await this.stopIterator();
this.audioIterator = this.audioSink.buffers(this.currentTime);
const BATCH_SIZE = 16;
while (true) {
if (localId !== this.asyncId || this.paused)
return;
const batch = [];
let batchDone = false;
try {
for (let i = 0; i < BATCH_SIZE; i++) {
const result = await this.audioIterator.next();
if (result.done) {
batchDone = true;
break;
}
batch.push(result.value);
}
} catch (e) {
console.error("Audio iterator error:", e);
batchDone = true;
}
if (localId !== this.asyncId || this.paused)
return;
if (batch.length > 0 && this.audioContext.state === "suspended") {
await this.audioContext.resume();
this.events.emit("canplay");
this.events.emit("playing");
}
for (const { buffer, timestamp } of batch) {
const node = this.audioContext.createBufferSource();
node.buffer = buffer;
node.connect(this.gainNode);
node.playbackRate.value = this.playbackRate;
const startAt = this.audioContextStartTime + (timestamp - this.playbackTimeAtStart) / this.playbackRate;
const duration = buffer.duration;
const endAt = startAt + duration / this.playbackRate;
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart;
if (endMediaTime > this.latestScheduledEndTime) {
this.latestScheduledEndTime = endMediaTime;
}
if (startAt >= this.audioContext.currentTime) {
node.start(startAt);
} else {
node.start(
this.audioContext.currentTime,
(this.audioContext.currentTime - startAt) * this.playbackRate
);
}
this.queuedNodes.add(node);
node.onended = () => this.queuedNodes.delete(node);
}
if (batchDone)
break;
await new Promise((resolve) => setTimeout(resolve, 0));
const BUFFER_AHEAD = 1;
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
if (localId !== this.asyncId || this.paused)
return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
}
await __privateGet(this, _pump).run(localId);
}
async play() {
if (__privateGet(this, _closed2))
return;
if (__privateGet(this, _playing))
return __privateGet(this, _playing);
if (!this.paused)
return;
if (!this.audioContext) {
this.ensureAudioContext();
const task = new AudioTask();
__privateSet(this, _playTask, task);
const generation = __privateGet(this, _generation);
const pending = __privateMethod(this, _AudioEngine_instances, play_fn).call(this, task, generation);
__privateSet(this, _playing, pending);
try {
await pending;
} finally {
if (__privateGet(this, _playTask) === task) {
__privateSet(this, _playTask, null);
__privateSet(this, _playing, null);
}
}
if (this.audioContext.state === "suspended") {
await this.audioContext.resume();
}
this.audioContextStartTime = this.audioContext.currentTime;
this.latestScheduledEndTime = this.playbackTimeAtStart;
this.paused = false;
const id = ++this.asyncId;
this.runIterator(id);
}
pause() {
if (this.paused)
return;
this.playbackTimeAtStart = this.currentTime;
if (!this.paused)
this.playbackTimeAtStart = this.currentTime;
this.paused = true;
this.stopIterator();
this.asyncId++;
__privateGet(this, _playTask)?.cancel();
__privateSet(this, _playTask, null);
__privateSet(this, _playing, null);
__privateGet(this, _pump).cancel();
this.stopQueuedNodes();
}
async seek(time) {
if (__privateGet(this, _closed2))
return;
const playing = !this.paused;
__privateGet(this, _pump).cancel();
this.stopQueuedNodes();
this.playbackTimeAtStart = Math.max(0, time);
this.audioContextStartTime = this.audioContext.currentTime;
this.audioContextStartTime = this.audioContext?.currentTime ?? 0;
this.latestScheduledEndTime = this.playbackTimeAtStart;
const id = ++this.asyncId;
if (!this.paused) {
this.runIterator(id);
}
if (playing)
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, id);
}
setVolume(volume, muted) {
this.volume = volume;
@@ -24259,27 +24539,118 @@ class AudioEngine {
this.updateGain();
}
setPlaybackRate(rate) {
if (rate === this.playbackRate)
if (__privateGet(this, _closed2) || rate === this.playbackRate)
return;
if (!this.paused) {
this.playbackTimeAtStart = this.currentTime;
this.audioContextStartTime = this.audioContext.currentTime;
}
const time = this.currentTime;
__privateGet(this, _pump).cancel();
this.stopQueuedNodes();
this.playbackTimeAtStart = time;
this.audioContextStartTime = this.audioContext?.currentTime ?? 0;
this.playbackRate = rate;
if (!this.paused) {
const id = ++this.asyncId;
this.runIterator(id);
}
this.latestScheduledEndTime = time;
const id = ++this.asyncId;
if (!this.paused)
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, id);
}
destroy() {
this.asyncId++;
this.pause();
this.audioContext?.close();
this.audioContext = null;
if (__privateGet(this, _closed2))
return;
__privateSet(this, _closed2, true);
__privateWrapper(this, _generation)._++;
const failures = [];
for (const release of [() => this.pause(), () => __privateGet(this, _contexts).destroy()]) {
try {
release();
} catch (error) {
failures.push(error);
}
}
this.input = null;
this.audioSink = null;
__privateSet(this, _loading, null);
if (failures.length)
throw failures[0];
}
}
_closed2 = new WeakMap();
_generation = new WeakMap();
_contexts = new WeakMap();
_nodes = new WeakMap();
_pump = new WeakMap();
_playTask = new WeakMap();
_playing = new WeakMap();
_loading = new WeakMap();
_onError = new WeakMap();
_AudioEngine_instances = new WeakSet();
load_fn = async function({ input, audioTrack, duration }, generation, onMetadata) {
const current = () => !__privateGet(this, _closed2) && __privateGet(this, _generation) === generation;
if (!current())
return;
this.input = input;
this.duration = duration;
try {
const decodable = audioTrack && audioTrack.codec !== null && await audioTrack.canDecode();
if (!current())
return;
if (!decodable) {
this.handleNoAudioTrack();
} else {
this.ensureAudioContext(audioTrack.sampleRate);
if (!current())
return;
this.audioSink = new AudioBufferSink(audioTrack);
}
if (current())
onMetadata?.();
} catch (error) {
if (current())
throw error;
}
};
startPump_fn = function(id) {
void this.runIterator(id).catch((error) => {
if (__privateGet(this, _closed2) || id !== this.asyncId)
return;
try {
this.pause();
} catch (failure) {
console.warn("MediaBunny audio failure cleanup:", failure);
}
try {
if (__privateGet(this, _onError))
__privateGet(this, _onError).call(this, error);
else
this.events.emit("error", error);
} catch (failure) {
console.warn("MediaBunny audio error listener:", failure);
}
});
};
play_fn = async function(task, generation) {
const current = () => !__privateGet(this, _closed2) && !task.cancelled && __privateGet(this, _generation) === generation;
try {
const context = __privateGet(this, _contexts).ensure();
if (!context || !current())
return;
if (context.state === "suspended") {
const resumed = await task.wait(__privateGet(this, _contexts).resume(context));
if (resumed === CANCELLED || !current())
return;
}
if (__privateGet(this, _loading)) {
const loaded = await task.wait(__privateGet(this, _loading));
if (loaded === CANCELLED || !current())
return;
}
this.audioContextStartTime = context.currentTime;
this.latestScheduledEndTime = this.playbackTimeAtStart;
this.paused = false;
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, ++this.asyncId);
} catch (error) {
if (current())
throw error;
}
};
async function selectQuality(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
@@ -24469,30 +24840,6 @@ class LoadSession {
this.disposeInput();
}
}
function publish(events, current, names) {
for (const name of names) {
if (!current())
return;
events.emit(name);
}
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
published = true;
ready();
}
}
return { video: () => complete("video"), audio: () => complete("audio") };
}
async function resolveDuration({ input, videoTrack, audioTrack }) {
const tracks = [videoTrack, audioTrack].filter((track) => track !== null);
const referenceTrack = videoTrack || audioTrack;
@@ -24537,7 +24884,6 @@ class Playback {
this.wanted = false;
this.playing = null;
this.seeking = null;
this.audioStart = null;
}
operation() {
return { ...completion(), source: this.host.loadSeq, complete: completion() };
@@ -24602,17 +24948,6 @@ class Playback {
this.runPlay(operation).then(operation.resolve, operation.reject);
return operation.promise;
}
async startAudio() {
if (!this.audioStart)
this.audioStart = this.host.audio.play();
const pending = this.audioStart;
try {
await pending;
} finally {
if (this.audioStart === pending)
this.audioStart = null;
}
}
async runPlay(operation) {
const current = () => this.current(operation, "playing") && this.wanted;
try {
@@ -24624,7 +24959,7 @@ class Playback {
if (!current())
return;
this.host.paused = false;
await this.startAudio();
await this.host.audio.play();
if (!current()) {
if (!this.wanted || this.host.destroyed)
this.host.audio.pause();
@@ -24974,16 +25309,16 @@ class Renderer {
class VideoEngine {
constructor({ canvas, ctx, events, timeupdateInterval = 250, avSyncTolerance = 0.12, dropLateFrames = false, poster = "", preflightRange: preflightRange2 = false, onError }) {
__privateAdd(this, _VideoEngine_instances);
__privateAdd(this, _generation, 0);
__privateAdd(this, _generation2, 0);
__privateAdd(this, _iteration, 0);
__privateAdd(this, _loading, null);
__privateAdd(this, _loading2, null);
__privateAdd(this, _createSink, null);
__privateAdd(this, _usedSink, null);
__privateAdd(this, _destroyed, false);
__privateAdd(this, _preparing, false);
__privateAdd(this, _poster, new Poster());
__privateAdd(this, _renderer);
__privateAdd(this, _onError);
__privateAdd(this, _onError2);
this.canvas = canvas;
this.ctx = ctx;
this.events = events;
@@ -25007,7 +25342,7 @@ class VideoEngine {
this.playbackRate = 1;
this.posterDrawn = false;
this.isFetching = false;
__privateSet(this, _onError, onError);
__privateSet(this, _onError2, onError);
__privateSet(this, _renderer, new Renderer(this, () => !__privateGet(this, _preparing), (frame) => __privateMethod(this, _VideoEngine_instances, draw_fn).call(this, frame, true), (error) => __privateMethod(this, _VideoEngine_instances, report_fn).call(this, error)));
}
async preflight(source, signal, current) {
@@ -25016,7 +25351,7 @@ class VideoEngine {
drawPoster() {
if (__privateGet(this, _destroyed) || !this.poster || this.posterDrawn)
return;
const generation = __privateGet(this, _generation);
const generation = __privateGet(this, _generation2);
__privateGet(this, _poster).draw(this.poster, () => __privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation), (image) => {
this.clear();
this.canvas.width = image.naturalWidth || this.canvas.width;
@@ -25035,13 +25370,13 @@ class VideoEngine {
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).clearRect(0, 0, this.canvas.width, this.canvas.height);
}
cancelPending() {
__privateWrapper(this, _generation)._++;
__privateWrapper(this, _generation2)._++;
__privateWrapper(this, _iteration)._++;
this.stop();
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
__privateGet(this, _poster).cancel();
__privateSet(this, _preparing, false);
__privateSet(this, _loading, null);
__privateSet(this, _loading2, null);
__privateSet(this, _createSink, null);
__privateSet(this, _usedSink, null);
this.videoSink = null;
@@ -25051,16 +25386,16 @@ class VideoEngine {
if (__privateGet(this, _destroyed))
return;
this.cancelPending();
const generation = __privateGet(this, _generation);
const generation = __privateGet(this, _generation2);
const iteration = __privateGet(this, _iteration);
__privateSet(this, _preparing, true);
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn).call(this, media, generation, iteration, onMetadata));
__privateSet(this, _loading, pending);
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn2).call(this, media, generation, iteration, onMetadata));
__privateSet(this, _loading2, pending);
try {
await pending;
} finally {
if (__privateGet(this, _loading) === pending)
__privateSet(this, _loading, null);
if (__privateGet(this, _loading2) === pending)
__privateSet(this, _loading2, null);
}
}
handleNoVideoTrack() {
@@ -25077,9 +25412,9 @@ class VideoEngine {
async resetIterator(time) {
if (__privateGet(this, _destroyed))
return;
const generation = __privateGet(this, _generation);
const generation = __privateGet(this, _generation2);
const iteration = ++__privateWrapper(this, _iteration)._;
const loading = __privateGet(this, _loading);
const loading = __privateGet(this, _loading2);
this.stop();
__privateGet(this, _poster).cancel();
__privateSet(this, _preparing, true);
@@ -25119,7 +25454,7 @@ class VideoEngine {
if (__privateGet(this, _destroyed))
return;
__privateSet(this, _destroyed, true);
__privateWrapper(this, _generation)._++;
__privateWrapper(this, _generation2)._++;
__privateGet(this, _renderer).destroy();
__privateGet(this, _poster).cancel();
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
@@ -25131,19 +25466,19 @@ class VideoEngine {
this.audioClock = null;
}
}
_generation = new WeakMap();
_generation2 = new WeakMap();
_iteration = new WeakMap();
_loading = new WeakMap();
_loading2 = new WeakMap();
_createSink = new WeakMap();
_usedSink = new WeakMap();
_destroyed = new WeakMap();
_preparing = new WeakMap();
_poster = new WeakMap();
_renderer = new WeakMap();
_onError = new WeakMap();
_onError2 = new WeakMap();
_VideoEngine_instances = new WeakSet();
current_fn = function(generation) {
return !__privateGet(this, _destroyed) && __privateGet(this, _generation) === generation;
return !__privateGet(this, _destroyed) && __privateGet(this, _generation2) === generation;
};
context_fn = function() {
if (!this.ctx)
@@ -25154,8 +25489,8 @@ report_fn = function(error) {
if (__privateGet(this, _destroyed))
return;
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
if (__privateGet(this, _onError))
__privateGet(this, _onError).call(this, error);
if (__privateGet(this, _onError2))
__privateGet(this, _onError2).call(this, error);
else
this.events.emit("error", error);
};
@@ -25168,7 +25503,7 @@ draw_fn = function(frame, clear) {
this.clear();
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(frame.canvas, 0, 0);
};
load_fn = async function(media, generation, iteration, onMetadata) {
load_fn2 = async function(media, generation, iteration, onMetadata) {
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
return;
try {
@@ -25242,7 +25577,14 @@ class MediaBunnyEngine {
__privateAdd(this, _playback);
this.events = events;
this.option = option;
this.audio = new AudioEngine(events);
this.audio = new AudioEngine(events, (error) => {
try {
this.pause();
} catch (failure) {
console.warn("MediaBunny audio coordinator cleanup:", failure);
}
this.reportError(error);
});
this.video = new VideoEngine({
canvas,
ctx,
@@ -25279,6 +25621,7 @@ class MediaBunnyEngine {
this.ended = true;
this.paused = true;
__privateGet(this, _playback).ended();
this.audio.pause();
});
}
async load(src) {
@@ -25287,6 +25630,7 @@ class MediaBunnyEngine {
const id = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
this.audio.cancelPending();
this.loadSession?.cancel();
if (id !== this.loadSeq || this.destroyed)
return;
@@ -25311,6 +25655,7 @@ class MediaBunnyEngine {
const failed = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.video.cancelPending();
this.audio.cancelPending();
session.cancel();
if (failed !== this.loadSeq || this.destroyed)
return;
@@ -1,6 +0,0 @@
import type { AudioPort } from './engine-ports'
import type EventTarget from './EventTarget'
// Exact coordinator-facing port; implementation migration and decoder ownership are MB-06.
declare const AudioEngine: new (events: EventTarget) => AudioPort
export default AudioEngine
@@ -1,289 +0,0 @@
/**
* Audio Engine for MediaBunny
* Handles audio playback using Web Audio API
*/
import {
AudioBufferSink,
} from 'mediabunny'
export default class AudioEngine {
constructor(events) {
this.events = events
// MediaBunny instances
this.input = null
this.audioSink = null
this.audioIterator = null
// Web Audio API
this.audioContext = null
this.gainNode = null
// Playback state
this.audioContextStartTime = 0
this.playbackTimeAtStart = 0
this.latestScheduledEndTime = 0
this.duration = Number.NaN
this.paused = true
// Audio settings
this.volume = 0.7
this.muted = false
this.playbackRate = 1
// Async control
this.asyncId = 0
this.queuedNodes = new Set()
}
get currentTime() {
if (this.paused)
return this.playbackTimeAtStart
return (
(this.audioContext.currentTime - this.audioContextStartTime) * this.playbackRate
+ this.playbackTimeAtStart
)
}
ensureAudioContext(sampleRate) {
if (this.audioContext)
return
const AudioContext = window.AudioContext || window.webkitAudioContext
try {
this.audioContext = new AudioContext({ sampleRate })
}
catch {
this.audioContext = new AudioContext()
}
this.gainNode = this.audioContext.createGain()
this.gainNode.connect(this.audioContext.destination)
this.updateGain()
}
updateGain() {
if (!this.gainNode)
return
const v = this.muted ? 0 : this.volume
this.gainNode.gain.value = v * v
}
stopQueuedNodes() {
this.queuedNodes.forEach(node => node.stop())
this.queuedNodes.clear()
}
async stopIterator() {
await this.audioIterator?.return()
this.audioIterator = null
}
handleNoAudioTrack() {
this.audioSink = null
this.ensureAudioContext()
}
async load(media, onMetadata) {
++this.asyncId
await this.stopIterator()
this.stopQueuedNodes()
this.paused = true
this.playbackTimeAtStart = 0
this.audioContextStartTime = 0
const { input, audioTrack, duration } = media
this.input = input
this.duration = duration
if (!audioTrack) {
this.handleNoAudioTrack()
onMetadata?.()
return
}
if (audioTrack.codec === null || !(await audioTrack.canDecode())) {
this.audioSink = null
this.ensureAudioContext()
onMetadata?.()
return
}
this.ensureAudioContext(audioTrack.sampleRate)
this.audioSink = new AudioBufferSink(audioTrack)
onMetadata?.()
}
async runIterator(localId) {
if (!this.audioSink)
return
await this.stopIterator()
this.audioIterator = this.audioSink.buffers(this.currentTime)
// Batch size: read multiple audio buffers per iteration to reduce
// IPC round-trip overhead. 16 buffers ≈ 340ms of audio.
const BATCH_SIZE = 16
while (true) {
if (localId !== this.asyncId || this.paused)
return
const batch = []
let batchDone = false
try {
for (let i = 0; i < BATCH_SIZE; i++) {
const result = await this.audioIterator.next()
if (result.done) {
batchDone = true
break
}
batch.push(result.value)
}
}
catch (e) {
console.error('Audio iterator error:', e)
batchDone = true
}
if (localId !== this.asyncId || this.paused)
return
// Resume if was suspended
if (batch.length > 0 && this.audioContext.state === 'suspended') {
await this.audioContext.resume()
this.events.emit('canplay')
this.events.emit('playing')
}
// Schedule all buffers in the batch
for (const { buffer, timestamp } of batch) {
const node = this.audioContext.createBufferSource()
node.buffer = buffer
node.connect(this.gainNode)
node.playbackRate.value = this.playbackRate
const startAt
= this.audioContextStartTime
+ (timestamp - this.playbackTimeAtStart) / this.playbackRate
const duration = buffer.duration
const endAt = startAt + duration / this.playbackRate
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart
if (endMediaTime > this.latestScheduledEndTime) {
this.latestScheduledEndTime = endMediaTime
}
if (startAt >= this.audioContext.currentTime) {
node.start(startAt)
}
else {
node.start(
this.audioContext.currentTime,
(this.audioContext.currentTime - startAt) * this.playbackRate,
)
}
this.queuedNodes.add(node)
node.onended = () => this.queuedNodes.delete(node)
}
if (batchDone)
break
// Yield main thread after each batch so video rAF callbacks can run.
// Without this, the audio pump's tight loop starves the render loop.
await new Promise(resolve => setTimeout(resolve, 0))
// Backpressure: if we've scheduled too far ahead, wait for playback
// to catch up before decoding more. This prevents the audio pump from
// consuming all WebCodecs resources and starving the video decoder.
const BUFFER_AHEAD = 1 // seconds
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
if (localId !== this.asyncId || this.paused)
return
await new Promise(resolve => setTimeout(resolve, 50))
}
}
}
async play() {
if (!this.paused)
return
if (!this.audioContext) {
this.ensureAudioContext()
}
if (this.audioContext.state === 'suspended') {
await this.audioContext.resume()
}
this.audioContextStartTime = this.audioContext.currentTime
this.latestScheduledEndTime = this.playbackTimeAtStart
this.paused = false
const id = ++this.asyncId
this.runIterator(id)
}
pause() {
if (this.paused)
return
this.playbackTimeAtStart = this.currentTime
this.paused = true
this.stopIterator()
this.stopQueuedNodes()
}
async seek(time) {
this.playbackTimeAtStart = Math.max(0, time)
this.audioContextStartTime = this.audioContext.currentTime
this.latestScheduledEndTime = this.playbackTimeAtStart
const id = ++this.asyncId
if (!this.paused) {
this.runIterator(id)
}
}
setVolume(volume, muted) {
this.volume = volume
this.muted = muted
this.updateGain()
}
setPlaybackRate(rate) {
if (rate === this.playbackRate)
return
if (!this.paused) {
this.playbackTimeAtStart = this.currentTime
this.audioContextStartTime = this.audioContext.currentTime
}
this.playbackRate = rate
if (!this.paused) {
const id = ++this.asyncId
this.runIterator(id)
}
}
destroy() {
this.asyncId++
this.pause()
this.audioContext?.close()
this.audioContext = null
this.input = null
this.audioSink = null
}
}
@@ -0,0 +1,288 @@
import type { AudioBufferSink as AudioSink, Input } from 'mediabunny'
import type { AudioIterator } from './audio-pump'
import type { AudioPort } from './engine-ports'
import type EventTarget from './EventTarget'
import type { PlaybackMedia } from './media'
import { AudioBufferSink } from 'mediabunny'
import { mediaTime } from './audio-clock'
import ContextOwner from './audio-context'
import NodeQueue from './audio-nodes'
import AudioPump from './audio-pump'
import AudioTask, { CANCELLED } from './audio-task'
export default class AudioEngine implements AudioPort {
declare events: EventTarget
declare input: Input | null
declare audioSink: AudioSink | null
declare audioIterator: AudioIterator | null
declare audioContext: AudioContext | null
declare gainNode: GainNode | null
declare audioContextStartTime: number
declare playbackTimeAtStart: number
declare latestScheduledEndTime: number
declare duration: number
declare paused: boolean
declare volume: number
declare muted: boolean
declare playbackRate: number
declare asyncId: number
declare queuedNodes: Set<AudioBufferSourceNode>
#closed = false
#generation = 0
#contexts: ContextOwner
#nodes: NodeQueue
#pump: AudioPump
#playTask: AudioTask | null = null
#playing: Promise<void> | null = null
#loading: Promise<void> | null = null
#onError: ((error: unknown) => void) | undefined
constructor(events: EventTarget, onError?: (error: unknown) => void) {
this.events = events
this.input = null
this.audioSink = null
this.audioIterator = null
this.audioContext = null
this.gainNode = null
this.audioContextStartTime = 0
this.playbackTimeAtStart = 0
this.latestScheduledEndTime = 0
this.duration = Number.NaN
this.paused = true
this.volume = 0.7
this.muted = false
this.playbackRate = 1
this.asyncId = 0
this.queuedNodes = new Set()
this.#contexts = new ContextOwner(this)
this.#nodes = new NodeQueue(this)
this.#pump = new AudioPump(this, this.#contexts, this.#nodes)
this.#onError = onError
}
get currentTime(): number {
return mediaTime(this)
}
ensureAudioContext(sampleRate?: number): void {
this.#contexts.ensure(sampleRate)
}
updateGain(): void {
this.#contexts.updateGain()
}
stopQueuedNodes(): void {
this.#nodes.stop()
}
async stopIterator(): Promise<void> {
await this.#pump.stopIterator()
}
handleNoAudioTrack(): void {
if (this.#closed)
return
this.audioSink = null
this.ensureAudioContext()
}
cancelPending(): void {
this.#generation++
try {
this.pause()
}
finally {
this.#loading = null
this.input = null
this.audioSink = null
}
}
async load(media: PlaybackMedia, onMetadata?: () => void): Promise<void> {
if (this.#closed)
return
this.cancelPending()
const generation = this.#generation
this.playbackTimeAtStart = 0
this.audioContextStartTime = 0
this.latestScheduledEndTime = 0
const pending = Promise.resolve().then(() => this.#load(media, generation, onMetadata))
this.#loading = pending
try {
await pending
}
finally {
if (this.#loading === pending)
this.#loading = null
}
}
async #load({ input, audioTrack, duration }: PlaybackMedia, generation: number, onMetadata?: () => void): Promise<void> {
const current = () => !this.#closed && this.#generation === generation
if (!current())
return
this.input = input
this.duration = duration
try {
const decodable = audioTrack && audioTrack.codec !== null && await audioTrack.canDecode()
if (!current())
return
if (!decodable) {
this.handleNoAudioTrack()
}
else {
this.ensureAudioContext(audioTrack.sampleRate)
if (!current())
return
this.audioSink = new AudioBufferSink(audioTrack)
}
if (current())
onMetadata?.()
}
catch (error) {
if (current())
throw error
}
}
async runIterator(localId: number): Promise<void> {
if (this.#closed)
return
await this.#pump.run(localId)
}
#startPump(id: number): void {
void this.runIterator(id).catch((error) => {
if (this.#closed || id !== this.asyncId)
return
try {
this.pause()
}
catch (failure) { console.warn('MediaBunny audio failure cleanup:', failure) }
try {
if (this.#onError)
this.#onError(error)
else
this.events.emit('error', error)
}
catch (failure) { console.warn('MediaBunny audio error listener:', failure) }
})
}
async play(): Promise<void> {
if (this.#closed)
return
if (this.#playing)
return this.#playing
if (!this.paused)
return
const task = new AudioTask()
this.#playTask = task
const generation = this.#generation
const pending = this.#play(task, generation)
this.#playing = pending
try {
await pending
}
finally {
if (this.#playTask === task) {
this.#playTask = null
this.#playing = null
}
}
}
async #play(task: AudioTask, generation: number): Promise<void> {
const current = () => !this.#closed && !task.cancelled && this.#generation === generation
try {
const context = this.#contexts.ensure()
if (!context || !current())
return
if (context.state === 'suspended') {
const resumed = await task.wait(this.#contexts.resume(context))
if (resumed === CANCELLED || !current())
return
}
if (this.#loading) {
const loaded = await task.wait(this.#loading)
if (loaded === CANCELLED || !current())
return
}
this.audioContextStartTime = context.currentTime
this.latestScheduledEndTime = this.playbackTimeAtStart
this.paused = false
this.#startPump(++this.asyncId)
}
catch (error) {
if (current())
throw error
}
}
pause(): void {
if (!this.paused)
this.playbackTimeAtStart = this.currentTime
this.paused = true
this.asyncId++
this.#playTask?.cancel()
this.#playTask = null
this.#playing = null
this.#pump.cancel()
this.stopQueuedNodes()
}
async seek(time: number): Promise<void> {
if (this.#closed)
return
const playing = !this.paused
this.#pump.cancel()
this.stopQueuedNodes()
this.playbackTimeAtStart = Math.max(0, time)
this.audioContextStartTime = this.audioContext?.currentTime ?? 0
this.latestScheduledEndTime = this.playbackTimeAtStart
const id = ++this.asyncId
if (playing)
this.#startPump(id)
}
setVolume(volume: number, muted: boolean): void {
this.volume = volume
this.muted = muted
this.updateGain()
}
setPlaybackRate(rate: number): void {
if (this.#closed || rate === this.playbackRate)
return
const time = this.currentTime
this.#pump.cancel()
this.stopQueuedNodes()
this.playbackTimeAtStart = time
this.audioContextStartTime = this.audioContext?.currentTime ?? 0
this.playbackRate = rate
this.latestScheduledEndTime = time
const id = ++this.asyncId
if (!this.paused)
this.#startPump(id)
}
destroy(): void {
if (this.#closed)
return
this.#closed = true
this.#generation++
const failures: unknown[] = []
for (const release of [() => this.pause(), () => this.#contexts.destroy()]) {
try {
release()
}
catch (error) { failures.push(error) }
}
this.input = null
this.audioSink = null
this.#loading = null
if (failures.length)
throw failures[0]
}
}
@@ -35,7 +35,15 @@ export default class MediaBunnyEngine implements EnginePort {
constructor({ canvas, ctx, events, option = {} }: EngineOptions) {
this.events = events
this.option = option
this.audio = new AudioEngine(events)
this.audio = new AudioEngine(events, (error) => {
try {
this.pause()
}
catch (failure) {
console.warn('MediaBunny audio coordinator cleanup:', failure)
}
this.reportError(error)
})
this.video = new VideoEngine({
canvas,
ctx,
@@ -73,6 +81,7 @@ export default class MediaBunnyEngine implements EnginePort {
this.ended = true
this.paused = true
this.#playback.ended()
this.audio.pause()
})
}
@@ -82,6 +91,7 @@ export default class MediaBunnyEngine implements EnginePort {
const id = ++this.loadSeq
this.#playback.invalidate()
this.video.cancelPending()
this.audio.cancelPending()
this.loadSession?.cancel()
if (id !== this.loadSeq || this.destroyed)
return
@@ -107,6 +117,7 @@ export default class MediaBunnyEngine implements EnginePort {
const failed = ++this.loadSeq
this.#playback.invalidate()
this.video.cancelPending()
this.audio.cancelPending()
session.cancel()
if (failed !== this.loadSeq || this.destroyed)
return
@@ -0,0 +1,20 @@
export interface AudioClock {
audioContext: AudioContext | null
paused: boolean
audioContextStartTime: number
playbackTimeAtStart: number
playbackRate: number
}
export function mediaTime(clock: AudioClock): number {
if (clock.paused || !clock.audioContext)
return clock.playbackTimeAtStart
return (clock.audioContext.currentTime - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart
}
export function bufferTiming(clock: AudioClock, timestamp: number, duration: number, now: number) {
const startAt = clock.audioContextStartTime + (timestamp - clock.playbackTimeAtStart) / clock.playbackRate
const endAt = startAt + duration / clock.playbackRate
const endMediaTime = (endAt - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart
return { startAt: Math.max(startAt, now), offset: startAt < now ? (now - startAt) * clock.playbackRate : null, endMediaTime }
}
@@ -0,0 +1,92 @@
interface ContextHost {
audioContext: AudioContext | null
gainNode: GainNode | null
volume: number
muted: boolean
}
export default class ContextOwner {
private closed = false
private resumes = new WeakMap<AudioContext, Promise<void>>()
constructor(private host: ContextHost) {}
ensure(sampleRate?: number): AudioContext | null {
if (this.closed)
return null
if (this.host.audioContext)
return this.host.audioContext
const Context = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
let context: AudioContext
try {
context = new Context({ sampleRate })
}
catch {
context = new Context()
}
if (this.closed) {
void context.close().catch(error => console.warn('MediaBunny obsolete context cleanup:', error))
return null
}
let gain: GainNode | null = null
try {
gain = context.createGain()
gain.connect(context.destination)
const volume = this.host.muted ? 0 : this.host.volume
gain.gain.value = volume * volume
if (this.closed) {
gain.disconnect()
void context.close().catch(error => console.warn('MediaBunny obsolete context cleanup:', error))
return null
}
this.host.audioContext = context
this.host.gainNode = gain
return context
}
catch (error) {
try {
gain?.disconnect()
}
catch (failure) { console.warn('MediaBunny gain setup cleanup:', failure) }
void context.close().catch(failure => console.warn('MediaBunny context setup cleanup:', failure))
throw error
}
}
updateGain(): void {
if (!this.host.gainNode)
return
const volume = this.host.muted ? 0 : this.host.volume
this.host.gainNode.gain.value = volume * volume
}
resume(context: AudioContext): Promise<void> {
const previous = this.resumes.get(context)
if (previous)
return previous
const pending = context.resume()
this.resumes.set(context, pending)
const clear = () => {
if (this.resumes.get(context) === pending)
this.resumes.delete(context)
}
pending.then(clear, clear)
return pending
}
destroy(): void {
if (this.closed)
return
this.closed = true
const { audioContext: context, gainNode: gain } = this.host
this.host.audioContext = null
this.host.gainNode = null
try {
gain?.disconnect()
}
finally {
if (context)
void context.close().catch(error => console.warn('MediaBunny context close error:', error))
}
}
}
@@ -0,0 +1,88 @@
import type { WrappedAudioBuffer } from 'mediabunny'
import type { AudioClock } from './audio-clock'
import { bufferTiming } from './audio-clock'
interface QueueHost extends AudioClock {
gainNode: GainNode | null
queuedNodes: Set<AudioBufferSourceNode>
latestScheduledEndTime: number
}
export default class NodeQueue {
constructor(private host: QueueHost) {}
private release(node: AudioBufferSourceNode, stop: boolean): void {
this.host.queuedNodes.delete(node)
node.onended = null
let failed = false
let failure: unknown
try {
if (stop)
node.stop()
}
catch (error) {
failed = true
failure = error
}
try {
node.disconnect()
}
catch (error) {
if (!failed) {
failed = true
failure = error
}
}
if (failed)
throw failure
}
stop(): void {
const failures: unknown[] = []
for (const node of [...this.host.queuedNodes]) {
try {
this.release(node, true)
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
schedule({ buffer, timestamp }: WrappedAudioBuffer): void {
const context = this.host.audioContext
const gain = this.host.gainNode
if (!context || !gain)
return
const timing = bufferTiming(this.host, timestamp, buffer.duration, context.currentTime)
if (timing.offset !== null && timing.offset >= buffer.duration)
return
const node = context.createBufferSource()
this.host.queuedNodes.add(node)
try {
node.buffer = buffer
node.connect(gain)
node.playbackRate.value = this.host.playbackRate
node.onended = () => {
if (!this.host.queuedNodes.has(node))
return
try {
this.release(node, false)
}
catch (error) { console.warn('MediaBunny ended node cleanup:', error) }
}
if (timing.offset === null)
node.start(timing.startAt)
else
node.start(timing.startAt, timing.offset)
this.host.latestScheduledEndTime = Math.max(this.host.latestScheduledEndTime, timing.endMediaTime)
}
catch (error) {
try {
this.release(node, true)
}
catch (failure) { console.warn('MediaBunny audio node cleanup:', failure) }
throw error
}
}
}
@@ -0,0 +1,106 @@
import type { AudioBufferSink, WrappedAudioBuffer } from 'mediabunny'
import type ContextOwner from './audio-context'
import type NodeQueue from './audio-nodes'
import type EventTarget from './EventTarget'
import AudioTask, { CANCELLED } from './audio-task'
import { publish } from './readiness'
export type AudioIterator = Pick<AsyncGenerator<WrappedAudioBuffer, void, unknown>, 'next' | 'return'>
export interface PumpHost {
audioSink: AudioBufferSink | null
audioIterator: AudioIterator | null
audioContext: AudioContext | null
paused: boolean
asyncId: number
readonly currentTime: number
latestScheduledEndTime: number
events: EventTarget
}
export default class AudioPump {
private task: AudioTask | null = null
private closing = new WeakMap<AudioIterator, Promise<void>>()
constructor(private host: PumpHost, private contexts: ContextOwner, private nodes: NodeQueue) {}
async stopIterator(): Promise<void> {
const iterator = this.host.audioIterator
this.host.audioIterator = null
if (!iterator)
return
let pending = this.closing.get(iterator)
if (!pending) {
pending = Promise.resolve().then(() => iterator.return()).then(() => {})
this.closing.set(iterator, pending)
}
await pending
}
cancel(): void {
this.task?.cancel()
this.task = null
void this.stopIterator().catch(error => console.warn('MediaBunny audio iterator cleanup:', error))
}
async run(id: number): Promise<void> {
if (this.host.asyncId !== id)
return
this.cancel()
const sink = this.host.audioSink
const context = this.host.audioContext
if (!sink || !context || this.host.paused || this.host.asyncId !== id)
return
const task = new AudioTask()
this.task = task
let iterator: AudioIterator | null = null
const current = () => !task.cancelled && this.task === task && this.host.asyncId === id && !this.host.paused && this.host.audioContext === context && (!iterator || this.host.audioIterator === iterator)
try {
iterator = sink.buffers(this.host.currentTime)
this.host.audioIterator = iterator
while (current()) {
const batch: WrappedAudioBuffer[] = []
let done = false
for (let index = 0; index < 16; index++) {
const result = await task.wait(iterator.next())
if (result === CANCELLED || !current())
return
if (result.done) {
done = true
break
}
batch.push(result.value)
}
if (batch.length && context.state === 'suspended') {
const resumed = await task.wait(this.contexts.resume(context))
if (resumed === CANCELLED || !current())
return
publish(this.host.events, current, ['canplay', 'playing'])
}
for (const buffer of batch) {
if (!current())
return
this.nodes.schedule(buffer)
}
if (done)
return
if (!(await task.delay(0)) || !current())
return
while (this.host.latestScheduledEndTime - this.host.currentTime > 1) {
if (!(await task.delay(50)) || !current())
return
}
}
}
catch (error) {
if (current())
throw error
}
finally {
task.cancel()
if (this.task === task) {
this.task = null
void this.stopIterator().catch(error => console.warn('MediaBunny audio iterator cleanup:', error))
}
}
}
}
@@ -0,0 +1,50 @@
export const CANCELLED = Symbol('cancelled')
export default class AudioTask {
cancelled = false
private waits = new Set<() => void>()
private timers = new Map<ReturnType<typeof setTimeout>, (active: boolean) => void>()
wait<T>(pending: Promise<T>): Promise<T | typeof CANCELLED> {
return new Promise((resolve, reject) => {
const cancel = () => resolve(CANCELLED)
if (this.cancelled)
cancel()
else
this.waits.add(cancel)
pending.then((value) => {
this.waits.delete(cancel)
resolve(value)
}, (error) => {
this.waits.delete(cancel)
reject(error)
})
})
}
delay(ms: number): Promise<boolean> {
if (this.cancelled)
return Promise.resolve(false)
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.timers.delete(timer)
resolve(!this.cancelled)
}, ms)
this.timers.set(timer, resolve)
})
}
cancel(): void {
if (this.cancelled)
return
this.cancelled = true
for (const cancel of this.waits)
cancel()
this.waits.clear()
for (const [timer, resolve] of this.timers) {
clearTimeout(timer)
resolve(false)
}
this.timers.clear()
}
}
@@ -4,6 +4,7 @@ import type EventTarget from './EventTarget'
import type { PlaybackMedia } from './media'
export interface AudioPort {
cancelPending: () => void
readonly currentTime: number
duration: number
paused: boolean
@@ -28,7 +28,6 @@ export default class Playback {
private wanted = false
private playing: Operation | null = null
private seeking: Operation | null = null
private audioStart: Promise<void> | null = null
constructor(private host: PlaybackHost) {}
@@ -103,19 +102,6 @@ export default class Playback {
return operation.promise
}
private async startAudio(): Promise<void> {
if (!this.audioStart)
this.audioStart = this.host.audio.play()
const pending = this.audioStart
try {
await pending
}
finally {
if (this.audioStart === pending)
this.audioStart = null
}
}
private async runPlay(operation: Operation): Promise<void> {
const current = () => this.current(operation, 'playing') && this.wanted
try {
@@ -127,7 +113,7 @@ export default class Playback {
if (!current())
return
this.host.paused = false
await this.startAudio()
await this.host.audio.play()
if (!current()) {
if (!this.wanted || this.host.destroyed)
this.host.audio.pause()