/* Copyright 2024 New Vector Ltd. SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE in the repository root for full details. */ import { logger } from "matrix-js-sdk/lib/logger"; import { useState, useEffect } from "react"; import { useObservableEagerState } from "observable-hooks"; import { soundEffectVolume as soundEffectVolumeSetting, useSetting, } from "./settings/settings"; import { useEarpieceAudioConfig, useMediaDevices } from "./MediaDevicesContext"; import { type PrefetchedSounds } from "./soundUtils"; import { useUrlParams } from "./UrlParams"; import * as controls from "./controls"; /** * Play a sound though a given AudioContext. Will take * care of connecting the correct buffer and gating * through gain. * @param volume The volume to play at. * @param ctx The context to play through. * @param buffer The buffer to play. * @returns A promise that resolves when the sound has finished playing. */ async function playSound( ctx: AudioContext, buffer: AudioBuffer, volume: number, stereoPan: number, ): Promise { const gain = ctx.createGain(); gain.gain.setValueAtTime(volume, 0); const pan = ctx.createStereoPanner(); pan.pan.setValueAtTime(stereoPan, 0); const src = ctx.createBufferSource(); src.buffer = buffer; src.connect(gain).connect(pan).connect(ctx.destination); const p = new Promise((r) => src.addEventListener("ended", () => r())); controls.setPlaybackStarted(); src.start(); return p; } 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; muted?: boolean; } interface UseAudioContext { playSound(soundName: S): Promise; } /** * Add an audio context which can be used to play * a set of preloaded sounds. * @param props * @returns Either an instance that can be used to play sounds, or null if not ready. */ export function useAudioContext( props: Props, ): UseAudioContext | null { const [soundEffectVolume] = useSetting(soundEffectVolumeSetting); const [audioContext, setAudioContext] = useState(); const [audioBuffers, setAudioBuffers] = useState>(); useEffect(() => { const sounds = props.sounds; if (!sounds) { return; } const ctx = new AudioContext({ // We want low latency for these effects. latencyHint: props.latencyHint, }); // We want to clone the content of our preloaded // sound buffers into this context. The context may // close during this process, so it's okay if it throws. (async (): Promise => { const buffers: Record = {}; for (const [name, buffer] of Object.entries(await sounds)) { const audioBuffer = await ctx.decodeAudioData(buffer.slice(0)); buffers[name] = audioBuffer; } setAudioBuffers(buffers as Record); })().catch((ex) => { logger.debug("Failed to setup audio context", ex); }); setAudioContext(ctx); return (): void => { void ctx.close().catch((ex) => { logger.debug("Failed to close audio engine", ex); }); setAudioContext(undefined); }; }, [props.sounds, props.latencyHint]); const audioOutputId = useObservableEagerState( useMediaDevices().audioOutput.selected$, )?.id; const { controlledAudioDevices } = useUrlParams(); // Update the sink ID whenever we change devices. useEffect(() => { if ( audioContext && "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(audioOutputId).catch((ex) => { logger.warn("Unable to change sink for audio context", ex); }); } }, [audioContext, audioOutputId, controlledAudioDevices]); const { pan: earpiecePan, volume: earpieceVolume } = useEarpieceAudioConfig(); // Don't return a function until we're ready. if (!audioContext || !audioBuffers || props.muted) { return null; } return { playSound: async (name): Promise => { if (!audioBuffers[name]) { logger.debug(`Tried to play a sound that wasn't buffered (${name})`); return; } return playSound( audioContext, audioBuffers[name], soundEffectVolume * earpieceVolume, earpiecePan, ); }, }; }