diff --git a/.jshintrc b/.jshintrc new file mode 100644 index 0000000..8245c1e --- /dev/null +++ b/.jshintrc @@ -0,0 +1,92 @@ +//Examples: https://github.com/jshint/jshint/blob/master/examples/.jshintrc +//Documentation: http://www.jshint.com/docs/ +//In Sublime: https://github.com/victorporof/Sublime-JSHint#using-your-own-jshintrc-options + +{ + // Custom globals. + "globals" : { + "console" : false, + + // Added the below for browser for component(1) compatibility + "require" : false, + "module" : false, + "exports" : false + }, + + // Settings + "passfail" : false, // Stop on first error. + "maxerr" : 100, // Maximum error before stopping. + + // Predefined globals whom JSHint will ignore. + "browser" : true, // Standard browser globals e.g. `window`, `document`. + + "node" : false, + "rhino" : false, + "couch" : false, + "wsh" : false, // Windows Scripting Host. + + "jquery" : false, + "prototypejs" : false, + "mootools" : false, + "dojo" : false, + + // Development. + "debug" : false, // Allow debugger statements e.g. browser breakpoints. + "devel" : false, // Allow developments statements e.g. `console.log();`. + + + // ECMAScript 5. + //NB: For client code we enable strict mode once at the top of the built concatenated script. + "strict" : false, // Require `use strict` pragma in every file. + "globalstrict" : true, // Allow global "use strict" (also enables 'strict'). + + + // The Good Parts. + "asi" : false, // Tolerate Automatic Semicolon Insertion (no semicolons). + "laxbreak" : true, // Tolerate unsafe line breaks e.g. `return [\n] x` without semicolons. + "bitwise" : false, // Prohibit bitwise operators (&, |, ^, etc.). + "boss" : false, // Tolerate assignments inside if, for & while. Usually conditions & loops are for comparison, not assignments. + "curly" : false, // Require {} for every new block or scope. + "eqeqeq" : true, // Require triple equals i.e. `===`. + "eqnull" : false, // Tolerate use of `== null`. + "evil" : false, // Tolerate use of `eval`. + "expr" : false, // Tolerate `ExpressionStatement` as Programs. + "forin" : false, // Tolerate `for in` loops without `hasOwnPrototype`. + "immed" : true, // Require immediate invocations to be wrapped in parens e.g. `( function(){}() );` + "latedef" : true, // Prohipit variable use before definition. + "loopfunc" : false, // Allow functions to be defined within loops. + "noarg" : true, // Prohibit use of `arguments.caller` and `arguments.callee`. + "regexp" : false, // Prohibit `.` and `[^...]` in regular expressions. + "regexdash" : false, // Tolerate unescaped last dash i.e. `[-...]`. + "scripturl" : false, // Tolerate script-targeted URLs. + "shadow" : false, // Allows re-define variables later in code e.g. `var x=1; x=2;`. + "supernew" : false, // Tolerate `new function () { ... };` and `new Object;`. + "undef" : true, // Require all non-global variables be declared before they are used. + "camelcase" : true, // require camelCase or ALL_CAPS + "unused" : true, // prohibit unused variables + "-W100" : true, // if unsafe characters should not be checked + + // + "maxparams" : 7, + "maxdepth" : 5, + "maxstatements" : 80, + "maxcomplexity" : 30, + + // Personal styling preferences. + "newcap" : true, // Require capitalization of all constructor functions e.g. `new F()`. + "noempty" : true, // Prohibit use of empty blocks. + "nonew" : true, // Prohibit use of constructors for side-effects. + "nomen" : false, // Prohibit use of initial or trailing underbars in names. + "onevar" : false, // Allow only one `var` statement per function. + "plusplus" : false, // Prohibit use of `++` & `--`. + "sub" : false, // Tolerate all forms of subscript notation besides dot notation e.g. `dict['key']` instead of `dict.key`. + "trailing" : true, // Prohibit trailing whitespaces. + "white" : true, // Check against strict whitespace and indentation rules. + "indent" : 4, // Specify indentation spacing (also shouts on case X: return n, and {x:1, y:2}) + "quotmark" : false, // Quotation mark consistency: + // false : do nothing (default) + // true : ensure whatever is used is consistent + // "single" : require single quotes + // "double" : require double quotes + "smarttabs" : true // true: Tolerate mixed tabs/spaces when used for alignment +} \ No newline at end of file diff --git a/ISound.js b/ISound.js index 6e7cdb5..a87738f 100644 --- a/ISound.js +++ b/ISound.js @@ -1,6 +1,3 @@ -// jscs:disable requireCurlyBraces - - //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ /** Sound Abstract class. * Implement dynamic loading / unloading mechanism. @@ -10,16 +7,18 @@ */ function ISound() { // public properties + this.playing = false; this.fade = 0; this.usedMemory = 0; this.poolRef = null; // the following properties are public but should NOT be assigned directly. - // instead, use the setter functions: setId, setVolume, setPan, setLoop. + // instead, use the setter functions: setId, setVolume, setPan, setLoop, setPitch. this.id = 0; this.volume = 1.0; this.pan = 0.0; this.loop = false; + this.pitch = 0.0; // private properties this._src = ''; @@ -56,6 +55,11 @@ ISound.prototype.setLoop = function (value) { this.loop = value; }; +//▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +ISound.prototype.setPitch = function (pitch) { + this.pitch = pitch; +}; + //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ /** Load sound. Abstract method to be overwritten * @private @@ -73,11 +77,11 @@ ISound.prototype._load = function (filePath) { * @param {Function} [cd] - optional callback function */ ISound.prototype.load = function (cb) { - if (!this.id) { return console.error('Can not load a sound without id.'); } - if (this._loaded) { return cb && cb(null, this); } + if (!this.id) return cb && cb('noId'); + if (this._loaded) return cb && cb(null, this); if (cb) { this._queuedCallback.push(cb); } - if (this._loading) { return; } + if (this._loading) return; this._loading = true; return this._load(this._src, this); @@ -116,7 +120,8 @@ ISound.prototype._finalizeLoad = function (error) { ISound.prototype.unload = function () { this._playTriggered = 0; this.setLoop(false); - this.fade = 0; + this.fade = 0; + this.pitch = 0; this.stop(); if (this._loading) { @@ -139,12 +144,13 @@ ISound.prototype.unload = function () { /** Play sound. If sound is not yet loaded, it is loaded in memory and flagged to be played * once loading has finished. If loading take too much time, playback may be cancelled. * - * @param {number} vol - volume - * @param {number} pan - panoramic + * @param {number} [vol] - optional volume + * @param {number} [pan] - optional panoramic + * @param {number} [pitch] - optional pitch value in semi-tone (available only if using webAudio) */ -ISound.prototype.play = function (vol, pan) { - if (vol !== undefined) { this.setVolume(vol); } - if (pan !== undefined) { this.setPan(pan); } +ISound.prototype.play = function (vol, pan, pitch) { + if (vol !== undefined && vol !== null) { this.setVolume(vol); } + if (pan !== undefined && pan !== null) { this.setPan(pan); } if (!this._loaded) { this._playTriggered = Date.now(); @@ -152,12 +158,13 @@ ISound.prototype.play = function (vol, pan) { return; } - this._play(); + this._play(pitch); }; //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ /** Play sound. Abstract method to be overwritten */ ISound.prototype._play = function () { + this.playing = true; console.log('ISound play call: "' + this._src + '"'); }; @@ -167,5 +174,6 @@ ISound.prototype._play = function () { * @param {Function} [cb] - optional callback function (use it when sound has a fade out) */ ISound.prototype.stop = function (cb) { + this.playing = false; return cb && cb(); }; diff --git a/README.md b/README.md index 2bca9da..e8fdbb6 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,126 @@ # AudioManager -play sounds using Web Audio, fallback on HTML5 Audio +Play sounds using Web Audio, fallback on HTML5 Audio. +`AudioManager` is specifically designed to works for games that have a big +quantity of audio assets. Loading and unloading is made easy and transparent. +If available, WizAsset is used for downloading files to disc. -## API +# API -Create audioManager object and define sound channels. +```javascript +// initialisation +var audioManager = new AudioManager(channelIds); +audioManager.init(); +audioManager.setVolume(channelId, volume); + +// play simple sound +audioManager.playSound(channelId, soundId, volume, panoramic, pitch); + +// sound group +audioManager.createSoundGroups(soundGroupDefs, channelId); +audioManager.playSoundGroup(channelId, groupId, volume, panoramic, pitch); + +// loop +audioManager.playLoopSound(channelId, soundId, volume); +audioManager.stopLoopSound(channelId); +audioManager.stopAllLoopSounds(); + +// release memory +audioManager.release(); +``` + +# Documentation + +### Create audioManager object and channels +Pass the list of channels to the constructor as an array of strings. ```javascript var channels = ['music', 'sfx', 'ui']; var audioManager = new AudioManager(channels); ``` -Setup audioManager path to sound assets folder +### Setup audioManager path to sound assets folder ```javascript audioManager.settings.audioPath = 'assets/audio/'; ``` -Start audio engine. -To work correctly on iOS, this must be called on an user interaction (e.g. user pressing a button) +### Start audio engine. +To work correctly on iOS, this must be called on an user interaction +(e.g. user pressing a button) ```javascript gameStartButton.on('tap', function () { audioManager.init(); }); ``` -Set channel volume +### Set channel volume +By default, channel volume is set to 0 and channel is muted. +No sounds will play until channel volume is set. +```javascript +var volume = 1.0; // volume is a float in range ]0..1] +audioManager.setVolume('ui', volume); +``` + +### Create and play a simple sound. +Create a sound and playing it in a channel. +Sound is created and loaded automatically. +```javascript +var fileName = 'laser1'; +audioManager.playSound('sfx', fileName); + +// volume and panoramic can be set optionally +var volume = 0.7; // volume is a float in range ]0..1] +var panoramic = 0.9; // panoramic is a float in range [-1..1], 0 is the center +audioManager.playSound('sfx', fileName, volume, panoramic); +``` + +Sounds creation and preloading can be forced. +```javascript +audioManager.createSound(fileName).load(); +``` + +Alternatively, sounds can be played outside channels. +```javascript +var sound = audioManager.createSound(fileName); +sound.play(volume, panoramic, pitch); // all parameters are optional. +``` +### Change pitch +This feature is only available with WebAudio. + +```javascript +var pitch = -7.0; // in semi-tone +sound.setPitch(pitch); +``` + +The pitch can be set at play. ```javascript -var volume = 1.0; // volume is a float in range ]0..1] -audioManager.setVolume('sfx', volume); +audioManager.playSound('ui', fileName, volume, panoramic, pitch); ``` -Load and play a simple sound. +While a sound is playing, the pitch can be changed dynamically ```javascript -var fileName = 'sound1'; // fileName is sound path without '.mp3' extension -audioManager.createSound(fileName); -audioManager.playSound('ui', fileName); +var portamento = 3.0; +sound.setPitch(pitch, portamento); ``` -Create and play sound groups. +### Create and play sound groups. +A sound group is a collection of sounds that will play alternatively in a +round-robin pattern on each `play` call. ```javascript var soundGroupDefs = { - groupId1: { id: ['sound1', 'sound2'], vol: [1.0, 0.8] }, + groupId1: { id: ['sound1', 'sound2'], vol: [1.0, 0.8], pitch: [0.0] }, groupId2: { ... }, ... }; audioManager.createSoundGroups(soundGroupDefs, 'sfx'); -var panoramic = 0.3; // panoramic is a float in range [-1..1]. - // set to 0, the sound will play at center. -audioManager.playSoundGroup('sfx', 'groupId1', panoramic); +var volume = 0.8; // volume is a float in range ]0..1] +var panoramic = 0.3; // panoramic is a float in range [-1..1], 0 is the center +var pitch = 3.0; // in semi-tone +audioManager.playSoundGroup('sfx', 'groupId1', volume, panoramic, pitch); ``` -Play and stop looped sounds (e.g. background music). Only one loop can play per channel. +### Play and stop looped sounds +Only one loop can play per channel. Playing a new looped sound in the same +channel will stop current playing sound before starting new one. ```javascript var volume = 1.0; // volume is a float in range ]0..1] var fileName = 'bgm1'; @@ -58,7 +129,7 @@ audioManager.stopLoopSound('music'); // stop looped sound in channel 'music' audioManager.stopAllLoopSounds(); // stop all looped sounds in all channel ``` -Release memory +### Release memory ```javascript audioManager.release(); ``` diff --git a/Sound.js b/Sound.js index 4ae0805..2702d92 100644 --- a/Sound.js +++ b/Sound.js @@ -1,4 +1,3 @@ -// jscs:disable requireCurlyBraces var inherits = require('util').inherits; var ISound = require('./ISound.js'); var PLAY_OPTIONS = { playAudioWhenScreenIsLocked: false }; @@ -101,6 +100,7 @@ Sound.prototype._play = function () { this._audio.pause(); this._audio.currentTime = 0; this._audio.play(PLAY_OPTIONS); + this.playing = true; }; //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ @@ -112,5 +112,6 @@ Sound.prototype.stop = function (cb) { this._audio.pause(); this._audio.currentTime = 0; this._playTriggered = 0; + this.playing = false; return cb && cb(); // TODO: fade-out }; diff --git a/SoundBuffered.js b/SoundBuffered.js index 21df5f9..792479a 100644 --- a/SoundBuffered.js +++ b/SoundBuffered.js @@ -1,5 +1,3 @@ -// jscs:disable requireCurlyBraces - var inherits = require('util').inherits; var ISound = require('./ISound.js'); @@ -11,7 +9,6 @@ var ISound = require('./ISound.js'); function SoundBuffered() { ISound.call(this); - this.playing = false; this.buffer = null; this.source = null; this.sourceConnector = null; @@ -19,13 +16,18 @@ function SoundBuffered() { this.panNode = null; this.rawAudioData = null; - if (this.audioContext) { this.init(); } + this._playPitch = 0.0; + this._fadeTimeout = null; + this._onStopCallback = null; + + if (this.audioContext) this.init(); } inherits(SoundBuffered, ISound); module.exports = SoundBuffered; SoundBuffered.prototype.init = function () { + var maxPlayLatency = this.audioManager.settings.maxPlayLatency; var self = this; // create webAudio nodes @@ -75,11 +77,8 @@ SoundBuffered.prototype.init = function () { //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ SoundBuffered.prototype.setVolume = function (value) { this.volume = value; - if (this.fade) { - this.gain.setTargetAtTime(value, this.audioContext.currentTime, this.fade); - } else { - this.gain.value = value; - } + if (!this.playing) return; + this.gain.setTargetAtTime(value, this.audioContext.currentTime, this.fade); }; //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ @@ -104,6 +103,26 @@ SoundBuffered.prototype.setLoop = function (value) { } }; +//▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +/** Set sound pitch + * + * @param {number} pitch - pitch in semi-tone + * @param {number} [portamento] - duration to slide from previous to new pitch. + */ +SoundBuffered.prototype.setPitch = function (pitch, portamento) { + this.pitch = pitch; + this._setPlaybackRate(pitch, portamento); +}; + +//▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +SoundBuffered.prototype._setPlaybackRate = function (pitch, portamento) { + if (!this.source) return; + var rate = Math.pow(2, (this._playPitch + pitch) / 12); + portamento = portamento || 0; + this.source.playbackRate.setTargetAtTime(rate, this.audioContext.currentTime, portamento); +}; + + //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ /** Load sound * @private @@ -130,7 +149,7 @@ SoundBuffered.prototype._load = function (filePath) { xobj.responseType = 'arraybuffer'; xobj.onreadystatechange = function onXhrStateChange() { - if (~~xobj.readyState !== 4) { return; } + if (~~xobj.readyState !== 4) return; if (~~xobj.status !== 200 && ~~xobj.status !== 0) { return loadFail(); } @@ -156,8 +175,17 @@ SoundBuffered.prototype._load = function (filePath) { /** Unload sound from memory */ SoundBuffered.prototype.unload = function () { if (ISound.prototype.unload.call(this)) { - this.buffer = null; - this.gain.value = 0; + if (this._fadeTimeout) { + this._onStopCallback = null; + this._stopAndClear(); + } + this.buffer = null; + this.gain.setTargetAtTime(0, this.audioContext.currentTime, 0); + if (this.source) { + this.source.onended = null; + this.source.stop(0); + this.source = null; + } } }; @@ -165,13 +193,33 @@ SoundBuffered.prototype.unload = function () { /** Play sound. If sound is not yet loaded, it is loaded in memory and flagged to be played * once loading has finished. If loading take too much time, playback may be cancelled. */ -SoundBuffered.prototype._play = function () { +SoundBuffered.prototype._play = function (pitch) { if (!this.buffer) { this._playTriggered = Date.now(); return; } + + // prevent a looped sound to play twice + if (this.loop && this.playing) { + // TODO: restart sound from beginning + // update pitch if needed + if ((pitch || pitch === 0) && pitch !== this._playPitch) { + this._playPitch = pitch; + this._setPlaybackRate(this.pitch + this._playPitch, 0); + } + return; + } + + // if sound is still fading out, we stop and clear it before restarting it + if (this._fadeTimeout) { + this._onStopCallback = null; + this._stopAndClear(); + } + this.playing = true; - var sourceNode = this.audioContext.createBufferSource(); + this.gain.setTargetAtTime(this.volume, this.audioContext.currentTime, this.fade); + + var sourceNode = this.source = this.audioContext.createBufferSource(); sourceNode.connect(this.sourceConnector); var self = this; @@ -181,14 +229,33 @@ SoundBuffered.prototype._play = function () { self.source = null; }; + this._playPitch = pitch || 0; + if (pitch || this.pitch) { + this._setPlaybackRate(this.pitch + this._playPitch, 0); + } + sourceNode.loop = this.loop; - this.source = sourceNode; sourceNode.buffer = this.buffer; sourceNode.loopStart = 0; sourceNode.loopEnd = this.buffer.duration; sourceNode.start(0); }; +//▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +SoundBuffered.prototype._stopAndClear = function () { + this.source.onended = null; + this.source.stop(0); + this.source = null; + if (this._fadeTimeout) { + window.clearTimeout(this._fadeTimeout); + this._fadeTimeout = null; + } + if (this._onStopCallback) { + this._onStopCallback(); + this._onStopCallback = null; + } +}; + //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ /** Stop sound * @@ -196,23 +263,25 @@ SoundBuffered.prototype._play = function () { */ SoundBuffered.prototype.stop = function (cb) { var fadeOutRatio = this.audioManager.settings.fadeOutRatio; - if (!this.playing) { return cb && cb(); } + if (!this.playing) return cb && cb(); this._playTriggered = 0; this.playing = false; - if (!this.source) { return cb && cb(); } + if (!this.source) return cb && cb(); - var self = this; - function stopSound() { - self.source.onended = null; - self.source.stop(0); - self.source = null; - return cb && cb(); - } + this._onStopCallback = cb; + + if (this._fadeTimeout) return; if (this.fade) { + var self = this; this.gain.setTargetAtTime(0, this.audioContext.currentTime, this.fade * fadeOutRatio); - return window.setTimeout(stopSound, this.fade * 1000); + this._fadeTimeout = window.setTimeout(function onFadeEnd() { + self._fadeTimeout = null; + self._stopAndClear(); + }, this.fade * 1000); + return; } - stopSound(); + this._stopAndClear(); }; + diff --git a/SoundGroup.js b/SoundGroup.js index 27d3a33..94fdfb4 100644 --- a/SoundGroup.js +++ b/SoundGroup.js @@ -1,20 +1,22 @@ -// jscs:disable requireCurlyBraces - +//▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ /** Set of sound played in sequence each times it triggers * used for animation sfx * @author Cedric Stoquer * - * @param {String} id - animation frame id (e.g.: '569/AnimMarche_5:0') + * @param {String} id - sound ground id * @param {number[]} soundIds - array of sound ids - * @param {number[]} volumes - array of volume (for each soundId) + * @param {number[]} volumes - array of volumes + * @param {number[]} pitches - array of pitches */ -function SoundGroup(id, soundIds, volumes, muted) { - this.id = id; - this.soundIds = soundIds; - this.volumes = []; - this.length = soundIds.length; - this.position = 0; - this.poolRef = null; +function SoundGroup(id, soundIds, volumes, pitches, muted) { + this.id = id; + this.soundIds = soundIds; + this.volumes = volumes || []; + this.pitches = pitches || []; + this.soundIndex = 0; + this.volIndex = 0; + this.pitchIndex = 0; + this.poolRef = null; for (var i = 0; i < soundIds.length; i++) { if (muted) { @@ -22,24 +24,37 @@ function SoundGroup(id, soundIds, volumes, muted) { } else { this.audioManager.loadSound(soundIds[i]); } - this.volumes.push(Math.max(0, Math.min(1, (~~volumes[i] / 100)))); } + + if (this.volumes.length === 0) this.volumes.push(1.0); + if (this.pitches.length === 0) this.pitches.push(0.0); } module.exports = SoundGroup; //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ -SoundGroup.prototype.play = function (volume, pan) { - if (this.length === 0) { return; } - var soundId = this.soundIds[this.position]; +/** Play sound group. + * + * @param {number} [volume] - optional volume + * @param {number} [pan] - optional panoramic + * @param {number} [pitch] - optional pitch value in semi-tone (available only if using webAudio) + */ +SoundGroup.prototype.play = function (volume, pan, pitch) { + if (this.soundIds.length === 0) return; + var soundId = this.soundIds[this.soundIndex++]; var sound = this.audioManager.getSound(soundId); - if (!sound) { return console.warn('[Sound Group: ' + this.id + '] sound id ' + soundId + ' cannot be played.'); } - volume *= this.volumes[this.position]; - sound.play(volume, pan); - this.position++; - if (this.position >= this.length) { this.position = 0; } + if (!sound) return console.warn('[Sound Group: ' + this.id + '] sound id ' + soundId + ' cannot be played.'); + volume = volume || 1.0; + pitch = pitch || 0.0; + volume *= this.volumes[this.volIndex++]; + pitch += this.pitches[this.pitchIndex++]; + sound.play(volume, pan, pitch); + if (this.soundIndex >= this.soundIds.length) { this.soundIndex = 0; } + if (this.volIndex >= this.volumes.length) { this.volIndex = 0; } + if (this.pitchIndex >= this.pitches.length) { this.pitchIndex = 0; } }; //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +/** Check that all sounds in group are correctly created */ SoundGroup.prototype.verifySounds = function () { for (var i = 0; i < this.soundIds.length; i++) { var soundId = this.soundIds[i]; diff --git a/component.json b/component.json index 02d1bb6..7d32013 100644 --- a/component.json +++ b/component.json @@ -1,7 +1,7 @@ { "name": "AudioManager", "repo": "Wizcorp/AudioManager", - "version": "0.1.0", + "version": "0.1.1", "description": "Play sounds using Web Audio, fallback to HTML5 Audio", "dependencies": { "Wizcorp/util": "0.1.0" diff --git a/index.js b/index.js index 0bb72f8..24f446e 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,3 @@ -// jscs:disable requireCurlyBraces var AudioContext = window.AudioContext || window.webkitAudioContext; var OrderedList = require('./OrderedList'); var SoundObject = require('./SoundBuffered.js'); @@ -13,14 +12,14 @@ if (!AudioContext) { } } -var audioContext = null; - //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ function AudioChannel() { - this.volume = 0.5; - this.muted = true; - this.sound = null; - this.soundParam = null; + this.volume = 1.0; + this.muted = true; + this.loopSound = null; + this.loopId = null; + this.loopVol = 0.0; + this.nextLoop = null; } //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ @@ -40,6 +39,7 @@ function AudioManager(channels) { this.soundGroupArchiveById = {}; this.usedMemory = 0; this.channels = {}; + this.audioContext = null; // settings this.settings = { @@ -55,6 +55,10 @@ function AudioManager(channels) { for (var i = 0; i < channels.length; i++) { this.channels[channels[i]] = new AudioChannel(); } + + // register self + SoundObject.prototype.audioManager = this; + SoundGroup.prototype.audioManager = this; } module.exports = AudioManager; @@ -62,11 +66,10 @@ module.exports = AudioManager; //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ AudioManager.prototype.init = function () { - if (audioContext || !AudioContext) { return; } - audioContext = new AudioContext(); - SoundObject.prototype.audioContext = audioContext; - SoundObject.prototype.audioManager = this; - SoundGroup.prototype.audioManager = this; + if (this.audioContext || !AudioContext) return; + this.audioContext = new AudioContext(); + SoundObject.prototype.audioContext = this.audioContext; + for (var id in this.soundsById) { this.soundsById[id].init(); } @@ -106,25 +109,26 @@ AudioManager.prototype.setup = function (channels) { */ AudioManager.prototype.setVolume = function (channelId, volume, muted) { var channel = this.channels[channelId]; - if (!channel) { return; } + if (!channel) return; var wasChannelMuted = channel.muted; channel.muted = volume === 0 || muted || false; channel.volume = volume; - if (!channel.soundParam) { return; } + if (!channel.loopId) return; // this is a channel with looped sound (music, ambient sfx) // we have to take care of this looped sound playback if channel state changed - if (channel.sound && channel.muted) { + if (channel.loopSound && channel.muted) { // a sound was playing, channel becomes muted - channel.sound.stop(); - } else if (channel.sound) { + channel.loopSound.stop(); + // TODO: unload sound ? + } else if (channel.loopSound) { // a sound is loaded in channel, updating volume & playback - channel.sound.setVolume(Math.max(0, Math.min(1, volume * channel.soundParam.volume))); - if (wasChannelMuted) { channel.sound.play(); } + channel.loopSound.setVolume(Math.max(0, Math.min(1, volume * channel.loopVol))); + if (wasChannelMuted) { channel.loopSound.play(); } } else if (!channel.muted) { // no sounds are loaded in channel, channel is unmutted - this.playLoopSound(channelId, channel.soundParam); + this.playLoopSound(channelId, channel.loopId, channel.loopVol); } }; @@ -147,7 +151,7 @@ AudioManager.prototype.loadSound = function (id, cb) { */ AudioManager.prototype.createSound = function (id) { var sound = this.getSound(id); - if (sound) { return sound; } + if (sound) return sound; sound = this.soundsById[id] = this.getEmptySound(); sound.setId(id); return sound; @@ -162,7 +166,7 @@ AudioManager.prototype.createSoundPermanent = function (id) { var sound = this.getSound(id); // TODO: Check if sound is permanent and move it to permanents list if it's not the case. // Because permanents sound (UI sounds) are created at app startup, this should not happend. - if (sound) { return sound; } + if (sound) return sound; sound = this.permanentSounds[id] = new SoundObject(); sound.setId(id); return sound; @@ -176,15 +180,15 @@ AudioManager.prototype.createSoundPermanent = function (id) { AudioManager.prototype.getSound = function (id) { // search sound in permanents var sound = this.permanentSounds[id]; - if (sound) { return sound; } + if (sound) return sound; // search sound in active list sound = this.soundsById[id]; - if (sound) { return sound; } + if (sound) return sound; // search sound in archives sound = this.soundArchiveById[id]; - if (!sound) { return null; } + if (!sound) return null; // remove sound from archives this.soundArchive.removeByRef(sound.poolRef); @@ -204,11 +208,11 @@ AudioManager.prototype.getSound = function (id) { AudioManager.prototype.getSoundGroup = function (id) { // search soundGroup in active list var soundGroup = this.soundGroupsById[id]; - if (soundGroup) { return soundGroup; } + if (soundGroup) return soundGroup; // search soundGroup in archives soundGroup = this.soundGroupArchiveById[id]; - if (!soundGroup) { return null; } + if (!soundGroup) return null; // remove soundGroup from archives this.soundGroupArchive.removeByRef(soundGroup.poolRef); @@ -230,12 +234,12 @@ AudioManager.prototype.getSoundGroup = function (id) { * @param {number} sound - sound wrapper object */ AudioManager.prototype.freeSound = function (sound) { - var id = sound.id; - if (this.soundsById[id]) { delete this.soundsById[id]; } - if (this.soundArchiveById[id]) { + var soundId = sound.id; + if (this.soundsById[soundId]) { delete this.soundsById[soundId]; } + if (this.soundArchiveById[soundId]) { this.soundArchive.removeByRef(sound.poolRef); sound.poolRef = null; - delete this.soundArchiveById[id]; + delete this.soundArchiveById[soundId]; } sound.unload(); this.freeSoundPool.push(sound); @@ -249,69 +253,61 @@ AudioManager.prototype.freeSound = function (sound) { * @param {string} soundId - sound id * @param {number} [volume] - sound volume, a integer in rage ]0..1] */ -AudioManager.prototype.playLoopSound = function (channelId, id, volume) { - var defaultFade = this.settings.defaultFade; - var channel = this.channels[channelId]; - var currentSoundId = channel.sound && channel.soundParam && channel.soundParam.id; - if (id === currentSoundId) { return; } - if (channel.muted) { return; } - var self = this; - var currentSound = channel.sound; +AudioManager.prototype.playLoopSound = function (channelId, soundId, volume) { + var defaultFade = this.settings.defaultFade; + var channel = this.channels[channelId]; + var currentSoundId = channel.loopId; + var currentSound = channel.loopSound; + volume = Math.max(0, Math.min(1, volume || 1)); - this.loadSound(id, function onSoundLoad(error, sound) { - if (error) { - // FIXME: should we stop current sound ? - return console.error(error); - } + channel.loopId = soundId; + channel.loopVol = volume; - channel.soundParam = { id: id, vol: volume }; - - function startLoop() { - if (!sound) { - channel.sound = null; - return; - } - sound.setLoop(true); // TODO: loop can be a number (set in game global options) - sound.fade = defaultFade; - sound.play(channel.volume * volume, 0, true); // TODO: use streaming for music - channel.sound = sound; - } + if (soundId === currentSoundId && currentSound && currentSound.playing) return; // TODO: update volume + if (channel.muted) return; - if (currentSound) { - // TODO: fade-out current music - currentSound.stop(function onSoundStop() { - self.freeSound(currentSound); - startLoop(); - }); - } else { - startLoop(); - } - }); + function switchLoop() { + var sound = channel.loopSound = channel.nextLoop; + channel.nextLoop = null; + sound.setLoop(true); + sound.fade = defaultFade; + sound.play(); + } + + function stopCurrentLoop() { + if (!channel.loopSound) return switchLoop(); + channel.loopSound.stop(function onStop() { + switchLoop(); + }); + } + + if (channel.nextLoop) this.freeSound(channel.nextLoop); + channel.nextLoop = this.loadSound(soundId, stopCurrentLoop); }; + + //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ /** Stop currently playing lopped sound in channel */ AudioManager.prototype.stopLoopSound = function (channelId) { var self = this; var channel = this.channels[channelId]; if (!channel) return console.warn('Channel id "' + channelId + '" does not exist.'); - var currentSound = channel.sound; + var currentSound = channel.loopSound; + channel.loopId = null; if (!currentSound) return; currentSound.stop(function onSoundStop() { self.freeSound(currentSound); - channel.sound = null; + channel.loopSound = null; }); }; //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ /** Stop and cleanup all looped sounds */ AudioManager.prototype.stopAllLoopSounds = function () { - for (var id in this.channels) { - var channel = this.channels[id]; - if (channel.sound) { channel.sound.stop(); } - channel.sound = null; - channel.soundParam = null; + for (var channelId in this.channels) { + this.stopLoopSound(channelId); } }; @@ -329,8 +325,8 @@ AudioManager.prototype.release = function () { var loopedSounds = {}; for (id in this.channels) { var channel = this.channels[id]; - if (channel.sound) { - loopedSounds[channel.sound.id] = true; + if (channel.loopSound) { + loopedSounds[channel.loopSound.id] = true; } } @@ -344,7 +340,7 @@ AudioManager.prototype.release = function () { // archive all sounds for (id in this.soundsById) { - if (loopedSounds[id]) { continue; } + if (loopedSounds[id]) continue; sound = this.soundsById[id]; sound.poolRef = this.soundArchive.add(sound); this.soundArchiveById[id] = sound; @@ -355,7 +351,7 @@ AudioManager.prototype.release = function () { var count = this.soundGroupArchive.getCount(); while (count > maxSoundGroup) { soundGroup = this.soundGroupArchive.popFirst(); - if (!soundGroup) { break; } + if (!soundGroup) break; soundGroup.poolRef = null; delete this.soundGroupArchiveById[soundGroup.id]; count -= 1; @@ -364,7 +360,7 @@ AudioManager.prototype.release = function () { // free sounds if memory limit is reached while (this.usedMemory > maxUsedMemory) { sound = this.soundArchive.popFirst(); - if (!sound) { break; } + if (!sound) break; sound.poolRef = null; delete this.soundArchiveById[sound.id]; this.freeSound(sound); @@ -376,46 +372,53 @@ AudioManager.prototype.release = function () { * * @param {String} channelId - channel id used to play sound * @param {String} soundId - sound id + * @param {number} [volume] - optional volume value. volume:]0..1] + * @param {number} [pan] - optional panoramic value. pan:[-1..1] + * @param {number} [pitch] - optional pitch value in semi-tone. Only work with webAudio enabled */ -AudioManager.prototype.playSound = function (channelId, soundId) { +AudioManager.prototype.playSound = function (channelId, soundId, volume, pan, pitch) { var channel = this.channels[channelId]; - if (channel.muted) { return; } + if (channel.muted) return; var sound = this.getSound(soundId); - if (!sound) { return; } - sound.play(channel.volume); + if (!sound) { sound = this.createSound(soundId); } + volume = volume || 1.0; + sound.play(channel.volume * volume, pan, pitch); }; //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ /** Play a sound group * - * @param {String} soundGroupId - sound group id - * @param {number} pan - panoramic value. pan:[-1..1] * @param {String} channelId - channel id used to play sound + * @param {String} soundGroupId - sound group id + * @param {number} [volume] - optional volume value. volume:]0..1] + * @param {number} [pan] - optional panoramic value. pan:[-1..1] */ -AudioManager.prototype.playSoundGroup = function (channelId, soundGroupId, pan) { +AudioManager.prototype.playSoundGroup = function (channelId, soundGroupId, volume, pan, pitch) { var channel = this.channels[channelId]; - if (channel.muted) { return; } + if (channel.muted) return; var soundGroup = this.getSoundGroup(soundGroupId); - if (!soundGroup) { return; } - soundGroup.play(channel.volume, pan); + if (!soundGroup) return console.warn('SoundGroup "' + soundGroupId + '" does not exist.'); + volume = volume || 1.0; + soundGroup.play(volume * channel.volume, pan, pitch); }; //▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ /** Create a list of sound groups. * - * @param {Object} soundGroupDefs - definitions of sound groups - * {String[]} soundGroupDefs[*].id - sound ids - * {String[]} soundGroupDefs[*].vol - sound volumes. vol:[0..1] - * @param {String} [channelId] - channel id the sound group will play in + * @param {Object} soundGroupDefs - definitions of sound groups + * {String[]} soundGroupDefs[*].id - sound ids + * {String[]} soundGroupDefs[*].vol - sound volumes. vol:[0..1] + * {String[]} soundGroupDefs[*].pitch - sound pitches in semi-tone. + * @param {String} [channelId] - channel id the sound group will play in */ AudioManager.prototype.createSoundGroups = function (soundGroupDefs, channelId) { var muted = channelId !== undefined ? this.channels[channelId].muted : false; for (var soundGroupId in soundGroupDefs) { - var anim = soundGroupDefs[soundGroupId]; - if (this.soundGroupsById[soundGroupId]) { continue; } + var def = soundGroupDefs[soundGroupId]; + if (this.soundGroupsById[soundGroupId]) continue; var soundGroup = this.getSoundGroup(soundGroupId); if (!soundGroup) { - soundGroup = new SoundGroup(soundGroupId, anim.id, anim.vol, muted); + soundGroup = new SoundGroup(soundGroupId, def.id, def.vol, def.pitch, muted); this.soundGroupsById[soundGroupId] = soundGroup; } }