diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index 57809ff0..5bb1ba19 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -40,8 +40,9 @@ import { CallEventAudioRenderer, MAX_PARTICIPANT_COUNT_FOR_SOUND, } from "./CallEventAudioRenderer"; -import { prefetchSounds, useAudioContext } from "../useAudioContext"; +import { useAudioContext } from "../useAudioContext"; import { TestReactionsWrapper } from "../utils/testReactions"; +import { prefetchSounds } from "../soundUtils"; const localRtcMember = mockRtcMembership("@carol:example.org", "CCCC"); const local = mockMatrixRoomMember(localRtcMember); @@ -53,6 +54,7 @@ const aliceId = `${alice.userId}:${aliceRtcMember.deviceId}`; const aliceParticipant = mockRemoteParticipant({ identity: aliceId }); vitest.mock("../useAudioContext"); +vitest.mock("../soundUtils"); afterEach(() => { vitest.resetAllMocks(); diff --git a/src/room/CallEventAudioRenderer.tsx b/src/room/CallEventAudioRenderer.tsx index ba70765c..6f4f0359 100644 --- a/src/room/CallEventAudioRenderer.tsx +++ b/src/room/CallEventAudioRenderer.tsx @@ -15,7 +15,8 @@ import leftCallSoundMp3 from "../sound/left_call.mp3"; import leftCallSoundOgg from "../sound/left_call.ogg"; import handSoundOgg from "../sound/raise_hand.ogg?url"; import handSoundMp3 from "../sound/raise_hand.mp3?url"; -import { prefetchSounds, useAudioContext } from "../useAudioContext"; +import { useAudioContext } from "../useAudioContext"; +import { prefetchSounds } from "../soundUtils"; import { useReactions } from "../useReactions"; import { useLatest } from "../useLatest"; diff --git a/src/room/ReactionAudioRenderer.test.tsx b/src/room/ReactionAudioRenderer.test.tsx index a6471114..2fec8a9a 100644 --- a/src/room/ReactionAudioRenderer.test.tsx +++ b/src/room/ReactionAudioRenderer.test.tsx @@ -29,8 +29,9 @@ import { playReactionsSound, soundEffectVolumeSetting, } from "../settings/settings"; -import { prefetchSounds, useAudioContext } from "../useAudioContext"; +import { useAudioContext } from "../useAudioContext"; import { GenericReaction, ReactionSet } from "../reactions"; +import { prefetchSounds } from "../soundUtils"; const memberUserIdAlice = "@alice:example.org"; const memberUserIdBob = "@bob:example.org"; @@ -60,6 +61,7 @@ function TestComponent({ } vitest.mock("../useAudioContext"); +vitest.mock("../soundUtils"); afterEach(() => { vitest.resetAllMocks(); diff --git a/src/room/ReactionAudioRenderer.tsx b/src/room/ReactionAudioRenderer.tsx index 20a662b6..15bfc90f 100644 --- a/src/room/ReactionAudioRenderer.tsx +++ b/src/room/ReactionAudioRenderer.tsx @@ -10,7 +10,8 @@ import { ReactNode, useDeferredValue, useEffect, useState } from "react"; import { useReactions } from "../useReactions"; import { playReactionsSound, useSetting } from "../settings/settings"; import { GenericReaction, ReactionSet } from "../reactions"; -import { prefetchSounds, useAudioContext } from "../useAudioContext"; +import { useAudioContext } from "../useAudioContext"; +import { prefetchSounds } from "../soundUtils"; import { useLatest } from "../useLatest"; const soundMap = Object.fromEntries([ @@ -66,5 +67,5 @@ export function ReactionsAudioRenderer(): ReactNode { } } }, [audioEngineRef, shouldPlay, oldReactions, reactions]); - return <>; + return null; } diff --git a/src/soundUtils.ts b/src/soundUtils.ts new file mode 100644 index 00000000..bd70c8f6 --- /dev/null +++ b/src/soundUtils.ts @@ -0,0 +1,56 @@ +import { logger } from "matrix-js-sdk/src/logger"; + +type SoundDefinition = { mp3?: string; ogg: string }; + +export type PrefetchedSounds = Promise< + Record +>; + +/** + * Determine the best format we can use to play our sounds + * through. We prefer ogg support if possible, but will fall + * back to MP3. + * @returns "ogg" if the browser is likely to support it, or "mp3" otherwise. + */ +function getPreferredAudioFormat(): "ogg" | "mp3" { + const a = document.createElement("audio"); + if (a.canPlayType("audio/ogg") === "maybe") { + return "ogg"; + } + // Otherwise just assume MP3, as that has a chance of being more widely supported. + return "mp3"; +} + +const preferredFormat = getPreferredAudioFormat(); + +/** + * Prefetch sounds to be used by the AudioContext. This should + * be called outside the scope of a component to ensure the + * sounds load ahead of time. + * @param sounds A set of sound files that may be played. + * @returns A map of sound files to buffers. + */ +export async function prefetchSounds( + sounds: Record, +): PrefetchedSounds { + const buffers: Record = {}; + await Promise.all( + Object.entries(sounds).map(async ([name, file]) => { + const { mp3, ogg } = file as SoundDefinition; + // Use preferred format, fallback to ogg if no mp3 is provided. + // Load an audio file + const response = await fetch( + preferredFormat === "ogg" ? ogg : (mp3 ?? ogg), + ); + if (!response.ok) { + // If the sound doesn't load, it's not the end of the world. We won't play + // the sound when requested, but it's better than failing the whole application. + logger.warn(`Could not load sound ${name}, response was not okay`); + return; + } + // Decode it + buffers[name] = await response.arrayBuffer(); + }), + ); + return buffers as Record; +} diff --git a/src/useAudioContext.test.tsx b/src/useAudioContext.test.tsx index 488b0009..5a1afe43 100644 --- a/src/useAudioContext.test.tsx +++ b/src/useAudioContext.test.tsx @@ -15,11 +15,13 @@ import { deviceStub, MediaDevicesContext } from "./livekit/MediaDevicesContext"; import { useAudioContext } from "./useAudioContext"; import { soundEffectVolumeSetting } from "./settings/settings"; +const staticSounds = Promise.resolve({ + aSound: new ArrayBuffer(0), +}); + const TestComponent: FC = () => { const audioCtx = useAudioContext({ - sounds: Promise.resolve({ - aSound: new ArrayBuffer(0), - }), + sounds: staticSounds, latencyHint: "balanced", }); if (!audioCtx) { diff --git a/src/useAudioContext.tsx b/src/useAudioContext.tsx index c8b261a9..ccf4cbd5 100644 --- a/src/useAudioContext.tsx +++ b/src/useAudioContext.tsx @@ -13,8 +13,7 @@ import { useSetting, } from "./settings/settings"; import { useMediaDevices } from "./livekit/MediaDevicesContext"; - -type SoundDefinition = { mp3?: string; ogg: string }; +import { PrefetchedSounds } from "./soundUtils"; /** * Play a sound though a given AudioContext. Will take @@ -37,58 +36,12 @@ function playSound( src.start(); } -/** - * Determine the best format we can use to play our sounds - * through. We prefer ogg support if possible, but will fall - * back to MP3. - * @returns "ogg" if the browser is likely to support it, or "mp3" otherwise. - */ -function getPreferredAudioFormat(): "ogg" | "mp3" { - const a = document.createElement("audio"); - if (a.canPlayType("audio/ogg") === "maybe") { - return "ogg"; - } - // Otherwise just assume MP3, as that has a chance of being more widely supported. - return "mp3"; -} - -const preferredFormat = getPreferredAudioFormat(); - -type PrefetchedSounds = Promise>; - -/** - * Prefetch sounds to be used by the AudioContext. This should - * be called outside the scope of a component to ensure the - * sounds load ahead of time. - * @param sounds A set of sound files that may be played. - * @returns A map of sound files to buffers. - */ -export async function prefetchSounds( - sounds: Record, -): PrefetchedSounds { - const buffers: Record = {}; - await Promise.all( - Object.entries(sounds).map(async ([name, file]) => { - const { mp3, ogg } = file as SoundDefinition; - // Use preferred format, fallback to ogg if no mp3 is provided. - // Load an audio file - const response = await fetch( - preferredFormat === "ogg" ? ogg : (mp3 ?? ogg), - ); - if (!response.ok) { - // If the sound doesn't load, it's not the end of the world. We won't play - // the sound when requested, but it's better than failing the whole application. - logger.warn(`Could not load sound ${name}, resposne was not okay`); - return; - } - // Decode it - buffers[name] = await response.arrayBuffer(); - }), - ); - return buffers as Record; -} - interface Props { + /** + * The sounds to play. If no sounds should be played then + * this can be set to null, which will prevent the audio + * context from being created. + */ sounds: PrefetchedSounds | null; latencyHint: AudioContextLatencyCategory; } @@ -112,8 +65,8 @@ export function useAudioContext( const [audioBuffers, setAudioBuffers] = useState>(); useEffect(() => { - const soundList = props.sounds; - if (!soundList) { + const sounds = props.sounds; + if (!sounds) { return; } const ctx = new AudioContext({ @@ -126,9 +79,7 @@ export function useAudioContext( // close during this process, so it's okay if it throws. (async (): Promise => { const buffers: Record = {}; - for (const [name, buffer] of Object.entries( - await soundList, - )) { + for (const [name, buffer] of Object.entries(await sounds)) { const audioBuffer = await ctx.decodeAudioData(buffer.slice(0)); buffers[name] = audioBuffer; }