use optional audio context and effect to initiate it + review

This commit is contained in:
Timo
2025-05-14 17:23:42 +02:00
parent 6b8c620bbb
commit 18a59dd7db
4 changed files with 33 additions and 23 deletions

View File

@@ -34,7 +34,7 @@ afterEach(() => {
vi.mock("@livekit/components-react", async (importOriginal) => { vi.mock("@livekit/components-react", async (importOriginal) => {
return { return {
...(await importOriginal()), // this will only affect "foo" outside of the original module ...(await importOriginal()),
AudioTrack: (props: { trackRef: TrackReference }): ReactNode => { AudioTrack: (props: { trackRef: TrackReference }): ReactNode => {
return ( return (
<audio data-testid={"audio"}> <audio data-testid={"audio"}>

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { getTrackReferenceId } from "@livekit/components-core"; import { getTrackReferenceId } from "@livekit/components-core";
import { type RemoteAudioTrack, Track } from "livekit-client"; import { type RemoteAudioTrack, Track } from "livekit-client";
import { useEffect, useMemo, useRef, type ReactNode } from "react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { import {
useTracks, useTracks,
AudioTrack, AudioTrack,
@@ -88,7 +88,8 @@ export function MatrixAudioRenderer({
}, },
).filter((ref) => { ).filter((ref) => {
const isValid = validIdentities?.has(ref.participant.identity); const isValid = validIdentities?.has(ref.participant.identity);
if (!isValid) logInvalid(ref.participant.identity, validIdentities); if (!isValid && !ref.participant.isLocal)
logInvalid(ref.participant.identity, validIdentities);
return ( return (
!ref.participant.isLocal && !ref.participant.isLocal &&
ref.publication.kind === Track.Kind.Audio && ref.publication.kind === Track.Kind.Audio &&
@@ -97,11 +98,11 @@ export function MatrixAudioRenderer({
}); });
// This component is also (in addition to the "only play audio for connected members" logic above) // This component is also (in addition to the "only play audio for connected members" logic above)
// to mimic earpice audio on iphones. // responsible for mimicking earpiece audio on iPhones.
// The safari audio devices enumeration does not expose an earpice audio device. // The Safari audio devices enumeration does not expose an earpiece audio device.
// We alternatively use the audioContext pan node to only use one of the stereo channels. // We alternatively use the audioContext pan node to only use one of the stereo channels.
// This component does get additionally complicated because of a safari bug. // This component does get additionally complicated because of a Safari bug.
// (see: https://bugs.webkit.org/show_bug.cgi?id=251532 // (see: https://bugs.webkit.org/show_bug.cgi?id=251532
// and the related issues: https://bugs.webkit.org/show_bug.cgi?id=237878 // and the related issues: https://bugs.webkit.org/show_bug.cgi?id=237878
// and https://bugs.webkit.org/show_bug.cgi?id=231105) // and https://bugs.webkit.org/show_bug.cgi?id=231105)
@@ -109,31 +110,38 @@ export function MatrixAudioRenderer({
// AudioContext gets stopped if the webview gets moved into the background. // AudioContext gets stopped if the webview gets moved into the background.
// Once the phone is in standby audio playback will stop. // Once the phone is in standby audio playback will stop.
// So we can only use the pan trick only works is the phone is not in standby. // So we can only use the pan trick only works is the phone is not in standby.
// If earpice mode is not used we do not use audioContext to allow standby playback. // If earpiece mode is not used we do not use audioContext to allow standby playback.
// shouldUseAudioContext is set to false if stereoPan === 0 to allow standby bluetooth playback. // shouldUseAudioContext is set to false if stereoPan === 0 to allow standby bluetooth playback.
const { pan: stereoPan, volume: volumeFactor } = useEarpieceAudioConfig(); const { pan: stereoPan, volume: volumeFactor } = useEarpieceAudioConfig();
const shouldUseAudioContext = stereoPan !== 0; const shouldUseAudioContext = stereoPan !== 0;
// initialize the potentially used audio context. // initialize the potentially used audio context.
const audioContext = useMemo(() => new AudioContext(), []); const [audioContext, setAudioContext] = useState<AudioContext | undefined>(
undefined,
);
useEffect(() => {
const ctx = new AudioContext();
setAudioContext(ctx);
return (): void => {
void ctx.close();
};
}, []);
const audioNodes = useMemo( const audioNodes = useMemo(
() => ({ () => ({
gain: audioContext.createGain(), gain: audioContext?.createGain(),
pan: audioContext.createStereoPanner(), pan: audioContext?.createStereoPanner(),
}), }),
[audioContext], [audioContext],
); );
// Simple effects to update the gain and pan node based on the props // Simple effects to update the gain and pan node based on the props
useEffect(() => { useEffect(() => {
audioNodes.pan.pan.value = stereoPan; if (audioNodes.pan) audioNodes.pan.pan.value = stereoPan;
}, [audioNodes.pan.pan, stereoPan]); }, [audioNodes.pan, stereoPan]);
useEffect(() => { useEffect(() => {
// *4 to balance the transition from audio context to normal audio playback. if (audioNodes.gain) audioNodes.gain.gain.value = volumeFactor;
// probably needed due to gain behaving differently than el.volume }, [audioNodes.gain, volumeFactor]);
audioNodes.gain.gain.value = volumeFactor;
}, [audioNodes.gain.gain, volumeFactor]);
return ( return (
// We add all audio elements into one <div> for the browser developer tool experience/tidyness. // We add all audio elements into one <div> for the browser developer tool experience/tidyness.
@@ -155,8 +163,8 @@ interface StereoPanAudioTrackProps {
muted?: boolean; muted?: boolean;
audioContext?: AudioContext; audioContext?: AudioContext;
audioNodes: { audioNodes: {
gain: GainNode; gain?: GainNode;
pan: StereoPannerNode; pan?: StereoPannerNode;
}; };
} }
@@ -182,15 +190,18 @@ function AudioTrackWithAudioNodes({
// (adding the audio context when already mounted did not work outside strict mode) // (adding the audio context when already mounted did not work outside strict mode)
const [trackReady, setTrackReady] = useReactiveState( const [trackReady, setTrackReady] = useReactiveState(
() => false, () => false,
[audioContext || audioNodes], // We only want the track to reset once both (audioNodes and audioContext) are set.
// for unsetting the audioContext its enough if one of the the is undefined.
[audioContext && audioNodes],
); );
useEffect(() => { useEffect(() => {
if (!trackRef || trackReady) return; if (!trackRef || trackReady) return;
const track = trackRef.publication.track as RemoteAudioTrack; const track = trackRef.publication.track as RemoteAudioTrack;
track.setAudioContext(audioContext); const useContext = audioContext && audioNodes.gain && audioNodes.pan;
track.setAudioContext(useContext ? audioContext : undefined);
track.setWebAudioPlugins( track.setWebAudioPlugins(
audioContext ? [audioNodes.gain, audioNodes.pan] : [], useContext ? [audioNodes.gain!, audioNodes.pan!] : [],
); );
setTrackReady(true); setTrackReady(true);
}, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]); }, [audioContext, audioNodes, setTrackReady, trackReady, trackRef]);

View File

@@ -44,7 +44,6 @@ import {
} from "../settings/settings"; } from "../settings/settings";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender"; import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement"; import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
// import { testAudioContext } from "../useAudioContext.test";
import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer"; import { MatrixAudioRenderer } from "../livekit/MatrixAudioRenderer";
// vi.hoisted(() => { // vi.hoisted(() => {

View File

@@ -49,7 +49,7 @@ export const DeviceSelection: FC<Props> = ({
); );
// There is no need to show the menu if there is no choice that can be made. // There is no need to show the menu if there is no choice that can be made.
if (device.available.size == 1) return null; if (device.available.size <= 1) return null;
return ( return (
<div className={styles.selection}> <div className={styles.selection}>