WebRTC 中的 DTMF 使用

为了更全面地支持音频/视频会议,WebRTC 支持在 RTCPeerConnection 上向远程对等端发送 DTMF。本文简要概述了 DTMF 如何通过 WebRTC 工作,然后为日常开发人员提供了如何在 RTCPeerConnection 上发送 DTMF 的指南。DTMF 系统通常被称为“触控按键”,这是该系统的一个旧商标名。

WebRTC 不将 DTMF 代码作为音频数据发送。相反,它们作为 RTP 有效负载带外发送。但是请注意,尽管可以使用 WebRTC 发送 DTMF,但目前无法检测或接收传入的 DTMF。WebRTC 目前会忽略这些有效负载;这是因为 WebRTC 的 DTMF 支持主要用于依赖 DTMF 音调执行任务(例如)的传统电话服务:

  • 电话会议系统
  • 菜单系统
  • 语音邮件系统
  • 输入信用卡或其他付款信息
  • 输入密码

注意:虽然 DTMF 不作为音频发送到远程对等端,但浏览器可能会选择向本地用户播放相应的音调,作为其用户体验的一部分,因为用户通常习惯于听到他们的电话播放可听见的音调。

在 RTCPeerConnection 上发送 DTMF

一个给定的 RTCPeerConnection 可以有多个媒体轨道在其上发送或接收。当您希望传输 DTMF 信号时,您首先需要决定要在哪个轨道上发送它们,因为 DTMF 是作为一系列带外有效负载在负责将该轨道数据传输到其他对等端的 RTCRtpSender 上发送的。

选择轨道后,您可以从其 RTCRtpSender 获取 RTCDTMFSender 对象,您将使用该对象发送 DTMF。然后,您可以调用 RTCDTMFSender.insertDTMF() 将 DTMF 信号排队,以便在轨道上发送给其他对等端。然后,RTCRtpSender 会将音调作为数据包与轨道的音频数据一起发送给其他对等端。

每次发送音调时,RTCPeerConnection 都会收到一个 tonechange 事件,其 tone 属性指定哪个音调播放完毕,这是一个更新界面元素的机会。当音调缓冲区为空,表示所有音调都已发送时,会向连接对象发送一个 tonechange 事件,其 tone 属性设置为空字符串 ("")。

如果您想了解其工作原理,请阅读 RFC 3550:RTP:实时应用程序传输协议RFC 4733:DTMF 数字、电话音和电话信号的 RTP 有效负载。RTP 上 DTMF 有效负载的处理细节超出了本文的范围。相反,我们将通过研究示例的工作原理来关注如何在 RTCPeerConnection 的上下文中使用 DTMF。

简单示例

这个简单的例子构造了两个 RTCPeerConnection,在它们之间建立连接,然后等待用户点击“拨号”按钮。点击按钮后,使用 RTCDTMFSender.insertDTMF() 通过连接发送 DTMF 字符串。音调传输完成后,连接关闭。

注意:这个例子显然有些牵强,因为通常两个 RTCPeerConnection 对象会存在于不同的设备上,并且信令会通过网络完成,而不是像这里这样全部内联连接。

HTML

此示例的 HTML 非常基本;只有三个重要元素:

  • 一个 <audio> 元素,用于播放由正在“呼叫”的 RTCPeerConnection 接收的音频。
  • 一个 <button> 元素,用于触发创建和连接两个 RTCPeerConnection 对象,然后发送 DTMF 音调。
  • 一个 <div> 用于接收和显示日志文本,以显示状态信息。
html
<p>
  This example demonstrates the use of DTMF in WebRTC. Note that this example is
  "cheating" by generating both peers in one code stream, rather than having
  each be a truly separate entity.
</p>

<audio id="audio" autoplay controls></audio><br />
<button name="dial" id="dial">Dial</button>

<div class="log"></div>

JavaScript

接下来我们看看 JavaScript 代码。请记住,这里建立连接的过程有些牵强;通常您不会在同一文档中构建连接的两端。

全局变量

首先,我们建立全局变量。

js
let dialString = "12024561111";

