使用 DTMF 与 WebRTC

为了更全面地支持音频/视频会议,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 获取将用于发送 DTMF 的 RTCDTMFSender 对象。从那里,您可以调用 RTCDTMFSender.insertDTMF() 将 DTMF 信号排队以在轨道上发送到另一方对等端。然后,RTCRtpSender 将这些音调作为数据包与轨道的音频数据一起发送到另一方对等端。

每次发送音调时,RTCPeerConnection 都会收到一个 tonechange 事件,其中包含一个 tone 属性,该属性指定哪个音调已完成播放,这为更新界面元素提供了一个机会,例如。当音调缓冲区为空时,表示所有音调都已发送,一个 tonechange 事件(其 tone 属性设置为 ""(空字符串))将传递到连接对象。

如果您想了解更多有关其工作原理的信息,请阅读 RFC 3550:RTP:实时应用程序的传输协议RFC 4733:DTMF 数字、电话音调和电话信号的 RTP 负载。本文档不涉及 DTMF 负载如何在 RTP 上处理的详细信息。相反,我们将重点介绍如何在 RTCPeerConnection 的上下文中使用 DTMF,方法是研究示例的工作原理。

简单示例

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

注意:此示例显然有些牵强附会,因为通常两个 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,
};

let offerOptions = {
  offerToReceiveAudio: 1,
  offerToReceiveVideo: 0,
};

let dialButton = null;
let logElement = null;

它们按顺序为

dialString

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

callerPCreceiverPC

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

dtmfSender

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

hasAddTrack

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

mediaConstraints

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

offerOptions

一个对象,用于提供在调用 RTCPeerConnection.createOffer() 时指定的选项。在这种情况下,我们声明我们希望接收音频但不接收视频。

dialButtonlogElement

这些变量将用于存储对拨号按钮和 <div> 的引用,日志信息将写入其中。它们将在页面首次加载时设置。请参阅下面的 初始化

初始化

页面加载时,我们会进行一些基本设置:我们获取对拨号按钮和日志输出框元素的引用,并使用 addEventListener() 向拨号按钮添加事件侦听器,以便单击它会调用 connectAndDial() 函数以开始连接过程。

js
window.addEventListener("load", () => {
  logElement = document.querySelector(".log");
  dialButton = document.querySelector("#dial");

  dialButton.addEventListener("click", connectAndDial, false);
});

启动连接过程

当点击拨号按钮时,会调用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事件处理程序;否则,我们将设置onaddstream。当媒体添加到连接时,会发送trackaddstream事件。

最后,我们调用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上调用它并获取返回的发件人列表中的第一个条目;这是负责传输呼叫上第一个音频轨道数据的RTCRtpSender(我们将通过它发送DTMF)。然后,我们获取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>元素分离。

然后,最后,通过调用其close()方法关闭每个RTCPeerConnection

向呼叫者添加候选者

当呼叫者的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()以在与我们之前存储dtmfSender中的音频数据方法相同的轨道上发送DTMF。

我们对insertDTMF()的调用不仅指定要发送的DTMF(dialString),还指定每个音调的长度(以毫秒为单位,400毫秒)以及音调之间的时间间隔(50毫秒)。

协商连接

当呼叫的RTCPeerConnection开始接收媒体(在将麦克风的流添加到它之后)时,会向呼叫者传递negotiationneeded事件,通知它现在是时候开始与接收者协商连接了。如前所述,我们的示例在某种程度上有所简化,因为我们控制了呼叫者和接收者,因此handleCallerNegotiationNeeded()能够通过将必要的调用链接在一起(如下所示)来快速构建连接,用于呼叫者和接收者。

js
function handleCallerNegotiationNeeded() {
  log("Negotiating…");
  callerPC
    .createOffer(offerOptions)
    .then((offer) => {
      log(`Setting caller's local description: ${offer.sdp}`);
      return callerPC.setLocalDescription(offer);
    })
    .then(() => {
      log(
        "Setting receiver's remote description to the same as caller's local",
      );
      return receiverPC.setRemoteDescription(callerPC.localDescription);
    })
    .then(() => {
      log("Creating answer");
      return receiverPC.createAnswer();
    })
    .then((answer) => {
      log(`Setting receiver's local description to ${answer.sdp}`);
      return receiverPC.setLocalDescription(answer);
    })
    .then(() => {
      log("Setting caller's remote description to match");
      return callerPC.setRemoteDescription(receiverPC.localDescription);
    })
    .catch((err) => log(`Error during negotiation: ${err.message}`));
}

由于参与协商连接的各种方法都返回promise,因此我们可以像这样将它们链接在一起。

  1. 调用callerPC.createOffer()以获取要约。
  2. 然后通过调用callerPC.setLocalDescription()将该要约设置为与呼叫者的本地描述匹配。
  3. 然后通过调用receiverPC.setRemoteDescription()将要约“传输”到接收者。这将配置接收者,使其知道呼叫者的配置方式。
  4. 然后接收者通过调用receiverPC.createAnswer()创建答案。
  5. 然后接收者通过调用receiverPC.setLocalDescription()将其本地描述设置为与新创建的答案匹配。
  6. 然后通过调用callerPC.setRemoteDescription()将答案“传输”到呼叫者。这使呼叫者知道接收者的配置。
  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事件具有非nullcandidate属性,我们将从event.candidate字符串创建一个新的RTCIceCandidate对象,并将其传递给callerPC.addIceCandidate(),将其传递给呼叫者。如果addIceCandidate()失败,则catch()子句会将错误输出到我们的日志框中。

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

将媒体添加到接收者

当接收器开始接收媒体时,会向接收器的RTCPeerConnectionreceiverPC)传递一个事件。如连接过程启动中所述,当前的 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`;
}

结果

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

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

另请参阅