Add sounds for ringing (#3490)

* add wait for pickup overlay

Signed-off-by: Timo K <toger5@hotmail.de>

* refactor and leave logic

Signed-off-by: Timo K <toger5@hotmail.de>

* recursive play sound logic

Signed-off-by: Timo K <toger5@hotmail.de>

* review

Signed-off-by: Timo K <toger5@hotmail.de>

* text color

Signed-off-by: Timo K <toger5@hotmail.de>

* overlay styling and interval fixes

Signed-off-by: Timo K <toger5@hotmail.de>

* fix permissions and styling

Signed-off-by: Timo K <toger5@hotmail.de>

* fix always getting pickup sound

Signed-off-by: Timo K <toger5@hotmail.de>

* Add sound effects for declined,timeout and ringtone

* better ringtone

* Integrate sounds

* Ensure leave sound does not play

* Remove unused blocked sound

* fix test

* Improve tests

* Loop ring sound inside Audio context for better perf.

* lint

* better ringtone

* Update to delay ringtone logic.

* lint + fix test

* Tidy up ring sync and add comments.

* lint

* Refactor onLeave to take a sound so we don't need to repeat the sound

* fix import

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo K <toger5@hotmail.de>
This commit is contained in:
Will Hunt
2025-09-15 15:41:15 +01:00
committed by GitHub
parent 76465d0e63
commit e201258af3
18 changed files with 207 additions and 54 deletions

View File

@@ -54,6 +54,8 @@ beforeEach(() => {
playSound = vitest.fn(); playSound = vitest.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound, playSound,
playSoundLooping: vitest.fn(),
soundDuration: {},
}); });
}); });
@@ -105,6 +107,20 @@ test("plays a sound when a user leaves", () => {
expect(playSound).toBeCalledWith("left"); expect(playSound).toBeCalledWith("left");
}); });
test("does not play a sound before the call is successful", () => {
const { vm, rtcMemberships$ } = getBasicCallViewModelEnvironment(
[local, alice],
[localRtcMember],
{ waitForCallPickup: true },
);
render(<CallEventAudioRenderer vm={vm} />);
act(() => {
rtcMemberships$.next([localRtcMember]);
});
expect(playSound).not.toBeCalledWith("left");
});
test("plays no sound when the participant list is more than the maximum size", () => { test("plays no sound when the participant list is more than the maximum size", () => {
const mockRtcMemberships: CallMembership[] = [localRtcMember]; const mockRtcMemberships: CallMembership[] = [localRtcMember];
for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) { for (let i = 0; i < MAX_PARTICIPANT_COUNT_FOR_SOUND; i++) {

View File

@@ -16,6 +16,10 @@ import handSoundOgg from "../sound/raise_hand.ogg";
import handSoundMp3 from "../sound/raise_hand.mp3"; import handSoundMp3 from "../sound/raise_hand.mp3";
import screenShareStartedOgg from "../sound/screen_share_started.ogg"; import screenShareStartedOgg from "../sound/screen_share_started.ogg";
import screenShareStartedMp3 from "../sound/screen_share_started.mp3"; import screenShareStartedMp3 from "../sound/screen_share_started.mp3";
import declineMp3 from "../sound/call_declined.mp3?url";
import declineOgg from "../sound/call_declined.ogg?url";
import timeoutMp3 from "../sound/call_timeout.mp3?url";
import timeoutOgg from "../sound/call_timeout.ogg?url";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
@@ -37,8 +41,18 @@ export const callEventAudioSounds = prefetchSounds({
mp3: screenShareStartedMp3, mp3: screenShareStartedMp3,
ogg: screenShareStartedOgg, ogg: screenShareStartedOgg,
}, },
decline: {
mp3: declineMp3,
ogg: declineOgg,
},
timeout: {
mp3: timeoutMp3,
ogg: timeoutOgg,
},
}); });
export type CallEventSounds = keyof Awaited<typeof callEventAudioSounds>;
export function CallEventAudioRenderer({ export function CallEventAudioRenderer({
vm, vm,
muted, muted,

View File

@@ -12,6 +12,7 @@ import {
onTestFinished, onTestFinished,
test, test,
vi, vi,
vitest,
} from "vitest"; } from "vitest";
import { render, waitFor, screen, act } from "@testing-library/react"; import { render, waitFor, screen, act } from "@testing-library/react";
import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk"; import { type MatrixClient, JoinRule, type RoomState } from "matrix-js-sdk";
@@ -97,13 +98,15 @@ beforeEach(() => {
playSound = vi.fn(); playSound = vi.fn();
(useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({ (useAudioContext as MockedFunction<typeof useAudioContext>).mockReturnValue({
playSound, playSound,
playSoundLooping: vi.fn(),
soundDuration: {},
}); });
// 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(
({ onLeave }) => { ({ onLeave }) => {
return ( return (
<div> <div>
<button onClick={() => onLeave()}>Leave</button> <button onClick={() => onLeave("user")}>Leave</button>
</div> </div>
); );
}, },
@@ -209,6 +212,8 @@ 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(),
soundDuration: {},
}); });
const { getByText, rtcSession } = createGroupCallView( const { getByText, rtcSession } = createGroupCallView(

View File

@@ -53,7 +53,10 @@ import { InviteModal } from "./InviteModal";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams"; import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
import { callEventAudioSounds } from "./CallEventAudioRenderer"; import {
callEventAudioSounds,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import { useLatest } from "../useLatest"; import { useLatest } from "../useLatest";
import { usePageTitle } from "../usePageTitle"; import { usePageTitle } from "../usePageTitle";
import { import {
@@ -317,8 +320,11 @@ export const GroupCallView: FC<Props> = ({
const navigate = useNavigate(); const navigate = useNavigate();
const onLeave = useCallback( const onLeave = useCallback(
(cause: "user" | "error" = "user"): void => { (
const audioPromise = leaveSoundContext.current?.playSound("left"); cause: "user" | "error" = "user",
playSound: CallEventSounds = "left",
): void => {
const audioPromise = leaveSoundContext.current?.playSound(playSound);
// In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent, // In embedded/widget mode the iFrame will be killed right after the call ended prohibiting the posthog event from getting sent,
// therefore we want the event to be sent instantly without getting queued/batched. // therefore we want the event to be sent instantly without getting queued/batched.
const sendInstantly = !!widget; const sendInstantly = !!widget;

View File

@@ -95,7 +95,10 @@ import {
} from "../reactions/useReactionsSender"; } from "../reactions/useReactionsSender";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer"; import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { ReactionsOverlay } from "./ReactionsOverlay"; import { ReactionsOverlay } from "./ReactionsOverlay";
import { CallEventAudioRenderer } from "./CallEventAudioRenderer"; import {
CallEventAudioRenderer,
type CallEventSounds,
} from "./CallEventAudioRenderer";
import { import {
debugTileLayout as debugTileLayoutSetting, debugTileLayout as debugTileLayoutSetting,
useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting, useExperimentalToDeviceTransport as useExperimentalToDeviceTransportSetting,
@@ -117,11 +120,8 @@ import { Avatar, Size as AvatarSize } from "../Avatar";
import waitingStyles from "./WaitingForJoin.module.css"; import waitingStyles from "./WaitingForJoin.module.css";
import { prefetchSounds } from "../soundUtils"; import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext"; import { useAudioContext } from "../useAudioContext";
// TODO: Dont use this!!! use the correct sound import ringtoneMp3 from "../sound/ringtone.mp3?url";
import genericSoundOgg from "../sound/reactions/generic.ogg?url"; import ringtoneOgg from "../sound/ringtone.ogg?url";
import genericSoundMp3 from "../sound/reactions/generic.mp3?url";
import leftCallSoundMp3 from "../sound/left_call.mp3";
import leftCallSoundOgg from "../sound/left_call.ogg";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {}); const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -232,7 +232,7 @@ export interface InCallViewProps {
livekitRoom: LivekitRoom; livekitRoom: LivekitRoom;
muteStates: MuteStates; muteStates: MuteStates;
/** Function to call when the user explicitly ends the call */ /** Function to call when the user explicitly ends the call */
onLeave: () => void; onLeave: (cause: "user", soundFile?: CallEventSounds) => void;
header: HeaderStyle; header: HeaderStyle;
otelGroupCallMembership?: OTelGroupCallMembership; otelGroupCallMembership?: OTelGroupCallMembership;
connState: ECConnectionState; connState: ECConnectionState;
@@ -281,14 +281,9 @@ export const InCallView: FC<InCallViewProps> = ({
// Preload a waiting and decline sounds // Preload a waiting and decline sounds
const pickupPhaseSoundCache = useInitial(async () => { const pickupPhaseSoundCache = useInitial(async () => {
return prefetchSounds({ return prefetchSounds({
waiting: { mp3: genericSoundMp3, ogg: genericSoundOgg }, waiting: { mp3: ringtoneMp3, ogg: ringtoneOgg },
decline: { mp3: leftCallSoundMp3, ogg: leftCallSoundOgg },
// Do we want a timeout sound?
}); });
}); });
// 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,
@@ -356,34 +351,47 @@ export const InCallView: FC<InCallViewProps> = ({
const showFooter = useBehavior(vm.showFooter$); const showFooter = useBehavior(vm.showFooter$);
const earpieceMode = useBehavior(vm.earpieceMode$); const earpieceMode = useBehavior(vm.earpieceMode$);
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$); const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
useSubscription(vm.autoLeave$, onLeave); useSubscription(vm.autoLeave$, () => onLeave("user"));
// We need to set the proper timings on the animation based upon the sound length.
const ringDuration = pickupPhaseAudio?.soundDuration["waiting"] ?? 1;
useEffect((): (() => void) => {
// The CSS animation includes the delay, so we must double the length of the sound.
window.document.body.style.setProperty(
"--call-ring-duration-s",
`${ringDuration * 2}s`,
);
window.document.body.style.setProperty(
"--call-ring-delay-s",
`${ringDuration}s`,
);
// Remove properties when we unload.
return () => {
window.document.body.style.removeProperty("--call-ring-duration-s");
window.document.body.style.removeProperty("--call-ring-delay-s");
};
}, [pickupPhaseAudio?.soundDuration, ringDuration]);
// When we enter timeout or decline we will leave the call. // When we enter timeout or decline we will leave the call.
useEffect((): void | (() => void) => { useEffect((): void | (() => void) => {
if (callPickupState === "timeout") { if (callPickupState === "timeout") {
onLeave(); onLeave("user", "timeout");
} }
if (callPickupState === "decline") { if (callPickupState === "decline") {
// Wait for the sound to finish before leaving onLeave("user", "decline");
void pickupPhaseAudio
?.playSound("decline")
.catch((e) => {
logger.error("Failed to play decline sound", e);
})
.finally(() => {
onLeave();
});
} }
}, [callPickupState, onLeave, pickupPhaseAudio]); }, [callPickupState, onLeave, pickupPhaseAudio]);
// 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", ringDuration);
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, ringDuration]);
// Waiting UI overlay // Waiting UI overlay
const waitingOverlay: JSX.Element | null = useMemo(() => { const waitingOverlay: JSX.Element | null = useMemo(() => {
@@ -823,7 +831,7 @@ export const InCallView: FC<InCallViewProps> = ({
<EndCallButton <EndCallButton
key="end_call" key="end_call"
onClick={function (): void { onClick={function (): void {
onLeave(); onLeave("user");
}} }}
onTouchEnd={onControlsTouchEnd} onTouchEnd={onControlsTouchEnd}
data-testid="incall_leave" data-testid="incall_leave"

View File

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

View File

@@ -25,7 +25,9 @@
inset: -12px; inset: -12px;
border-radius: 9999px; border-radius: 9999px;
border: 12px solid rgba(255, 255, 255, 0.6); border: 12px solid rgba(255, 255, 255, 0.6);
animation: pulse 1.6s ease-out infinite; animation: pulse var(--call-ring-duration-s) ease-out infinite;
animation-delay: 1s;
opacity: 0;
} }
.text { .text {
@@ -36,13 +38,24 @@
0% { 0% {
transform: scale(0.95); transform: scale(0.95);
opacity: 0.7; opacity: 0.7;
transform: scale(0);
opacity: 1;
} }
70% { 35% {
transform: scale(1.15); transform: scale(1.15);
opacity: 0.15; opacity: 0.15;
} }
100% { 50% {
transform: scale(1.2); transform: scale(1.2);
opacity: 0; opacity: 0;
} }
50.01% {
transform: scale(0);
}
85% {
transform: scale(0);
}
100% {
transform: scale(0);
}
} }

Binary file not shown.

Binary file not shown.

BIN
src/sound/call_declined.mp3 Normal file

Binary file not shown.

BIN
src/sound/call_declined.ogg Normal file

Binary file not shown.

BIN
src/sound/call_timeout.mp3 Normal file

Binary file not shown.

BIN
src/sound/call_timeout.ogg Normal file

Binary file not shown.

BIN
src/sound/ringtone.mp3 Normal file

Binary file not shown.

BIN
src/sound/ringtone.ogg Normal file

Binary file not shown.

View File

@@ -49,6 +49,7 @@ import {
race, race,
scan, scan,
skip, skip,
skipWhile,
startWith, startWith,
switchAll, switchAll,
switchMap, switchMap,
@@ -853,17 +854,6 @@ export class CallViewModel extends ViewModel {
throttleTime(THROTTLE_SOUND_EFFECT_MS), throttleTime(THROTTLE_SOUND_EFFECT_MS),
); );
public readonly leaveSoundEffect$ = this.userMedia$.pipe(
pairwise(),
filter(
([prev, current]) =>
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current.length < prev.length,
),
map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS),
);
/** /**
* The number of participants currently in the call. * The number of participants currently in the call.
* *
@@ -963,7 +953,9 @@ export class CallViewModel extends ViewModel {
* - "success": Someone else joined. The call is in a normal state. No audiovisual feedback. * - "success": Someone else joined. The call is in a normal state. No audiovisual feedback.
* - null: EC is configured to never show any waiting for answer state. * - null: EC is configured to never show any waiting for answer state.
*/ */
public readonly callPickupState$ = this.options.waitForCallPickup public readonly callPickupState$: Behavior<
"unknown" | "ringing" | "timeout" | "decline" | "success" | null
> = this.options.waitForCallPickup
? this.scope.behavior< ? this.scope.behavior<
"unknown" | "ringing" | "timeout" | "decline" | "success" "unknown" | "ringing" | "timeout" | "decline" | "success"
>( >(
@@ -983,6 +975,24 @@ export class CallViewModel extends ViewModel {
) )
: constant(null); : constant(null);
public readonly leaveSoundEffect$ = combineLatest([
this.callPickupState$,
this.userMedia$,
]).pipe(
// Until the call is successful, do not play a leave sound.
// If callPickupState$ is null, then we always play the sound as it will not conflict with a decline sound.
skipWhile(([c]) => c !== null && c !== "success"),
map(([, userMedia]) => userMedia),
pairwise(),
filter(
([prev, current]) =>
current.length <= MAX_PARTICIPANT_COUNT_FOR_SOUND &&
current.length < prev.length,
),
map(() => {}),
throttleTime(THROTTLE_SOUND_EFFECT_MS),
);
/** /**
* List of MediaItems that we want to display, that are of type ScreenShare * List of MediaItems that we want to display, that are of type ScreenShare
*/ */

View File

@@ -32,6 +32,8 @@ async function playSound(
buffer: AudioBuffer, buffer: AudioBuffer,
volume: number, volume: number,
stereoPan: number, stereoPan: number,
delayS = 0,
abort?: AbortController,
): Promise<void> { ): Promise<void> {
const gain = ctx.createGain(); const gain = ctx.createGain();
gain.gain.setValueAtTime(volume, 0); gain.gain.setValueAtTime(volume, 0);
@@ -39,13 +41,62 @@ async function playSound(
pan.pan.setValueAtTime(stereoPan, 0); pan.pan.setValueAtTime(stereoPan, 0);
const src = ctx.createBufferSource(); const src = ctx.createBufferSource();
src.buffer = buffer; src.buffer = buffer;
src.connect(gain).connect(pan).connect(ctx.destination); abort?.signal.addEventListener("abort", () => {
src.disconnect();
});
const p = new Promise<void>((r) => src.addEventListener("ended", () => r())); const p = new Promise<void>((r) => src.addEventListener("ended", () => r()));
src.connect(gain).connect(pan).connect(ctx.destination);
controls.setPlaybackStarted(); controls.setPlaybackStarted();
src.start(); src.start(ctx.currentTime + delayS);
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,
delayS?: number,
): () => Promise<void> {
if (delayS === 0) {
throw Error("Looping sounds must have a delay");
}
// Our audio loop
let lastSoundPromise: Promise<void>;
let nextSoundPromise: Promise<void>;
let ac: AbortController | undefined;
void (async (): Promise<void> => {
ac = new AbortController();
// Play a sound immediately
lastSoundPromise = Promise.resolve();
do {
// Queue up the next sound.
nextSoundPromise = playSound(ctx, buffer, volume, stereoPan, delayS, ac);
// Await the previous sound.
await lastSoundPromise;
// Swap the promises over, and loop round to play the next sound.
lastSoundPromise = nextSoundPromise;
} while (!ac.signal.aborted);
})();
return async () => {
ac?.abort();
// Wait for sounds to finish.
await lastSoundPromise;
await nextSoundPromise;
};
}
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
@@ -57,8 +108,13 @@ interface Props<S extends string> {
muted?: boolean; muted?: boolean;
} }
interface UseAudioContext<S> { interface UseAudioContext<S extends string> {
playSound(soundName: S): Promise<void>; playSound(soundName: S): Promise<void>;
playSoundLooping(soundName: S, delayS?: number): () => Promise<void>;
/**
* Map of sound name to duration in seconds.
*/
soundDuration: Record<string, number>;
} }
/** /**
@@ -146,5 +202,23 @@ export function useAudioContext<S extends string>(
earpiecePan, earpiecePan,
); );
}, },
playSoundLooping: (name, delayS: number): (() => 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,
delayS,
);
},
soundDuration: Object.fromEntries(
Object.entries(audioBuffers).map(([k, v]) => [
k,
(v as AudioBuffer).duration,
]),
),
}; };
} }

View File

@@ -22,7 +22,10 @@ import {
} from "matrix-js-sdk"; } from "matrix-js-sdk";
import { E2eeType } from "../e2ee/e2eeType"; import { E2eeType } from "../e2ee/e2eeType";
import { CallViewModel } from "../state/CallViewModel"; import {
CallViewModel,
type CallViewModelOptions,
} from "../state/CallViewModel";
import { import {
mockLivekitRoom, mockLivekitRoom,
mockMatrixRoom, mockMatrixRoom,
@@ -122,6 +125,7 @@ export function getBasicRTCSession(
export function getBasicCallViewModelEnvironment( export function getBasicCallViewModelEnvironment(
members: RoomMember[], members: RoomMember[],
initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember], initialRtcMemberships: CallMembership[] = [localRtcMember, aliceRtcMember],
callViewModelOptions: Partial<CallViewModelOptions> = {},
): { ): {
vm: CallViewModel; vm: CallViewModel;
rtcMemberships$: BehaviorSubject<CallMembership[]>; rtcMemberships$: BehaviorSubject<CallMembership[]>;
@@ -148,6 +152,7 @@ export function getBasicCallViewModelEnvironment(
mockMediaDevices({}), mockMediaDevices({}),
{ {
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT }, encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
...callViewModelOptions,
}, },
of(ConnectionState.Connected), of(ConnectionState.Connected),
handRaisedSubject$, handRaisedSubject$,