let callerPC = null;
let receiverPC = null;
let dtmfSender = null;

let hasAddTrack = false;

let mediaConstraints = {
  audio: true,
  video: false,
};

它们依次是:

dialString

当点击“拨号”按钮时,呼叫者将发送的 DTMF 字符串。

callerPCreceiverPC

分别代表呼叫者和接收者的 RTCPeerConnection 对象。它们将在呼叫开始时在我们的 connectAndDial() 函数中初始化,如下面的启动连接过程所示。

dtmfSender

连接的 RTCDTMFSender 对象。这将在设置连接时在 gotStream() 函数中获取,如将音频添加到连接中所示。

hasAddTrack

由于某些浏览器尚未实现 RTCPeerConnection.addTrack(),因此需要使用过时的 addStream() 方法,我们使用这个布尔值来确定用户代理是否支持 addTrack();如果不支持,我们将回退到 addStream()。这将在 connectAndDial() 中确定,如启动连接过程中所示。

mediaConstraints

一个对象,指定启动连接时要使用的约束。我们希望是仅音频连接,因此 videofalse,而 audiotrue

初始化

我们获取拨号按钮和日志输出框元素的引用,并使用 addEventListener() 向拨号按钮添加一个事件监听器,以便单击它时调用 connectAndDial() 函数开始连接过程。

js
const dialButton = document.querySelector("#dial");
const logElement = document.querySelector(".log");
dialButton.addEventListener("click", connectAndDial);

启动连接过程

单击拨号按钮时,会调用 connectAndDial()。这会开始构建 WebRTC 连接,为发送 DTMF 代码做准备。

js
function connectAndDial() {
  callerPC = new RTCPeerConnection();

  hasAddTrack = callerPC.addTrack !== undefined;

  callerPC.onicecandidate = handleCallerIceEvent;
  callerPC.onnegotiationneeded = handleCallerNegotiationNeeded;
  callerPC.oniceconnectionstatechange = handleCallerIceConnectionStateChange;
  callerPC.onsignalingstatechange = handleCallerSignalingStateChangeEvent;
  callerPC.onicegatheringstatechange = handleCallerGatheringStateChangeEvent;

  receiverPC = new RTCPeerConnection();
  receiverPC.onicecandidate = handleReceiverIceEvent;

  if (hasAddTrack) {
    receiverPC.ontrack = handleReceiverTrackEvent;
  } else {
    receiverPC.onaddstream = handleReceiverAddStreamEvent;
  }

  navigator.mediaDevices
    .getUserMedia(mediaConstraints)
    .then(gotStream)
    .catch((err) => log(err.message));
}

在为呼叫者 (callerPC) 创建 RTCPeerConnection 后,我们查看它是否具有 addTrack() 方法。如果具有,我们将 hasAddTrack 设置为 true;否则,我们将其设置为 false。此变量将允许示例甚至在尚未实现较新的 addTrack() 方法的浏览器上运行;我们将通过回退到较旧的 addStream() 方法来做到这一点。

接下来,建立呼叫者的事件处理程序。我们稍后将详细介绍这些。

然后创建第二个 RTCPeerConnection,该连接表示呼叫的接收端,并存储在 receiverPC 中;它的 onicecandidate 事件处理程序也已设置。

如果支持 addTrack(),我们设置接收者的 ontrack 事件处理程序;否则,我们设置 onaddstreamtrackaddstream 事件在媒体添加到连接时发送。

最后,我们调用 getUserMedia() 以获取对呼叫者麦克风的访问权限。如果成功,则调用函数 gotStream(),否则我们记录错误,因为呼叫失败。

将音频添加到连接

如上所述,当获取麦克风的音频输入时,会调用 gotStream()。其任务是构建发送给接收者的流,以便实际的传输过程可以开始。它还可以访问我们将用于在连接上发出 DTMF 的 RTCDTMFSender

