Use html audio element to call setsink id for reactions and call sounds.

This commit is contained in:
Timo
2025-06-05 11:53:01 +02:00
parent 45fd7fdc7f
commit 9ff6bcb583
3 changed files with 24 additions and 15 deletions

View File

@@ -16,15 +16,18 @@ import { type RemoteAudioTrack } from "livekit-client";
import { type ReactNode } from "react"; import { type ReactNode } from "react";
import { useTracks } from "@livekit/components-react"; import { useTracks } from "@livekit/components-react";
import { testAudioContext } from "../useAudioContext.test"; import {
TestAudioConstructor,
testAudioContext,
TestAudioContextConstructor,
} from "../useAudioContext.test";
import * as MediaDevicesContext from "./MediaDevicesContext"; import * as MediaDevicesContext from "./MediaDevicesContext";
import { MatrixAudioRenderer } from "./MatrixAudioRenderer"; import { MatrixAudioRenderer } from "./MatrixAudioRenderer";
import { mockTrack } from "../utils/test"; import { mockTrack } from "../utils/test";
export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
beforeEach(() => { beforeEach(() => {
vi.stubGlobal("AudioContext", TestAudioContextConstructor); vi.stubGlobal("AudioContext", TestAudioContextConstructor);
vi.stubGlobal("Audio", TestAudioConstructor);
}); });
afterEach(() => { afterEach(() => {

View File

@@ -87,12 +87,19 @@ export const testAudioContext = {
createGain: vi.fn().mockReturnValue(gainNode), createGain: vi.fn().mockReturnValue(gainNode),
createStereoPanner: vi.fn().mockReturnValue(panNode), createStereoPanner: vi.fn().mockReturnValue(panNode),
close: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined),
createMediaStreamDestination: vi.fn().mockReturnValue({ stream: undefined }),
}; };
export const TestAudioContextConstructor = vi.fn(() => testAudioContext); export const TestAudioContextConstructor = vi.fn(() => testAudioContext);
export const testAudioElement = {
setSinkId: vi.fn().mockResolvedValue(null),
};
export const TestAudioConstructor = vi.fn(() => testAudioElement);
let user: UserEvent; let user: UserEvent;
beforeEach(() => { beforeEach(() => {
vi.stubGlobal("AudioContext", TestAudioContextConstructor); vi.stubGlobal("AudioContext", TestAudioContextConstructor);
vi.stubGlobal("Audio", TestAudioConstructor);
user = userEvent.setup(); user = userEvent.setup();
}); });
@@ -114,6 +121,7 @@ test("will ignore sounds that are not registered", async () => {
}); });
test("will use the correct device", () => { test("will use the correct device", () => {
testAudioElement.setSinkId.mockClear();
render( render(
<MediaDevicesContext.Provider <MediaDevicesContext.Provider
value={{ value={{
@@ -134,7 +142,7 @@ test("will use the correct device", () => {
</MediaDevicesContext.Provider>, </MediaDevicesContext.Provider>,
); );
expect(testAudioContext.createBufferSource).not.toHaveBeenCalled(); expect(testAudioContext.createBufferSource).not.toHaveBeenCalled();
expect(testAudioContext.setSinkId).toHaveBeenCalledWith("chosen-device"); expect(testAudioElement.setSinkId).toHaveBeenCalledWith("chosen-device");
}); });
test("will use the correct volume level", async () => { test("will use the correct volume level", async () => {

View File

@@ -18,6 +18,7 @@ import {
} from "./livekit/MediaDevicesContext"; } from "./livekit/MediaDevicesContext";
import { type PrefetchedSounds } from "./soundUtils"; import { type PrefetchedSounds } from "./soundUtils";
import { useUrlParams } from "./UrlParams"; import { useUrlParams } from "./UrlParams";
import { useInitial } from "./useInitial";
/** /**
* Play a sound though a given AudioContext. Will take * Play a sound though a given AudioContext. Will take
@@ -76,15 +77,18 @@ export function useAudioContext<S extends string>(
const [audioContext, setAudioContext] = useState<AudioContext>(); const [audioContext, setAudioContext] = useState<AudioContext>();
const [audioBuffers, setAudioBuffers] = useState<Record<S, AudioBuffer>>(); const [audioBuffers, setAudioBuffers] = useState<Record<S, AudioBuffer>>();
const htmlAudioElement = useInitial((): HTMLAudioElement => new Audio());
useEffect(() => { useEffect(() => {
const sounds = props.sounds; const sounds = props.sounds;
if (!sounds) { if (!sounds || !htmlAudioElement) {
return; return;
} }
const ctx = new AudioContext({ const ctx = new AudioContext({
// We want low latency for these effects. // We want low latency for these effects.
latencyHint: props.latencyHint, latencyHint: props.latencyHint,
}); });
htmlAudioElement.srcObject = ctx.createMediaStreamDestination().stream;
// We want to clone the content of our preloaded // We want to clone the content of our preloaded
// sound buffers into this context. The context may // sound buffers into this context. The context may
@@ -107,22 +111,16 @@ export function useAudioContext<S extends string>(
}); });
setAudioContext(undefined); setAudioContext(undefined);
}; };
}, [props.sounds, props.latencyHint]); }, [props.sounds, props.latencyHint, htmlAudioElement]);
// Update the sink ID whenever we change devices. // Update the sink ID whenever we change devices.
useEffect(() => { useEffect(() => {
if ( if (!controlledAudioDevices && audioOutput.selectedId) {
audioContext && htmlAudioElement.setSinkId(audioOutput.selectedId).catch((ex) => {
"setSinkId" in audioContext &&
!controlledAudioDevices
) {
// https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/setSinkId
// @ts-expect-error - setSinkId doesn't exist yet in types, maybe because it's not supported everywhere.
audioContext.setSinkId(audioOutput.selectedId).catch((ex) => {
logger.warn("Unable to change sink for audio context", ex); logger.warn("Unable to change sink for audio context", ex);
}); });
} }
}, [audioContext, audioOutput.selectedId, controlledAudioDevices]); }, [audioOutput.selectedId, controlledAudioDevices, htmlAudioElement]);
const { pan: earpiecePan, volume: earpieceVolume } = useEarpieceAudioConfig(); const { pan: earpiecePan, volume: earpieceVolume } = useEarpieceAudioConfig();
// Don't return a function until we're ready. // Don't return a function until we're ready.