diff --git a/README.md b/README.md index d5183ca..c97b7f1 100644 --- a/README.md +++ b/README.md @@ -83,14 +83,17 @@ Please see the following resources for more information on MediaStreamConstraint ### DTMF -- Description: send a set of VoIP-network-friendly DTMF tones. The tone amplitude and duration can not be controlled +- Description: send DTMF tones via the browser's native `RTCDTMFSender` (RFC 4733). Tones are forwarded as telephone-event RTP packets by the Bandwidth gateway. - Params: - - tone: the digits to send, as a string, chosen from the set of valid DTMF characters [0-9,*,#,\,] - - streamId (optional): the stream to 'play' the tone on + - tone: the digits to send, as a string, chosen from the set of valid DTMF characters [0-9,*,#,A-D,\,] + - streamId (optional): the stream to send the tone on; defaults to all published streams + - duration (optional): tone duration in milliseconds, between 40 and 6000 (default: 100) + - interToneGap (optional): gap between tones in milliseconds, minimum 30 (default: 70) ```javascript bandwidthRtc.sendDtmf("3"); bandwidthRtc.sendDtmf("313,3211*#"); +bandwidthRtc.sendDtmf("5", undefined, 200, 100); // 200ms tone, 100ms gap ``` ## Event Listeners diff --git a/src/bandwidthRtc.ts b/src/bandwidthRtc.ts index cc78111..2b1cdcd 100644 --- a/src/bandwidthRtc.ts +++ b/src/bandwidthRtc.ts @@ -211,12 +211,12 @@ class BandwidthRtc { return devices; } - sendDtmf(tone: string, streamId?: string) { + sendDtmf(tone: string, streamId?: string, duration: number = 100, interToneGap: number = 70) { if (!this.delegate) { throw new BandwidthRtcError("You must call 'connect' before 'sendDtmf'"); } - return this.delegate.sendDtmf(tone, streamId); + return this.delegate.sendDtmf(tone, streamId, duration, interToneGap); } /** diff --git a/src/dtmfSender.test.ts b/src/dtmfSender.test.ts deleted file mode 100644 index f838cb4..0000000 --- a/src/dtmfSender.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import DtmfSender, { MaxToneDurationMs, MinToneDurationMs } from "./dtmfSender"; -import { setupMocks } from "./mocks"; - -beforeAll(() => { - setupMocks(); -}); - -test("test dtmfSender constructor", () => { - const mockSender = { - track: {}, - replaceTrack: jest.fn(), - } as unknown as RTCRtpSender; - - const dtmfSender = new DtmfSender(mockSender); - expect(dtmfSender).toBeDefined(); - expect(dtmfSender.sendDtmf).toBeDefined(); - expect(dtmfSender.disconnect).toBeDefined(); -}); - -test("test dtmfSender sendDtmf", () => { - const mockSender = { - track: {}, - replaceTrack: jest.fn(), - } as unknown as RTCRtpSender; - - const dtmfSender = new DtmfSender(mockSender); - expect(() => dtmfSender.sendDtmf("1", 100)).not.toThrow(); - expect(() => dtmfSender.sendDtmf("A", 500)).not.toThrow(); - expect(() => dtmfSender.sendDtmf("*")).not.toThrow(); - expect(() => dtmfSender.sendDtmf("5", MinToneDurationMs - 10)).toThrow(); // Below minimum - expect(() => dtmfSender.sendDtmf("Z", 100)).toThrow(); // Invalid character - expect(() => dtmfSender.sendDtmf("3", MaxToneDurationMs + 1000)).toThrow(); // Above maximum -}); diff --git a/src/dtmfSender.ts b/src/dtmfSender.ts deleted file mode 100644 index 3e5274c..0000000 --- a/src/dtmfSender.ts +++ /dev/null @@ -1,127 +0,0 @@ -// Literals -export const MaxToneDurationMs = 6000; -export const DefaultToneDurationMs = 300; -export const MinToneDurationMs = 40; -const Gain = 0.25; - -class DtmfSender { - // Audio context - private outputNode: MediaStreamAudioDestinationNode; - private outputStream: MediaStream; - private gain: GainNode; - private filter: BiquadFilterNode; - private sourceNode: MediaStreamAudioSourceNode; - private osc1: OscillatorNode; - private osc2: OscillatorNode; - - // State - private toneDuration: number = DefaultToneDurationMs; - private tone: string = ""; - private playing: boolean = false; - - private dtmfFreq: Map> = new Map([ - ["1", [1209, 697]], - ["2", [1336, 697]], - ["3", [1477, 697]], - ["4", [1209, 770]], - ["5", [1336, 770]], - ["6", [1477, 770]], - ["7", [1209, 852]], - ["8", [1336, 852]], - ["9", [1477, 852]], - ["*", [1209, 941]], - ["0", [1336, 941]], - ["#", [1477, 941]], - ]); - - constructor(sender: RTCRtpSender) { - if (!sender || !sender.track) { - throw new Error("Invalid RTCRtpSender"); - } - - let audioCtx = new AudioContext(); - this.outputNode = audioCtx.createMediaStreamDestination(); - this.outputStream = this.outputNode.stream; - - let inputStream: MediaStream = new MediaStream([sender.track]); - - this.sourceNode = audioCtx.createMediaStreamSource(inputStream); - this.sourceNode.connect(this.outputNode); - - this.osc1 = audioCtx.createOscillator(); - this.osc1.type = "sine"; - this.osc1.frequency.value = 0; - this.osc1.connect(this.outputNode); - this.osc1.start(0); - - this.osc2 = audioCtx.createOscillator(); - this.osc2.type = "sine"; - this.osc2.frequency.value = 0; - this.osc2.connect(this.outputNode); - this.osc2.start(0); - - this.gain = audioCtx.createGain(); - this.gain.gain.value = Gain; - - this.filter = audioCtx.createBiquadFilter(); - this.filter.type = "lowpass"; - - this.osc1.connect(this.gain); - this.osc2.connect(this.gain); - - this.gain.connect(this.filter); - this.filter.connect(audioCtx.destination); - - sender.replaceTrack(this.outputStream.getAudioTracks()[0]); - return this; - } - - sendDtmf(tone: string, duration = DefaultToneDurationMs) { - if (tone.length !== 1 || /[^0-9a-d#\*,]/i.test(tone)) { - throw new Error("Invalid tone"); - } - - if (duration < MinToneDurationMs || duration > MaxToneDurationMs) { - throw new Error(`Invalid duration ${duration}, must be between ${MinToneDurationMs} and ${MaxToneDurationMs}`); - } - - this.toneDuration = duration; - this.tone = tone; - - if (!this.playing) { - setTimeout(this.playTone.bind(this), 0); - this.playing = true; - } - } - - private playTone() { - let digit: string = this.tone[0]; - let f = this.dtmfFreq.get(digit.toLowerCase()); - - // Stop the tone immediately if frequencies are not found - let toneDuration: number = 0; - if (f) { - this.osc1.frequency.value = f[0]; - this.osc2.frequency.value = f[1]; - toneDuration = this.toneDuration; - } - setTimeout(this.stopTone.bind(this), toneDuration); - } - - private stopTone() { - this.playing = false; - this.osc1.frequency.value = 0; - this.osc2.frequency.value = 0; - } - - disconnect() { - this.outputNode.disconnect(); - this.gain.disconnect(); - this.filter.disconnect(); - this.sourceNode.disconnect(); - this.osc1.disconnect(); - this.osc2.disconnect(); - } -} - -export default DtmfSender; diff --git a/src/v1/bandwidthRtc.test.ts b/src/v1/bandwidthRtc.test.ts index 61169d2..0c4f45c 100644 --- a/src/v1/bandwidthRtc.test.ts +++ b/src/v1/bandwidthRtc.test.ts @@ -55,6 +55,70 @@ describe("bandwidhthRtcV1 constructor", () => { }); }); +describe("bandwidthRtcV1 sendDtmf", () => { + beforeAll(() => { + setupNavigatorMocks(); + setupMocks(); + }); + + function makeDtmfSender() { + return { insertDTMF: jest.fn() }; + } + + test("calls insertDTMF on all registered senders when no streamId given", () => { + const brtc = new BandwidthRtc(); + const sender1 = makeDtmfSender(); + const sender2 = makeDtmfSender(); + (brtc as any).localDtmfSenders.set("stream-1", sender1); + (brtc as any).localDtmfSenders.set("stream-2", sender2); + + brtc.sendDtmf("5"); + + expect(sender1.insertDTMF).toHaveBeenCalledTimes(1); + expect(sender1.insertDTMF).toHaveBeenCalledWith("5", undefined, undefined); + expect(sender2.insertDTMF).toHaveBeenCalledTimes(1); + expect(sender2.insertDTMF).toHaveBeenCalledWith("5", undefined, undefined); + }); + + test("calls insertDTMF only on the specified stream when streamId given", () => { + const brtc = new BandwidthRtc(); + const sender1 = makeDtmfSender(); + const sender2 = makeDtmfSender(); + (brtc as any).localDtmfSenders.set("stream-1", sender1); + (brtc as any).localDtmfSenders.set("stream-2", sender2); + + brtc.sendDtmf("9", "stream-1"); + + expect(sender1.insertDTMF).toHaveBeenCalledTimes(1); + expect(sender2.insertDTMF).not.toHaveBeenCalled(); + }); + + test("forwards duration and interToneGap to insertDTMF", () => { + const brtc = new BandwidthRtc(); + const sender = makeDtmfSender(); + (brtc as any).localDtmfSenders.set("stream-1", sender); + + brtc.sendDtmf("1", undefined, 200, 80); + + expect(sender.insertDTMF).toHaveBeenCalledWith("1", 200, 80); + }); + + test("does not throw when no senders are registered", () => { + const brtc = new BandwidthRtc(); + expect(() => brtc.sendDtmf("5")).not.toThrow(); + }); + + test("does nothing for an unknown streamId", () => { + const brtc = new BandwidthRtc(); + const sender = makeDtmfSender(); + (brtc as any).localDtmfSenders.set("stream-1", sender); + + brtc.sendDtmf("5", "nonexistent"); + + expect(sender.insertDTMF).not.toHaveBeenCalled(); + }); +}); + describe("bandwidthRtcV1 connect method", () => { beforeAll(() => { setupNavigatorMocks(); diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index dab9d9f..d8e77f7 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -36,9 +36,24 @@ const RTC_CONFIGURATION: RTCConfiguration = { bundlePolicy: "max-bundle", rtcpMuxPolicy: "require", }; + const HEARTBEAT_DATA_CHANNEL_LABEL = "__heartbeat__"; const DIAGNOSTICS_DATA_CHANNEL_LABEL = "__diagnostics__"; +const PEER_CONNECTION_TYPE_PUBLISH = "publish"; +const PEER_CONNECTION_TYPE_SUBSCRIBE = "subscribe"; + +const TRACK_KIND_AUDIO = "audio"; +const TRACK_KIND_VIDEO = "video"; +const TELEPHONE_EVENT_MIME_TYPE = "audio/telephone-event"; + +const HEARTBEAT_PING = "PING"; +const HEARTBEAT_PONG = "PONG"; +const DATA_CHANNEL_STATE_OPEN = "open"; + +const CONNECTION_STATE_FAILED = "failed"; +const CONNECTION_STATE_DISCONNECTED = "disconnected"; + export class BandwidthRtc { private options?: RtcOptions; @@ -109,6 +124,16 @@ export class BandwidthRtc { this.signaling.on("ready", this.handleReady.bind(this)); this.signaling.on("sdpOffer", this.handleSubscribeSdpOffer.bind(this)); this.signaling.on("init", this.init.bind(this)); + this.signaling.on("streamAvailable", ({ callId, autoAccepted }: { callId: string; autoAccepted: boolean }) => { + if (this.streamAvailableHandler) { + this.streamAvailableHandler({ mediaTypes: [MediaType.AUDIO], callId, autoAccepted }); + } + }); + this.signaling.on("streamUnavailable", ({ callId }: { callId: string }) => { + if (this.streamUnavailableHandler) { + this.streamUnavailableHandler({ mediaTypes: [MediaType.AUDIO], callId }); + } + }); await this.signaling.connect(authParams, options); logger.info("Successfully connected"); @@ -274,15 +299,17 @@ export class BandwidthRtc { } /** - * DTMF Sender that layers DTMF tones onto an existing stream. - * @param tone The DTMF tones to send - a string composed of the characters [0-9,*,#,\,]* - * @param streamId The optional stream id to play on. + * Send DTMF tones via the browser's native RTCDTMFSender (RFC 4733). + * @param tone The DTMF tones to send - a string composed of the characters [0-9,*,#,A-D,\,]* + * @param streamId The optional stream id to send on; defaults to all published streams. + * @param duration Tone duration in milliseconds (default: 100). Must be between 40 and 6000. + * @param interToneGap Gap between tones in milliseconds (default: 70). Minimum 30. */ - sendDtmf(tone: string, streamId?: string) { + sendDtmf(tone: string, streamId?: string, duration: number = 100, interToneGap: number = 70) { if (streamId) { - this.localDtmfSenders.get(streamId)?.insertDTMF(tone); + this.localDtmfSenders.get(streamId)?.insertDTMF(tone, duration, interToneGap); } else { - this.localDtmfSenders.forEach((dtmfSender) => dtmfSender.insertDTMF(tone)); + this.localDtmfSenders.forEach((dtmfSender) => dtmfSender.insertDTMF(tone, duration, interToneGap)); } } @@ -366,7 +393,7 @@ export class BandwidthRtc { ), ); logger.debug("publish metadata", publishMetadata); - const remoteSdpAnswer = await this.signaling.offerSdp("publish", localSdpOffer.sdp!); + const remoteSdpAnswer = await this.signaling.offerSdp(PEER_CONNECTION_TYPE_PUBLISH, localSdpOffer.sdp!); await this.publishingPeerConnection!.setLocalDescription(localSdpOffer); logger.debug("remoteSdpAnswer", remoteSdpAnswer); @@ -414,7 +441,7 @@ export class BandwidthRtc { } await this.subscribingPeerConnection!.setLocalDescription(localSdpAnswer); - await this.signaling.answerSdp(localSdpAnswer.sdp, "subscribe"); + await this.signaling.answerSdp(localSdpAnswer.sdp, PEER_CONNECTION_TYPE_SUBSCRIBE); this.subscribingPeerConnectionSdpRevision = subscribeSdpOffer.sdpRevision; logger.debug(`set current SDP revision to ${this.subscribingPeerConnectionSdpRevision}`); @@ -429,7 +456,7 @@ export class BandwidthRtc { const publishOnTrackHandler = (event: RTCTrackEvent) => { logger.debug("publish ontrack event", event); }; - this.publishingPeerConnection = await this.setupPeerConnection("publish", publishOnTrackHandler, setMediaPreferencesResponse.publishSdpOffer.sdpOffer); + this.publishingPeerConnection = await this.setupPeerConnection(PEER_CONNECTION_TYPE_PUBLISH, publishOnTrackHandler, setMediaPreferencesResponse.publishSdpOffer.sdpOffer); let streamTracks: Map> = new Map(); @@ -490,7 +517,7 @@ export class BandwidthRtc { } }; this.subscribingPeerConnection = await this.setupPeerConnection( - "subscribe", + PEER_CONNECTION_TYPE_SUBSCRIBE, subscriptionOnTrackHandler, setMediaPreferencesResponse.subscribeSdpOffer.sdpOffer, ); @@ -510,7 +537,7 @@ export class BandwidthRtc { const pc = event.target as RTCPeerConnection; let connectionState = pc.connectionState; logger.debug("onconnectionstatechange", connectionState, pc); - if (connectionState === "failed") { + if (connectionState === CONNECTION_STATE_FAILED) { logger.warn("Connection failed, attempting to restart ICE TODO"); // await this.offerPublishSdp(true); // connectionState = pc.connectionState; @@ -556,9 +583,9 @@ export class BandwidthRtc { // Handle heartbeat messages dataChannel.onmessage = (event) => { logger.debug("Heartbeat Data Channel message", event.data); - if (event.data == "PING" && dataChannel.readyState === "open") { + if (event.data == HEARTBEAT_PING && dataChannel.readyState === DATA_CHANNEL_STATE_OPEN) { logger.debug("Received PING, sending PONG"); - dataChannel.send("PONG"); + dataChannel.send(HEARTBEAT_PONG); } }; } else if (dataChannel.label === DIAGNOSTICS_DATA_CHANNEL_LABEL) { @@ -575,7 +602,7 @@ export class BandwidthRtc { const pc = event.target as RTCPeerConnection; logger.debug("onconnectionstatechange", pc.connectionState, pc); const connectionState = pc.connectionState; - if (connectionState === "disconnected") { + if (connectionState === CONNECTION_STATE_DISCONNECTED) { logger.warn("Peer disconnected, connection may be reestablished"); } } catch (err) { @@ -639,15 +666,27 @@ export class BandwidthRtc { streams: [mediaStream], }); - // Inject DTMF into one audio track in the stream - if (track.kind === "audio" && !this.localDtmfSenders.has(mediaStream.id)) { - this.localDtmfSenders.set(mediaStream.id, transceiver.sender.dtmf!); + // Inject DTMF into one audio track in the stream via the browser's native + // RTCDTMFSender. rtpSender.dtmf can be null when the browser doesn't + // support DTMF for this track, so guard before storing. + const dtmfSender = transceiver.sender.dtmf; + if (track.kind === TRACK_KIND_AUDIO && dtmfSender && !this.localDtmfSenders.has(mediaStream.id)) { + this.localDtmfSenders.set(mediaStream.id, dtmfSender); } if (codecPreferences) { - if (track.kind === "audio" && codecPreferences.audio) { - transceiver.setCodecPreferences(codecPreferences.audio); - } else if (track.kind === "video" && codecPreferences.video) { + if (track.kind === TRACK_KIND_AUDIO && codecPreferences.audio) { + // setCodecPreferences is a strict allowlist: any codec omitted from the + // list is dropped from the SDP offer. telephone-event must always be + // present so that RTCDTMFSender can send RFC 4733 DTMF packets. + const hasTelephoneEvent = codecPreferences.audio.some((c) => c.mimeType.toLowerCase() === TELEPHONE_EVENT_MIME_TYPE); + if (!hasTelephoneEvent) { + const telephoneEventCodec = RTCRtpSender.getCapabilities(TRACK_KIND_AUDIO)?.codecs.find((c) => c.mimeType.toLowerCase() === TELEPHONE_EVENT_MIME_TYPE); + transceiver.setCodecPreferences(telephoneEventCodec ? [...codecPreferences.audio, telephoneEventCodec] : codecPreferences.audio); + } else { + transceiver.setCodecPreferences(codecPreferences.audio); + } + } else if (track.kind === TRACK_KIND_VIDEO && codecPreferences.video) { transceiver.setCodecPreferences(codecPreferences.video); } }