diff --git a/README.md b/README.md index 866615c..9d22583 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,13 @@ -soundForGames -============= +Sound for games +=============== -A micro-library to load, play and generate sound effects and music for games and interactive applications +"Sound for Games" is micro-library to load, play and generate sound effects and music for +games and interactive applications. At it's heart it's composed of +just two, short functions. The `makeSound` function helps you +load and play sound files (mp3, wav, ogg, and webm). The `soundEffect` +function helps you generate a wide range of sound and music effects. +These two functions are completely modular free of dependencies, so +you can +copy and paste whichever parts of them you need into your own +projects. All the code is in the `sound.js` file. Take a look at the +`index.html` file for a working example of all the features. diff --git a/index.html b/index.html new file mode 100644 index 0000000..af05bd2 --- /dev/null +++ b/index.html @@ -0,0 +1,203 @@ + + +Sound for games + + +

Press the number keys to control the sounds +
+
Sounds from loaded sound files: +
+
a - Shoot sound +
b - Play music +
c - Pause music +
d - Restart music +
e - Play music from the 10 second point +
f - Play the bounce sound with an echo effect +
+
Generated sound effects +
+
g - Shoot +
h - Jump +
i - Explosion +
j - Bonus +

+ + diff --git a/sound.js b/sound.js new file mode 100644 index 0000000..a77943e --- /dev/null +++ b/sound.js @@ -0,0 +1,798 @@ +/* +Prologue: Fixing the WebAudio API +-------------------------- + +The WebAudio API is so new that it's API is not consistently implemented properly across +all modern browsers. Thankfully, Chris Wilson's Audio Context Monkey Patch script +normalizes the API for maximum compatibility. + +https://github.com/cwilso/AudioContext-MonkeyPatch/blob/gh-pages/AudioContextMonkeyPatch.js + +It's included here. +Thank you, Chris! + +*/ + +(function (global, exports, perf) { + 'use strict'; + + function fixSetTarget(param) { + if (!param) // if NYI, just return + return; + if (!param.setTargetAtTime) + param.setTargetAtTime = param.setTargetValueAtTime; + } + + if (window.hasOwnProperty('webkitAudioContext') && + !window.hasOwnProperty('AudioContext')) { + window.AudioContext = webkitAudioContext; + + if (!AudioContext.prototype.hasOwnProperty('createGain')) + AudioContext.prototype.createGain = AudioContext.prototype.createGainNode; + if (!AudioContext.prototype.hasOwnProperty('createDelay')) + AudioContext.prototype.createDelay = AudioContext.prototype.createDelayNode; + if (!AudioContext.prototype.hasOwnProperty('createScriptProcessor')) + AudioContext.prototype.createScriptProcessor = AudioContext.prototype.createJavaScriptNode; + + AudioContext.prototype.internal_createGain = AudioContext.prototype.createGain; + AudioContext.prototype.createGain = function() { + var node = this.internal_createGain(); + fixSetTarget(node.gain); + return node; + }; + + AudioContext.prototype.internal_createDelay = AudioContext.prototype.createDelay; + AudioContext.prototype.createDelay = function(maxDelayTime) { + var node = maxDelayTime ? this.internal_createDelay(maxDelayTime) : this.internal_createDelay(); + fixSetTarget(node.delayTime); + return node; + }; + + AudioContext.prototype.internal_createBufferSource = AudioContext.prototype.createBufferSource; + AudioContext.prototype.createBufferSource = function() { + var node = this.internal_createBufferSource(); + if (!node.start) { + node.start = function ( when, offset, duration ) { + if ( offset || duration ) + this.noteGrainOn( when, offset, duration ); + else + this.noteOn( when ); + } + } + if (!node.stop) + node.stop = node.noteOff; + fixSetTarget(node.playbackRate); + return node; + }; + + AudioContext.prototype.internal_createDynamicsCompressor = AudioContext.prototype.createDynamicsCompressor; + AudioContext.prototype.createDynamicsCompressor = function() { + var node = this.internal_createDynamicsCompressor(); + fixSetTarget(node.threshold); + fixSetTarget(node.knee); + fixSetTarget(node.ratio); + fixSetTarget(node.reduction); + fixSetTarget(node.attack); + fixSetTarget(node.release); + return node; + }; + + AudioContext.prototype.internal_createBiquadFilter = AudioContext.prototype.createBiquadFilter; + AudioContext.prototype.createBiquadFilter = function() { + var node = this.internal_createBiquadFilter(); + fixSetTarget(node.frequency); + fixSetTarget(node.detune); + fixSetTarget(node.Q); + fixSetTarget(node.gain); + return node; + }; + + if (AudioContext.prototype.hasOwnProperty( 'createOscillator' )) { + AudioContext.prototype.internal_createOscillator = AudioContext.prototype.createOscillator; + AudioContext.prototype.createOscillator = function() { + var node = this.internal_createOscillator(); + if (!node.start) + node.start = node.noteOn; + if (!node.stop) + node.stop = node.noteOff; + fixSetTarget(node.frequency); + fixSetTarget(node.detune); + return node; + }; + } + } +}(window)); + +//Define the audio context +var actx = new AudioContext(); + +/* +# sounds +All the loaded sound files are stored in this object. It has +a `load` method that manages asset loading. You can load sounds at +any time during the game by using the `sounds.load` method. + +Here's how to load three sound files from the `sounds` folder and +call a `setup` method when they're finished loading: + + sounds.load([ + "sounds/shoot.wav", + "sounds/music.wav", + "sounds/bounce.mp3" + ]); + sounds.whenLoaded = setup; + +You can now acess these loaded sounds like this: + +var shoot = sounds["sounds/shoot.wav"], + music = sounds["sounds/music.wav"], + bounce = sounds["sounds/bounce.mp3"]; + +*/ + +var sounds = { + //Properties to help track the assets being loaded. + toLoad: 0, + loaded: 0, + + //File extensions for different types of sounds. + audioExtensions: ["mp3", "ogg", "wav", "webm"], + + //The callback function that should run when all assets have loaded. + //Assign this when you load the fonts, like this: `assets.whenLoaded = makeSprites;`. + whenLoaded: undefined, + + //The load method creates and loads all the assets. Use it like this: + //`assets.load(["images/anyImage.png", "fonts/anyFont.otf"]);`. + + load: function(sources) { + console.log("Loading sounds.."); + //Get a reference to this asset object so we can + //refer to it in the `forEach` loop ahead. + var self = this; + //Find the number of files that need to be loaded. + self.toLoad = sources.length; + sources.forEach(function(source){ + //Find the file extension of the asset. + var extension = source.split('.').pop(); + + //#### Sounds + //Load audio files that have file extensions that match + //the `audioExtensions` array. + if (self.audioExtensions.indexOf(extension) !== -1) { + //Create a sound sprite. + var soundSprite = makeSound(source, self.loadHandler.bind(self)); + //Get the sound file name. + soundSprite.name = source; + //If you just want to extract the file name with the + //extension, you can do it like this: + //soundSprite.name = source.split("/").pop(); + //Assign the sound as a property of the assets object so + //we can access it like this: `assets["sounds/sound.mp3"]`. + self[soundSprite.name] = soundSprite; + } + + //Display a message if the file type isn't recognized. + else { + console.log("File type not recognized: " + source); + } + }); + }, + + //#### loadHandler + //The `loadHandler` will be called each time an asset finishes loading. + loadHandler: function () { + var self = this; + self.loaded += 1; + console.log(self.loaded); + //Check whether everything has loaded. + if (self.toLoad === self.loaded) { + //If it has, run the callback function that was assigned to the `whenLoaded` property + console.log("Sounds finished loading"); + //Reset `loaded` and `toLoaded` so we can load more assets + //later if we want to. + self.toLoad = 0; + self.loaded = 0; + self.whenLoaded(); + } + } +}; + +/* +#makeSound +`makeSound` creates and returns and WebAudio sound sprite. +You can use it to load a sound like this: + + var anySound = makeSound("sounds/anySound.mp3", loadHandler); + +(However, it's more convenient to load the sound file using +the `sounds.load` method described above.) + +After the sound has been loaded you can access and use it like this: + + function loadHandler() { + anySound.loop = true; + anySound.pan = 0.8; + anySound.volume = 0.5; + anySound.play(); + anySound.pause(); + anySound.playFrom(second); + anySound.restart(); + } +*/ + +function makeSound(source, loadHandler) { + + //The sound object that this function returns. + var o = {}; + + //Set the default properties. + o.volumeNode = actx.createGain(); + o.panNode = actx.createPanner(); + o.delayNode = actx.createDelay(); + o.feedbackNode = actx.createGain(); + o.filterNode = actx.createBiquadFilter(); + o.convolverNode = actx.createConvolver(); + o.soundNode = null; + o.buffer = null; + o.source = null; + o.loop = false; + o.isPlaying = false; + + //The function that should run when the sound is loaded. + o.loadHandler = undefined; + + //Values for the `pan` and `volume` getters/setters. + o.panValue = 0; + o.volumeValue = 1; + + //Values to help track and set the start and pause times. + o.startTime = 0; + o.startOffset = 0; + + //Set the playback rate. + o.playbackRate = 1; + + //Echo properties. + o.echo = false; + o.delayValue = 0.3; + o.feebackValue = 0.3; + o.filterValue = 0; + + //Reverb properties + o.reverb = false; + o.reverbImpulse = null; + + //The sound object's methods. + o.play = function() { + + //Set the start time (it will be `0` when the sound + //first starts. + o.startTime = actx.currentTime; + + //Create a sound node. + o.soundNode = actx.createBufferSource(); + + //Set the sound node's buffer property to the loaded sound. + o.soundNode.buffer = o.buffer; + + //Set the playback rate + o.soundNode.playbackRate.value = this.playbackRate; + + //Connect the sound to the pan, connect the pan to the + //volume, and connect the volume to the destination. + o.soundNode.connect(o.volumeNode); + + //If there's no reverb, bypass the convolverNode + if (o.reverb === false) { + o.volumeNode.connect(o.panNode); + } + + //If there is reverb, connect the `convolverNode` and apply + //the impulse response + else { + o.volumeNode.connect(o.convolverNode); + o.convolverNode.connect(o.panNode); + o.convolverNode.buffer = o.reverbImpulse; + } + + //Connect the `panNode` to the destination to complete the chain. + o.panNode.connect(actx.destination); + + //Add optional echo. + if (o.echo) { + + //Set the values. + o.feedbackNode.gain.value = o.feebackValue; + o.delayNode.delayTime.value = o.delayValue; + o.filterNode.frequency.value = o.filterValue; + + //Create the delay loop, with optional filtering. + o.delayNode.connect(o.feedbackNode); + if (o.filterValue > 0) { + o.feedbackNode.connect(o.filterNode); + o.filterNode.connect(o.delayNode); + } else { + o.feedbackNode.connect(o.delayNode); + } + + //Capture the sound from the main node chain, send it to the + //delay loop, and send the final echo effect to the `panNode` which + //will then route it to the destination. + o.volumeNode.connect(o.delayNode); + o.delayNode.connect(o.panNode); + } + + //Will the sound loop? This can be `true` or `false`. + o.soundNode.loop = o.loop; + + //Finally, use the `start` method to play the sound. + //The start time will either be `0`, + //or a later time if the sound was paused. + o.soundNode.start( + 0, o.startOffset % o.buffer.duration + ); + + //Set `isPlaying` to `true` to help control the + //`pause` and `restart` methods. + o.isPlaying = true; + }; + + o.pause = function() { + //Pause the sound if it's playing, and calculate the + //`startOffset` to save the current position. + if (o.isPlaying) { + o.soundNode.stop(0); + o.startOffset += actx.currentTime - o.startTime; + o.isPlaying = false; + } + }; + + o.restart = function() { + //Stop the sound if it's playing, reset the start and offset times, + //then call the `play` method again. + if (o.isPlaying) { + o.soundNode.stop(0); + } + o.startOffset = 0; + o.play(); + }; + + o.playFrom = function(value) { + if (o.isPlaying) { + o.soundNode.stop(0); + } + o.startOffset = value; + o.play(); + }; + + o.setEcho = function(delayValue, feedbackValue, filterValue) { + if (delayValue === undefined) delayValue = 0.3; + if (feedbackValue === undefined) feedbackValue = 0.3; + if (filterValue === undefined) filterValue = 0; + o.delayValue = delayValue; + o.feebackValue = feedbackValue; + o.filterValue = filterValue; + o.echo = true; + }; + + o.setReverb = function(duration, decay, reverse) { + if (duration === undefined) duration = 2; + if (decay === undefined) decay = 2; + if (reverse === undefined) reverse = false; + o.reverbImpulse = impulseResponse(duration, decay, reverse, actx); + o.reverb = true; + }; + + //Volume and pan getters/setters. + Object.defineProperties(o, { + volume: { + get: function() { + return o.volumeValue; + }, + set: function(value) { + o.volumeNode.gain.value = value; + o.volumeValue = value; + }, + enumerable: true, configurable: true + }, + pan: { + get: function() { + return o.panValue; + }, + set: function(value) { + //Panner objects accept x, y and z coordinates for 3D + //sound. However, because we're only doing 2D left/right + //panning we're only interested in the x coordinate, + //the first one. However, for a natural effect, the z + //value also has to be set proportionately. + var x = value, + y = 0, + z = 1 - Math.abs(x); + o.panNode.setPosition(x, y, z); + o.panValue = value; + }, + enumerable: true, configurable: true + } + }); + + //The `load` method. It will call the `loadHandler` passed + //that was passed as an argument when the sound has loaded. + o.load = function() { + var xhr = new XMLHttpRequest(); + + //Use xhr to load the sound file. + xhr.open("GET", source, true); + xhr.responseType = "arraybuffer"; + xhr.addEventListener("load", function() { + + //Decode the sound and store a reference to the buffer. + actx.decodeAudioData( + xhr.response, + function(buffer) { + o.buffer = buffer; + o.hasLoaded = true; + + //This next bit is optional, but important. + //If you have a load manager in your game, call it here so that + //the sound is registered as having loaded. + if (loadHandler) { + loadHandler(); + } + }, + + //Throw an error if the sound can't be decoded. + function(error) { + throw new Error("Audio could not be decoded: " + error); + } + ); + }); + + //Send the request to load the file. + xhr.send(); + }; + + //Load the sound. + o.load(); + + //Return the sound object. + return o; +}; + + +//The `soundEffect` function that makes all these sounds +function soundEffect( + frequencyValue, //The sound's fequency pitch in Hertz + attack, //The time, in seconds, to fade the sound in + decay, //The time, in seconds, to fade the sound out + type, //waveform type: "sine", "triangle", "square", "sawtooth" + volumeValue, //The sound's maximum volume + panValue, //The speaker pan. left: -1, middle: 0, right: 1 + wait, //The time, in seconds, to wait before playing the sound + pitchBendAmount, //The number of Hz in which to bend the sound's pitch down + reverse, //If `reverse` is true the pitch will bend up + randomValue, //A range, in Hz, within which to randomize the pitch + dissonance, //A value in Hz. It creates 2 dissonant frequencies above below the target pitch + echo, //An array: [delayTimeInSeconds, feedbackTimeInSeconds, filterValueInHz] + reverb //An array: [durationInSeconds, decayRateInSeconds, reverse] +) { + + //Set the default values + if (frequencyValue === undefined) frequencyValue = 200; + if (attack === undefined) attack = 0; + if (decay === undefined) decay = 1; + if (type === undefined) type = "sine"; + if (volumeValue === undefined) volumeValue = 1; + if (panValue === undefined) panValue = 0; + if (wait === undefined) wait = 0; + if (pitchBendAmount === undefined) pitchBendAmount = 0; + if (reverse === undefined) reverse = false; + if (randomValue === undefined) randomValue = 0; + if (dissonance === undefined) dissonance = 0; + if (echo === undefined) echo = undefined; + if (reverb === undefined) reverb = undefined; + + //Create an oscillator, gain and pan nodes, and connect them + //together to the destination + var oscillator = actx.createOscillator(), + volume = actx.createGain(), + pan = actx.createPanner(); + oscillator.connect(volume); + volume.connect(pan); + pan.connect(actx.destination); + + //Set the supplied values + volume.gain.value = volumeValue; + pan.setPosition(panValue, 0, 1 - Math.abs(panValue)); + oscillator.type = type; + + //Optionally randomize the pitch. If the `randomValue` is greater + //than zero, a random pitch is selected that's within the range + //specified by `frequencyValue`. The random pitch will be either + //above or below the target frequency. + var frequency; + var randomInt = function(min, max){ + return Math.floor(Math.random() * (max - min + 1)) + min + }; + if (randomValue > 0) { + frequency = randomInt( + frequencyValue - randomValue / 2, + frequencyValue + randomValue / 2 + ); + } else { + frequency = frequencyValue; + } + oscillator.frequency.value = frequency; + + //Apply effects + if (attack > 0) fadeIn(volume); + fadeOut(volume); + if (pitchBendAmount > 0) pitchBend(oscillator); + if (echo) addEcho(volume); + if (reverb) addReverb(volume); + if (dissonance > 0) addDissonance(); + + //Play the sound + play(oscillator); + + //The helper functions: + + function addReverb(volumeNode) { + var convolver = actx.createConvolver(); + convolver.buffer = impulseResponse(reverb[0], reverb[1], reverb[2], actx); + volumeNode.connect(convolver); + convolver.connect(pan); + } + + function addEcho(volumeNode) { + + //Create the nodes + var feedback = actx.createGain(), + delay = actx.createDelay(), + filter = actx.createBiquadFilter(); + + //Set their values (delay time, feedback time and filter frequency) + delay.delayTime.value = echo[0]; + feedback.gain.value = echo[1]; + if (echo[2]) filter.frequency.value = echo[2]; + + //Create the delay feedback loop, with + //optional filtering + delay.connect(feedback); + if (echo[2]) { + feedback.connect(filter); + filter.connect(delay); + } else { + feedback.connect(delay); + } + + //Connect the delay loop to the oscillator's volume + //node, and then to the destination + volumeNode.connect(delay); + + //Connect the delay loop to the main sound chain's + //pan node, so that the echo effect is directed to + //the correct speaker + delay.connect(pan); + } + + //The `fadeIn` function + function fadeIn(volumeNode) { + + //Set the volume to 0 so that you can fade + //in from silence + volumeNode.gain.value = 0; + + volumeNode.gain.linearRampToValueAtTime( + 0, actx.currentTime + wait + ); + volumeNode.gain.linearRampToValueAtTime( + volumeValue, actx.currentTime + wait + attack + ); + } + + //The `fadeOut` function + function fadeOut(volumeNode) { + volumeNode.gain.linearRampToValueAtTime( + volumeValue, actx.currentTime + attack + wait + ); + volumeNode.gain.linearRampToValueAtTime( + 0, actx.currentTime + wait + attack + decay + ); + } + + //The `pitchBend` function + function pitchBend(oscillatorNode) { + //If `reverse` is true, make the note drop in frequency. Useful for + //shooting sounds + + //Get the frequency of the current oscillator + var frequency = oscillatorNode.frequency.value; + + //If `reverse` is true, make the sound drop in pitch + if (!reverse) { + oscillatorNode.frequency.linearRampToValueAtTime( + frequency, + actx.currentTime + wait + ); + oscillatorNode.frequency.linearRampToValueAtTime( + frequency - pitchBendAmount, + actx.currentTime + wait + attack + decay + ); + } + + //If `reverse` is false, make the note rise in pitch. Useful for + //jumping sounds + else { + oscillatorNode.frequency.linearRampToValueAtTime( + frequency, + actx.currentTime + wait + ); + oscillatorNode.frequency.linearRampToValueAtTime( + frequency + pitchBendAmount, + actx.currentTime + wait + attack + decay + ); + } + } + + //The `addDissonance` function + function addDissonance() { + + //Create two more oscillators and gain nodes + var d1 = actx.createOscillator(), + d2 = actx.createOscillator(), + d1Volume = actx.createGain(), + d2Volume = actx.createGain(); + + //Set the volume to the `volumeValue` + d1Volume.gain.value = volumeValue; + d2Volume.gain.value = volumeValue; + + //Connect the oscillators to the gain and destination nodes + d1.connect(d1Volume); + d1Volume.connect(actx.destination); + d2.connect(d2Volume); + d2Volume.connect(actx.destination); + + //Set the waveform to "sawtooth" for a harsh effect + d1.type = "sawtooth"; + d2.type = "sawtooth"; + + //Make the two oscillators play at frequencies above and + //below the main sound's frequency. Use whatever value was + //supplied by the `dissonance` argument + d1.frequency.value = frequency + dissonance; + d2.frequency.value = frequency - dissonance; + + //Fade in/out, pitch bend and play the oscillators + //to match the main sound + if (attack > 0) { + fadeIn(d1Volume); + fadeIn(d2Volume); + } + if (decay > 0) { + fadeOut(d1Volume); + fadeOut(d2Volume); + } + if (pitchBendAmount > 0) { + pitchBend(d1); + pitchBend(d2); + } + if (echo) { + addEcho(d1Volume); + addEcho(d2Volume); + } + if (reverb) { + addReverb(d1Volume); + addReverb(d2Volume); + } + play(d1); + play(d2); + } + + //The `play` function + function play(node) { + node.start(actx.currentTime + wait); + } +} + +/* +# impulseResponse +The `makeSound` and `soundEffect` functions uses `impulseResponse` to help create an optional reverb effect. +It simulates a model of sound reverberation in an acoustic space which +a convolver node can blend with the source sound. +*/ + +function impulseResponse(duration, decay, reverse, actx) { + + //The length of the buffer. + var length = actx.sampleRate * duration; + + //Create an audio buffer (an empty sound container) to store the reverb effect. + var impulse = actx.createBuffer(2, length, actx.sampleRate); + + //Use `getChannelData` to initialize empty arrays to store sound data for + //the left and right channels. + var left = impulse.getChannelData(0), + right = impulse.getChannelData(1); + + //Loop through each sample-frame and fill the channel + //data with random noise. + for (var i = 0; i < length; i++){ + + //Apply the reverse effect, if `reverse` is `true`. + var n; + if (reverse) { + n = length - i; + } else { + n = i; + } + + //Fill the left and right channels with random white noise which + //decays exponentially. + left[i] = (Math.random() * 2 - 1) * Math.pow(1 - n / length, decay); + right[i] = (Math.random() * 2 - 1) * Math.pow(1 - n / length, decay); + } + + //Return the `impulse`. + return impulse; +} + + +/* +# keyboard +The `keyboard` helper function creates `key` objects +that listen for keyboard events. Create a new key object like +this: + + var keyObject = g.keyboard(asciiKeyCodeNumber); + +Then assign `press` and `release` methods like this: + + keyObject.press = function() { + //key object pressed + }; + keyObject.release = function() { + //key object released + }; + +Keyboard objects also have `isDown` and `isUp` Booleans that you can check. +*/ + +function keyboard(keyCode) { + var key = {}; + key.code = keyCode; + key.isDown = false; + key.isUp = true; + key.press = undefined; + key.release = undefined; + //The `downHandler` + key.downHandler = function(event) { + if (event.keyCode === key.code) { + if (key.isUp && key.press) key.press(); + key.isDown = true; + key.isUp = false; + } + event.preventDefault(); + }; + + //The `upHandler` + key.upHandler = function(event) { + if (event.keyCode === key.code) { + if (key.isDown && key.release) key.release(); + key.isDown = false; + key.isUp = true; + } + event.preventDefault(); + }; + + //Attach event listeners + window.addEventListener( + "keydown", key.downHandler.bind(key), false + ); + window.addEventListener( + "keyup", key.upHandler.bind(key), false + ); + return key; +} + diff --git a/sounds/bounce.mp3 b/sounds/bounce.mp3 new file mode 100644 index 0000000..0b857dd Binary files /dev/null and b/sounds/bounce.mp3 differ diff --git a/sounds/explosion.wav b/sounds/explosion.wav new file mode 100644 index 0000000..b77d423 Binary files /dev/null and b/sounds/explosion.wav differ diff --git a/sounds/music.wav b/sounds/music.wav new file mode 100644 index 0000000..76ec551 Binary files /dev/null and b/sounds/music.wav differ diff --git a/sounds/shoot.wav b/sounds/shoot.wav new file mode 100644 index 0000000..03a006d Binary files /dev/null and b/sounds/shoot.wav differ