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(); playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound, playSound,
playSoundLooping: vitest.fn(),
}); });
}); });

View File

@@ -12,6 +12,7 @@ import {
onTestFinished, onTestFinished,
test, test,
vi, vi,
vitest,
} from "vitest"; } from "vitest";
import { render, waitFor, screen } from "@testing-library/react"; import { render, waitFor, screen } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
@@ -94,6 +95,7 @@ beforeEach(() => {
playSound = vi.fn(); playSound = vi.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound, playSound,
playSoundLooping: vi.fn(),
}); });
// A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here. // A trivial implementation of Active call to ensure we are testing GroupCallView exclusively here.
(ActiveCall as MockedFunction<typeof ActiveCall>).mockImplementation( (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({ (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound, playSound,
playSoundLooping: vitest.fn(),
}); });
const { getByText, rtcSession } = createGroupCallView( const { getByText, rtcSession } = createGroupCallView(

View File

@@ -287,9 +287,6 @@ export const InCallView: FC<InCallViewProps> = ({
timeout: { mp3: timeoutMp3, ogg: timeoutOgg }, 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({ const pickupPhaseAudio = useAudioContext({
sounds: pickupPhaseSoundCache, sounds: pickupPhaseSoundCache,
@@ -386,11 +383,13 @@ export const InCallView: FC<InCallViewProps> = ({
// When waiting for pickup, loop a waiting sound // When waiting for pickup, loop a waiting sound
useEffect((): void | (() => void) => { useEffect((): void | (() => void) => {
if (callPickupState !== "ringing") return; if (callPickupState !== "ringing" || !pickupPhaseAudio) return;
const interval = window.setInterval(() => { const endSound = pickupPhaseAudio.playSoundLooping("waiting");
void pickupPhaseAudio?.playSound("waiting"); return () => {
}, PICKUP_SOUND_INTERVAL); void endSound().catch((e) => {
return (): void => window.clearInterval(interval); logger.error("Failed to stop ringing sound", e);
});
};
}, [callPickupState, pickupPhaseAudio]); }, [callPickupState, pickupPhaseAudio]);
// Waiting UI overlay // Waiting UI overlay

View File

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

View File

@@ -46,6 +46,40 @@ async function playSound(
return p; 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> { interface Props<S extends string> {
/** /**
* The sounds to play. If no sounds should be played then * The sounds to play. If no sounds should be played then
@@ -59,6 +93,7 @@ interface Props<S extends string> {
interface UseAudioContext<S> { interface UseAudioContext<S> {
playSound(soundName: S): Promise<void>; playSound(soundName: S): Promise<void>;
playSoundLooping(soundName: S): () => Promise<void>;
} }
/** /**
@@ -146,5 +181,16 @@ export function useAudioContext<S extends string>(
earpiecePan, 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,
);
},
}; };
} }