Repository

js/Mobilizing/renderer/audio/Source.js

import Component from '../../core/Component';
import Renderer from './Renderer';

/**
Source Class. Encapsulates the concept of Audio source, a sound-emitting entity with a given position in space. requires the Web Audio API.

The webAudio graph used here is :
AudioBufferSourceNode -> GainNode -> MasterGainNode (a GainNode inside the renderer).

if the property "is3D" is set to "true" then it is :
AudioBufferSourceNode -> GainNode -> PannerNode -> MasterGainNode (a GainNode inside the renderer).
*/

const EVT_PLAY_ENDED = "ended";

export default class Source extends Component {
    /**
    * @param {Object} params Parameters object, given by the constructor.
    * @param {Renderer} params.renderer the audio renderer
    * @param {bool} params.is3D is the audio source positionned in 3D ?
    * @param {bool} params.loop should the source be played in loop ?
    * @param {bool} params.autoUpdateRotation should the orientation of the source be updated automatically ?
    */
    constructor({
        renderer = new Renderer(),
        is3D = false,
        loop = false,
        autoUpdateRotation = false,
        mediaElement = undefined
    } = {}) {

        super(...arguments);

        this.renderer = renderer;
        this.is3D = is3D;
        this.loop = loop;
        this.autoUpdateRotation = autoUpdateRotation;

        this.mediaElement = mediaElement;

        this.duration = undefined;
        this.startedTime = 0; //for current time calculation
        this.currentTime = 0;
        this.playbackRate = 1; //reading speed
        this.offsetStartTime = 0; //startTime inside the buffer
        this.scheduleStartTime = 0; //when to start the sound playing (in audio rendrer time coordinates)
        this.loopCount = 0;

        if (typeof this.renderer.audioContext !== "undefined") {

            //panner Node to place sound in 3D space
            this.panner = this.renderer.audioContext.createPanner();
            this.panner.panningModel = "HRTF";
            this.panner.distanceModel = "inverse";//linear inverse exponential
            this.panner.refDistance = 1;
            this.panner.maxDistance = 10000;
            this.panner.rolloffFactor = 1;
            this.panner.coneInnerAngle = 360;
            this.panner.coneOuterAngle = 0;
            this.panner.coneOuterGain = 0;

            //this sound gain, for per-sound volume control
            this.gain = this.renderer.audioContext.createGain();
            //console.log(this.gain);

            if (this.is3D) {
                this.gain.connect(this.panner);
                this.panner.connect(this.renderer.masterGain);
            }
            else {
                this.gain.connect(this.renderer.masterGain);
            }
        }

        this.setGain(0.9);
    }

    /**
     * 
     * @param {MediaStream} stream the MediaStream from which to create this AudioSource
     * @param {boolean=true} autoConnect if false is given, this source' gain will be disconnected from the destination (renderer masterGain node). Usefull for mic stream analysis that don't need to hear but need to connect to the analyser
     */
    createFromMediaStream(stream, autoConnect) {
        const mediaStreamSource = this.renderer.audioContext.createMediaStreamSource(stream);
        this.nativeAudioSource = mediaStreamSource;
        this.nativeAudioSource.connect(this.gain);

        if (!autoConnect) {
            this.gain.disconnect();
        }
    }

    generateAudioBufferSource() {

        if (this.mediaElement) {
            if (!this.nativeAudioSource) {
                this.nativeAudioSource = this.renderer.audioContext.createMediaElementSource(this.mediaElement);
            }
            //media Element have already all the infos inside!
            this.duration = this.mediaElement.duration;
            this.nativeAudioSource.loop = this.loop;
            this.nativeAudioSource.playbackRate.value = this.playbackRate;
        }
        else {
            this.nativeAudioSource = this.renderer.audioContext.createBufferSource();
            this.nativeAudioSource.loop = this.loop;
            this.nativeAudioSource.playbackRate.value = this.playbackRate;
            this.nativeAudioSource.buffer = this.buffer.getNativeBuffer();
            //we get the duration from the buffer
            this.duration = this.buffer.getNativeBuffer().duration;
            //console.log(this.buffer, this.nativeAudioSource);
        }

        this.nativeAudioSource.connect(this.gain);
    }

    /**
     * If you need to connect WebAudio AudioNode by yourself, use this method with the access to the WebAudio Node given by getNativeSource()
     * @param {AudioNode} dest the destination to connect this source to. If this param is undefined, the connection will be made automatically with the renderer's masterGain
     */
    connect(dest) {
        if (dest) {
            this.gain.connect(dest);
        }
        else {
            this.gain.connect(this.renderer.masterGain);
        }
    }

    /**
     * Disconnects this source from it current destination
     */
    disconnect() {
        this.gain.disconnect();
    }

    /**
    * Return the underlying WebAudio audio source
    * @return {AudioBufferSourceNode} webAudio source
    */
    getNativeSource() {
        return this.nativeAudioSource;
    }

    /**
    set the current Source buffer
    @param {Object} buffer the AudioBuffer we want the source to play.
    */
    setBuffer(buffer) {
        this.buffer = buffer;
    }

    /**
     * Set this source's gain volume, use a 1 sec linear ramp to avoid clicks
     * @param {Number} gain
     * @param {Number} rampValue in seconds
     */
    setGain(gainValue, rampValue) {

        let value = gainValue;

        if (gainValue === 0) {
            value = 0.001;
        }

        this.gain.gain.linearRampToValueAtTime(value, this.renderer.getCurrentTime() + rampValue);
        //this.gain.gain.value = val;
    }

