import { ResourceManager } from "./resources" ; import { Util } from "./util" ; import { state } from "./state" ; import { TimeState } from "./level" ; import { StorageManager } from "./storage" ; import { Vector3 } from "./math/vector3" ; /** A class used as an utility for sound playback. */ export abstract class AudioManager { static context : AudioContext ; static masterGain : GainNode ; static soundGain : GainNode ; static musicGain : GainNode ; static assetPath : string ; static audioBufferCache = new Map < string , Promise < AudioBuffer > > ( ) ; /** Stores a list of all currently playing audio sources. */ static audioSources : AudioSource [ ] = [ ] ; static oggDecoder : ReturnType < typeof OggdecModule > ; static init ( ) { let AudioContext = window . AudioContext ?? ( window as any ) . webkitAudioContext ; this . context = new AudioContext ( ) ; this . masterGain = this . context . createGain ( ) ; this . masterGain . gain . value = 1 ; this . masterGain . connect ( this . context . destination ) ; this . soundGain = this . context . createGain ( ) ; this . soundGain . gain . value = 0 ; this . soundGain . connect ( this . masterGain ) ; this . musicGain = this . context . createGain ( ) ; this . musicGain . gain . value = 0 ; this . musicGain . connect ( this . masterGain ) ; this . updateVolumes ( ) ; if ( typeof OggdecModule !== 'undefined' ) this . oggDecoder = OggdecModule ( ) ; window . onfocus = ( ) => { if ( Util . isTouchDevice ) this . masterGain . gain . value = 1 ; } ; window . onblur = ( ) => { if ( Util . isTouchDevice ) this . masterGain . gain . value = 0 ; } ; } static setAssetPath ( path ) { this . assetPath = path ; } static toFullPath ( path ) { let fullPath = this . assetPath + path ; return fullPath ; } /** Loads an audio buffer from a path. Returns the cached version whenever possible. */ static loadBuffer ( path ) { let fullPath = this . toFullPath ( path ) ; let mission = state . level ?. mission ; let zipFile ; if ( mission && mission . zipDirectory && mission . zipDirectory . files [ 'data/sound/' + path ] ) { zipFile = mission . zipDirectory . files [ 'data/sound/' + path ] ; } else { if ( this . audioBufferCache . has ( fullPath ) ) return this . audioBufferCache . get ( fullPath ) ; } let promise = new Promise < AudioBuffer > ( async ( resolve , reject ) => { try { let blob = zipFile ? await zipFile . async ( 'blob' ) : await ResourceManager . loadResource ( fullPath ) ; let arrayBuffer = await ResourceManager . readBlobAsArrayBuffer ( blob ) ; let audioBuffer ; if ( path . endsWith ( '.ogg' ) && Util . isSafari ( ) ) { audioBuffer = await this . oggDecoder . decodeOggData ( arrayBuffer ) ; } else if ( window . AudioContext ) { try { audioBuffer = await this . context . decodeAudioData ( arrayBuffer ) ; } catch ( e ) { audioBuffer = await this . oggDecoder . decodeOggData ( arrayBuffer ) ; } } else { audioBuffer = await new Promise ( ( res , rej ) => { this . context . decodeAudioData ( arrayBuffer , buff => res ( buff ) , err => rej ( err ) ) ; } ) ; } resolve ( audioBuffer ) ; } catch ( e ) { reject ( e ) ; console . log ( "Errored path: " + path ) ; } } ) ; if ( ! zipFile ) this . audioBufferCache . set ( fullPath , promise ) ; return promise ; } static loadBuffers ( paths ) { return Promise . all ( paths . map ( ( path ) => this . loadBuffer ( path ) ) ) ; } param path param destination param position param preferStreaming static createAudioSource ( path , destination = this . soundGain , position ? , preferStreaming = false ) { let chosenPath = ( typeof path === "string" ) ? path : Util . randomFromArray ( path ) ; let fullPath = this . toFullPath ( chosenPath ) ; let audioSource ; if ( chosenPath . endsWith ( '.ogg' ) && Util . isSafari ( ) ) preferStreaming = false ; if ( preferStreaming ) { if ( this . audioBufferCache . has ( fullPath ) ) { preferStreaming = false ; } else { let audioElement = new Audio ( ) ; audioElement . src = fullPath ; audioElement . preload = 'auto' ; audioSource = new AudioSource ( audioElement , destination , position ) ; } } if ( ! preferStreaming ) { let bufferPromise = this . loadBuffer ( chosenPath ) ; audioSource = new AudioSource ( bufferPromise , destination , position ) ; } if ( position ) { audioSource . gain . gain . value = 0 ; } this . audioSources . push ( audioSource ) ; return audioSource ; } /** Utility method for creating an audio source and playing it immediately. */ static play ( path , volume = 1 , destination = this . soundGain , position ? ) { let audioSource = this . createAudioSource ( path , destination , position ) ; audioSource . gain . gain . value = position ? 0 : volume ; audioSource . play ( ) ; } /** Updates the pan and volume of positional audio sources based on the listener's location. */ static updatePositionalAudio ( time , listenerPos , listenerYaw ) { let quat = state . level . getOrientationQuat ( time ) ; quat . conjugate ( ) ; for ( let source of this . audioSources ) { if ( ! source . position ) continue ; let relativePosition = source . position . clone ( ) . sub ( listenerPos ) ; relativePosition . applyQuaternion ( quat ) ; relativePosition . applyAxisAngle ( new Vector3 ( 0 , 0 , 1 ) , - listenerYaw ) ; relativePosition . normalize ( ) ; relativePosition . z = 0 ; let distance = source . position . distanceTo ( listenerPos ) ; let panRemoval = Util . clamp ( distance / 1 , 0 , 1 ) ; source . setPannerValue ( - relativePosition . y * 0.7 * panRemoval ) ; source . gain . gain . value = Util . clamp ( 1 - distance / 30 , 0 , 1 ) * source . gainFactor ; } } static updateVolumes ( ) { this . musicGain . gain . linearRampToValueAtTime ( StorageManager . data . settings . musicVolume ** 2 , this . context . currentTime + 0.01 ) ; this . soundGain . gain . linearRampToValueAtTime ( StorageManager . data . settings . soundVolume ** 2 , this . context . currentTime + 0.01 ) ; } static stopAllAudio ( ) { for ( let source of this . audioSources . slice ( ) ) { source . stop ( ) ; } } /** Normalizes the volume of positional audio sources based on the sounds around them to prevent the user's permanent loss of hearing. */ static normalizePositionalAudioVolume ( ) { let sources = this . audioSources . filter ( x => x . position ) ; for ( let i = 0 ; i < sources . length ; i ++ ) { let source = sources [ i ] ; let receivedVolume = 0 ; for ( let j = 0 ; j < sources . length ; j ++ ) { if ( i === j ) continue ; let otherSource = sources [ j ] ; let distance = source . position . distanceTo ( otherSource . position ) ; let preceivedVolume = Util . clamp ( 1 - distance / 30 , 0 , 1 ) ; receivedVolume += preceivedVolume ; } source . gainFactor = Math . min ( 1 / receivedVolume , 1 ) ; } } } /** A small wrapper around audio nodes that are used to play a sound. */ export class AudioSource { promise : Promise < AudioBuffer | void > ; destination : AudioNode ; node : AudioBufferSourceNode | MediaElementAudioSourceNode ; gain : GainNode ; panner : StereoPannerNode | PannerNode ; position : Vector3 ; stopped = false ; playing = false ; gainFactor = 1 ; audioElement : HTMLAudioElement ; loop = false ; playbackRate = 1 ; constructor ( source , destination , position ? ) { if ( source instanceof Promise ) { this . promise = source ; } else { this . audioElement = source ; this . promise = new Promise ( resolve => { source . addEventListener ( 'canplaythrough' , ( ) => resolve ( ) ) ; } ) ; } this . destination = destination ; this . position = position ; this . gain = AudioManager . context . createGain ( ) ; if ( AudioManager . context . createStereoPanner ) this . panner = AudioManager . context . createStereoPanner ( ) ; else this . panner = AudioManager . context . createPanner ( ) ; this . gain . connect ( this . panner ) ; this . panner . connect ( this . destination ) ; if ( source instanceof Promise ) { this . node = AudioManager . context . createBufferSource ( ) ; } else { this . node = AudioManager . context . createMediaElementSource ( source ) ; } this . node . connect ( this . gain ) ; } setPannerValue ( val ) { if ( AudioManager . context . createStereoPanner ) { ( this . panner as StereoPannerNode ) . pan . value = val ; } else { ( this . panner as PannerNode ) . panningModel = 'equalpower' ; ( this . panner as PannerNode ) . setPosition ( val , 0 , 1 - Math . abs ( val ) ) ; } } setLoop ( loop ) { if ( this . audioElement ) this . audioElement . loop = loop ; else ( this . node as AudioBufferSourceNode ) . loop = loop ; this . loop = loop ; } setPlaybackRate ( playbackRate ) { if ( this . audioElement ) this . audioElement . playbackRate = playbackRate ; else ( this . node as AudioBufferSourceNode ) . playbackRate . value = playbackRate ; this . playbackRate = playbackRate ; } async play ( ) { if ( this . playing ) return ; if ( this . stopped ) { this . stopped = false ; if ( this . node instanceof AudioBufferSourceNode ) { this . node = AudioManager . context . createBufferSource ( ) ; this . node . connect ( this . gain ) ; this . node . loop = this . loop ; this . node . playbackRate . value = this . playbackRate ; this . stopped = false ; AudioManager . audioSources . push ( this ) ; } } let maybeBuffer = await this . promise ; if ( this . stopped ) return ; if ( this . node instanceof AudioBufferSourceNode ) { if ( this . node . buffer ) return ; this . node . buffer = maybeBuffer as AudioBuffer ; this . node . start ( ) ; this . node . onended = ( ) => { this . stop ( ) ; } ; } else { this . audioElement ?. play ( ) ; this . audioElement ?. addEventListener ( 'ended' , ( ) => this . stop ( ) ) ; } this . playing = true ; } stop ( ) { this . stopped = true ; this . playing = false ; try { if ( this . audioElement ) { this . audioElement . pause ( ) ; this . audioElement . currentTime = 0 ; this . audioElement . load ( ) ; } else { ( this . node as AudioBufferSourceNode ) . stop ( ) ; } } catch ( e ) { } Util . removeFromArray ( AudioManager . audioSources , this ) ; } }