audioEngine.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. import { IDisposable } from "../scene";
  2. import { Analyser } from "./analyser";
  3. import { Nullable } from "../types";
  4. import { Observable } from "../Misc/observable";
  5. import { Logger } from "../Misc/logger";
  6. import { Engine } from "../Engines/engine";
  7. /**
  8. * This represents an audio engine and it is responsible
  9. * to play, synchronize and analyse sounds throughout the application.
  10. * @see https://doc.babylonjs.com/how_to/playing_sounds_and_music
  11. */
  12. export interface IAudioEngine extends IDisposable {
  13. /**
  14. * Gets whether the current host supports Web Audio and thus could create AudioContexts.
  15. */
  16. readonly canUseWebAudio: boolean;
  17. /**
  18. * Gets the current AudioContext if available.
  19. */
  20. readonly audioContext: Nullable<AudioContext>;
  21. /**
  22. * The master gain node defines the global audio volume of your audio engine.
  23. */
  24. readonly masterGain: GainNode;
  25. /**
  26. * Gets whether or not mp3 are supported by your browser.
  27. */
  28. readonly isMP3supported: boolean;
  29. /**
  30. * Gets whether or not ogg are supported by your browser.
  31. */
  32. readonly isOGGsupported: boolean;
  33. /**
  34. * Defines if Babylon should emit a warning if WebAudio is not supported.
  35. * @ignoreNaming
  36. */
  37. WarnedWebAudioUnsupported: boolean;
  38. /**
  39. * Defines if the audio engine relies on a custom unlocked button.
  40. * In this case, the embedded button will not be displayed.
  41. */
  42. useCustomUnlockedButton: boolean;
  43. /**
  44. * Gets whether or not the audio engine is unlocked (require first a user gesture on some browser).
  45. */
  46. readonly unlocked: boolean;
  47. /**
  48. * Event raised when audio has been unlocked on the browser.
  49. */
  50. onAudioUnlockedObservable: Observable<AudioEngine>;
  51. /**
  52. * Event raised when audio has been locked on the browser.
  53. */
  54. onAudioLockedObservable: Observable<AudioEngine>;
  55. /**
  56. * Flags the audio engine in Locked state.
  57. * This happens due to new browser policies preventing audio to autoplay.
  58. */
  59. lock(): void;
  60. /**
  61. * Unlocks the audio engine once a user action has been done on the dom.
  62. * This is helpful to resume play once browser policies have been satisfied.
  63. */
  64. unlock(): void;
  65. /**
  66. * Gets the global volume sets on the master gain.
  67. * @returns the global volume if set or -1 otherwise
  68. */
  69. getGlobalVolume(): number;
  70. /**
  71. * Sets the global volume of your experience (sets on the master gain).
  72. * @param newVolume Defines the new global volume of the application
  73. */
  74. setGlobalVolume(newVolume: number): void;
  75. /**
  76. * Connect the audio engine to an audio analyser allowing some amazing
  77. * synchornization between the sounds/music and your visualization (VuMeter for instance).
  78. * @see https://doc.babylonjs.com/how_to/playing_sounds_and_music#using-the-analyser
  79. * @param analyser The analyser to connect to the engine
  80. */
  81. connectToAnalyser(analyser: Analyser): void;
  82. }
  83. // Sets the default audio engine to Babylon.js
  84. Engine.AudioEngineFactory = (hostElement: Nullable<HTMLElement>) => { return new AudioEngine(hostElement); };
  85. /**
  86. * This represents the default audio engine used in babylon.
  87. * It is responsible to play, synchronize and analyse sounds throughout the application.
  88. * @see https://doc.babylonjs.com/how_to/playing_sounds_and_music
  89. */
  90. export class AudioEngine implements IAudioEngine {
  91. private _audioContext: Nullable<AudioContext> = null;
  92. private _audioContextInitialized = false;
  93. private _muteButton: Nullable<HTMLButtonElement> = null;
  94. private _hostElement: Nullable<HTMLElement>;
  95. /**
  96. * Gets whether the current host supports Web Audio and thus could create AudioContexts.
  97. */
  98. public canUseWebAudio: boolean = false;
  99. /**
  100. * The master gain node defines the global audio volume of your audio engine.
  101. */
  102. public masterGain: GainNode;
  103. /**
  104. * Defines if Babylon should emit a warning if WebAudio is not supported.
  105. * @ignoreNaming
  106. */
  107. public WarnedWebAudioUnsupported: boolean = false;
  108. /**
  109. * Gets whether or not mp3 are supported by your browser.
  110. */
  111. public isMP3supported: boolean = false;
  112. /**
  113. * Gets whether or not ogg are supported by your browser.
  114. */
  115. public isOGGsupported: boolean = false;
  116. /**
  117. * Gets whether audio has been unlocked on the device.
  118. * Some Browsers have strong restrictions about Audio and won t autoplay unless
  119. * a user interaction has happened.
  120. */
  121. public unlocked: boolean = true;
  122. /**
  123. * Defines if the audio engine relies on a custom unlocked button.
  124. * In this case, the embedded button will not be displayed.
  125. */
  126. public useCustomUnlockedButton: boolean = false;
  127. /**
  128. * Event raised when audio has been unlocked on the browser.
  129. */
  130. public onAudioUnlockedObservable = new Observable<AudioEngine>();
  131. /**
  132. * Event raised when audio has been locked on the browser.
  133. */
  134. public onAudioLockedObservable = new Observable<AudioEngine>();
  135. /**
  136. * Gets the current AudioContext if available.
  137. */
  138. public get audioContext(): Nullable<AudioContext> {
  139. if (!this._audioContextInitialized) {
  140. this._initializeAudioContext();
  141. }
  142. else {
  143. if (!this.unlocked && !this._muteButton) {
  144. this._displayMuteButton();
  145. }
  146. }
  147. return this._audioContext;
  148. }
  149. private _connectedAnalyser: Nullable<Analyser>;
  150. /**
  151. * Instantiates a new audio engine.
  152. *
  153. * There should be only one per page as some browsers restrict the number
  154. * of audio contexts you can create.
  155. * @param hostElement defines the host element where to display the mute icon if necessary
  156. */
  157. constructor(hostElement: Nullable<HTMLElement> = null) {
  158. if (typeof window.AudioContext !== 'undefined' || typeof window.webkitAudioContext !== 'undefined') {
  159. window.AudioContext = window.AudioContext || window.webkitAudioContext;
  160. this.canUseWebAudio = true;
  161. }
  162. var audioElem = document.createElement('audio');
  163. this._hostElement = hostElement;
  164. try {
  165. if (audioElem && !!audioElem.canPlayType && audioElem.canPlayType('audio/mpeg; codecs="mp3"').replace(/^no$/, '')) {
  166. this.isMP3supported = true;
  167. }
  168. }
  169. catch (e) {
  170. // protect error during capability check.
  171. }
  172. try {
  173. if (audioElem && !!audioElem.canPlayType && audioElem.canPlayType('audio/ogg; codecs="vorbis"').replace(/^no$/, '')) {
  174. this.isOGGsupported = true;
  175. }
  176. }
  177. catch (e) {
  178. // protect error during capability check.
  179. }
  180. }
  181. /**
  182. * Flags the audio engine in Locked state.
  183. * This happens due to new browser policies preventing audio to autoplay.
  184. */
  185. public lock() {
  186. this._triggerSuspendedState();
  187. }
  188. /**
  189. * Unlocks the audio engine once a user action has been done on the dom.
  190. * This is helpful to resume play once browser policies have been satisfied.
  191. */
  192. public unlock() {
  193. this._triggerRunningState();
  194. }
  195. private _resumeAudioContext(): Promise<void> {
  196. let result: Promise<void>;
  197. if (this._audioContext!.resume !== undefined) {
  198. result = this._audioContext!.resume();
  199. }
  200. return result! || Promise.resolve();
  201. }
  202. private _initializeAudioContext() {
  203. try {
  204. if (this.canUseWebAudio) {
  205. this._audioContext = new AudioContext();
  206. // create a global volume gain node
  207. this.masterGain = this._audioContext.createGain();
  208. this.masterGain.gain.value = 1;
  209. this.masterGain.connect(this._audioContext.destination);
  210. this._audioContextInitialized = true;
  211. if (this._audioContext.state === "running") {
  212. // Do not wait for the promise to unlock.
  213. this._triggerRunningState();
  214. }
  215. }
  216. }
  217. catch (e) {
  218. this.canUseWebAudio = false;
  219. Logger.Error("Web Audio: " + e.message);
  220. }
  221. }
  222. private _tryToRun = false;
  223. private _triggerRunningState() {
  224. if (this._tryToRun) {
  225. return;
  226. }
  227. this._tryToRun = true;
  228. this._resumeAudioContext()
  229. .then(() => {
  230. this._tryToRun = false;
  231. if (this._muteButton) {
  232. this._hideMuteButton();
  233. }
  234. // Notify users that the audio stack is unlocked/unmuted
  235. this.unlocked = true;
  236. this.onAudioUnlockedObservable.notifyObservers(this);
  237. }).catch(() => {
  238. this._tryToRun = false;
  239. this.unlocked = false;
  240. });
  241. }
  242. private _triggerSuspendedState() {
  243. this.unlocked = false;
  244. this.onAudioLockedObservable.notifyObservers(this);
  245. this._displayMuteButton();
  246. }
  247. private _displayMuteButton() {
  248. if (this.useCustomUnlockedButton || this._muteButton) {
  249. return;
  250. }
  251. this._muteButton = <HTMLButtonElement>document.createElement("BUTTON");
  252. this._muteButton.className = "babylonUnmuteIcon";
  253. this._muteButton.id = "babylonUnmuteIconBtn";
  254. this._muteButton.title = "Unmute";
  255. const imageUrl = !window.SVGSVGElement ? "https://cdn.babylonjs.com/Assets/audio.png" : "data:image/svg+xml;charset=UTF-8,%3Csvg%20version%3D%221.1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2239%22%20height%3D%2232%22%20viewBox%3D%220%200%2039%2032%22%3E%3Cpath%20fill%3D%22white%22%20d%3D%22M9.625%2018.938l-0.031%200.016h-4.953q-0.016%200-0.031-0.016v-12.453q0-0.016%200.031-0.016h4.953q0.031%200%200.031%200.016v12.453zM12.125%207.688l8.719-8.703v27.453l-8.719-8.719-0.016-0.047v-9.938zM23.359%207.875l1.406-1.406%204.219%204.203%204.203-4.203%201.422%201.406-4.219%204.219%204.219%204.203-1.484%201.359-4.141-4.156-4.219%204.219-1.406-1.422%204.219-4.203z%22%3E%3C%2Fpath%3E%3C%2Fsvg%3E";
  256. var css = ".babylonUnmuteIcon { position: absolute; left: 20px; top: 20px; height: 40px; width: 60px; background-color: rgba(51,51,51,0.7); background-image: url(" + imageUrl + "); background-size: 80%; background-repeat:no-repeat; background-position: center; background-position-y: 4px; border: none; outline: none; transition: transform 0.125s ease-out; cursor: pointer; z-index: 9999; } .babylonUnmuteIcon:hover { transform: scale(1.05) } .babylonUnmuteIcon:active { background-color: rgba(51,51,51,1) }";
  257. var style = document.createElement('style');
  258. style.appendChild(document.createTextNode(css));
  259. document.getElementsByTagName('head')[0].appendChild(style);
  260. document.body.appendChild(this._muteButton);
  261. this._moveButtonToTopLeft();
  262. this._muteButton.addEventListener('touchend', () => {
  263. this._triggerRunningState();
  264. }, true);
  265. this._muteButton.addEventListener('click', () => {
  266. this._triggerRunningState();
  267. }, true);
  268. window.addEventListener("resize", this._onResize);
  269. }
  270. private _moveButtonToTopLeft() {
  271. if (this._hostElement && this._muteButton) {
  272. this._muteButton.style.top = this._hostElement.offsetTop + 20 + "px";
  273. this._muteButton.style.left = this._hostElement.offsetLeft + 20 + "px";
  274. }
  275. }
  276. private _onResize = () => {
  277. this._moveButtonToTopLeft();
  278. }
  279. private _hideMuteButton() {
  280. if (this._muteButton) {
  281. document.body.removeChild(this._muteButton);
  282. this._muteButton = null;
  283. }
  284. }
  285. /**
  286. * Destroy and release the resources associated with the audio ccontext.
  287. */
  288. public dispose(): void {
  289. if (this.canUseWebAudio && this._audioContextInitialized) {
  290. if (this._connectedAnalyser && this._audioContext) {
  291. this._connectedAnalyser.stopDebugCanvas();
  292. this._connectedAnalyser.dispose();
  293. this.masterGain.disconnect();
  294. this.masterGain.connect(this._audioContext.destination);
  295. this._connectedAnalyser = null;
  296. }
  297. this.masterGain.gain.value = 1;
  298. }
  299. this.WarnedWebAudioUnsupported = false;
  300. this._hideMuteButton();
  301. window.removeEventListener("resize", this._onResize);
  302. this.onAudioUnlockedObservable.clear();
  303. this.onAudioLockedObservable.clear();
  304. }
  305. /**
  306. * Gets the global volume sets on the master gain.
  307. * @returns the global volume if set or -1 otherwise
  308. */
  309. public getGlobalVolume(): number {
  310. if (this.canUseWebAudio && this._audioContextInitialized) {
  311. return this.masterGain.gain.value;
  312. }
  313. else {
  314. return -1;
  315. }
  316. }
  317. /**
  318. * Sets the global volume of your experience (sets on the master gain).
  319. * @param newVolume Defines the new global volume of the application
  320. */
  321. public setGlobalVolume(newVolume: number): void {
  322. if (this.canUseWebAudio && this._audioContextInitialized) {
  323. this.masterGain.gain.value = newVolume;
  324. }
  325. }
  326. /**
  327. * Connect the audio engine to an audio analyser allowing some amazing
  328. * synchornization between the sounds/music and your visualization (VuMeter for instance).
  329. * @see https://doc.babylonjs.com/how_to/playing_sounds_and_music#using-the-analyser
  330. * @param analyser The analyser to connect to the engine
  331. */
  332. public connectToAnalyser(analyser: Analyser): void {
  333. if (this._connectedAnalyser) {
  334. this._connectedAnalyser.stopDebugCanvas();
  335. }
  336. if (this.canUseWebAudio && this._audioContextInitialized && this._audioContext) {
  337. this._connectedAnalyser = analyser;
  338. this.masterGain.disconnect();
  339. this._connectedAnalyser.connectAudioNodes(this.masterGain, this._audioContext.destination);
  340. }
  341. }
  342. }