    /**
     * Get this audioContext's gain volume
     * @param {Number} val 
     */
    getGain() {
        return this.gain.gain.value;
    }

    /**
     * Set this source playback rate (speed)
     * @param {Number} val 
     */
    setPlaybackRate(val) {
        this.playbackRate = val;
        this.nativeAudioSource.playbackRate.value = this.playbackRate;
    }

    /**
     * Get this source playback rate (speed)
     * @return {Number}
     */
    getPlaybackRate() {
        return this.playbackRate;
    }

    /**
    * Sets the reference distance for sound attenuation through space
    * @param {Number} val
    */
    setRefDistance(val) {
        this.panner.refDistance = val;
    }

    /**
    * Sets the maximum distance for sound propagation in space
    * @param {Number} val
    */
    setMaxDistance(val) {
        this.panner.maxDistance = val;
    }

    /**
    * set 3D propagation
    * @param {Boolean} enabled
    */
    /* set3D(enabled) {
        this.is3D = enabled;
    } */

    /**
    set the current Transform that represents the Source position in space.
    @param {Object} transform the Transform.
    */
    setTransform(transform) {
        this.transform = transform;
    }

    /**
    * set this source loop
    * @param {Boolean} enabled
    */
    setLoop(val) {
        this.loop = val;
        this.nativeAudioSource.loop = this.loop;
    }

    /**
    * get this source loop
    * @return {Boolean} enabled
    */
     getLoop() {
        return this.loop;
    }

    /**
     * 
     * @param {Number} angle 
     */
    setConeInnerAngle(angle) {
        this.panner.coneInnerAngle = angle;
    }

    /**
     * 
     * @param {Number} angle 
     */
    setConeOuterAngle(angle) {
        this.panner.coneOuterAngle = angle;
    }

    /**
     * 
     * @param {Number} val 
     */
    setConeOuterGain(val) {
        this.panner.coneOuterGain = val;
    }

    /**
     * 
     * @param {Boolean} val 
     */
    setAutoUpdateRotation(val) {
        this.autoUpdateRotation = val;
    }

    /**
     * Sets the offset play of this sound
     * @param {Number} val the time to start to play the sound buffer
     */
    setOffsetStartTime(val) {
        this.offsetStartTime = val;
    }

    /**
     * Gets the offset play of this sound
     * @return {Number} the time to start to play the sound buffer
     */
    getOffsetStartTime() {
        return this.offsetStartTime;
    }

    /**
     * Sets the start time play of this sound in current rendrer time
     * @param {Number} val the time to start to play the sound buffer
     */
    setScheduleStartTime(val) {
        this.scheduleStartTime = val;
    }

    /**
     * Gets the offset play of this sound
     * @return {Number} the time to start to play the sound buffer
     */
    getScheduleStartTime() {
        return this.scheduleStartTime;
    }

    on() {
        super.on();
        //this.play();
    }

    off() {
        super.off();
        this.stop();
    }

    /**
     * Play this sound source
     */
    play() {

        this.startedTime = this.renderer.audioContext.currentTime;

        if (this.nativeAudioSource) {
            this.stop();
        }
        this.generateAudioBufferSource();

        this.playing = true;

        if (!this.mediaElement) {
            this.nativeAudioSource.start(this.scheduleStartTime, this.offsetStartTime);
        }
        else {
            this.mediaElement.play();
        }

    }

    offlinePlay() {
        this.nativeAudioSource.start(this.scheduleStartTime, this.offsetStartTime);
    }

    /**
    pauses the Source.
    */
    pause() {
        if (this.nativeAudioSource) {
            this.playing = false;
        }
    }

    /**
    stops the Source.
    */
    stop() {
        if (this.nativeAudioSource) {
            if (!this.mediaElement) {
                this.nativeAudioSource.stop(0);
                delete this.nativeAudioSource;
            }
            else {
                this.mediaElement.pause();
                this.mediaElement.currentTime = 0;
            }
            this.playing = false;
        }
    }

    /**
    * Updates the source's position in space if given from a transform
    */
    update() {

        if (!this.mediaElement) {
            //calculate the currentTime of this sound
            this.currentTime = this.renderer.audioContext.currentTime - this.startedTime;
        }
        else {
            this.currentTime = this.mediaElement.currentTime;
        }

        if (this.currentTime > this.duration) {
            this.startedTime = this.renderer.audioContext.currentTime;
            this.loopCount++;
             //send an event when files is over or looping
            this.events.trigger(EVT_PLAY_ENDED, this.loopCount);
        }

        if (this.transform) {
            const pos = this.transform.getLocalPosition();
            this.panner.setPosition(pos.x, pos.y, pos.z);

            if (this.autoUpdateRotation) {
                const quat = this.transform.getLocalQuaternion();
                const directions = quat.getDirections();

                //console.log("directions.forward", directions.forwardVector);

                if (this.panner.orientationX) {
                    this.panner.orientationX.value = directions.forwardVector.x;
                    this.panner.orientationY.value = directions.forwardVector.y;
                    this.panner.orientationZ.value = directions.forwardVector.z;
                }
                else {
                    this.panner.setOrientation(directions.forwardVector.x, directions.forwardVector.y, directions.forwardVector.z);
                }
            }
        }
    }
}