Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cluster/static/assets/audio/sounds.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion apps/lines/static/assets/audio/sounds.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion apps/price/static/assets/audio/sounds.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion apps/scatter/static/assets/audio/sounds.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion apps/ways/static/assets/audio/sounds.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
113 changes: 93 additions & 20 deletions packages/utils-sound/src/createSound.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Howler } from 'howler';
import { Howl, Howler } from 'howler';

import { type LoadedAudio } from 'pixi-svelte';
import { stateSoundDerived } from 'state-shared';
Expand Down Expand Up @@ -27,34 +27,107 @@ function createSound<TSoundName extends string>() {
// loadedAudio
loadedAudio = loadedAudioValue;

const howl = new Howl({
src: loadedAudio.src,
sprite: loadedAudio.sprite,
volume: 1,
});
// players
players = {
music: createPlayer<TSoundName, PlayMusic>({ loadedAudio, loop: true, howl, createPlay: createPlayMusic<TSoundName> }), // prettier-ignore
loop: createPlayer<TSoundName, PlayLoop>({ loadedAudio, loop: true, howl, createPlay: createPlayLoop<TSoundName> }), // prettier-ignore
once: createPlayer<TSoundName, PlayOnce>({ loadedAudio, loop: false, howl, createPlay: createPlayOnce<TSoundName> }), // 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<TSoundName, PlayMusic>({ loadedAudio, loop: true, howl: howlInstance, createPlay: createPlayMusic<TSoundName> }), // prettier-ignore
loop: createPlayer<TSoundName, PlayLoop>({ loadedAudio, loop: true, howl: howlInstance, createPlay: createPlayLoop<TSoundName> }), // prettier-ignore
once: createPlayer<TSoundName, PlayOnce>({ loadedAudio, loop: false, howl: howlInstance, createPlay: createPlayOnce<TSoundName> }), // 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 {
Expand Down