import '../../../assert/js/assert.js';
import '../../../globals.js';
// Copyright 2018-2024, University of Colorado Boulder
/**
 * 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 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 optionize from '../../../phet-core/js/optionize.js';
// constants
const MAX_PLAY_DEFER_TIME = 0.2; // seconds, max time to defer a play request while waiting for audio context state change
const DEFAULT_TC = soundConstants.DEFAULT_PARAM_CHANGE_TIME_CONSTANT;
const DEFAULT_STOP_DELAY = 0.1;
class SoundClip extends SoundGenerator {
    constructor(wrappedAudioBuffer, providedOptions) {
        const options = optionize()({
            loop: false,
            trimSilence: true,
            initialPlaybackRate: 1,
            initiateWhenDisabled: false,
            rateChangesAffectPlayingSounds: true
        }, providedOptions);
        super(options);
        // initialize local state
        this.wrappedAudioBuffer = wrappedAudioBuffer;
        this.loop = options.loop;
        this.rateChangesAffectPlayingSounds = options.rateChangesAffectPlayingSounds;
        this.initiateWhenDisabled = options.initiateWhenDisabled;
        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) => {
                if (audioBuffer) {
                    const loopBoundsInfo = SoundUtils.detectSoundBounds(audioBuffer);
                    this.soundStart = loopBoundsInfo.soundStart;
                    this.soundEnd = loopBoundsInfo.soundEnd;
                    this.wrappedAudioBuffer.audioBufferProperty.unlink(setStartAndEndPoints);
                }
            };
            this.wrappedAudioBuffer.audioBufferProperty.link(setStartAndEndPoints);
        }
        this.activeBufferSources = [];
        this.localGainNode = this.audioContext.createGain();
        this.localGainNode.connect(this.soundSourceDestination);
        this._playbackRate = options.initialPlaybackRate;
        this.isPlayingProperty = new BooleanProperty(false);
        this.timeOfDeferredPlayRequest = Number.NEGATIVE_INFINITY;
        // callback for when audio context isn't in 'running' state, see usage
        this.audioContextStateChangeListener = state => {
            if (state === 'running') {
                // initiate deferred play if this is a loop or if it hasn't been too long since the request was made
                if (this.loop || (Date.now() - this.timeOfDeferredPlayRequest) / 1000 < MAX_PLAY_DEFER_TIME) {
                    // Play the sound, but with a little bit of delay.  The delay was found to be needed because otherwise on
                    // some browsers the sound would be somewhat muted, probably due to some sort of fade in of the audio levels
                    // that the browser does automatically to avoid having the web page's sound start too abruptly.  The amount of
                    // delay was empirically determined by testing on multiple browsers.
                    this.play(0.1);
                }
                // automatically remove after firing
                audioContextStateChangeMonitor.removeStateChangeListener(this.audioContext, this.audioContextStateChangeListener);
            }
        };
        // Listen to the Property that indicates whether we are fully enabled and stop one-shot sounds when it goes false.
        this.fullyEnabledProperty.lazyLink(fullyEnabled => {
            if (!this.loop && !fullyEnabled) {
                this.stop();
            }
        });
        // Clean up memory references when this object is disposed to avoid memory leaks.
        this.disposeEmitter.addListener(() => {
            if (audioContextStateChangeMonitor.hasListener(this.audioContext, this.audioContextStateChangeListener)) {
                audioContextStateChangeMonitor.removeStateChangeListener(this.audioContext, this.audioContextStateChangeListener);
            }
        });
    }
    /**
     * Start playing the sound.
     */
    play(delay = 0) {
        if (this.audioContext.state === 'running' && this.wrappedAudioBuffer.audioBufferProperty.value) {
            const now = this.audioContext.currentTime;
            if ((this.loop && !this.isPlayingProperty.get()) ||
                (!this.loop && (this.fullyEnabled || this.initiateWhenDisabled))) {
                // create an audio buffer source node that uses the previously decoded audio data
                const bufferSource = this.audioContext.createBufferSource();
                bufferSource.buffer = this.wrappedAudioBuffer.audioBufferProperty.value;
                bufferSource.loop = this.loop;
                bufferSource.loopStart = this.soundStart;
                if (this.soundEnd) {
                    bufferSource.loopEnd = this.soundEnd;
                }
                // make sure the local gain is set to unity value
                this.localGainNode.gain.cancelScheduledValues(now);
                this.localGainNode.gain.setValueAtTime(1, now);
                bufferSource.connect(this.soundSourceDestination);
                // add this to the list of active sources so that it can be stopped if necessary
                this.activeBufferSources.push(bufferSource);
                if (!this.loop) {
                    // add a handler for when the sound finishes playing
                    bufferSource.onended = () => {
                        // remove the source from the list of active sources
                        const indexOfSource = this.activeBufferSources.indexOf(bufferSource);
                        if (indexOfSource > -1) {
                            this.activeBufferSources.splice(indexOfSource, 1);
                        }
                        this.isPlayingProperty.value = this.activeBufferSources.length > 0;
                    };
                }
                // set the playback rate and start playback
                bufferSource.playbackRate.setValueAtTime(this._playbackRate, now);
                bufferSource.start(now + delay, this.soundStart);
                this.isPlayingProperty.value = true;
            }
        }
        else if (this.audioContext.state === 'suspended') {
            // The play method was called when the audio context was not yet running, so add a listener to play if and when
            // the audio context state changes.  This will start any loops, and will also play a one-shot sound if the time
            // between the request and the state change isn't too great.  Note that this does NOT queue up more than one
            // individual sound to be played.
            this.timeOfDeferredPlayRequest = Date.now();
            if (!audioContextStateChangeMonitor.hasListener(this.audioContext, this.audioContextStateChangeListener)) {
                audioContextStateChangeMonitor.addStateChangeListener(this.audioContext, this.audioContextStateChangeListener);
            }
        }
    }
    /**
     * 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 [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.
     */
    stop(delay = 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.
        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
        // down the gain, effectively doing a fade out, and then stopping playback.
        const now = this.audioContext.currentTime;
        const stopTime = now + delay;
        this.localGainNode.gain.cancelScheduledValues(now);
        this.localGainNode.gain.setTargetAtTime(0, now, fadeTimeConstant);
        this.activeBufferSources.forEach(source => { source.stop(stopTime); });
        // The WebAudio spec is a bit unclear about whether stopping a sound will trigger an onended event.  In testing
        // on Chrome in September 2018, I (jbphet) found that onended was NOT being fired when stop() was called, so the
        // code below is needed to clear the array of all active buffer sources.
        this.activeBufferSources.length = 0;
        // clear the flag
        this.isPlayingProperty.value = false;
        if (audioContextStateChangeMonitor.hasListener(this.audioContext, this.audioContextStateChangeListener)) {
            // remove the state change listener that was going to do a deferred play, since the sound has now been stopped
            audioContextStateChangeMonitor.removeStateChangeListener(this.audioContext, this.audioContextStateChangeListener);
        }
    }
    /**
     * 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) {
        assert && assert(playbackRate > 0, 'invalid playback rate: ' + playbackRate);
        if (this.rateChangesAffectPlayingSounds) {
            const now = this.audioContext.currentTime;
            this.activeBufferSources.forEach(bufferSource => {
                bufferSource.playbackRate.cancelScheduledValues(now);
                bufferSource.playbackRate.setTargetAtTime(playbackRate, now, timeConstant);
            });
        }
        this._playbackRate = playbackRate;
    }
    /**
     * 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.
     */
    getPlaybackRate() {
        return this._playbackRate;
    }
    /**
     * ES5 getter for playback rate
     */
    get playbackRate() {
        return this.getPlaybackRate();
    }
    /**
     * Get a value that indicates whether sound is currently being played.
     */
    get isPlaying() {
        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.
     */
    getNumberOfPlayingInstances() {
        return this.activeBufferSources.length;
    }
}
tambo.register('SoundClip', SoundClip);
export default SoundClip;
//# sourceMappingURL=SoundClip.js.map