From 25a831ec885a0b3b4bd7dfbedfdc071ba32ae4ca Mon Sep 17 00:00:00 2001
From: John Shaughnessy <johnfshaughnessy@gmail.com>
Date: Tue, 31 Aug 2021 16:13:03 -0700
Subject: [PATCH 1/4] Disable panner node settings for safari

---
 src/components/avatar-audio-source.js | 10 ++++++++--
 src/systems/sound-effects-system.js   |  3 ++-
 src/update-audio-settings.js          | 20 ++++++++++++++++++--
 src/utils/detect-safari.js            |  6 ++++++
 4 files changed, 34 insertions(+), 5 deletions(-)
 create mode 100644 src/utils/detect-safari.js

diff --git a/src/components/avatar-audio-source.js b/src/components/avatar-audio-source.js
index 9559df7f38..426c9be521 100644
--- a/src/components/avatar-audio-source.js
+++ b/src/components/avatar-audio-source.js
@@ -50,8 +50,15 @@ AFRAME.registerComponent("avatar-audio-source", {
     const isRemoved = !this.el.parentNode;
     if (!stream || isRemoved) return;
 
+    APP.sourceType.set(this.el, SourceType.AVATAR_AUDIO_SOURCE);
+    const { audioType } = getCurrentAudioSettings(this.el);
     const audioListener = this.el.sceneEl.audioListener;
-    const audio = new THREE.PositionalAudio(audioListener);
+    let audio;
+    if (audioType === AudioType.PannerNode) {
+      audio = new THREE.PositionalAudio(audioListener);
+    } else {
+      audio = new THREE.Audio(audioListener);
+    }
 
     this.audioSystem.removeAudio(audio);
     this.audioSystem.addAudio(SourceType.AVATAR_AUDIO_SOURCE, audio);
@@ -69,7 +76,6 @@ AFRAME.registerComponent("avatar-audio-source", {
     this.el.emit("sound-source-set", { soundSource: destinationSource });
 
     APP.audios.set(this.el, audio);
-    APP.sourceType.set(this.el, SourceType.AVATAR_AUDIO_SOURCE);
     updateAudioSettings(this.el, audio);
   },
 
diff --git a/src/systems/sound-effects-system.js b/src/systems/sound-effects-system.js
index 31660bc5a3..1b475de5da 100644
--- a/src/systems/sound-effects-system.js
+++ b/src/systems/sound-effects-system.js
@@ -16,6 +16,7 @@ import URL_MEDIA_LOADED from "../assets/sfx/A_bendUp.mp3";
 import URL_MEDIA_LOADING from "../assets/sfx/suspense.mp3";
 import URL_SPAWN_EMOJI from "../assets/sfx/emoji.mp3";
 import { setMatrixWorld } from "../utils/three-utils";
+import { isSafari } from "../utils/detect-safari";
 
 let soundEnum = 0;
 export const SOUND_HOVER_OR_GRAB = soundEnum++;
@@ -137,7 +138,7 @@ export class SoundEffectsSystem {
     const audioBuffer = this.sounds.get(sound);
     if (!audioBuffer) return null;
 
-    const disablePositionalAudio = window.APP.store.state.preferences.audioOutputMode === "audio";
+    const disablePositionalAudio = isSafari() || window.APP.store.state.preferences.audioOutputMode === "audio";
     const positionalAudio = disablePositionalAudio
       ? new THREE.Audio(this.scene.audioListener)
       : new THREE.PositionalAudio(this.scene.audioListener);
diff --git a/src/update-audio-settings.js b/src/update-audio-settings.js
index fb37898022..1365cf7e50 100644
--- a/src/update-audio-settings.js
+++ b/src/update-audio-settings.js
@@ -1,4 +1,11 @@
-import { SourceType, MediaAudioDefaults, AvatarAudioDefaults, TargetAudioDefaults } from "./components/audio-params";
+import {
+  AudioType,
+  SourceType,
+  MediaAudioDefaults,
+  AvatarAudioDefaults,
+  TargetAudioDefaults
+} from "./components/audio-params";
+import { isSafari } from "./utils/detect-safari";
 
 const defaultSettingsForSourceType = Object.freeze(
   new Map([
@@ -28,7 +35,16 @@ export function getCurrentAudioSettings(el) {
   const audioDebugPanelOverrides = APP.audioDebugPanelOverrides.get(sourceType);
   const audioOverrides = APP.audioOverrides.get(el);
   const zoneSettings = APP.zoneOverrides.get(el);
-  const settings = Object.assign({}, defaults, sceneOverrides, audioDebugPanelOverrides, audioOverrides, zoneSettings);
+  const safariOverrides = isSafari() ? { audioType: AudioType.Stereo } : {};
+  const settings = Object.assign(
+    {},
+    defaults,
+    sceneOverrides,
+    audioDebugPanelOverrides,
+    audioOverrides,
+    zoneSettings,
+    safariOverrides
+  );
 
   if (APP.clippingState.has(el) || APP.linkedMutedState.has(el)) {
     settings.gain = 0;
diff --git a/src/utils/detect-safari.js b/src/utils/detect-safari.js
new file mode 100644
index 0000000000..30858a8e84
--- /dev/null
+++ b/src/utils/detect-safari.js
@@ -0,0 +1,6 @@
+import { detect } from "detect-browser";
+
+export function isSafari() {
+  const browser = detect();
+  return ["iOS", "Mac OS"].includes(browser.os) && ["safari", "ios"].includes(browser.name);
+}

From 6970cd76bfad2a1138f73c278b4cdaca2b595e2a Mon Sep 17 00:00:00 2001
From: John Shaughnessy <johnfshaughnessy@gmail.com>
Date: Tue, 31 Aug 2021 16:43:09 -0700
Subject: [PATCH 2/4] Refactor: Remove unnecessary uses of "window." and
 "this."

---
 src/components/media-video.js | 11 +++++------
 1 file changed, 5 insertions(+), 6 deletions(-)

diff --git a/src/components/media-video.js b/src/components/media-video.js
index 0aace6959e..ee47a447b7 100644
--- a/src/components/media-video.js
+++ b/src/components/media-video.js
@@ -163,17 +163,16 @@ AFRAME.registerComponent("media-video", {
       evt.detail.cameraEl.getObject3D("camera").add(sceneEl.audioListener);
     });
 
-    // TODO Probably we will get rid of this at some point
-    this.audioOutputModePref = window.APP.store.state.preferences.audioOutputMode;
+    let audioOutputModePref = APP.store.state.preferences.audioOutputMode;
     this.onPreferenceChanged = () => {
-      const newPref = window.APP.store.state.preferences.audioOutputMode;
-      const shouldRecreateAudio = this.audioOutputModePref !== newPref && this.audio && this.mediaElementAudioSource;
-      this.audioOutputModePref = newPref;
+      const newPref = APP.store.state.preferences.audioOutputMode;
+      const shouldRecreateAudio = audioOutputModePref !== newPref && this.audio && this.mediaElementAudioSource;
+      audioOutputModePref = newPref;
       if (shouldRecreateAudio) {
         this.setupAudio();
       }
     };
-    window.APP.store.addEventListener("statechanged", this.onPreferenceChanged);
+    APP.store.addEventListener("statechanged", this.onPreferenceChanged);
   },
 
   isMineOrLocal() {

From 441954383aac8ee7ed5ba49c9b6c83fae6929584 Mon Sep 17 00:00:00 2001
From: John Shaughnessy <johnfshaughnessy@gmail.com>
Date: Tue, 31 Aug 2021 16:44:11 -0700
Subject: [PATCH 3/4] Add audioOutputMode pref as an audio settings override

---
 src/components/avatar-audio-source.js | 11 +++++++++++
 src/message-dispatch.js               |  9 +++++++++
 src/systems/audio-settings-system.js  | 21 +++++++++++----------
 src/update-audio-settings.js          |  3 +++
 4 files changed, 34 insertions(+), 10 deletions(-)

diff --git a/src/components/avatar-audio-source.js b/src/components/avatar-audio-source.js
index 426c9be521..c73c1a10ed 100644
--- a/src/components/avatar-audio-source.js
+++ b/src/components/avatar-audio-source.js
@@ -96,6 +96,17 @@ AFRAME.registerComponent("avatar-audio-source", {
     // This could happen in case there is an ICE failure that requires a transport recreation.
     APP.dialog.on("stream_updated", this._onStreamUpdated, this);
     this.createAudio();
+
+    let audioOutputModePref = APP.store.state.preferences.audioOutputMode;
+    this.onPreferenceChanged = () => {
+      const newPref = APP.store.state.preferences.audioOutputMode;
+      const shouldRecreateAudio = audioOutputModePref !== newPref;
+      audioOutputModePref = newPref;
+      if (shouldRecreateAudio) {
+        this.createAudio();
+      }
+    };
+    APP.store.addEventListener("statechanged", this.onPreferenceChanged);
   },
 
   async _onStreamUpdated(peerId, kind) {
diff --git a/src/message-dispatch.js b/src/message-dispatch.js
index 4ff5c287dc..68ae5fa558 100644
--- a/src/message-dispatch.js
+++ b/src/message-dispatch.js
@@ -66,6 +66,8 @@ export default class MessageDispatch extends EventTarget {
     uiRoot = uiRoot || document.getElementById("ui-root");
     const isGhost = !entered && uiRoot && uiRoot.firstChild && uiRoot.firstChild.classList.contains("isGhost");
 
+    // TODO: Some of the commands below should be available without requiring
+    //       room entry. For example, audiomode should not require room entry.
     if (!entered && (!isGhost || command === "duck")) {
       this.log(LogMessageType.roomEntryRequired);
       return;
@@ -174,8 +176,15 @@ export default class MessageDispatch extends EventTarget {
         {
           const shouldEnablePositionalAudio = window.APP.store.state.preferences.audioOutputMode === "audio";
           window.APP.store.update({
+            // TODO: This should probably just be a boolean to disable panner node settings
+            // and even if it's not, "audio" is a weird name for the "audioOutputMode" that means
+            // "stereo" / "not panner".
             preferences: { audioOutputMode: shouldEnablePositionalAudio ? "panner" : "audio" }
           });
+          // TODO: The user message here is a little suspicious. We might be ignoring the
+          // user preference (e.g. if panner nodes are broken in safari, then we never create
+          // panner nodes, regardless of user preference.)
+          // Warning: This comment may be out of date when you read it.
           this.log(
             shouldEnablePositionalAudio ? LogMessageType.positionalAudioEnabled : LogMessageType.positionalAudioDisabled
           );
diff --git a/src/systems/audio-settings-system.js b/src/systems/audio-settings-system.js
index b8e61b5236..765b6866cf 100644
--- a/src/systems/audio-settings-system.js
+++ b/src/systems/audio-settings-system.js
@@ -5,16 +5,17 @@ export class AudioSettingsSystem {
   constructor(sceneEl) {
     sceneEl.addEventListener("reset_scene", this.onSceneReset);
 
-    // TODO: Remove these hacks
-    if (
-      !window.APP.store.state.preferences.audioOutputMode ||
-      window.APP.store.state.preferences.audioOutputMode === "audio"
-    ) {
-      //hack to always reset to "panner"
-      window.APP.store.update({
-        preferences: { audioOutputMode: "panner" }
-      });
-    }
+    // HACK We are scared that users are going to set this preference and then
+    // forget about it and have a bad time, so we always remove the preference
+    // whenever the user refreshes the page.
+    // TODO: This is pretty weird and surprising. If the preference is exposed
+    // in the preference screen, then we would not be so scared about this.
+    // Also, if we feel so concerned about people using it, we should consider
+    // ways to make it safer or remove it.
+    window.APP.store.update({
+      preferences: { audioOutputMode: undefined }
+    });
+
     if (window.APP.store.state.preferences.audioNormalization !== 0.0) {
       //hack to always reset to 0.0 (disabled)
       window.APP.store.update({
diff --git a/src/update-audio-settings.js b/src/update-audio-settings.js
index 1365cf7e50..ef24dcc77b 100644
--- a/src/update-audio-settings.js
+++ b/src/update-audio-settings.js
@@ -35,6 +35,8 @@ export function getCurrentAudioSettings(el) {
   const audioDebugPanelOverrides = APP.audioDebugPanelOverrides.get(sourceType);
   const audioOverrides = APP.audioOverrides.get(el);
   const zoneSettings = APP.zoneOverrides.get(el);
+  const preferencesOverrides =
+    APP.store.state.preferences.audioOutputMode === "audio" ? { audioType: AudioType.Stereo } : {};
   const safariOverrides = isSafari() ? { audioType: AudioType.Stereo } : {};
   const settings = Object.assign(
     {},
@@ -43,6 +45,7 @@ export function getCurrentAudioSettings(el) {
     audioDebugPanelOverrides,
     audioOverrides,
     zoneSettings,
+    preferencesOverrides,
     safariOverrides
   );
 

From fe4eb6ffbc94fb411f8d98754376aed7e0c2b01a Mon Sep 17 00:00:00 2001
From: John Shaughnessy <johnfshaughnessy@gmail.com>
Date: Tue, 31 Aug 2021 18:02:17 -0700
Subject: [PATCH 4/4] Reintroduce distance-based attenuation changes

---
 src/components/avatar-audio-source.js |  7 ++++++-
 src/components/media-video.js         |  1 +
 src/hub.js                            |  1 +
 src/systems/audio-gain-system.js      | 18 +++++++++++++++---
 src/update-audio-settings.js          |  4 ++++
 5 files changed, 27 insertions(+), 4 deletions(-)

diff --git a/src/components/avatar-audio-source.js b/src/components/avatar-audio-source.js
index c73c1a10ed..dcec1a8bbe 100644
--- a/src/components/avatar-audio-source.js
+++ b/src/components/avatar-audio-source.js
@@ -44,6 +44,8 @@ async function getMediaStream(el) {
 
 AFRAME.registerComponent("avatar-audio-source", {
   createAudio: async function() {
+    APP.supplementaryAttenuation.delete(this.el);
+
     this.isCreatingAudio = true;
     const stream = await getMediaStream(this.el);
     this.isCreatingAudio = false;
@@ -88,6 +90,7 @@ AFRAME.registerComponent("avatar-audio-source", {
 
     APP.audios.delete(this.el);
     APP.sourceType.delete(this.el);
+    APP.supplementaryAttenuation.delete(this.el);
   },
 
   init() {
@@ -100,7 +103,7 @@ AFRAME.registerComponent("avatar-audio-source", {
     let audioOutputModePref = APP.store.state.preferences.audioOutputMode;
     this.onPreferenceChanged = () => {
       const newPref = APP.store.state.preferences.audioOutputMode;
-      const shouldRecreateAudio = audioOutputModePref !== newPref;
+      const shouldRecreateAudio = audioOutputModePref !== newPref && !this.isCreatingAudio;
       audioOutputModePref = newPref;
       if (shouldRecreateAudio) {
         this.createAudio();
@@ -276,6 +279,7 @@ AFRAME.registerComponent("audio-target", {
   },
 
   createAudio: function() {
+    APP.supplementaryAttenuation.delete(this.el);
     APP.sourceType.set(this.el, SourceType.AUDIO_TARGET);
     const audioListener = this.el.sceneEl.audioListener;
     let audio = null;
@@ -323,6 +327,7 @@ AFRAME.registerComponent("audio-target", {
     this.audioSystem.removeAudio(this.audio);
     this.el.removeObject3D(this.attrName);
 
+    APP.supplementaryAttenuation.delete(this.el);
     APP.audios.delete(this.el);
     APP.sourceType.delete(this.el);
   }
diff --git a/src/components/media-video.js b/src/components/media-video.js
index ee47a447b7..712cdfde7c 100644
--- a/src/components/media-video.js
+++ b/src/components/media-video.js
@@ -788,6 +788,7 @@ AFRAME.registerComponent("media-video", {
     APP.gainMultipliers.delete(this.el);
     APP.audios.delete(this.el);
     APP.sourceType.delete(this.el);
+    APP.supplementaryAttenuation.delete(this.el);
 
     if (this.audio) {
       this.el.removeObject3D("sound");
diff --git a/src/hub.js b/src/hub.js
index 78591634c3..b0b459279d 100644
--- a/src/hub.js
+++ b/src/hub.js
@@ -210,6 +210,7 @@ APP.zoneOverrides = new Map(); //                    el -> AudioSettings
 APP.audioDebugPanelOverrides = new Map(); // SourceType -> AudioSettings
 APP.sceneAudioDefaults = new Map(); //       SourceType -> AudioSettings
 APP.gainMultipliers = new Map(); //                  el -> Number
+APP.supplementaryAttenuation = new Map(); //         el -> Number
 APP.clippingState = new Set();
 APP.linkedMutedState = new Set();
 APP.isAudioPaused = new Set();
diff --git a/src/systems/audio-gain-system.js b/src/systems/audio-gain-system.js
index 36ac4fb790..38b04576cd 100644
--- a/src/systems/audio-gain-system.js
+++ b/src/systems/audio-gain-system.js
@@ -1,5 +1,5 @@
 import { CLIPPING_THRESHOLD_ENABLED, CLIPPING_THRESHOLD_DEFAULT } from "../react-components/preferences-screen";
-import { updateAudioSettings } from "../update-audio-settings";
+import { getCurrentAudioSettings, updateAudioSettings } from "../update-audio-settings";
 
 function isClippingEnabled() {
   const { enableAudioClipping } = window.APP.store.state.preferences;
@@ -36,20 +36,32 @@ const calculateAttenuation = (() => {
         audio.panner.rolloffFactor,
         audio.panner.refDistance,
         audio.panner.maxDistance
+        // TODO: Why are coneInnerAngle, coneOuterAngle and coneOuterGain not used?
       );
     } else {
-      return 1.0;
+      const { distanceModel, rolloffFactor, refDistance, maxDistance } = getCurrentAudioSettings(el);
+      return distanceModels[distanceModel](distance, rolloffFactor, refDistance, maxDistance);
     }
   };
 })();
 
+// TODO: Rename "GainSystem" because the name is suspicious
 export class GainSystem {
   tick() {
     const clippingEnabled = isClippingEnabled();
     const clippingThreshold = getClippingThreshold();
     for (const [el, audio] of APP.audios.entries()) {
+      const attenuation = calculateAttenuation(el, audio);
+
+      if (!audio.panner) {
+        // For Audios that are not PositionalAudios, we reintroduce
+        // distance-based attenuation manually.
+        APP.supplementaryAttenuation.set(el, attenuation);
+        updateAudioSettings(el, audio);
+      }
+
       const isClipped = APP.clippingState.has(el);
-      const shouldBeClipped = clippingEnabled && calculateAttenuation(el, audio) < clippingThreshold;
+      const shouldBeClipped = clippingEnabled && attenuation < clippingThreshold;
       if (isClipped !== shouldBeClipped) {
         if (shouldBeClipped) {
           APP.clippingState.add(el);
diff --git a/src/update-audio-settings.js b/src/update-audio-settings.js
index ef24dcc77b..82554bc10c 100644
--- a/src/update-audio-settings.js
+++ b/src/update-audio-settings.js
@@ -55,6 +55,10 @@ export function getCurrentAudioSettings(el) {
     settings.gain = settings.gain * APP.gainMultipliers.get(el);
   }
 
+  if (APP.supplementaryAttenuation.has(el)) {
+    settings.gain = settings.gain * APP.supplementaryAttenuation.get(el);
+  }
+
   return settings;
 }