From 0b015dced88b232e98796c0b9dbf313984baf674 Mon Sep 17 00:00:00 2001 From: Half-Shot Date: Fri, 12 Sep 2025 17:44:36 +0100 Subject: [PATCH] Loop ring sound inside Audio context for better perf. --- src/room/CallEventAudioRenderer.test.tsx | 1 + src/room/GroupCallView.test.tsx | 3 ++ src/room/InCallView.tsx | 15 ++++---- src/room/ReactionAudioRenderer.test.tsx | 1 + src/useAudioContext.tsx | 46 ++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/room/CallEventAudioRenderer.test.tsx b/src/room/CallEventAudioRenderer.test.tsx index beae9cae..4ba24a5e 100644 --- a/src/room/CallEventAudioRenderer.test.tsx +++ b/src/room/CallEventAudioRenderer.test.tsx @@ -54,6 +54,7 @@ beforeEach(() => { playSound = vitest.fn(); (useAudioContext as MockedFunction).mockReturnValue({ playSound, + playSoundLooping: vitest.fn(), }); }); diff --git a/src/room/GroupCallView.test.tsx b/src/room/GroupCallView.test.tsx index 12dfdf61..23249f46 100644 --- a/src/room/GroupCallView.test.tsx +++ b/src/room/GroupCallView.test.tsx @@ -12,6 +12,7 @@ import { onTestFinished, test, vi, + vitest, } from "vitest"; import { render, waitFor, screen } from "@testing-library/react"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; @@ -94,6 +95,7 @@ beforeEach(() => { playSound = vi.fn(); (useAudioContext as MockedFunction).mockReturnValue({ playSound, + playSoundLooping: vi.fn(), }); // A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here. (ActiveCall as MockedFunction).mockImplementation( @@ -206,6 +208,7 @@ test("GroupCallView plays a leave sound synchronously in widget mode", async () ); (useAudioContext as MockedFunction).mockReturnValue({ playSound, + playSoundLooping: vitest.fn(), }); const { getByText, rtcSession } = createGroupCallView( diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 8783c0de..383502a3 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -287,9 +287,6 @@ export const InCallView: FC = ({ timeout: { mp3: timeoutMp3, ogg: timeoutOgg }, }); }); - // configure this to sth that fits to the pickup waiting sound. - // 1600 is in sync with the animation. - const PICKUP_SOUND_INTERVAL = 1600; const pickupPhaseAudio = useAudioContext({ sounds: pickupPhaseSoundCache, @@ -386,11 +383,13 @@ export const InCallView: FC = ({ // When waiting for pickup, loop a waiting sound useEffect((): void | (() => void) => { - if (callPickupState !== "ringing") return; - const interval = window.setInterval(() => { - void pickupPhaseAudio?.playSound("waiting"); - }, PICKUP_SOUND_INTERVAL); - return (): void => window.clearInterval(interval); + if (callPickupState !== "ringing" || !pickupPhaseAudio) return; + const endSound = pickupPhaseAudio.playSoundLooping("waiting"); + return () => { + void endSound().catch((e) => { + logger.error("Failed to stop ringing sound", e); + }); + }; }, [callPickupState, pickupPhaseAudio]); // Waiting UI overlay diff --git a/src/room/ReactionAudioRenderer.test.tsx b/src/room/ReactionAudioRenderer.test.tsx index c61cbd82..1d963491 100644 --- a/src/room/ReactionAudioRenderer.test.tsx +++ b/src/room/ReactionAudioRenderer.test.tsx @@ -69,6 +69,7 @@ beforeEach(() => { playSound = vitest.fn(); (useAudioContext as MockedFunction).mockReturnValue({ playSound, + playSoundLooping: vitest.fn(), }); }); diff --git a/src/useAudioContext.tsx b/src/useAudioContext.tsx index 1b00e82b..433db800 100644 --- a/src/useAudioContext.tsx +++ b/src/useAudioContext.tsx @@ -46,6 +46,40 @@ async function playSound( return p; } +/** + * Play a sound though a given AudioContext, looping until stopped. 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 function used to end the sound. This function will return a promise when the sound has stopped. + */ +function playSoundLooping( + 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); + controls.setPlaybackStarted(); + src.loop = true; + src.start(); + return () => { + const p = new Promise((r) => + src.addEventListener("ended", () => r()), + ); + src.stop(); + return p; + }; +} + interface Props { /** * The sounds to play. If no sounds should be played then @@ -59,6 +93,7 @@ interface Props { interface UseAudioContext { playSound(soundName: S): Promise; + playSoundLooping(soundName: S): () => Promise; } /** @@ -146,5 +181,16 @@ export function useAudioContext( earpiecePan, ); }, + playSoundLooping: (name): (() => Promise) => { + if (!audioBuffers[name]) { + throw Error(`Tried to play a sound that wasn't buffered (${name})`); + } + return playSoundLooping( + audioContext, + audioBuffers[name], + soundEffectVolume * earpieceVolume, + earpiecePan, + ); + }, }; }