Skip to content

Commit 296fd21

Browse files
authored
fix: harden DTMF sending and always negotiate telephone-event codec (#8)
Guard sendDtmf against unready or throwing RTCDTMFSender instances so one bad sender doesn't block others, and apply codec preferences unconditionally on audio tracks so telephone-event is always offered regardless of caller-provided codecPreferences. Also warn when an audio SDP offer lacks telephone-event, since DTMF can never negotiate for that session. Co-authored-by: smoghe-bw <smoghe-bw>
1 parent a750236 commit 296fd21

2 files changed

Lines changed: 97 additions & 17 deletions

File tree

src/v1/bandwidthRtc.test.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ describe("bandwidthRtcV1 sendDtmf", () => {
6161
setupMocks();
6262
});
6363

64-
function makeDtmfSender() {
65-
return { insertDTMF: jest.fn() };
64+
function makeDtmfSender(canInsertDTMF: boolean = true) {
65+
return { insertDTMF: jest.fn(), canInsertDTMF };
6666
}
6767

6868
test("calls insertDTMF on all registered senders when no streamId given", () => {
@@ -117,9 +117,41 @@ describe("bandwidthRtcV1 sendDtmf", () => {
117117

118118
expect(sender.insertDTMF).not.toHaveBeenCalled();
119119
});
120+
121+
test("skips a sender that is not ready (canInsertDTMF false) without throwing", () => {
122+
const brtc = new BandwidthRtc();
123+
const notReady = makeDtmfSender(false);
124+
const ready = makeDtmfSender(true);
125+
(brtc as any).localDtmfSenders.set("stream-1", notReady);
126+
(brtc as any).localDtmfSenders.set("stream-2", ready);
127+
128+
expect(() => brtc.sendDtmf("5")).not.toThrow();
129+
130+
expect(notReady.insertDTMF).not.toHaveBeenCalled();
131+
expect(ready.insertDTMF).toHaveBeenCalledWith("5", 100, 70);
132+
});
133+
134+
test("catches an insertDTMF error on one sender and still calls the others", () => {
135+
const brtc = new BandwidthRtc();
136+
const throwing = makeDtmfSender();
137+
throwing.insertDTMF.mockImplementation(() => {
138+
throw new DOMException("not ready", "InvalidStateError");
139+
});
140+
const healthy = makeDtmfSender();
141+
(brtc as any).localDtmfSenders.set("stream-1", throwing);
142+
(brtc as any).localDtmfSenders.set("stream-2", healthy);
143+
144+
expect(() => brtc.sendDtmf("5")).not.toThrow();
145+
146+
expect(healthy.insertDTMF).toHaveBeenCalledWith("5", 100, 70);
147+
});
120148
});
121149

122150
describe("bandwidthRtcV1 addStreamToPublishingPeerConnection", () => {
151+
afterEach(() => {
152+
delete (global as any).RTCRtpSender;
153+
});
154+
123155
function makeTransceiver(dtmf: RTCDTMFSender | null = { insertDTMF: jest.fn() } as any) {
124156
return { sender: { dtmf }, setCodecPreferences: jest.fn() };
125157
}
@@ -191,6 +223,30 @@ describe("bandwidthRtcV1 addStreamToPublishingPeerConnection", () => {
191223

192224
expect(transceiver.setCodecPreferences).toHaveBeenCalledWith([opusCodec]);
193225
});
226+
227+
test("forces telephone-event into codec preferences even without explicit codecPreferences", () => {
228+
const brtc = new BandwidthRtc();
229+
const transceiver = makeTransceiver();
230+
withPublishingPeerConnection(brtc, transceiver);
231+
232+
const opusCodec = { mimeType: "audio/opus", clockRate: 48000 };
233+
const telephoneEventCodec = { mimeType: "audio/telephone-event", clockRate: 8000 };
234+
(global as any).RTCRtpSender = { getCapabilities: jest.fn().mockReturnValue({ codecs: [opusCodec, telephoneEventCodec] }) };
235+
236+
(brtc as any).addStreamToPublishingPeerConnection(makeMockStream("stream-1", "audio"));
237+
238+
expect(transceiver.setCodecPreferences).toHaveBeenCalledWith([opusCodec, telephoneEventCodec]);
239+
});
240+
241+
test("skips setCodecPreferences when RTCRtpSender is unavailable (e.g. non-browser environment)", () => {
242+
const brtc = new BandwidthRtc();
243+
const transceiver = makeTransceiver();
244+
withPublishingPeerConnection(brtc, transceiver);
245+
246+
expect(() => (brtc as any).addStreamToPublishingPeerConnection(makeMockStream("stream-1", "audio"))).not.toThrow();
247+
248+
expect(transceiver.setCodecPreferences).not.toHaveBeenCalled();
249+
});
194250
});
195251

196252
describe("bandwidthRtcV1 connect method", () => {

src/v1/bandwidthRtc.ts

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -292,10 +292,27 @@ export class BandwidthRtc {
292292
* @param interToneGap Gap between tones in milliseconds (default: 70). Minimum 30.
293293
*/
294294
sendDtmf(tone: string, streamId?: string, duration: number = 100, interToneGap: number = 70) {
295+
const insert = (dtmfSender: RTCDTMFSender, id: string) => {
296+
if (!dtmfSender.canInsertDTMF) {
297+
logger.warn(`sendDtmf: DTMF sender for stream ${id} is not ready (canInsertDTMF is false); skipping`);
298+
return;
299+
}
300+
try {
301+
dtmfSender.insertDTMF(tone, duration, interToneGap);
302+
} catch (err) {
303+
logger.warn(`sendDtmf: insertDTMF failed for stream ${id}`, err);
304+
}
305+
};
306+
295307
if (streamId) {
296-
this.localDtmfSenders.get(streamId)?.insertDTMF(tone, duration, interToneGap);
308+
const dtmfSender = this.localDtmfSenders.get(streamId);
309+
if (dtmfSender) {
310+
insert(dtmfSender, streamId);
311+
} else {
312+
logger.warn(`sendDtmf: no DTMF sender registered for stream ${streamId}`);
313+
}
297314
} else {
298-
this.localDtmfSenders.forEach((dtmfSender) => dtmfSender.insertDTMF(tone, duration, interToneGap));
315+
this.localDtmfSenders.forEach(insert);
299316
}
300317
}
301318

@@ -386,6 +403,12 @@ export class BandwidthRtc {
386403
iceRestart: restartIce,
387404
});
388405

406+
// Diagnostic only: if an audio m-line is offered without telephone-event, DTMF
407+
// can never negotiate for this session regardless of how long sendDtmf waits.
408+
if (localSdpOffer.sdp?.includes("m=audio") && !localSdpOffer.sdp.includes(TELEPHONE_EVENT_MIME_TYPE.split("/")[1])) {
409+
logger.warn("Publish SDP offer has an audio track but no telephone-event codec; DTMF will not be able to negotiate for this session");
410+
}
411+
389412
let publishMetadata = {
390413
mediaStreams: {},
391414
dataChannels: {},
@@ -675,23 +698,24 @@ export class BandwidthRtc {
675698
this.localDtmfSenders.set(mediaStream.id, dtmfSender);
676699
}
677700

678-
if (codecPreferences) {
679-
if (track.kind === TRACK_KIND_AUDIO && codecPreferences.audio) {
680-
// setCodecPreferences is a strict allowlist: any codec omitted from the
681-
// list is dropped from the SDP offer. telephone-event must always be
682-
// present so that RTCDTMFSender can send RFC 4733 DTMF packets.
683-
const hasTelephoneEvent = codecPreferences.audio.some((c) => c.mimeType.toLowerCase() === TELEPHONE_EVENT_MIME_TYPE);
701+
if (track.kind === TRACK_KIND_AUDIO) {
702+
// setCodecPreferences is a strict allowlist: any codec omitted from the list is
703+
// dropped from the SDP offer. Apply it unconditionally (not just when the caller
704+
// passes codecPreferences) so telephone-event is always present and RTCDTMFSender
705+
// can send RFC 4733 DTMF packets, regardless of the browser's default codec offer.
706+
const audioCapabilities = typeof RTCRtpSender !== "undefined" ? RTCRtpSender.getCapabilities(TRACK_KIND_AUDIO) : undefined;
707+
const audioCodecs = codecPreferences?.audio ?? audioCapabilities?.codecs;
708+
if (audioCodecs) {
709+
const hasTelephoneEvent = audioCodecs.some((c) => c.mimeType.toLowerCase() === TELEPHONE_EVENT_MIME_TYPE);
684710
if (!hasTelephoneEvent) {
685-
const telephoneEventCodec = RTCRtpSender.getCapabilities(TRACK_KIND_AUDIO)?.codecs.find(
686-
(c) => c.mimeType.toLowerCase() === TELEPHONE_EVENT_MIME_TYPE,
687-
);
688-
transceiver.setCodecPreferences(telephoneEventCodec ? [...codecPreferences.audio, telephoneEventCodec] : codecPreferences.audio);
711+
const telephoneEventCodec = audioCapabilities?.codecs.find((c) => c.mimeType.toLowerCase() === TELEPHONE_EVENT_MIME_TYPE);
712+
transceiver.setCodecPreferences(telephoneEventCodec ? [...audioCodecs, telephoneEventCodec] : audioCodecs);
689713
} else {
690-
transceiver.setCodecPreferences(codecPreferences.audio);
714+
transceiver.setCodecPreferences(audioCodecs);
691715
}
692-
} else if (track.kind === TRACK_KIND_VIDEO && codecPreferences.video) {
693-
transceiver.setCodecPreferences(codecPreferences.video);
694716
}
717+
} else if (track.kind === TRACK_KIND_VIDEO && codecPreferences?.video) {
718+
transceiver.setCodecPreferences(codecPreferences.video);
695719
}
696720
});
697721
}

0 commit comments

Comments
 (0)