diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json
index 78c86257..938e4ff7 100644
--- a/public/locales/en-GB/app.json
+++ b/public/locales/en-GB/app.json
@@ -116,6 +116,7 @@
"Talking…": "Talking…",
"Thanks! We'll get right on it.": "Thanks! We'll get right on it.",
"This call already exists, would you like to join?": "This call already exists, would you like to join?",
+ "This feature is only supported on Firefox.": "This feature is only supported on Firefox.",
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy2> and <6>Terms of Service6> apply.<9>9>By clicking \"Register\", you agree to our <12>Terms and conditions12>": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy2> and <6>Terms of Service6> apply.<9>9>By clicking \"Register\", you agree to our <12>Terms and conditions12>",
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)",
"This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.": "This will send anonymised data (such as the duration of a call and the number of participants) to the Element Call team to help us optimise the application based on how it is used.",
diff --git a/src/input/Input.module.css b/src/input/Input.module.css
index fce0a938..b9c7c7af 100644
--- a/src/input/Input.module.css
+++ b/src/input/Input.module.css
@@ -151,6 +151,15 @@
margin-right: 10px;
}
+.checkboxField.disabled,
+.checkboxField.disabled .description {
+ color: var(--quinary-content);
+}
+
+.checkboxField.disabled .checkbox {
+ border-color: var(--quinary-content);
+}
+
.checkbox svg {
display: none;
}
diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx
index 8f8dc6cd..e773b454 100644
--- a/src/room/InCallView.tsx
+++ b/src/room/InCallView.tsx
@@ -55,14 +55,14 @@ import { useShowInspector, useSpatialAudio } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { useAudioContext } from "../video-grid/useMediaStream";
import { useFullscreen } from "../video-grid/useFullscreen";
-import { AudioContainer } from "../video-grid/AudioContainer";
-import { useAudioOutputDevice } from "../video-grid/useAudioOutputDevice";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { widget, ElementWidgetActions } from "../widget";
import { useJoinRule } from "./useJoinRule";
import { useUrlParams } from "../UrlParams";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
-import { ConnectionState, ParticipantInfo } from "./useGroupCall";
+import { ParticipantInfo } from "./useGroupCall";
+import { TileDescriptor } from "../video-grid/TileDescriptor";
+import { AudioSink } from "../video-grid/AudioSink";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@@ -91,18 +91,6 @@ interface Props {
hideHeader: boolean;
}
-// Represents something that should get a tile on the layout,
-// ie. a user's video feed or a screen share feed.
-export interface TileDescriptor {
- id: string;
- member: RoomMember;
- focused: boolean;
- presenter: boolean;
- callFeed?: CallFeed;
- isLocal?: boolean;
- connectionState: ConnectionState;
-}
-
export function InCallView({
client,
groupCall,
@@ -145,15 +133,12 @@ export function InCallView({
const [spatialAudio] = useSpatialAudio();
- const [audioContext, audioDestination, audioRef] = useAudioContext();
- const { audioOutput } = useMediaHandler();
+ const [audioContext, audioDestination] = useAudioContext();
const [showInspector] = useShowInspector();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
- useAudioOutputDevice(audioRef, audioOutput);
-
const { hideScreensharing } = useUrlParams();
useEffect(() => {
@@ -348,6 +333,27 @@ export function InCallView({
[styles.maximised]: maximisedParticipant,
});
+ // If spatial audio is disabled, we render one audio tag for each participant
+ // (with spatial audio, all the audio goes via the Web Audio API)
+ // We also do this if there's a feed maximised because we only trigger spatial
+ // audio rendering for feeds that we're displaying, which will need to be fixed
+ // once we start having more participants than we can fit on a screen, but this
+ // is a workaround for now.
+ const { audioOutput } = useMediaHandler();
+ const audioElements: JSX.Element[] = [];
+ if (!spatialAudio || maximisedParticipant) {
+ for (const item of items) {
+ if (item.isLocal) continue; // We don't want to render own audio
+ audioElements.push(
+
+ );
+ }
+ }
+
let footer: JSX.Element | null;
if (noControls) {
@@ -386,14 +392,7 @@ export function InCallView({
return (
-
- {(!spatialAudio || maximisedParticipant) && (
-
- )}
+ <>{audioElements}>
{!hideHeader && !maximisedParticipant && (
diff --git a/src/settings/SettingsModal.tsx b/src/settings/SettingsModal.tsx
index 5bf02e78..b93f9150 100644
--- a/src/settings/SettingsModal.tsx
+++ b/src/settings/SettingsModal.tsx
@@ -32,6 +32,7 @@ import {
useSpatialAudio,
useShowInspector,
useOptInAnalytics,
+ canEnableSpatialAudio,
} from "./useSetting";
import { FieldRow, InputField } from "../input/Input";
import { Button } from "../button";
@@ -115,9 +116,14 @@ export const SettingsModal = (props: Props) => {
label={t("Spatial audio")}
type="checkbox"
checked={spatialAudio}
- description={t(
- "This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
- )}
+ disabled={!canEnableSpatialAudio()}
+ description={
+ canEnableSpatialAudio()
+ ? t(
+ "This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)"
+ )
+ : t("This feature is only supported on Firefox.")
+ }
onChange={(event: React.ChangeEvent) =>
setSpatialAudio(event.target.checked)
}
diff --git a/src/settings/useSetting.ts b/src/settings/useSetting.ts
index b5925351..75754e5a 100644
--- a/src/settings/useSetting.ts
+++ b/src/settings/useSetting.ts
@@ -65,7 +65,27 @@ export const setSetting = (name: string, newValue: T) => {
settingsBus.emit(name, newValue);
};
-export const useSpatialAudio = () => useSetting("spatial-audio", false);
+export const canEnableSpatialAudio = () => {
+ const { userAgent } = navigator;
+ // Spatial audio means routing audio through audio contexts. On Chrome,
+ // this bypasses the AEC processor and so breaks echo cancellation.
+ // We only allow spatial audio to be enabled on Firefox which we know
+ // passes audio context audio through the AEC algorithm.
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=687574 is the
+ // chrome bug for this: once this is fixed and the updated version is deployed
+ // widely enough, we can allow spatial audio everywhere. It's currently in a
+ // chrome flag, so we could enable this in Electron if we enabled the chrome flag
+ // in the Electron wrapper.
+ return userAgent.includes("Firefox");
+};
+
+export const useSpatialAudio = (): [boolean, (val: boolean) => void] => {
+ const settingVal = useSetting("spatial-audio", false);
+ if (canEnableSpatialAudio()) return settingVal;
+
+ return [false, (_: boolean) => {}];
+};
+
export const useShowInspector = () => useSetting("show-inspector", false);
export const useOptInAnalytics = () => useSetting("opt-in-analytics", false);
export const useKeyboardShortcuts = () =>
diff --git a/src/video-grid/AudioContainer.tsx b/src/video-grid/AudioContainer.tsx
deleted file mode 100644
index 442f9605..00000000
--- a/src/video-grid/AudioContainer.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
-Copyright 2022 New Vector Ltd
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import React, { FC, useEffect, useRef } from "react";
-
-import { TileDescriptor } from "../room/InCallView";
-import { useCallFeed } from "./useCallFeed";
-import { useMediaStreamTrackCount } from "./useMediaStream";
-
-// XXX: These in fact do not render anything but to my knowledge this is the
-// only way to a hook on an array
-
-interface AudioForParticipantProps {
- item: TileDescriptor;
- audioContext: AudioContext;
- audioDestination: AudioNode;
-}
-
-export const AudioForParticipant: FC = ({
- item,
- audioContext,
- audioDestination,
-}) => {
- const { stream, localVolume } = useCallFeed(item.callFeed);
- const [audioTrackCount] = useMediaStreamTrackCount(stream);
-
- const gainNodeRef = useRef();
- const sourceRef = useRef();
-
- useEffect(() => {
- // We don't compare the audioMuted flag of useCallFeed here, since unmuting
- // depends on to-device messages which may lag behind the audio actually
- // starting to flow over the network
- if (!item.isLocal && audioContext && audioTrackCount > 0) {
- if (!gainNodeRef.current) {
- gainNodeRef.current = new GainNode(audioContext, {
- gain: localVolume,
- });
- }
- if (!sourceRef.current) {
- sourceRef.current = audioContext.createMediaStreamSource(stream);
- }
-
- const source = sourceRef.current;
- const gainNode = gainNodeRef.current;
-
- gainNode.gain.value = localVolume;
- source.connect(gainNode).connect(audioDestination);
-
- return () => {
- source.disconnect();
- gainNode.disconnect();
- };
- }
- }, [
- item,
- audioContext,
- audioDestination,
- stream,
- localVolume,
- audioTrackCount,
- ]);
-
- return null;
-};
-
-interface AudioContainerProps {
- items: TileDescriptor[];
- audioContext: AudioContext;
- audioDestination: AudioNode;
-}
-
-export const AudioContainer: FC = ({ items, ...rest }) => {
- return (
- <>
- {items
- .filter((item) => !item.isLocal)
- .map((item) => (
-
- ))}
- >
- );
-};
diff --git a/src/video-grid/AudioSink.tsx b/src/video-grid/AudioSink.tsx
new file mode 100644
index 00000000..2cdc636f
--- /dev/null
+++ b/src/video-grid/AudioSink.tsx
@@ -0,0 +1,46 @@
+/*
+Copyright 2022 New Vector Ltd
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+
+import { TileDescriptor } from "./TileDescriptor";
+import { useCallFeed } from "./useCallFeed";
+import { useMediaStream } from "./useMediaStream";
+
+interface Props {
+ tileDescriptor: TileDescriptor;
+ audioOutput: string;
+}
+
+// Renders and