"use strict"; /// vwf/model/sound.js is a sound driver that wraps the capabilities of the /// HTML5 web audio API. /// /// @module vwf/model/sound /// @requires vwf/model // References: // http://www.html5rocks.com/en/tutorials/webaudio/intro/ // https://dvcs.w3.org/hg/audio/raw-file/tip/webaudio/specification.html // http://www.html5rocks.com/en/tutorials/webaudio/fieldrunners/ define( [ "module", "vwf/model" ], function( module, model ) { // TODO: should these be stored in this.state so that the view can access them? var context; var soundData = {}; var soundGroups = {}; var masterVolume = 1.0; var logger; var soundDriver; var startTime = Date.now(); var driver = model.load( module, { initialize: function() { // In case somebody tries to reference it before we get a chance to create it. // (it's created in the view) this.state.soundManager = {}; soundDriver = this; logger = this.logger; try { // I quote: "For WebKit- and Blink-based browsers, you // currently need to use the webkit prefix, i.e. // webkitAudioContext." // http://www.html5rocks.com/en/tutorials/webaudio/intro/ if ( !window.AudioContext ) { window.AudioContext = window.webkitAudioContext; } context = new AudioContext(); } catch( e ) { // alert( 'Web Audio API is not supported in this browser' ); logger.warnx( "initialize", "Web Audio API is not supported in this browser." ); } }, callingMethod: function( nodeID, methodName, params ) { if ( nodeID !== this.state.soundManager.nodeID ) { return undefined; } if ( !context ) { return undefined; } // variables that we'll need in the switch statement below. These all have function // scope, might as well declare them here. var soundDefinition, successCallback, failureCallback, exitCallback; var soundName, soundNames, soundDatum, soundDefinition, soundInstance; var instanceIDs, instanceID, i, volume, fadeTime, fadeMethod, instanceHandle; var soundGroup, groupName; switch( methodName ) { // arguments: soundDefinition, successCallback, failureCallback case "loadSound": soundDefinition = params[ 0 ]; successCallback = params[ 1 ]; failureCallback = params[ 2 ]; if ( soundDefinition === undefined ) { logger.errorx( "loadSound", "The 'loadSound' method requires " + "a definition for the sound." ); return undefined; } soundName = soundDefinition.soundName; if ( soundName === undefined ) { logger.errorx( "loadSound", "The sound definition must contain soundName." ); return undefined; } if ( soundData[ soundName ] !== undefined ) { logger.errorx( "loadSound", "Duplicate sound named '" + soundName + "'." ); return undefined; } soundData[ soundName ] = new SoundDatum( soundDefinition, successCallback, failureCallback ); return; // arguments: soundName // returns: true if sound is done loading and is playable case "isReady": soundDatum = getSoundDatum( params[ 0 ] ); return soundDatum !== undefined ? !!soundDatum.buffer : false; // arguments: soundName, exitCallback (which is called when the sound stops) // returns: an instance handle, which is an object: // { soundName: value, instanceID: value } // instanceID is -1 on failure case "playSound": soundName = params[ 0 ]; soundDatum = getSoundDatum( soundName ); exitCallback = params[ 1 ]; return soundDatum ? soundDatum.playSound( exitCallback ) : { soundName: soundName, instanceID: -1 }; case "playSequence": soundNames = params; soundDatum = getSoundDatum( soundNames[ 0 ] ); var playNext = function ( soundNames, current ){ if (current !== soundNames.length){ soundDatum = getSoundDatum( soundNames[ current ] ); soundDatum.playSound( playNext ( soundNames, current + 1 ) ); } } return soundDatum ? soundDatum.playSound( playNext ( soundNames, 0 ) ) : { soundName: soundName, instanceID: - 1 }; // arguments: soundName // returns: true if sound is currently playing case "isSoundPlaying": soundDatum = getSoundDatum( params[ 0 ] ); return soundDatum ? soundDatum.isPlaying() : false; // arguments: instanceHandle // returns: true if sound is currently playing case "isInstancePlaying": return getSoundInstance( params[ 0 ] ) !== undefined; // // arguments: instanceHandle, volume, fadeTime, fadeMethod case "setVolume": instanceHandle = params [ 0 ]; soundDatum = getSoundDatum( instanceHandle.soundName ); if ( soundDatum ){ soundDatum.setVolume ( params [ 0 ], params [ 1 ], params [ 2 ], params [ 3 ] ); } // // arguments: volume (0.0-1.0) case "setMasterVolume": masterVolume = params [ 0 ]; for ( soundName in soundData ){ soundDatum = soundData[ soundName ]; if ( soundDatum ) { soundDatum.resetOnMasterVolumeChange(); } } // // arguments: instanceHandle case "hasSubtitle": instanceHandle = params [ 0 ]; soundDatum = getSoundDatum( instanceHandle.soundName ); return soundDatum ? !!soundDatum.subtitle : undefined; // // arguments: instanceHandle case "getSubtitle": instanceHandle = params [ 0 ]; soundDatum = getSoundDatum( instanceHandle.soundName ); return soundDatum ? soundDatum.subtitle : undefined; // arguments: instanceHandle // returns: the duration of the sound case "getDuration": instanceHandle = params[ 0 ]; soundDatum = getSoundDatum( instanceHandle.soundName ); return soundDatum && soundDatum.buffer ? soundDatum.buffer.duration : undefined; // arguments: instanceHandle case "stopSoundInstance": instanceHandle = params[ 0 ]; //If a user chooses to pass just a soundName, stop all instances with that name. if ( !instanceHandle.soundName ){ soundName = params[ 0 ]; soundDatum = getSoundDatum( soundName ); soundDatum && soundDatum.stopDatumSoundInstances(); } else { //Otherwise stop the specific instance. soundDatum = getSoundDatum( instanceHandle.soundName ); soundDatum && soundDatum.stopInstance( instanceHandle ); } return; // arguments: groupName case "stopSoundGroup": groupName = params[ 0 ]; soundGroup = soundGroups[ groupName ]; soundGroup && soundGroup.clearQueue(); soundGroup && soundGroup.stopPlayingSound(); return; // arguments: none case "stopAllSoundInstances": for ( groupName in soundGroups ) { soundGroup = soundGroups[ groupName ]; soundGroup && soundGroup.clearQueue(); } for ( soundName in soundData ) { soundDatum = soundData[ soundName ]; soundDatum && soundDatum.stopDatumSoundInstances(); } return undefined; // arguments: soundName case "getSoundDefinition": soundDatum = getSoundDatum( params[ 0 ] ); return soundDatum ? soundDatum.soundDefinition : undefined; } return undefined; } } ); function SoundDatum( soundDefinition, successCallback, failureCallback ) { this.initialize( soundDefinition, successCallback, failureCallback ); return this; } SoundDatum.prototype = { constructor: SoundDatum, // the name name: "", // the actual sound buffer: null, // a hashtable of sound instances playingInstances: null, // control parameters initialVolume: 1.0, isLooping: false, allowMultiplay: false, soundDefinition: null, playOnLoad: false, subtitle: undefined, soundGroup: undefined, groupReplacementMethod: undefined, queueDelayTime: undefined, // in seconds // a counter for creating instance IDs instanceIDCounter: 0, initialize: function( soundDefinition, successCallback, failureCallback ) { this.name = soundDefinition.soundName; this.playingInstances = {}; this.soundDefinition = soundDefinition; if ( this.soundDefinition.isLooping !== undefined ) { this.isLooping = soundDefinition.isLooping; } if ( this.soundDefinition.allowMultiplay !== undefined ) { this.allowMultiplay = soundDefinition.allowMultiplay; } if (this.soundDefinition.initialVolume !== undefined ) { this.initialVolume = soundDefinition.initialVolume; } if ( this.soundDefinition.playOnLoad !== undefined ) { this.playOnLoad = soundDefinition.playOnLoad; } this.subtitle = this.soundDefinition.subtitle; var soundGroupName = this.soundDefinition.soundGroup; if ( soundGroupName ) { if ( !soundGroups[ soundGroupName ] ) { soundGroups[ soundGroupName ] = new SoundGroup( soundGroupName ); } this.soundGroup = soundGroups[ soundGroupName ]; } this.groupReplacementMethod = this.soundDefinition.groupReplacementMethod; if ( this.groupReplacementMethod && !this.soundGroup ) { logger.warnx( "SoundDatum.initialize", "You defined a replacement method but not a sound " + "group. Replacement is only done when you replace " + "another sound in the same group!" ); } if ( this.soundDefinition.queueDelayTime !== undefined ) { this.queueDelayTime = this.soundDefinition.queueDelayTime; if ( this.groupReplacementMethod !== "queue" ) { logger.warnx( "SoundDatum.initialize", "You defined a queue delay time, but " + "the replacement method is not 'queue'."); } } else { this.queueDelayTime = this.groupReplacementMethod === "queue" ? 0.8 : 0; } // Create & send the request to load the sound asynchronously var request = new XMLHttpRequest(); request.open( 'GET', soundDefinition.soundURL, true ); request.responseType = 'arraybuffer'; var thisSoundDatum = this; request.onload = function() { context.decodeAudioData( request.response, function( buffer ) { thisSoundDatum.buffer = buffer; if ( thisSoundDatum.playOnLoad === true ) { thisSoundDatum.playSound( null, true ); } successCallback && successCallback(); }, function() { logger.warnx( "SoundDatum.initialize", "Failed to load sound: '" + thisSoundDatum.name + "'." ); delete soundData[ thisSoundDatum.name ]; failureCallback && failureCallback(); } ); } request.send(); }, playSound: function( exitCallback ) { if ( !this.buffer ) { logger.errorx( "SoundDatum.playSound", "Sound '" + this.name + "' hasn't finished " + "loading, or loaded improperly." ); return { soundName: this.name, instanceID: -1 }; } if ( !this.allowMultiplay && this.isPlaying() ) { return { soundName: this.name, instanceID: this.playingInstances[ 0 ] }; } var id = this.instanceIDCounter; ++this.instanceIDCounter; this.playingInstances[ id ] = new PlayingInstance( this, id, exitCallback ); return { soundName: this.name, instanceID: id }; }, stopInstance: function( instanceHandle ) { var soundInstance = this.playingInstances[ instanceHandle.instanceID ]; soundInstance && soundInstance.stop(); }, stopDatumSoundInstances: function () { for ( var instanceID in this.playingInstances ) { var soundInstance = this.playingInstances[ instanceID ]; soundInstance && soundInstance.stop(); } // I have no freaking idea why uncommenting this breaks absolutely // everything, but it does! // this.playingInstances = {}; }, resetOnMasterVolumeChange: function () { for ( var instanceID in this.playingInstances ) { var soundInstance = this.playingInstances[ instanceID ]; if ( soundInstance ) { soundInstance.resetVolume(); } } }, setVolume: function ( instanceHandle, volume, fadeTime, fadeMethod ) { // arguments: instanceHandle, volume, fadeTime, fadeMethod var soundInstance = getSoundInstance( instanceHandle ); soundInstance && soundInstance.setVolume( volume, fadeTime, fadeMethod ); }, isPlaying: function() { var instanceIDs = Object.keys( this.playingInstances ); return instanceIDs.length > 0; }, } function PlayingInstance( soundDatum, id, exitCallback, successCallback ) { this.initialize( soundDatum, id, exitCallback, successCallback ); return this; } PlayingInstance.prototype = { constructor: PlayingInstance, // our id, for future reference id: undefined, // a reference back to the soundDatum soundDatum: null, // the control nodes for the flowgraph sourceNode: undefined, gainNode: undefined, // we need to know the volume for this node *before* the master volume // adjustment is applied. This is that value. localVolume$: undefined, // stopped, delayed, playing, stopping, delayCancelled playStatus: undefined, initialize: function( soundDatum, id, exitCallback, successCallback ) { // NOTE: from http://www.html5rocks.com/en/tutorials/webaudio/intro/: // // An important point to note is that on iOS, Apple currently mutes all sound // output until the first time a sound is played during a user interaction // event - for example, calling playSound() inside a touch event handler. // You may struggle with Web Audio on iOS "not working" unless you circumvent // this - in order to avoid problems like this, just play a sound (it can even // be muted by connecting to a Gain Node with zero gain) inside an early UI // event - e.g. "touch here to play". this.id = id; this.soundDatum = soundDatum; this.playStatus = "stopped"; this.sourceNode = context.createBufferSource(); this.sourceNode.buffer = this.soundDatum.buffer; this.sourceNode.loop = this.soundDatum.isLooping; this.localVolume$ = this.soundDatum.initialVolume; this.gainNode = context.createGain(); this.sourceNode.connect( this.gainNode ); this.gainNode.connect( context.destination ); this.resetVolume(); var group = soundDatum.soundGroup; // Browsers will handle onended differently depending on audio // filetype - needs support. var thisInstance = this; this.sourceNode.onended = function() { thisInstance.playStatus = "stopped"; fireSoundEvent( "soundFinished", thisInstance ); // logger.logx( "PlayingInstance.onended", // "Sound ended: '" + thisInstance.soundDatum.name + // "', Timestamp: " + timestamp() ); if ( group ) { group.soundFinished( thisInstance ); var nextInstance = group.unQueueSound(); if ( nextInstance ) { var delaySeconds = nextInstance.soundDatum.queueDelayTime; // logger.logx( "PlayingInstance.onended", // "Popped from the queue: '" + // nextInstance.soundDatum.name + // ", Timeout: " + delaySeconds + // ", Timestamp: " + timestamp() ); if ( delaySeconds > 0 ) { nextInstance.startDelayed( delaySeconds ); } else { nextInstance.start(); } } } delete soundDatum.playingInstances[ id ]; exitCallback && exitCallback(); } if ( group ) { switch ( soundDatum.groupReplacementMethod ) { case "queue": // We're only going to play the sound if there isn't // already a sound from this group playing. // Otherwise, add it to a queue to play later. if ( group.getPlayingSound() ) { group.queueSound( this ); } else { this.start(); } break; case "replace": group.clearQueue(); group.stopPlayingSound(); this.start(); break; default: logger.errorx( "PlayingInstance.initialize", "This sound ('" + thisInstance.soundDatum.name + "') is in a group, but doesn't " + "have a valid replacement method!" ); group.clearQueue(); group.stopPlayingSound(); this.start(); } } else { this.start(); } }, getVolume: function() { return this.localVolume$ * ( masterVolume !== undefined ? masterVolume : 1.0 ); }, setVolume: function( volume, fadeTime, fadeMethod ) { if ( !volume ) { logger.errorx( "PlayingInstance.setVolume", "The 'setVolume' " + "method requires a volume." ); return; } this.localVolume$ = volume; if ( !fadeTime || ( fadeTime <= 0 ) ) { fadeMethod = "immediate"; } var now = context.currentTime; this.gainNode.gain.cancelScheduledValues( now ); switch( fadeMethod ) { case "linear": var endTime = now + fadeTime; this.gainNode.gain.linearRampToValueAtTime( this.getVolume(), endTime ); break; case "exponential": case undefined: this.gainNode.gain.setTargetValueAtTime( this.getVolume(), now, fadeTime ); break; case "immediate": this.gainNode.gain.value = this.getVolume(); break; default: logger.errorx( "PlayingInstance.setVolume", "Unknown fade method: '" + fadeMethod + "'. Using an exponential " + "fade." ); this.gainNode.gain.setTargetValueAtTime( this.getVolume(), now, fadeTime ); } }, resetVolume: function() { this.setVolume(this.localVolume$); }, start: function() { switch ( this.playStatus ) { case "playing": logger.warnx( "PlayingInstance.start", "Duplicate call to start. Sound: '" + this.soundDatum.name + "'." ); break; case "stopping": logger.warnx( "PlayingInstance.start", "Start is being " + "called, but we're not done stopping yet. " + "Is that bad?" ); // deliberately drop through - we can restart it. case "delayed": case "stopped": var group = this.soundDatum.soundGroup; var playingSound = group ? group.getPlayingSound() : undefined; if ( !group || !playingSound || ( ( playingSound === this ) && ( this.playStatus !== "stopping" ) ) ) { this.playStatus = "playing"; group && group.setPlayingSound( this ); this.sourceNode.start( 0 ); // logger.logx( "startSoundInstance", // "Sound started: '" + this.soundDatum.name + // "', Timestamp: " + timestamp() ); fireSoundEvent( "soundStarted", this ); } else { if ( ( playingSound !== this ) && ( playingSound.playStatus != "stopping" ) ) { logger.errorx( "PlayingInstance.start", "We are trying to start a sound " + "('" + this.soundDatum.name + "') that is in a sound group, " + "but the currently playing sound " + "in that group ('" + playingSound.soundDatum.name + "') isn't in the process of " + "stopping. This is probably bad." ); } // Because the sound API is asynchronous, this happens // fairly often. The trick is to just stuff this // sound onto the front of the queue, and let it run // whenever whatever is playing right now finishes. group.jumpQueue( this ); } break; case "delayCancelled": // don't start - we've been trumped. // NOTE: it's theoretically possible to re-queue the sound // in between when the delay is cancelled and when it // finishes the delay and calls start. In this case the // sound might be delayed more than desired, but should // still eventually play (I think). If you're restarting // sounds alot, consider looking into this. this.playStatus = "stopped"; break; default: logger.errorx( "PlayingInstance.start", "Invalid " + "playStatus: '" + this.playStatus + "'!" ); } }, startDelayed: function( delaySeconds ) { var group = this.soundDatum.soundGroup; if ( group ) { if ( group.getPlayingSound() ) { logger.errorx( "PlayingInstance.startDelayed", "How is there already a sound playing " + "when startDelayed() is called?" ); return; } group.setPlayingSound( this ); } this.playStatus = "delayed"; setTimeout( this.start.bind(this), delaySeconds * 1000 ); }, stop: function() { switch ( this.playStatus ) { case "playing": this.playStatus = "stopping"; this.sourceNode.stop(); break; case "delayed": this.playStatus = "delayCancelled"; var group = this.soundDatum.soundGroup; if ( group ) { group.soundFinished( this ); } break; case "delayCancelled": case "stopping": case "stopped": logger.warnx( "PlayingInstance.stop", "Duplicate call " + "to stop (or it was never started). " + "Sound: '" + this.soundDatum.name + "'." ); break; default: logger.errorx( "PlayingInstance.stop", "Invalid " + "playStatus: '" + this.playStatus + "'!" ); } }, } function SoundGroup( groupName ) { this.initialize( groupName ); return this; } SoundGroup.prototype = { constructor: SoundGroup, // Trying out a new convention - make the values in the prototype be // something obvious, so I can tell if they don't get reset. name$: "PROTOTYPE", // the name of the group, for debugging queue$: "PROTOTYPE", // for storing queued sounds while they wait playingSound$: "PROTOTYPE", // the sound that is currently playing initialize: function( groupName ) { this.name$ = groupName; this.queue$ = []; this.playingSound$ = undefined; }, getPlayingSound: function() { return this.playingSound$; }, setPlayingSound: function( playingInstance ) { if ( this.playingSound$ ) { if ( this.playingSound$ !== playingInstance ) { logger.errorx( "SoundGroup.setPlayingSound", "Trying to set playingSound to '" + playingInstance.soundDatum.name + "', but it is already set to '" + this.playingSound$.soundDatum.name + "'!"); } else if ( !this.playingSound$.playStatus !== "delayed" ) { logger.errorx("SoundGroup.setPlayingSound", "How are we re-setting the playing sound " + "when we're not in a delay? Sound: '" + this.playingSound$.soundDatum.name + "'." ); } } else { this.playingSound$ = playingInstance; } }, soundFinished: function( playingInstance ) { if ( playingInstance !== this.playingSound$ ) { logger.errorx( "SoundGroup.soundFinished", "'" + playingInstance.soundDatum.name + "' just " + "repored that it is finished, but we thought " + "that '" + this.playingSound$.soundDatum.name + "' was playing!"); return; } this.playingSound$ = undefined; }, stopPlayingSound: function() { this.playingSound$ && this.playingSound$.stop(); }, // get in the back of the queue of sounds to play queueSound: function( playingInstance ) { this.queue$.unshift( playingInstance ); }, // jump to the front of the queue of sounds to play jumpQueue: function( playingInstance ) { this.queue$.push( playingInstance ); }, unQueueSound: function() { return this.queue$.pop(); }, clearQueue: function() { this.queue$.length = 0; }, hasQueuedSounds: function() { return queue$.length > 0; }, } function getSoundDatum( soundName ) { if ( soundName === undefined ) { logger.errorx( "getSoundDatum", "The 'getSoundDatum' method " + "requires the sound name." ); return undefined; } var soundDatum = soundData[ soundName ]; if ( soundDatum === undefined ) { logger.errorx( "getSoundDatum", "Sound '" + soundName + "' not found." ); return undefined; } return soundDatum; } function getSoundInstance( instanceHandle ) { if ( instanceHandle === undefined ) { logger.errorx( "getSoundInstance", "The 'GetSoundInstance' " + "method requires the instance ID." ); return undefined; } if ( ( instanceHandle.soundName === undefined ) || ( instanceHandle.instanceID === undefined ) ) { logger.errorx( "getSoundInstance", "The instance handle must " + "contain soundName and instanceID values"); return undefined; } var soundDatum = getSoundDatum( instanceHandle.soundName ); if ( soundDatum.isLayered === true ) { return soundDatum; } else { return soundDatum ? soundDatum.playingInstances[ instanceHandle.instanceID ] : undefined; } } function fireSoundEvent( eventString, instance ) { vwf_view.kernel.fireEvent( soundDriver.state.soundManager.nodeID, eventString, [ { soundName: instance.soundDatum.name, instanceID: instance.id } ] ); } function timestamp() { var delta = Date.now() - startTime; var minutes = Math.floor( delta / 60000 ); var seconds = ( delta % 60000 ) / 1000; return "" + minutes + ":" + seconds; } return driver; } );