Skip to content

Commit b85e96a

Browse files
authored
feat: Media call - generated DTMF tone sounds (RocketChat#37074)
1 parent d166e2a commit b85e96a

4 files changed

Lines changed: 143 additions & 1 deletion

File tree

.changeset/plenty-tips-care.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@rocket.chat/ui-voip": minor
3+
---
4+
5+
Introduces audio feedback for the Voice Call Dialpad.

packages/ui-voip/src/v2/MediaCallProvider.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { useCallSounds } from './useCallSounds';
2121
import { useMediaSession } from './useMediaSession';
2222
import { useMediaSessionInstance } from './useMediaSessionInstance';
2323
import useMediaStream from './useMediaStream';
24+
import { isValidTone, useTonePlayer } from './useTonePlayer';
2425
import { stopTracks, useDevicePermissionPrompt2, PermissionRequestCancelledCallRejectedError } from '../hooks/useDevicePermissionPrompt';
2526

2627
const MediaCallProvider = ({ children }: { children: React.ReactNode }) => {
@@ -40,7 +41,7 @@ const MediaCallProvider = ({ children }: { children: React.ReactNode }) => {
4041
const setOutputMediaDevice = useSetOutputMediaDevice();
4142
const setInputMediaDevice = useSetInputMediaDevice();
4243

43-
const { audioInput } = useSelectedDevices() || {};
44+
const { audioInput, audioOutput } = useSelectedDevices() || {};
4445

4546
const requestDevice = useDevicePermissionPrompt2();
4647

@@ -178,8 +179,13 @@ const MediaCallProvider = ({ children }: { children: React.ReactNode }) => {
178179
setModal(<TransferModal onCancel={onCancel} onConfirm={onConfirm} />);
179180
};
180181

182+
const playTone = useTonePlayer(audioOutput?.id);
183+
181184
const onTone = (tone: string) => {
182185
session.sendTone(tone);
186+
if (isValidTone(tone)) {
187+
playTone(tone);
188+
}
183189
};
184190

185191
const onEndCall = () => {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import type { Meta, StoryFn } from '@storybook/react';
22

33
import Keypad from './Keypad';
4+
import { useTonePlayer } from '../../useTonePlayer';
45

56
export default {
67
title: 'V2/Components/Keypad',
78
component: Keypad,
89
} satisfies Meta<typeof Keypad>;
910

1011
export const KeypadStory: StoryFn<typeof Keypad> = () => <Keypad onKeyPress={(key) => console.log(key)} />;
12+
export const KeypadStoryWithTone: StoryFn<typeof Keypad> = () => {
13+
const playTone = useTonePlayer();
14+
return <Keypad onKeyPress={(key) => playTone(key as any)} />;
15+
};
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { useCallback, useEffect, useRef } from 'react';
2+
3+
class TonePlayer {
4+
private audioContext: AudioContext;
5+
6+
private audioElement: HTMLAudioElement;
7+
8+
private gainNode: GainNode;
9+
10+
private filter: BiquadFilterNode;
11+
12+
private destination: MediaStreamAudioDestinationNode;
13+
14+
constructor() {
15+
this.audioContext = new AudioContext();
16+
this.audioElement = new Audio();
17+
18+
// Route audio through an audio element
19+
// In order to be able to set the sink id
20+
this.destination = this.audioContext.createMediaStreamDestination();
21+
this.audioElement.srcObject = this.destination.stream;
22+
23+
// Audio volume control
24+
this.gainNode = this.audioContext.createGain();
25+
this.gainNode.gain.value = 0.5;
26+
27+
// This filter makes the sound more natural
28+
this.filter = this.audioContext.createBiquadFilter();
29+
this.filter.type = 'lowpass';
30+
this.filter.frequency.value = 8000;
31+
32+
this.gainNode.connect(this.filter);
33+
this.filter.connect(this.destination);
34+
}
35+
36+
public setSinkId(sinkId: string) {
37+
if (this.audioElement.setSinkId) {
38+
return this.audioElement.setSinkId(sinkId);
39+
}
40+
console.warn('setSinkId not supported on this browser');
41+
}
42+
43+
public static setupOscillator(audioCtx: AudioContext, filter: AudioNode) {
44+
const oscillator = audioCtx.createOscillator();
45+
oscillator.type = 'sine';
46+
oscillator.connect(filter);
47+
return oscillator;
48+
}
49+
50+
public play(highFreq: number, lowFreq: number, durationMs?: number) {
51+
const highFrequencyOscillator = TonePlayer.setupOscillator(this.audioContext, this.gainNode);
52+
const lowFrequencyOscillator = TonePlayer.setupOscillator(this.audioContext, this.gainNode);
53+
54+
lowFrequencyOscillator.frequency.value = lowFreq;
55+
highFrequencyOscillator.frequency.value = highFreq;
56+
57+
lowFrequencyOscillator.start();
58+
highFrequencyOscillator.start();
59+
60+
// Ensure audio element is playing
61+
if (this.audioElement.paused) {
62+
this.audioElement.play().catch((error) => {
63+
console.warn('Failed to play audio element:', error);
64+
});
65+
}
66+
67+
setTimeout(() => {
68+
lowFrequencyOscillator.stop();
69+
highFrequencyOscillator.stop();
70+
highFrequencyOscillator.disconnect();
71+
lowFrequencyOscillator.disconnect();
72+
}, durationMs ?? 400);
73+
}
74+
75+
public destroy() {
76+
this.audioContext.close();
77+
this.audioElement.pause();
78+
this.audioElement.srcObject = null;
79+
}
80+
}
81+
82+
const DIGIT_TONE_MAP = {
83+
'1': [1209, 697],
84+
'2': [1336, 697],
85+
'3': [1477, 697],
86+
'4': [1209, 770],
87+
'5': [1336, 770],
88+
'6': [1477, 770],
89+
'7': [1209, 852],
90+
'8': [1336, 852],
91+
'9': [1477, 852],
92+
'*': [1209, 941],
93+
'0': [1336, 941],
94+
'#': [1477, 941],
95+
} as const;
96+
97+
export const isValidTone = (tone: string): tone is keyof typeof DIGIT_TONE_MAP => {
98+
return Object.keys(DIGIT_TONE_MAP).includes(tone);
99+
};
100+
101+
export const useTonePlayer = (sinkId?: string) => {
102+
const tonePlayer = useRef<TonePlayer | null>(null);
103+
104+
useEffect(() => {
105+
tonePlayer.current = new TonePlayer();
106+
return () => tonePlayer.current?.destroy();
107+
}, []);
108+
109+
useEffect(() => {
110+
if (tonePlayer.current && sinkId) {
111+
tonePlayer.current.setSinkId(sinkId);
112+
}
113+
}, [sinkId]);
114+
115+
const playTone = useCallback(
116+
(digit: keyof typeof DIGIT_TONE_MAP) => {
117+
if (!tonePlayer.current) {
118+
return;
119+
}
120+
tonePlayer.current.play(DIGIT_TONE_MAP[digit][0], DIGIT_TONE_MAP[digit][1], 250);
121+
},
122+
[tonePlayer],
123+
);
124+
125+
return playTone;
126+
};

0 commit comments

Comments
 (0)