Skip to content

Commit 123878b

Browse files
smoghe-bwclaude
andcommitted
feat(dtmf): expose duration/interToneGap and fix null safety on RTCDTMFSender
- Add `duration` and `interToneGap` params to `sendDtmf` on both the public wrapper and v1 implementation, forwarded directly to `RTCDTMFSender.insertDTMF()`. README previously stated these could not be controlled; that was incorrect for the native browser API. - Guard `transceiver.sender.dtmf` before storing — the property is `RTCDTMFSender | null` per spec and the previous `!` assertion would throw in environments where DTMF is unsupported for a track. - Add five unit tests covering: broadcast, targeted, parameterised, no-senders, and unknown-streamId paths. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
1 parent 22921b4 commit 123878b

4 files changed

Lines changed: 86 additions & 14 deletions

File tree

README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,14 +83,17 @@ Please see the following resources for more information on MediaStreamConstraint
8383

8484
### DTMF
8585

86-
- Description: send a set of VoIP-network-friendly DTMF tones. The tone amplitude and duration can not be controlled
86+
- Description: send DTMF tones via the browser's native `RTCDTMFSender` (RFC 4733). Tones are forwarded as telephone-event RTP packets by the Bandwidth gateway.
8787
- Params:
88-
- tone: the digits to send, as a string, chosen from the set of valid DTMF characters [0-9,*,#,\,]
89-
- streamId (optional): the stream to 'play' the tone on
88+
- tone: the digits to send, as a string, chosen from the set of valid DTMF characters [0-9,*,#,A-D,\,]
89+
- streamId (optional): the stream to send the tone on; defaults to all published streams
90+
- duration (optional): tone duration in milliseconds, between 40 and 6000 (default: 100)
91+
- interToneGap (optional): gap between tones in milliseconds, minimum 30 (default: 70)
9092

9193
```javascript
9294
bandwidthRtc.sendDtmf("3");
9395
bandwidthRtc.sendDtmf("313,3211*#");
96+
bandwidthRtc.sendDtmf("5", undefined, 200, 100); // 200ms tone, 100ms gap
9497
```
9598

9699
## Event Listeners

src/bandwidthRtc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,12 @@ class BandwidthRtc {
211211
return devices;
212212
}
213213

214-
sendDtmf(tone: string, streamId?: string) {
214+
sendDtmf(tone: string, streamId?: string, duration?: number, interToneGap?: number) {
215215
if (!this.delegate) {
216216
throw new BandwidthRtcError("You must call 'connect' before 'sendDtmf'");
217217
}
218218

219-
return this.delegate.sendDtmf(tone, streamId);
219+
return this.delegate.sendDtmf(tone, streamId, duration, interToneGap);
220220
}
221221

222222
/**

src/v1/bandwidthRtc.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,70 @@ describe("bandwidhthRtcV1 constructor", () => {
5555
});
5656
});
5757

58+
describe("bandwidthRtcV1 sendDtmf", () => {
59+
beforeAll(() => {
60+
setupNavigatorMocks();
61+
setupMocks();
62+
});
63+
64+
function makeDtmfSender() {
65+
return { insertDTMF: jest.fn() };
66+
}
67+
68+
test("calls insertDTMF on all registered senders when no streamId given", () => {
69+
const brtc = new BandwidthRtc();
70+
const sender1 = makeDtmfSender();
71+
const sender2 = makeDtmfSender();
72+
(brtc as any).localDtmfSenders.set("stream-1", sender1);
73+
(brtc as any).localDtmfSenders.set("stream-2", sender2);
74+
75+
brtc.sendDtmf("5");
76+
77+
expect(sender1.insertDTMF).toHaveBeenCalledTimes(1);
78+
expect(sender1.insertDTMF).toHaveBeenCalledWith("5", undefined, undefined);
79+
expect(sender2.insertDTMF).toHaveBeenCalledTimes(1);
80+
expect(sender2.insertDTMF).toHaveBeenCalledWith("5", undefined, undefined);
81+
});
82+
83+
test("calls insertDTMF only on the specified stream when streamId given", () => {
84+
const brtc = new BandwidthRtc();
85+
const sender1 = makeDtmfSender();
86+
const sender2 = makeDtmfSender();
87+
(brtc as any).localDtmfSenders.set("stream-1", sender1);
88+
(brtc as any).localDtmfSenders.set("stream-2", sender2);
89+
90+
brtc.sendDtmf("9", "stream-1");
91+
92+
expect(sender1.insertDTMF).toHaveBeenCalledTimes(1);
93+
expect(sender2.insertDTMF).not.toHaveBeenCalled();
94+
});
95+
96+
test("forwards duration and interToneGap to insertDTMF", () => {
97+
const brtc = new BandwidthRtc();
98+
const sender = makeDtmfSender();
99+
(brtc as any).localDtmfSenders.set("stream-1", sender);
100+
101+
brtc.sendDtmf("1", undefined, 200, 80);
102+
103+
expect(sender.insertDTMF).toHaveBeenCalledWith("1", 200, 80);
104+
});
105+
106+
test("does not throw when no senders are registered", () => {
107+
const brtc = new BandwidthRtc();
108+
expect(() => brtc.sendDtmf("5")).not.toThrow();
109+
});
110+
111+
test("does nothing for an unknown streamId", () => {
112+
const brtc = new BandwidthRtc();
113+
const sender = makeDtmfSender();
114+
(brtc as any).localDtmfSenders.set("stream-1", sender);
115+
116+
brtc.sendDtmf("5", "nonexistent");
117+
118+
expect(sender.insertDTMF).not.toHaveBeenCalled();
119+
});
120+
});
121+
58122
describe("bandwidthRtcV1 connect method", () => {
59123
beforeAll(() => {
60124
setupNavigatorMocks();

src/v1/bandwidthRtc.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -274,15 +274,17 @@ export class BandwidthRtc {
274274
}
275275

276276
/**
277-
* DTMF Sender that layers DTMF tones onto an existing stream.
278-
* @param tone The DTMF tones to send - a string composed of the characters [0-9,*,#,\,]*
279-
* @param streamId The optional stream id to play on.
277+
* Send DTMF tones via the browser's native RTCDTMFSender (RFC 4733).
278+
* @param tone The DTMF tones to send - a string composed of the characters [0-9,*,#,A-D,\,]*
279+
* @param streamId The optional stream id to send on; defaults to all published streams.
280+
* @param duration Tone duration in milliseconds (default: 100). Must be between 40 and 6000.
281+
* @param interToneGap Gap between tones in milliseconds (default: 70). Minimum 30.
280282
*/
281-
sendDtmf(tone: string, streamId?: string) {
283+
sendDtmf(tone: string, streamId?: string, duration?: number, interToneGap?: number) {
282284
if (streamId) {
283-
this.localDtmfSenders.get(streamId)?.insertDTMF(tone);
285+
this.localDtmfSenders.get(streamId)?.insertDTMF(tone, duration, interToneGap);
284286
} else {
285-
this.localDtmfSenders.forEach((dtmfSender) => dtmfSender.insertDTMF(tone));
287+
this.localDtmfSenders.forEach((dtmfSender) => dtmfSender.insertDTMF(tone, duration, interToneGap));
286288
}
287289
}
288290

@@ -639,9 +641,12 @@ export class BandwidthRtc {
639641
streams: [mediaStream],
640642
});
641643

642-
// Inject DTMF into one audio track in the stream
643-
if (track.kind === "audio" && !this.localDtmfSenders.has(mediaStream.id)) {
644-
this.localDtmfSenders.set(mediaStream.id, transceiver.sender.dtmf!);
644+
// Inject DTMF into one audio track in the stream via the browser's native
645+
// RTCDTMFSender. rtpSender.dtmf can be null when the browser doesn't
646+
// support DTMF for this track, so guard before storing.
647+
const dtmfSender = transceiver.sender.dtmf;
648+
if (track.kind === "audio" && dtmfSender && !this.localDtmfSenders.has(mediaStream.id)) {
649+
this.localDtmfSenders.set(mediaStream.id, dtmfSender);
645650
}
646651

647652
if (codecPreferences) {

0 commit comments

Comments
 (0)