Last active
July 23, 2019 10:09
-
-
Save Danetag/2b1e8c8e776dec75c931b77671a8a5e1 to your computer and use it in GitHub Desktop.
Sound Manager using Web Audio API
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const SOUNDS = [ | |
{ | |
id: ID_OF_THE_SOUND, | |
url: '/static/sounds/a_sound.mp3', | |
url_ogg: '/static/sounds/a_sound.ogg', | |
loop: false | |
} | |
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const loadSound = (url, context) => { | |
if (!context || !url) return; | |
return new Promise((resolve, reject) => { | |
const request = new XMLHttpRequest(); | |
request.open('GET', url, true); | |
request.responseType = 'arraybuffer'; | |
// Decode asynchronously | |
request.onload = () => { | |
context.decodeAudioData(request.response, buffer => { | |
resolve(buffer); | |
}, (err) => { | |
console.log('error loading sound', url, err); | |
reject(); | |
}); | |
} | |
request.send(); | |
}); | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Constants | |
import { SOUNDS } from './constants'; | |
// Utils | |
import { loadSound } from 'utils/load'; | |
import { IS_SAFARI, IS_IE } from 'utils/misc'; | |
class Sound { | |
constructor(options) { | |
super(options); | |
// Custom way of "watching" a volume value change from a redux store | |
this.watchers = { | |
'sound.volume': this.onVolumeChanged, | |
}; | |
const a = document.createElement('audio'); | |
this.OGG = !!(a.canPlayType && a.canPlayType('audio/mpeg;').replace(/no/, '')); | |
if (IS_SAFARI || IS_IE) this.OGG = false; | |
// Fix up prefixing | |
window.AudioContext = window.AudioContext || window.webkitAudioContext; | |
this.context = new window.AudioContext(); | |
this.unlockAudioContext(); | |
// Set the name of the hidden property and the change event for visibility | |
this.hidden = null; | |
let visibilityChange = null; | |
if (typeof document.hidden !== "undefined") { // Opera 12.10 and Firefox 18 and later support | |
this.hidden = "hidden"; | |
visibilityChange = "visibilitychange"; | |
} else if (typeof document.msHidden !== "undefined") { | |
this.hidden = "msHidden"; | |
visibilityChange = "msvisibilitychange"; | |
} else if (typeof document.webkitHidden !== "undefined") { | |
this.hidden = "webkitHidden"; | |
visibilityChange = "webkitvisibilitychange"; | |
} | |
// Visibility API to turn the sound off when locking phones | |
if (typeof document.addEventListener !== "undefined" && this.hidden !== null && visibilityChange !== null) { | |
document.addEventListener(visibilityChange, this.handleVisibilityChange, false); | |
} | |
} | |
unlockAudioContext() { | |
if (this.context.state !== 'suspended') return; | |
const b = document.body; | |
const unlock = () => { this.context.resume().then(clean);} | |
const clean = () => { events.forEach(e => b.removeEventListener(e, unlock)); } | |
const events = ['touchstart','touchend', 'mousedown','keydown']; | |
events.forEach(e => b.addEventListener(e, unlock, false)); | |
} | |
handleVisibilityChange = () => { | |
if (document[this.hidden]) { | |
this.onWindowBlur(); | |
} else { | |
this.onWindowFocus(); | |
} | |
} | |
onWindowFocus = () => { | |
if (this.context.state === 'suspended') return; | |
// fade to current state | |
const volume = this.getState().get('sound').get('volume'); | |
if (volume) this.onVolumeChanged(volume); | |
} | |
onWindowBlur = () => { | |
if (this.context.state === 'suspended') return; | |
// fade to 0 anyway | |
this.onVolumeChanged(0) | |
} | |
// Preload all UI sounds so they are already available | |
loadUISounds() { | |
const promises = SOUNDS | |
.filter( sound => !sound.loop) | |
.map((sound) => loadSound(this.OGG ? sound.url_ogg : sound.url, this.context).then(buffer => this.onSoundLoaded(sound, buffer))); | |
return Promise.all(promises); | |
} | |
onSoundLoaded(sound, buffer) { | |
sound.buffer = buffer; | |
} | |
// Creates source | |
createBufferSource(sound) { | |
// destroy current one if one | |
this.destroyBufferSource(sound); | |
const source = this.context.createBufferSource(); | |
source.buffer = sound.buffer; | |
source.loop = sound.loop !== undefined ? sound.loop : false; | |
// gain node | |
const gainNode = this.context.createGain(); | |
gainNode.gain.value = 0; | |
if (sound.loop) gainNode.gain.setValueAtTime(0, this.context.currentTime); | |
source.connect(gainNode); | |
gainNode.connect(this.context.destination); | |
source.onended = () => { | |
if (!sound.loop) this.destroyBufferSource(sound); | |
}; | |
sound.gainNode = gainNode; | |
sound.source = source; | |
return sound; | |
} | |
destroyBufferSource(sound){ | |
if (sound.source) { | |
sound.gainNode.gain.value = 0; | |
sound.gainNode.disconnect(); | |
sound.source.disconnect(); | |
sound.source.onended = null; | |
} | |
sound.source = null; | |
sound.gainNode = null; | |
} | |
onVolumeChanged = (volume) => { | |
SOUNDS | |
.filter(sound => sound.gainNode) | |
.forEach(sound => { | |
if (sound.loop){ | |
sound.gainNode.gain.setValueAtTime(volume === 0 ? 1 : 0, this.context.currentTime); | |
sound.gainNode.gain.linearRampToValueAtTime(volume, this.context.currentTime + 0.5); | |
} else | |
sound.gainNode.gain.value = volume; | |
}) | |
} | |
getSound(soundID) { | |
const sounds = SOUNDS.filter(sound => sound.id === soundID); | |
return sounds.length ? sounds[0] : null; | |
} | |
async play(soundID) { | |
// get sound | |
const sound = this.getSound(soundID); | |
if (!sound || sound.source) return; | |
// not loaded yet | |
if (!sound.buffer) await loadSound(this.OGG ? sound.url_ogg : sound.url, this.context).then(buffer => this.onSoundLoaded(sound, buffer)); | |
const volume = this.getState().get('sound').get('volume'); | |
// sound now has a source and gain | |
this.createBufferSource(sound); | |
// nice transition if ambient sound (loop = false) | |
if (sound.loop){ | |
sound.gainNode.gain.value = 0; | |
sound.gainNode.gain.setValueAtTime(0, this.context.currentTime); | |
sound.gainNode.gain.linearRampToValueAtTime(volume, this.context.currentTime + 1); | |
} | |
else | |
sound.gainNode.gain.value = volume; | |
let playPosition = 0; | |
if (sound.randomPlayPosition) { | |
playPosition = Math.floor(Math.random() * sound.buffer.duration); | |
} | |
if (sound.source.start) { | |
sound.source.start(0, playPosition); | |
} else if (sound.source.play) { | |
sound.source.play(0, playPosition); | |
} else if (sound.source.noteOn) { | |
sound.source.noteOn(0, playPosition); | |
} | |
} | |
stop(soundID) { | |
// get sound | |
const sound = this.getSound(soundID); | |
if (!sound || !sound.source) return; | |
if (sound.loop) | |
sound.gainNode.gain.linearRampToValueAtTime(0, this.context.currentTime + 1); | |
else | |
sound.gainNode.gain.value = 0; | |
setTimeout(() => { | |
if (!sound.source) return; | |
sound.source.stop(); | |
this.destroyBufferSource(sound); | |
}, 1000) | |
} | |
} | |
export default new Sound(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment