diff --git a/extensions/sound.js b/extensions/sound.js new file mode 100644 index 0000000000..31d00cbe14 --- /dev/null +++ b/extensions/sound.js @@ -0,0 +1,187 @@ +(Scratch => { + 'use strict'; + + const audioEngine = Scratch.vm.runtime.audioEngine; + + const fetchAsArrayBufferWithTimeout = (url) => new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + let timeout = setTimeout(() => { + xhr.abort(); + throw new Error('Timed out'); + }, 5000); + xhr.onload = () => { + clearTimeout(timeout); + if (xhr.status === 200) { + resolve(xhr.response); + } else { + reject(new Error(`HTTP error ${xhr.status} while fetching ${url}`)); + } + }; + xhr.onerror = () => { + clearTimeout(timeout); + reject(new Error(`Failed to request ${url}`)); + }; + xhr.responseType = 'arraybuffer'; + xhr.open('GET', url); + xhr.send(); + }); + + /** + * @type {Map} + */ + const soundPlayerCache = new Map(); + + /** + * @param {string} url + * @returns {Promise} + */ + const decodeSoundPlayer = async (url) => { + const cached = soundPlayerCache.get(url); + if (cached) { + if (cached.sound) { + return cached.sound; + } + throw cached.error; + } + + try { + const arrayBuffer = await fetchAsArrayBufferWithTimeout(url); + const soundPlayer = await audioEngine.decodeSoundPlayer({ + data: { + buffer: arrayBuffer + } + }); + soundPlayerCache.set(url, { + sound: soundPlayer, + error: null + }); + return soundPlayer; + } catch (e) { + soundPlayerCache.set(url, { + sound: null, + error: e + }); + throw e; + } + }; + + /** + * @param {string} url + * @param {VM.Target} target + * @returns {Promise} true if the sound could be played, false if the sound could not be decoded + */ + const playWithAudioEngine = async (url, target) => { + const soundBank = target.sprite.soundBank; + + /** @type {AudioEngine.SoundPlayer} */ + let soundPlayer; + try { + const originalSoundPlayer = await decodeSoundPlayer(url); + // @ts-expect-error + soundPlayer = originalSoundPlayer.take(); + } catch (e) { + console.warn('Could not fetch audio; falling back to primitive approach', e); + return false; + } + + soundBank.addSoundPlayer(soundPlayer); + await soundBank.playSound(target, soundPlayer.id); + + delete soundBank.soundPlayers[soundPlayer.id]; + // @ts-expect-error + soundBank.playerTargets.delete(soundPlayer.id); + // @ts-expect-error + soundBank.soundEffects.delete(soundPlayer.id); + + return true; + }; + + /** + * @param {string} url + * @param {VM.Target} target + * @returns {Promise} + */ + const playWithAudioElement = (url, target) => new Promise((resolve, reject) => { + // Unfortunately, we can't play all sounds with the audio engine. + // For these sounds, fall back to a primitive