diff --git a/apps/cluster/static/assets/audio/sounds.json b/apps/cluster/static/assets/audio/sounds.json index ea6c8abbb..614753df3 100644 --- a/apps/cluster/static/assets/audio/sounds.json +++ b/apps/cluster/static/assets/audio/sounds.json @@ -54,9 +54,9 @@ "tumble_win_5": [404000, 989.6145124716327] }, "src": [ + "./assets/audio/sounds.mp3", "./assets/audio/sounds.ogg", "./assets/audio/sounds.m4a", - "./assets/audio/sounds.mp3", "./assets/audio/sounds.ac3" ], "config": { diff --git a/apps/lines/static/assets/audio/sounds.json b/apps/lines/static/assets/audio/sounds.json index ea6c8abbb..614753df3 100644 --- a/apps/lines/static/assets/audio/sounds.json +++ b/apps/lines/static/assets/audio/sounds.json @@ -54,9 +54,9 @@ "tumble_win_5": [404000, 989.6145124716327] }, "src": [ + "./assets/audio/sounds.mp3", "./assets/audio/sounds.ogg", "./assets/audio/sounds.m4a", - "./assets/audio/sounds.mp3", "./assets/audio/sounds.ac3" ], "config": { diff --git a/apps/price/static/assets/audio/sounds.json b/apps/price/static/assets/audio/sounds.json index ea6c8abbb..614753df3 100644 --- a/apps/price/static/assets/audio/sounds.json +++ b/apps/price/static/assets/audio/sounds.json @@ -54,9 +54,9 @@ "tumble_win_5": [404000, 989.6145124716327] }, "src": [ + "./assets/audio/sounds.mp3", "./assets/audio/sounds.ogg", "./assets/audio/sounds.m4a", - "./assets/audio/sounds.mp3", "./assets/audio/sounds.ac3" ], "config": { diff --git a/apps/scatter/static/assets/audio/sounds.json b/apps/scatter/static/assets/audio/sounds.json index ea6c8abbb..614753df3 100644 --- a/apps/scatter/static/assets/audio/sounds.json +++ b/apps/scatter/static/assets/audio/sounds.json @@ -54,9 +54,9 @@ "tumble_win_5": [404000, 989.6145124716327] }, "src": [ + "./assets/audio/sounds.mp3", "./assets/audio/sounds.ogg", "./assets/audio/sounds.m4a", - "./assets/audio/sounds.mp3", "./assets/audio/sounds.ac3" ], "config": { diff --git a/apps/ways/static/assets/audio/sounds.json b/apps/ways/static/assets/audio/sounds.json index ea6c8abbb..614753df3 100644 --- a/apps/ways/static/assets/audio/sounds.json +++ b/apps/ways/static/assets/audio/sounds.json @@ -54,9 +54,9 @@ "tumble_win_5": [404000, 989.6145124716327] }, "src": [ + "./assets/audio/sounds.mp3", "./assets/audio/sounds.ogg", "./assets/audio/sounds.m4a", - "./assets/audio/sounds.mp3", "./assets/audio/sounds.ac3" ], "config": { diff --git a/packages/utils-sound/src/createSound.svelte.ts b/packages/utils-sound/src/createSound.svelte.ts index 3afa12334..e61a63fe8 100644 --- a/packages/utils-sound/src/createSound.svelte.ts +++ b/packages/utils-sound/src/createSound.svelte.ts @@ -1,4 +1,4 @@ -import { Howler } from 'howler'; +import { Howl, Howler } from 'howler'; import { type LoadedAudio } from 'pixi-svelte'; import { stateSoundDerived } from 'state-shared'; @@ -27,34 +27,107 @@ function createSound() { // loadedAudio loadedAudio = loadedAudioValue; - const howl = new Howl({ - src: loadedAudio.src, - sprite: loadedAudio.sprite, - volume: 1, - }); - // players - players = { - music: createPlayer({ loadedAudio, loop: true, howl, createPlay: createPlayMusic }), // prettier-ignore - loop: createPlayer({ loadedAudio, loop: true, howl, createPlay: createPlayLoop }), // prettier-ignore - once: createPlayer({ loadedAudio, loop: false, howl, createPlay: createPlayOnce }), // prettier-ignore - }; + let destroyed = false; + let objectUrl: string | null = null; + let howl: Howl | null = null; - // audioContextState and visibilityState const onAudioContextChange = () => (audioContextState = Howler.ctx.state); const onVisibilityStateChange = () => (visibilityState = document.visibilityState); - Howler.ctx.addEventListener('statechange', onAudioContextChange); - document.addEventListener('visibilitychange', onVisibilityStateChange); + const getMimeType = (url: string) => { + if (/\.mp3($|\?)/i.test(url)) return 'audio/mpeg'; + if (/\.ogg($|\?)/i.test(url)) return 'audio/ogg'; + if (/\.m4a($|\?)/i.test(url)) return 'audio/mp4'; + if (/\.ac3($|\?)/i.test(url)) return 'audio/ac3'; + return 'application/octet-stream'; + }; + + // Howler picks the codec from the URL extension and rejects URLs without one + // (e.g. blob: URLs) with "No codec support for selected audio sources." Passing + // the extension explicitly via the `format` option lets Howler accept the blob URL. + const getFormat = (url: string) => { + const clean = url.split('?')[0].split('#')[0]; + const match = /\.([^.]+)$/.exec(clean); + return match ? match[1].toLowerCase() : undefined; + }; + + const setupPlayers = (src: string | string[], html5: boolean, format?: string[]) => { + if (destroyed) return; + + const howlInstance = new Howl({ + src, + sprite: loadedAudio.sprite, + volume: 1, + html5, + format, + }); + howl = howlInstance; + + // players + players = { + music: createPlayer({ loadedAudio, loop: true, howl: howlInstance, createPlay: createPlayMusic }), // prettier-ignore + loop: createPlayer({ loadedAudio, loop: true, howl: howlInstance, createPlay: createPlayLoop }), // prettier-ignore + once: createPlayer({ loadedAudio, loop: false, howl: howlInstance, createPlay: createPlayOnce }), // prettier-ignore + }; + + // audioContextState and visibilityState + Howler.ctx.addEventListener('statechange', onAudioContextChange); + document.addEventListener('visibilitychange', onVisibilityStateChange); + + // apply the current volume settings to the freshly created players + volumeMusicEffect(); + volumeLoopEffect(); + volumeOnceEffect(); + }; + + // Preload the audio ourselves with a Range request and hand Howler a Blob URL. + // Some dev servers/browsers cancel a plain GET of an audio file with a 204 (empty + // body -> "Decoding audio data failed"), while a ranged request returns the whole + // file (206). Loading from a Blob keeps Howler on the WebAudio path, which supports + // real audio sprites and overlapping one-shot effects. + const preload = async () => { + const srcList = Array.isArray(loadedAudio.src) ? loadedAudio.src : [loadedAudio.src]; + + for (const rawSrc of srcList) { + try { + const response = await fetch(rawSrc, { headers: { Range: 'bytes=0-' } }); + if (!response.ok && response.status !== 206) continue; + + const buffer = await response.arrayBuffer(); + if (buffer.byteLength === 0) continue; + + objectUrl = URL.createObjectURL( + new Blob([buffer], { type: getMimeType(rawSrc) }), + ); + + if (destroyed) { + URL.revokeObjectURL(objectUrl); + objectUrl = null; + return; + } + + const format = getFormat(rawSrc); + setupPlayers(objectUrl, false, format ? [format] : undefined); + return; + } catch (error) { + // try the next source + } + } + + // Fallback: let Howler load the source directly through the HTML5 media path. + if (!destroyed) setupPlayers(loadedAudio.src, true); + }; + + preload(); const destroy = () => { + destroyed = true; + Howler.ctx.removeEventListener('statechange', onAudioContextChange); document.removeEventListener('visibilitychange', onVisibilityStateChange); - if (players) { - players.music.howl.unload(); - players.loop.howl.unload(); - players.once.howl.unload(); - } + if (howl) howl.unload(); + if (objectUrl) URL.revokeObjectURL(objectUrl); }; return {