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..81a4f61 100644 --- a/src/v1/bandwidthRtc.test.ts +++ b/src/v1/bandwidthRtc.test.ts @@ -55,6 +55,144 @@ 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", 100, 70); + expect(sender2.insertDTMF).toHaveBeenCalledTimes(1); + expect(sender2.insertDTMF).toHaveBeenCalledWith("5", 100, 70); + }); + + 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 addStreamToPublishingPeerConnection", () => { + function makeTransceiver(dtmf: RTCDTMFSender | null = { insertDTMF: jest.fn() } as any) { + return { sender: { dtmf }, setCodecPreferences: jest.fn() }; + } + + function makeMockStream(id: string, trackKind: string) { + return { id, getTracks: () => [{ kind: trackKind, id: "track-1" }] }; + } + + function withPublishingPeerConnection(brtc: BandwidthRtc, transceiver: ReturnType) { + (brtc as any).publishingPeerConnection = { addTransceiver: jest.fn().mockReturnValue(transceiver) }; + } + + test("stores dtmf sender for audio track", () => { + const brtc = new BandwidthRtc(); + const dtmfSender = { insertDTMF: jest.fn() }; + const transceiver = makeTransceiver(dtmfSender as any); + withPublishingPeerConnection(brtc, transceiver); + + (brtc as any).addStreamToPublishingPeerConnection(makeMockStream("stream-1", "audio")); + + expect((brtc as any).localDtmfSenders.get("stream-1")).toBe(dtmfSender); + }); + + test("does not store dtmf sender when sender.dtmf is null", () => { + const brtc = new BandwidthRtc(); + withPublishingPeerConnection(brtc, makeTransceiver(null)); + + (brtc as any).addStreamToPublishingPeerConnection(makeMockStream("stream-1", "audio")); + + expect((brtc as any).localDtmfSenders.has("stream-1")).toBe(false); + }); + + test("appends telephone-event codec when missing from audio preferences", () => { + const brtc = new BandwidthRtc(); + const transceiver = makeTransceiver(); + withPublishingPeerConnection(brtc, transceiver); + + const telephoneEventCodec = { mimeType: "audio/telephone-event", clockRate: 8000 }; + (global as any).RTCRtpSender = { getCapabilities: jest.fn().mockReturnValue({ codecs: [telephoneEventCodec] }) }; + + const opusCodec = { mimeType: "audio/opus", clockRate: 48000 }; + (brtc as any).addStreamToPublishingPeerConnection(makeMockStream("stream-1", "audio"), { audio: [opusCodec] }); + + expect(transceiver.setCodecPreferences).toHaveBeenCalledWith([opusCodec, telephoneEventCodec]); + }); + + test("does not duplicate telephone-event when already in preferences", () => { + const brtc = new BandwidthRtc(); + const transceiver = makeTransceiver(); + withPublishingPeerConnection(brtc, transceiver); + + const opusCodec = { mimeType: "audio/opus", clockRate: 48000 }; + const telephoneEventCodec = { mimeType: "audio/telephone-event", clockRate: 8000 }; + (brtc as any).addStreamToPublishingPeerConnection(makeMockStream("stream-1", "audio"), { audio: [opusCodec, telephoneEventCodec] }); + + expect(transceiver.setCodecPreferences).toHaveBeenCalledWith([opusCodec, telephoneEventCodec]); + expect(transceiver.setCodecPreferences).toHaveBeenCalledTimes(1); + }); + + test("falls back to original preferences when telephone-event not found in capabilities", () => { + const brtc = new BandwidthRtc(); + const transceiver = makeTransceiver(); + withPublishingPeerConnection(brtc, transceiver); + + (global as any).RTCRtpSender = { getCapabilities: jest.fn().mockReturnValue({ codecs: [] }) }; + + const opusCodec = { mimeType: "audio/opus", clockRate: 48000 }; + (brtc as any).addStreamToPublishingPeerConnection(makeMockStream("stream-1", "audio"), { audio: [opusCodec] }); + + expect(transceiver.setCodecPreferences).toHaveBeenCalledWith([opusCodec]); + }); +}); + describe("bandwidthRtcV1 connect method", () => { beforeAll(() => { setupNavigatorMocks(); diff --git a/src/v1/bandwidthRtc.ts b/src/v1/bandwidthRtc.ts index dab9d9f..42901dd 100644 --- a/src/v1/bandwidthRtc.ts +++ b/src/v1/bandwidthRtc.ts @@ -36,23 +36,34 @@ 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; - // Batches diagnostic data for debugging private diagnosticsBatcher: DiagnosticsBatcher; - - // Communicates with the Bandwidth WebRTC platform private signaling: Signaling; - // One peer for all published (outgoing) streams, one for all subscribed (incoming) streams + // One peer connection for all published (outgoing) streams, one for all subscribed (incoming) streams private publishingPeerConnection?: RTCPeerConnection; private subscribingPeerConnection?: RTCPeerConnection; - // Standard datachannels used for platform diagnostics and health checks private publishHeartbeatDataChannel?: RTCDataChannel; private publishDiagnosticsDataChannel?: RTCDataChannel; private publishedDataChannels: Map = new Map(); @@ -64,17 +75,14 @@ export class BandwidthRtc { private publishMutex: Mutex = new Mutex(); private subscribeMutex: Mutex = new Mutex(); - // Lookup maps for streams, keyed by mediastream id (msid) private publishedStreams: Map = new Map(); private subscribedStreams: Map = new Map(); // Current SDP revision for the subscribing peer; used to reject outdated SDP offers private subscribingPeerConnectionSdpRevision = 0; - // DTMF private localDtmfSenders: Map = new Map(); - // Event handlers private streamAvailableHandler?: { (event: RtcStream): void }; private streamUnavailableHandler?: { (event: RtcStream): void }; private readyHandler?: { (readyMetadata: ReadyMetadata): void }; @@ -109,7 +117,6 @@ 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)); - await this.signaling.connect(authParams, options); logger.info("Successfully connected"); } @@ -274,15 +281,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 +375,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 +423,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 +438,11 @@ 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 +503,7 @@ export class BandwidthRtc { } }; this.subscribingPeerConnection = await this.setupPeerConnection( - "subscribe", + PEER_CONNECTION_TYPE_SUBSCRIBE, subscriptionOnTrackHandler, setMediaPreferencesResponse.subscribeSdpOffer.sdpOffer, ); @@ -504,23 +517,14 @@ export class BandwidthRtc { logger.debug("Setting up RTCPeerConnection"); const peerConnection = this.createPeerConnection(); this.setupNewPeerConnection(peerConnection, onTrack); - // Attempt to restart ice if connection fails peerConnection.onconnectionstatechange = async (event: Event) => { try { const pc = event.target as RTCPeerConnection; - let connectionState = pc.connectionState; + const 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; - // // TODO: add timeout so we dont loop here forever - // while (connectionState === "failed") { - // await new Promise((resolve) => setTimeout(resolve, 5000)); - // // Don't block on this, we should try multiple times - // this.offerPublishSdp(true); - // connectionState = pc.connectionState; - // } + // TODO: add timeout here } } catch (err) { if (globalThis.window) { @@ -528,7 +532,6 @@ export class BandwidthRtc { } } }; - // Do an initial sdp negotiation logger.debug("Initial SDP offer", initialSdpOffer); if (initialSdpOffer != undefined) { logger.debug("Setting initial SDP offer", initialSdpOffer); @@ -552,21 +555,18 @@ export class BandwidthRtc { const dataChannel: RTCDataChannel = event.channel; if (dataChannel.label === HEARTBEAT_DATA_CHANNEL_LABEL) { logger.info("Heartbeat Data Channel opened", dataChannel); - - // 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) { logger.info("Diagnostics Data Channel opened", dataChannel); } else { logger.info("Custom Data Channel opened", dataChannel); - // // Custom data channel - // this.subscribedDataChannels.set(dataChannel.label, dataChannel); + // TODO: custom data channel } }; @@ -575,7 +575,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 +639,29 @@ 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); } }