@@ -14,7 +14,7 @@ import {
1414 type PlayerCover ,
1515 type QueueConfig ,
1616} from "./types" ;
17- import { TypedEventTarget } from "./utils" ;
17+ import { getErrorMessage , TypedEventTarget } from "./utils" ;
1818
1919const TIMEUPDATE_INTERVAL_MS = 250 ;
2020
@@ -33,6 +33,12 @@ export class FFmpegAudioEngine extends TypedEventTarget<EngineEventMap> {
3333 private audioController : MainAudioController | null = null ;
3434 private sharedBuffer : SharedArrayBuffer | null = null ;
3535
36+ private _assetsPreloaded = false ;
37+ private ffmpegWasmBlobUrl : string | null = null ;
38+ private soundtouchWasmBuffer : ArrayBuffer | null = null ;
39+ private preloadPromise : Promise < void > | null = null ;
40+ private abortController : AbortController | null = null ;
41+
3642 private _state : EngineState = "idle" ;
3743 private _duration = 0 ;
3844 private _metadata : Record < string , string > = { } ;
@@ -64,7 +70,6 @@ export class FFmpegAudioEngine extends TypedEventTarget<EngineEventMap> {
6470 this . renderer = new AudioRenderer (
6571 config . audioContext ,
6672 config . assets . workletUrl ,
67- config . assets . soundtouchWasmUrl ,
6873 config . gainNode ,
6974 ) ;
7075
@@ -211,10 +216,68 @@ export class FFmpegAudioEngine extends TypedEventTarget<EngineEventMap> {
211216 this . renderer . setRate ( this . _rate ) ;
212217 }
213218
219+ public async preloadAssets ( ) : Promise < void > {
220+ if ( this . _assetsPreloaded ) {
221+ return ;
222+ }
223+
224+ if ( this . preloadPromise ) {
225+ return this . preloadPromise ;
226+ }
227+
228+ this . abortController = new AbortController ( ) ;
229+ const signal = this . abortController . signal ;
230+
231+ this . preloadPromise = ( async ( ) => {
232+ try {
233+ const [ ffmpegWasmBuffer , stWasmBuffer ] = await Promise . all ( [
234+ this . fetchAndValidateWasm ( this . config . assets . ffmpegWasmUrl , signal ) ,
235+ this . fetchAndValidateWasm (
236+ this . config . assets . soundtouchWasmUrl ,
237+ signal ,
238+ ) ,
239+ this . pingResource ( this . config . assets . workerUrl , signal ) ,
240+ this . pingResource ( this . config . assets . workletUrl , signal ) ,
241+ ] ) ;
242+
243+ const blob = new Blob ( [ ffmpegWasmBuffer ] , { type : "application/wasm" } ) ;
244+ this . ffmpegWasmBlobUrl = URL . createObjectURL ( blob ) ;
245+
246+ this . soundtouchWasmBuffer = stWasmBuffer ;
247+ this . _assetsPreloaded = true ;
248+ } catch ( e ) {
249+ if ( e instanceof DOMException && e . name === "AbortError" ) {
250+ return ;
251+ }
252+ const msg = getErrorMessage ( e ) ;
253+ this . handleError (
254+ EngineErrorCode . Network ,
255+ `Asset preload failed: ${ msg } ` ,
256+ ) ;
257+ throw e ;
258+ } finally {
259+ this . preloadPromise = null ;
260+ }
261+ } ) ( ) ;
262+
263+ return this . preloadPromise ;
264+ }
265+
214266 /**
215267 * Loads a file, prepares the multithreading environment, and extracts metadata.
216268 */
217269 public async loadFile ( file : File ) : Promise < void > {
270+ if ( ! this . _assetsPreloaded ) {
271+ await this . preloadAssets ( ) ;
272+ }
273+
274+ if ( ! this . ffmpegWasmBlobUrl || ! this . soundtouchWasmBuffer ) {
275+ const msg =
276+ "Assets are missing even after preload was called. Engine cannot proceed." ;
277+ this . handleError ( EngineErrorCode . Network , msg ) ;
278+ throw new Error ( msg ) ;
279+ }
280+
218281 const currentSessionId = ++ this . loadSessionId ;
219282 this . reset ( ) ;
220283
@@ -223,46 +286,56 @@ export class FFmpegAudioEngine extends TypedEventTarget<EngineEventMap> {
223286 const channels = this . renderer . maxChannels ;
224287 const sampleRate = this . renderer . sampleRate ;
225288
226- await this . renderer . initialize ( channels ) ;
289+ try {
290+ await this . renderer . initialize ( channels ) ;
227291
228- if ( this . loadSessionId !== currentSessionId ) {
229- return ;
230- }
292+ if ( this . loadSessionId !== currentSessionId ) {
293+ return ;
294+ }
231295
232- this . sharedBuffer = allocateAudioQueueMemory (
233- sampleRate ,
234- channels ,
235- this . queueConfig ,
236- ) ;
296+ this . sharedBuffer = allocateAudioQueueMemory (
297+ sampleRate ,
298+ channels ,
299+ this . queueConfig ,
300+ ) ;
237301
238- this . audioController = createMainController ( this . sharedBuffer ) ;
302+ this . audioController = createMainController ( this . sharedBuffer ) ;
239303
240- await this . renderer . bindQueue (
241- this . sharedBuffer ,
242- channels ,
243- this . _tempo ,
244- this . _pitch ,
245- this . _rate ,
246- ) ;
304+ await this . renderer . bindQueue (
305+ this . sharedBuffer ,
306+ channels ,
307+ this . _tempo ,
308+ this . _pitch ,
309+ this . _rate ,
310+ this . soundtouchWasmBuffer ,
311+ ) ;
247312
248- if ( this . loadSessionId !== currentSessionId ) {
249- return ;
250- }
313+ if ( this . loadSessionId !== currentSessionId ) {
314+ return ;
315+ }
251316
252- const loadPromise = new Promise < void > ( ( resolve , reject ) => {
253- this . loadResolve = resolve ;
254- this . loadReject = reject ;
255- } ) ;
317+ const loadPromise = new Promise < void > ( ( resolve , reject ) => {
318+ this . loadResolve = resolve ;
319+ this . loadReject = reject ;
320+ } ) ;
256321
257- this . workerClient . init (
258- file ,
259- sampleRate ,
260- channels ,
261- this . sharedBuffer ,
262- this . config . assets . ffmpegWasmUrl ,
263- ) ;
322+ this . workerClient . init (
323+ file ,
324+ sampleRate ,
325+ channels ,
326+ this . sharedBuffer ,
327+ this . ffmpegWasmBlobUrl ,
328+ ) ;
264329
265- await loadPromise ;
330+ await loadPromise ;
331+ } catch ( e ) {
332+ const msg = getErrorMessage ( e ) ;
333+ this . handleError (
334+ EngineErrorCode . Decode ,
335+ `Engine initialization failed: ${ msg } ` ,
336+ ) ;
337+ throw e ;
338+ }
266339 }
267340
268341 public async play ( ) : Promise < void > {
@@ -301,13 +374,23 @@ export class FFmpegAudioEngine extends TypedEventTarget<EngineEventMap> {
301374 }
302375
303376 public destroy ( ) : void {
377+ if ( this . abortController ) {
378+ this . abortController . abort ( ) ;
379+ this . abortController = null ;
380+ }
304381 this . stopTimeupdate ( ) ;
305382 this . workerClient . destroy ( ) ;
306383 this . renderer . destroyNode ( ) ;
307384 this . sharedBuffer = null ;
308385 this . audioController = null ;
309386 this . resetState ( ) ;
310387 this . _state = "idle" ;
388+ if ( this . ffmpegWasmBlobUrl ) {
389+ URL . revokeObjectURL ( this . ffmpegWasmBlobUrl ) ;
390+ this . ffmpegWasmBlobUrl = null ;
391+ }
392+ this . soundtouchWasmBuffer = null ;
393+ this . _assetsPreloaded = false ;
311394 }
312395
313396 private reset ( ) : void {
@@ -322,6 +405,39 @@ export class FFmpegAudioEngine extends TypedEventTarget<EngineEventMap> {
322405 //#endregion
323406
324407 //#region Internal Callbacks & Utils
408+ private async fetchAndValidateWasm (
409+ url : string ,
410+ signal : AbortSignal ,
411+ ) : Promise < ArrayBuffer > {
412+ const resp = await fetch ( url , { signal } ) ;
413+ if ( ! resp . ok ) {
414+ throw new Error ( `HTTP ${ resp . status } - ${ resp . statusText } ` ) ;
415+ }
416+
417+ const buffer = await resp . arrayBuffer ( ) ;
418+
419+ if ( buffer . byteLength < 4 ) {
420+ throw new Error ( `File too small to be a valid WASM: ${ url } ` ) ;
421+ }
422+ const view = new DataView ( buffer ) ;
423+ const magic = view . getUint32 ( 0 , false ) ;
424+ if ( magic !== 0x0061736d /* \0asm */ ) {
425+ throw new Error (
426+ `Invalid WASM magic number detected. Server might have returned a 404 HTML page for: ${ url } ` ,
427+ ) ;
428+ }
429+
430+ return buffer ;
431+ }
432+
433+ private async pingResource ( url : string , signal : AbortSignal ) : Promise < void > {
434+ const resp = await fetch ( url , { signal } ) ;
435+ if ( ! resp . ok ) {
436+ throw new Error ( `Failed to load resource (HTTP ${ resp . status } ): ${ url } ` ) ;
437+ }
438+ await resp . arrayBuffer ( ) ;
439+ }
440+
325441 private handleWorkerInitDone ( payload : {
326442 duration : number ;
327443 metadata : Record < string , string > ;
0 commit comments