js
function gotStream(stream) {
  log("Got access to the microphone.");

  let audioTracks = stream.getAudioTracks();

  if (hasAddTrack) {
    if (audioTracks.length > 0) {
      audioTracks.forEach((track) => callerPC.addTrack(track, stream));
    }
  } else {
    log(
      "Your browser doesn't support RTCPeerConnection.addTrack(). Falling " +
        "back to the <strong>deprecated</strong> addStream() method…",
    );
    callerPC.addStream(stream);
  }

  if (callerPC.getSenders) {
    dtmfSender = callerPC.getSenders()[0].dtmf;
  } else {
    log(
      "Your browser doesn't support RTCPeerConnection.getSenders(), so " +
        "falling back to use <strong>deprecated</strong> createDTMFSender() " +
        "instead.",
    );
    dtmfSender = callerPC.createDTMFSender(audioTracks[0]);
  }

  dtmfSender.ontonechange = handleToneChangeEvent;
}

audioTracks 设置为来自用户麦克风的流上的音频轨道列表后,是时候将媒体添加到呼叫者的 RTCPeerConnection 了。如果 RTCPeerConnection 上有 addTrack(),我们将流的每个音频轨道逐个添加到连接中,使用 RTCPeerConnection.addTrack()。否则,我们调用 RTCPeerConnection.addStream() 将流作为一个单元添加到呼叫中。

接下来,我们查看是否实现了 RTCPeerConnection.getSenders() 方法。如果实现了,我们调用 callerPC 上的该方法,并获取返回的发送者列表中的第一个条目;这是负责传输呼叫中第一个音频轨道数据(我们将通过该轨道发送 DTMF)的 RTCRtpSender。然后,我们获取 RTCRtpSenderdtmf 属性,这是一个 RTCDTMFSender 对象,可以在连接上从呼叫者发送 DTMF 给接收者。

如果 getSenders() 不可用,我们转而调用 RTCPeerConnection.createDTMFSender() 来获取 RTCDTMFSender 对象。尽管此方法已过时,但此示例支持它作为回退,以让较旧的浏览器(以及尚未更新以支持当前 WebRTC DTMF API 的浏览器)运行该示例。

最后,我们设置 DTMF 发送器的 ontonechange 事件处理程序,以便在每次 DTMF 音调播放完毕时收到通知。

您可以在文档底部找到日志函数。

当一个音调播放完毕时

每次 DTMF 音调播放完毕时,都会向 callerPC 传递一个 tonechange 事件。这些事件的事件监听器实现为 handleToneChangeEvent() 函数。

js
function handleToneChangeEvent(event) {
  if (event.tone !== "") {
    log(`Tone played: ${event.tone}`);
  } else {
    log("All tones have played. Disconnecting.");
    callerPC.getLocalStreams().forEach((stream) => {
      stream.getTracks().forEach((track) => {
        track.stop();
      });
    });
    receiverPC.getLocalStreams().forEach((stream) => {
      stream.getTracks().forEach((track) => {
        track.stop();
      });
    });

    audio.pause();
    audio.srcObject = null;
    receiverPC.close();
    callerPC.close();
  }
}

tonechange 事件用于指示单个音调何时播放完毕以及所有音调何时播放完毕。事件的 tone 属性是一个字符串,指示哪个音调刚刚播放完毕。如果所有音调都播放完毕,则 tone 为空字符串;在这种情况下,RTCDTMFSender.toneBuffer 为空。

在此示例中,我们将哪个音调刚刚播放完毕记录到屏幕上。在更高级的应用程序中,您可能会更新用户界面,例如,以指示当前正在播放哪个音调。

另一方面,如果音调缓冲区为空,我们的示例旨在断开呼叫。这是通过迭代每个 RTCPeerConnection 的轨道列表(由其 getTracks() 方法返回)并调用每个轨道的 stop() 方法来停止呼叫者和接收者上的每个流来完成的。

一旦呼叫者和接收者的媒体轨道都停止,我们暂停 <audio> 元素并将其 srcObject 设置为 null。这会将音频流从 <audio> 元素中分离出来。

然后,最后,通过调用每个 RTCPeerConnectionclose() 方法来关闭它们。

将候选者添加到呼叫者

