Loop ring sound inside Audio context for better perf.

This commit is contained in:
Half-Shot
2025-09-12 17:44:36 +01:00
parent bf53a803f7
commit 0b015dced8
5 changed files with 58 additions and 8 deletions

View File

@@ -54,6 +54,7 @@ beforeEach(() => {
playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
});
});

View File

@@ -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<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vi.fn(),
});
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation(
@@ -206,6 +208,7 @@ test("GroupCallView plays a leave sound synchronously in widget mode", async ()
);
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
});
const { getByText, rtcSession } = createGroupCallView(

View File

@@ -287,9 +287,6 @@ export const InCallView: FC<InCallViewProps> = ({
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<InCallViewProps> = ({
// 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

View File

@@ -69,6 +69,7 @@ beforeEach(() => {
playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound,
playSoundLooping: vitest.fn(),
});
});

View File

@@ -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<void> {
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<void>((r) =>
src.addEventListener("ended", () => r()),
);
src.stop();
return p;
};
}
interface Props<S extends string> {
/**
* The sounds to play. If no sounds should be played then
@@ -59,6 +93,7 @@ interface Props<S extends string> {
interface UseAudioContext<S> {
playSound(soundName: S): Promise<void>;
playSoundLooping(soundName: S): () => Promise<void>;
}
/**
@@ -146,5 +181,16 @@ export function useAudioContext<S extends string>(
earpiecePan,
);
},
playSoundLooping: (name): (() => Promise<void>) => {
if (!audioBuffers[name]) {
throw Error(`Tried to play a sound that wasn't buffered (${name})`);
}
return playSoundLooping(
audioContext,
audioBuffers[name],
soundEffectVolume * earpieceVolume,
earpiecePan,
);
},
};
}