Skip to content

Commit

Permalink
converted SoundClip to TS, see #160
Browse files Browse the repository at this point in the history
  • Loading branch information
jbphet committed Mar 25, 2022
1 parent cbbe449 commit 4ec062e
Show file tree
Hide file tree
Showing 2 changed files with 98 additions and 81 deletions.
8 changes: 7 additions & 1 deletion js/SoundUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ import tambo from './tambo.js';
// forever (one can only hope). See https://github.com/phetsims/tambo/issues/35.
const AUDIO_DATA_THRESHOLD = 0.05;

/**
* @typedef {Object} SoundBounds
* @property {number} soundStart
* @property {number|null} soundEnd
*/

/**
* sound utility object definition
*/
Expand All @@ -21,7 +27,7 @@ const SoundUtils = {
* helper function that sets the start and end points for a decoded set of sound samples based on where the sound
* data first and last exceeds a certain threshold
* {AudioBuffer} audioBuffer
* @returns {Object} - an object with values for the time at which the sound starts and ends
* @returns {SoundBounds} - an object with values for the time at which the sound starts and ends
*/
detectSoundBounds: audioBuffer => {

Expand Down
171 changes: 91 additions & 80 deletions js/sound-generators/SoundClip.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,46 @@
// Copyright 2018-2022, University of Colorado Boulder

// @ts-nocheck

/**
* A sound generator that plays pre-recorded sounds, either as a one-shot or as a loop.
*
* @author John Blanco (PhET Interactive Simulations)
*/

import BooleanProperty from '../../../axon/js/BooleanProperty.js';
import merge from '../../../phet-core/js/merge.js';
import audioContextStateChangeMonitor from '../audioContextStateChangeMonitor.js';
import soundConstants from '../soundConstants.js';
import SoundUtils from '../SoundUtils.js';
import tambo from '../tambo.js';
import SoundGenerator from './SoundGenerator.js';
import SoundGenerator, { SoundGeneratorOptions } from './SoundGenerator.js';
import WrappedAudioBuffer from '../WrappedAudioBuffer.js';
import optionize from '../../../phet-core/js/optionize.js';

type SelfOptions = {

// controls whether this sound will wrap around and start over when done or just be played once
loop?: boolean;

// controls whether the silence at the beginning and (in the case of loops) the end is omitted when sound is played
trimSilence?: boolean;

// Initial playback rate for this clip. This value is a multiplier, so 1 is the nominal playback rate, 0.5 is half
// speed (or an octave lower in musical terms) and a value of 2 is twice normal speed (or an octave higher in musical
// terms). The playback rate can be changed after construction via the API.
initialPlaybackRate?: number;

// Controls whether sound generation can be initiated when this sound generator is disabled. This is useful for a
// one-shot sound that is long, so if the user does something that generally would cause a sound, but sound is
// disabled, but then they immediately re-enable sound, the "tail" of this sound would be heard. This option is
// ignored for loops, since loops always allow initiation when disabled.
initiateWhenDisabled?: boolean;

// Controls whether changes to the playback rate via the API causes changes to the sounds that are already being
// played as opposed to only sounds that are started after the playback rate is changed. This is relevant for both
// loops and one-shot sounds, since a one-shot sound (especially one that is fairly long) could be in the process of
// playing when a playback rate change occurs.
rateChangesAffectPlayingSounds?: boolean;
};
export type SoundClipOptions = SelfOptions & SoundGeneratorOptions;

// constants
const MAX_PLAY_DEFER_TIME = 0.2; // seconds, max time to defer a play request while waiting for audio context state change
Expand All @@ -23,63 +49,72 @@ const DEFAULT_STOP_DELAY = 0.1;

class SoundClip extends SoundGenerator {

/**
* @param {WrappedAudioBuffer} wrappedAudioBuffer
* @param {Object} [options]
* @constructor
*/
constructor( wrappedAudioBuffer, options ) {
// an object containing the audio buffer and flag that indicates readiness, i.e. whether it is fully loaded
private readonly wrappedAudioBuffer: WrappedAudioBuffer;

options = merge( {
// flag that controls whether this is a one-shot or loop sound
public readonly loop: boolean;

// {boolean} - controls whether this sound will wrap around and start over when done or just be played once
loop: false,
// flag that controls whether changes to the playback rate affects in-progress sounds
private readonly rateChangesAffectPlayingSounds: boolean;

// {boolean} - controls whether the silence at the beginning and (in the case of loops) the end is omitted
trimSilence: true,
// Controls whether this clip can be initiated when it is disabled, see description in options type definition above.
// This is part of the API and can be changed if needed, though such a need is generally quite rare.
public initiateWhenDisabled: boolean;

// {boolean} - Playback rate for this clip, can be changed after construction via API. This value is a
// multiplier, so 1 is the nominal playback rate, 0.5 is half speed (or an octave lower in musical terms) and a
// value of 2 is twice normal speed (or an octave higher in musical terms).
initialPlaybackRate: 1,
// start point for playback of the sound data
private soundStart: number;

// {boolean} - controls whether sound generation can be initiated when this sound generator is disabled. This is
// useful for a one-shot sound that is long, so if the user does something that generally would cause a sound, but
// sound is disabled, but they immediately re-enable it, the "tail" of this sound would be heard. This option is
// ignored for loops, since loops always allow initiation when disabled.
initiateWhenDisabled: false,
// stop or wrap around point for playback of the sound data
private soundEnd: number | null;

// {boolean} - controls whether changes to the playback rate via the API causes changes to the sounds that are
// already in the process of playing or only those that are played in the future. This is relevant for both loops
// and one-shot sounds, since a one-shot sound (especially one that is fairly long) could be in the process of
// playing when a playback rate change occurs.
rateChangesAffectPlayingSounds: true
// A list of active source buffer nodes, used so that this clip can be played multiple times without each initiation
// interfering with the other.
private readonly activeBufferSources: AudioBufferSourceNode[];

// a gain node that is used to prevent clicks when stopping the sound
private readonly localGainNode: GainNode;

// The rate at which clip is being played back, 1 is normal, above 1 is faster, below 1 is slower. See online docs
// for AudioBufferSourceNode.playbackRate for more information.
private _playbackRate: number;

}, options );
// indicates whether the sound is being played
private readonly isPlayingProperty: BooleanProperty;

// time at which a deferred play request occurred, in milliseconds since epoch
private timeOfDeferredPlayRequest: number;

// callback for when audio context isn't in 'running' state, see usage
private readonly audioContextStateChangeListener: ( state: string ) => void;

constructor( wrappedAudioBuffer: WrappedAudioBuffer, providedOptions?: SoundClipOptions ) {

const options = optionize<SoundClipOptions, SelfOptions, SoundGeneratorOptions>( {
loop: false,
trimSilence: true,
initialPlaybackRate: 1,
initiateWhenDisabled: false,
rateChangesAffectPlayingSounds: true
}, providedOptions );

super( options );

// @private {WrappedAudioBuffer} - an object containing the audio buffer and flag that indicates readiness
// initialize local state
this.wrappedAudioBuffer = wrappedAudioBuffer;

// @private {boolean} - flag that controls whether this is a one-shot or loop sound
this.loop = options.loop;

// @private {boolean} - flag that controls whether changes to the playback rate affects in-progress sounds
this.rateChangesAffectPlayingSounds = options.rateChangesAffectPlayingSounds;

// @public {boolean} - see description in options above
this.initiateWhenDisabled = options.initiateWhenDisabled;

// @private {number} - start and end points for playback of the sound data
this.soundStart = 0;
this.soundEnd = null;
if ( options.trimSilence ) {

// For sounds that are created statically during the module load phase this listener will interpret the audio
// data once the load of that data has completed. For all sounds constructed after the module load phase has
// completed, this will process right away.
const setStartAndEndPoints = audioBuffer => {
// TODO: (for and from @jbphet) - Review the 'any' typespec below with a developer who understands this better, see https://github.com/phetsims/tambo/issues/160
// const setStartAndEndPoints = ( audioBuffer: AudioBuffer ) => {
const setStartAndEndPoints = ( audioBuffer: any ) => {
if ( audioBuffer ) {
const loopBoundsInfo = SoundUtils.detectSoundBounds( audioBuffer );
this.soundStart = loopBoundsInfo.soundStart;
Expand All @@ -89,26 +124,14 @@ class SoundClip extends SoundGenerator {
};
this.wrappedAudioBuffer.audioBufferProperty.link( setStartAndEndPoints );
}

// @private {AudioBufferSourceNode[]} - a list of active source buffer nodes, used so that this clip can be played
// multiple times without each initiation interfering with the other
this.activeBufferSources = [];

// @private {GainNode} - a gain node that is used to prevent clicks when stopping the sound
this.localGainNode = this.audioContext.createGain();
this.localGainNode.connect( this.soundSourceDestination );

// @private {number} - rate at which clip is being played back, 1 is normal, above 1 is faster, below 1 is slower,
// see online docs for AudioBufferSourceNode.playbackRate for more information
this._playbackRate = options.initialPlaybackRate;

// @public (read-only) - BooleanProperty that indicates whether the sound is being played
this.isPlayingProperty = new BooleanProperty( false );

// @private {number} - time at which a deferred play request occurred, in milliseconds since epoch
this.timeOfDeferredPlayRequest = Number.NEGATIVE_INFINITY;

// @private {function} - callback for when audio context isn't in 'running' state, see usage
// callback for when audio context isn't in 'running' state, see usage
this.audioContextStateChangeListener = state => {

if ( state === 'running' ) {
Expand Down Expand Up @@ -140,11 +163,9 @@ class SoundClip extends SoundGenerator {
}

/**
* start playing the sound
* @param {number} [delay] - optional delay parameter, in seconds
* @public
* Start playing the sound.
*/
play( delay = 0 ) {
public play( delay: number = 0 ) {

if ( this.audioContext.state === 'running' && this.wrappedAudioBuffer.audioBufferProperty.value ) {

Expand Down Expand Up @@ -208,20 +229,19 @@ class SoundClip extends SoundGenerator {
}

/**
* stop playing the sound
* Stop playing the sound.
*
* Note: Doing rapid stops and starts of a loop using this method can cause sound glitches. If you have a need to
* do that, use volume fades combined with zero delay stops.
*
* @param {number} [delay] - The amount of time to wait before stopping, generally used to prevent sudden stops, which can
* cause audible clicks. If greater than zero (which it is by default), this method will try to fade out the sound
* @param [delay] - The amount of time to wait before stopping, generally used to prevent sudden stops, which can
* cause audible clicks. If greater than zero, which it is by default, this method will try to fade out the sound
* fully prior to stopping the audio playback.
* @public
*/
stop( delay = DEFAULT_STOP_DELAY ) {
public stop( delay: number = DEFAULT_STOP_DELAY ) {

// Calculate a time constant to fade output level by 99% by the stop time, see Web Audio time constant
// information to understand this calculation.
// Calculate a time constant to fade output level by 99% by the stop time, see Web Audio time constant information
// to understand this calculation.
const fadeTimeConstant = delay > 0 ? delay / 4.61 : soundConstants.DEFAULT_PARAM_CHANGE_TIME_CONSTANT;

// Simply calling stop() on the buffer source frequently causes an audible click, so we use a gain node and turn
Expand Down Expand Up @@ -251,13 +271,9 @@ class SoundClip extends SoundGenerator {
}

/**
* play sound and change the speed as playback occurs
* @param {number} playbackRate - desired playback speed, 1 = normal speed
* @param {number} [timeConstant] - time-constant in seconds for the first-order filter (exponential) approach to
* the target value. The larger this value is, the slower the transition will be.
* @public
* Set the playback rate. Based on the way this SoundClip was created, this may or may not affect in-progress sounds.
*/
setPlaybackRate( playbackRate, timeConstant = DEFAULT_TC ) {
public setPlaybackRate( playbackRate: number, timeConstant: number = DEFAULT_TC ) {
assert && assert( playbackRate > 0 );
if ( this.rateChangesAffectPlayingSounds ) {
const now = this.audioContext.currentTime;
Expand All @@ -272,10 +288,8 @@ class SoundClip extends SoundGenerator {
/**
* Get the current playback rate. Note that it is possible that there are audio buffers that are playing that are not
* playing at the returned rate if the rate was recently changed.
* @returns {number}
* @public
*/
getPlaybackRate() {
public getPlaybackRate(): number {
return this._playbackRate;
}

Expand All @@ -284,26 +298,23 @@ class SoundClip extends SoundGenerator {
* @returns {number}
* @public
*/
get playbackRate() {
public get playbackRate(): number {
return this.getPlaybackRate();
}

/**
* indicates whether sound is currently being played
* @returns {boolean}
* @public
* Get a value that indicates whether sound is currently being played.
*/
get isPlaying() {
public get isPlaying(): boolean {
return this.isPlayingProperty.value;
}

/**
* Get the number of instances of the audio buffer that are currently playing. This can be greater than one because
* SoundClip supports multiple buffers playing at the same time. This method is generally used to limit the number
* of instances that are playing at the same time.
* @public
*/
getNumberOfPlayingInstances() {
public getNumberOfPlayingInstances() {
return this.activeBufferSources.length;
}
}
Expand Down

0 comments on commit 4ec062e

Please sign in to comment.