当呼叫者的 RTCPeerConnection ICE 层提出新的候选者时,它会向 callerPC 发出 icecandidate 事件。icecandidate 事件处理程序的任务是将候选者传输给接收者。在我们的示例中,我们直接控制呼叫者和接收者,因此我们可以通过调用接收者的 addIceCandidate() 方法直接将候选者添加到接收者。这由 handleCallerIceEvent() 处理

js
function handleCallerIceEvent(event) {
  if (event.candidate) {
    log(`Adding candidate to receiver: ${event.candidate.candidate}`);

    receiverPC
      .addIceCandidate(new RTCIceCandidate(event.candidate))
      .catch((err) => log(`Error adding candidate to receiver: ${err}`));
  } else {
    log("Caller is out of candidates.");
  }
}

如果 icecandidate 事件具有非 nullcandidate 属性,我们从 event.candidate 字符串创建一个新的 RTCIceCandidate 对象,并通过调用 receiverPC.addIceCandidate()(提供新的 RTCIceCandidate 作为其输入)将其“传输”给接收者。如果 addIceCandidate() 失败,则 catch() 子句将错误输出到我们的日志框中。

如果 event.candidatenull,则表示没有更多可用候选者,我们会记录该信息。

连接打开后拨号

我们的设计要求连接建立后,立即发送 DTMF 字符串。为此,我们监控呼叫者是否收到 iceconnectionstatechange 事件。当 ICE 连接过程状态发生一系列变化时(包括成功建立连接),会发送此事件。

js
function handleCallerIceConnectionStateChange() {
  log(`Caller's connection state changed to ${callerPC.iceConnectionState}`);
  if (callerPC.iceConnectionState === "connected") {
    log(`Sending DTMF: "${dialString}"`);
    dtmfSender.insertDTMF(dialString, 400, 50);
  }
}

iceconnectionstatechange 事件实际上不包含新状态,因此我们从 callerPCRTCPeerConnection.iceConnectionState 属性获取连接过程的当前状态。记录新状态后,我们查看状态是否为 "connected"。如果是,我们记录即将发送 DTMF 的事实,然后我们调用 dtmf.insertDTMF() 以在音频数据方法所在的 RTCDTMFSender 对象上发送 DTMF,该对象我们之前存储dtmfSender 中。

我们的 insertDTMF() 调用不仅指定要发送的 DTMF(dialString),还指定每个音调的持续时间(400 毫秒)以及音调之间的时间量(50 毫秒)。

协商连接

当呼叫的 RTCPeerConnection 开始接收媒体时(在将麦克风流添加到其中之后),会向呼叫者传递一个 negotiationneeded 事件,告知它需要开始与接收者协商连接。如前所述,由于我们同时控制呼叫者和接收者,我们的示例有所简化,因此 handleCallerNegotiationNeeded() 能够通过调用呼叫者和接收者的方法快速构建连接,如下所示。

js
// Offer to receive audio but not video
const constraints = { audio: true, video: false };

async function handleCallerNegotiationNeeded() {
  log("Negotiating…");
  try {
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    for (const track of stream.getTracks()) {
      pc.addTrack(track, stream);
    }
    const offer = await callerPC.createOffer();
    log(`Setting caller's local description: ${offer.sdp}`);
    await callerPC.setLocalDescription(offer);
    log("Setting receiver's remote description to the same as caller's local");
    await receiverPC.setRemoteDescription(callerPC.localDescription);
    log("Creating answer");
    const answer = await receiverPC.createAnswer();
    log(`Setting receiver's local description to ${answer.sdp}`);
    await receiverPC.setLocalDescription(answer);
    log("Setting caller's remote description to match");
    await callerPC.setRemoteDescription(receiverPC.localDescription);
  } catch (err) {
    log(`Error during negotiation: ${err.message}`);
  }
}

