diff --git a/js/gravity-force-lab/view/ForceSoundGenerator.js b/js/gravity-force-lab/view/ForceSoundGenerator.js new file mode 100644 index 00000000..48bbfa39 --- /dev/null +++ b/js/gravity-force-lab/view/ForceSoundGenerator.js @@ -0,0 +1,131 @@ +// Copyright 2019, University of Colorado Boulder + +/** + * sound generator for changes to the force between two masses + * + * @author John Blanco (PhET Interactive Simulations) + */ +define( require => { + 'use strict'; + + // modules + const gravityForceLab = require( 'GRAVITY_FORCE_LAB/gravityForceLab' ); + const merge = require( 'PHET_CORE/merge' ); + const SoundClip = require( 'TAMBO/sound-generators/SoundClip' ); + + // constants + const FADE_START_DELAY = 0.2; // in seconds, time to wait before starting fade + const FADE_TIME = 0.15; // in seconds, duration of fade out + const DELAY_BEFORE_STOP = 0.1; // in seconds, amount of time from full fade to stop of sound, done to avoid glitches + const PITCH_RANGE_IN_SEMI_TONES = 36; + const PITCH_CENTER_OFFSET = 2; + + // sounds + + // The saturated sine loop is precisely optimized for good looping, which is why it is a .wav and not a .mp3 file + const forceSound = require( 'sound!GRAVITY_FORCE_LAB/saturated-sine-loop-trimmed.wav' ); + + class ForceSoundGenerator extends SoundClip { + + /** + * @param {GFLBModel} model + * @param {BooleanProperty} resetInProgressProperty + * @param {Object} [options] + * @constructor + */ + constructor( model, options ) { + + options = merge( { + initialOutputLevel: 0.7, + loop: true, + trimSilence: false + }, options ); + + // options checking + assert && assert( !options || !options.loop || options.loop === true, 'must be a loop to work correctly' ); + + super( forceSound, options ); + + // @private {number} - the output level before fade out starts + this.nonFadedOutputLevel = options.initialOutputLevel; + + // @private {number} - countdown time used for fade out + this.fadeCountdownTime = 0; + + // start with the output level at zero so that the initial sound generation has a bit of fade in + this.setOutputLevel( 0, 0 ); + + // function for starting the force sound or adjusting the volume + const forceListener = force => { + + if ( !model.resetInProgressProperty.value ) { + + // calculate the playback rate based on the amount of force, see the design document for detailed explanation + const normalizedForce = Math.log( force / model.getMinForce() ) / Math.log( model.getMaxForce() / model.getMinForce() ); + const centerForce = normalizedForce - 0.5; + const midiNote = PITCH_RANGE_IN_SEMI_TONES / 2 * centerForce + PITCH_CENTER_OFFSET; + const playbackRate = Math.pow( 2, midiNote / 12 ); + + this.setPlaybackRate( playbackRate ); + this.setOutputLevel( this.nonFadedOutputLevel ); + if ( !this.playing ) { + this.play(); + } + + // reset the fade countdown + this.fadeCountdownTime = FADE_START_DELAY + FADE_TIME + DELAY_BEFORE_STOP; + } + }; + model.forceProperty.lazyLink( forceListener ); + + // @private {function} + this.disposeForceSoundGenerator = () => { model.forceProperty.unlink( forceListener ); }; + } + + /** + * @public + */ + dispose() { + this.disposeForceSoundGenerator(); + super.dispose(); + } + + /** + * step this sound generator, used for fading out the sound in the absence of user interaction + * @param dt + */ + step( dt ) { + if ( this.fadeCountdownTime > 0 ) { + this.fadeCountdownTime = Math.max( this.fadeCountdownTime - dt, 0 ); + + if ( this.fadeCountdownTime < FADE_TIME + DELAY_BEFORE_STOP && this.outputLevel > 0 ) { + + // the sound is fading out, adjust the output level + const outputLevel = Math.max( + ( this.fadeCountdownTime - DELAY_BEFORE_STOP ) / FADE_TIME * this.nonFadedOutputLevel, + 0 + ); + this.setOutputLevel( outputLevel ); + } + + // fade out complete, stop playback + if ( this.fadeCountdownTime === 0 && this.isPlaying ) { + this.stop( 0 ); + } + } + } + + /** + * stop any in-progress sound generation + * @public + */ + reset() { + this.stop(); + this.fadeCountdownTime = 0; + } + } + + gravityForceLab.register( 'ForceSoundGenerator', ForceSoundGenerator ); + + return ForceSoundGenerator; +} ); \ No newline at end of file diff --git a/js/gravity-force-lab/view/MassSoundGenerator.js b/js/gravity-force-lab/view/MassSoundGenerator.js new file mode 100644 index 00000000..6b75ce6a --- /dev/null +++ b/js/gravity-force-lab/view/MassSoundGenerator.js @@ -0,0 +1,72 @@ +// Copyright 2019, University of Colorado Boulder + +/** + * sound generator for mass changes + * + * @author John Blanco (PhET Interactive Simulations) + */ +define( require => { + 'use strict'; + + // modules + const SoundClip = require( 'TAMBO/sound-generators/SoundClip' ); + const gravityForceLab = require( 'GRAVITY_FORCE_LAB/gravityForceLab' ); + + // sounds + const massSound = require( 'sound!GRAVITY_FORCE_LAB/rubber-band-v3.mp3' ); + + // constants + const PITCH_RANGE_IN_SEMI_TONES = 30; + + class MassSoundGenerator extends SoundClip { + + /** + * @param {NumberProperty} massProperty + * @param {Range} massRange + * @param {BooleanProperty} resetInProgressProperty + * @param {Object} [options] + * @constructor + */ + constructor( massProperty, massRange, resetInProgressProperty, options ) { + + // Rate changes should never affect the mass sound that is already playing. + options.rateChangesAffectPlayingSounds = false; + + super( massSound, options ); + + // function for playing the mass sound + const massListener = mass => { + + // range checking + assert && assert( massRange.contains( mass ), 'mass value out of range' ); + + if ( !resetInProgressProperty.value ) { + + // convert the mass to a playback rate, see the design document for an explanation + const normalizedMass = ( mass - massRange.min ) / ( massRange.max - massRange.min ); + const centerAndFlippedNormMass = ( ( 1 - normalizedMass ) - 0.5 ); + const midiNote = PITCH_RANGE_IN_SEMI_TONES / 2 * centerAndFlippedNormMass; + const playbackSpeed = Math.pow( 2, midiNote / 12 ); + this.setPlaybackRate( playbackSpeed ); + this.play(); + } + }; + massProperty.lazyLink( massListener ); + + // @private {function} + this.disposeMassSoundGenerator = () => { massProperty.unlink( massListener ); }; + } + + /** + * @public + */ + dispose() { + this.disposeMassSoundGenerator(); + super.dispose(); + } + } + + gravityForceLab.register( 'MassSoundGenerator', MassSoundGenerator ); + + return MassSoundGenerator; +} ); \ No newline at end of file diff --git a/sounds/license.json b/sounds/license.json new file mode 100644 index 00000000..3737971f --- /dev/null +++ b/sounds/license.json @@ -0,0 +1,26 @@ +{ + "rubber-band-v3.mp3": { + "text": [ + "Copyright 2002-2019 University of Colorado Boulder" + ], + "projectURL": "http://phet.colorado.edu", + "license": "contact phethelp@colorado.edu", + "notes": "created by Ashton Morris (PhET Interactive Simulations)" + }, + "saturated-sine-loop-trimmed.wav": { + "text": [ + "Copyright 2002-2019 University of Colorado Boulder" + ], + "projectURL": "http://phet.colorado.edu", + "license": "contact phethelp@colorado.edu", + "notes": "created by Ashton Morris (PhET Interactive Simulations), edited by John Blanco" + }, + "scrunched-mass-collision-sonic-womp.mp3": { + "text": [ + "Copyright 2002-2019 University of Colorado Boulder" + ], + "projectURL": "http://phet.colorado.edu", + "license": "contact phethelp@colorado.edu", + "notes": "created by Ashton Morris (PhET Interactive Simulations)" + } +} \ No newline at end of file diff --git a/sounds/rubber-band-v3.mp3 b/sounds/rubber-band-v3.mp3 new file mode 100644 index 00000000..172ae6ee Binary files /dev/null and b/sounds/rubber-band-v3.mp3 differ diff --git a/sounds/saturated-sine-loop-trimmed.wav b/sounds/saturated-sine-loop-trimmed.wav new file mode 100644 index 00000000..3d1fad74 Binary files /dev/null and b/sounds/saturated-sine-loop-trimmed.wav differ diff --git a/sounds/scrunched-mass-collision-sonic-womp.mp3 b/sounds/scrunched-mass-collision-sonic-womp.mp3 new file mode 100644 index 00000000..ff122ad6 Binary files /dev/null and b/sounds/scrunched-mass-collision-sonic-womp.mp3 differ