由于协商连接所涉及的各种方法都返回 promise,我们可以像这样将它们链接起来:

  1. 调用 callerPC.createOffer() 以获取一个 offer。
  2. 然后获取该 offer,并通过调用 callerPC.setLocalDescription() 设置呼叫者的本地描述以匹配该 offer。
  3. 然后通过调用 receiverPC.setRemoteDescription() 将 offer“传输”给接收者。这会配置接收者,使其知道呼叫者是如何配置的。
  4. 然后接收者通过调用 receiverPC.createAnswer() 创建一个 answer。
  5. 然后接收者通过调用 receiverPC.setLocalDescription() 设置其本地描述以匹配新创建的 answer。
  6. 然后通过调用 callerPC.setRemoteDescription() 将 answer“传输”给呼叫者。这让呼叫者知道接收者的配置是什么。
  7. 如果在任何时候发生错误,catch() 子句会将错误消息输出到日志中。

跟踪其他状态变化

我们还可以观察信令状态的变化(通过接受 signalingstatechange 事件)和 ICE 收集状态(通过接受 icegatheringstatechange 事件)。我们没有将这些用于任何目的,所以我们所做的只是记录它们。我们本可以根本不设置这些事件监听器。

js
function handleCallerSignalingStateChangeEvent() {
  log(`Caller's signaling state changed to ${callerPC.signalingState}`);
}

function handleCallerGatheringStateChangeEvent() {
  log(`Caller's ICE gathering state changed to ${callerPC.iceGatheringState}`);
}

将候选者添加到接收者

当接收者的 RTCPeerConnection ICE 层提出新的候选者时,它会向 receiverPC 发出 icecandidate 事件。icecandidate 事件处理程序的任务是将候选者传输给呼叫者。在我们的示例中,我们直接控制呼叫者和接收者,因此我们可以通过调用呼叫者的 addIceCandidate() 方法直接将候选者添加到呼叫者。这由 handleReceiverIceEvent() 处理。

此代码与上面将候选者添加到呼叫者中所示的呼叫者的 icecandidate 事件处理程序类似。

js
function handleReceiverIceEvent(event) {
  if (event.candidate) {
    log(`Adding candidate to caller: ${event.candidate.candidate}`);

    callerPC
      .addIceCandidate(new RTCIceCandidate(event.candidate))
      .catch((err) => log(`Error adding candidate to caller: ${err}`));
  } else {
    log("Receiver is out of candidates.");
  }
}

如果 icecandidate 事件的 candidate 属性非 null,我们从 event.candidate 字符串创建一个新的 RTCIceCandidate 对象,并通过将其作为输入传递给 callerPC.addIceCandidate() 来将其传递给呼叫者。如果 addIceCandidate() 失败,则 catch() 子句将错误输出到我们的日志框中。

如果 event.candidatenull,则表示没有更多可用候选者,我们会记录该信息。

将媒体添加到接收者

当接收者开始接收媒体时,事件将传递到接收者的 RTCPeerConnection,即 receiverPC。如启动连接过程中所述,当前 WebRTC 规范使用 track 事件来实现此目的。由于某些浏览器尚未更新以支持此功能,因此我们还需要处理 addstream 事件。这将在下面的 handleReceiverTrackEvent()handleReceiverAddStreamEvent() 方法中进行演示。

js
function handleReceiverTrackEvent(event) {
  audio.srcObject = event.streams[0];
}

function handleReceiverAddStreamEvent(event) {
  audio.srcObject = event.stream;
}

track 事件包含一个 streams 属性,其中包含该轨道所属的流数组(一个轨道可以是许多流的一部分)。我们获取第一个流并将其附加到 <audio> 元素。

addstream 事件包含一个 stream 属性,该属性指定添加到轨道的一个流。我们将其附加到 <audio> 元素。

日志记录

整个代码中使用了一个简单的 log() 函数,用于将文本附加到 <div> 框中,以向用户显示状态和错误。

js
function log(msg) {
  logElement.innerText += `${msg}\n`;
}

结果

您可以在此处尝试此示例。当您单击“拨号”按钮时,您应该会看到一系列日志消息输出;然后拨号将开始。如果您的浏览器将音调作为其用户体验的一部分可听地播放,您应该在传输时听到它们。

一旦音调传输完成,连接将关闭。您可以再次单击“拨号”以重新连接并发送音调。

另见