WebRTC:在 Firefox 中发送 DTMF

WebRTC 中定义的功能之一是发送 DTMF 音调(在一些市场上被称为“按键音”)。虽然这在浏览器到浏览器的场景中基本没有用处,但当使用 WebRTC 拨打传统电话网络时,它就变得相当重要:许多公司仍然使用语音菜单系统,要求用户发送 DTMF 数字来表明他们拨打的理由、输入信用卡号码和密码以及执行类似的操作。

直到最近,开发人员对使用此接口表示出很少兴趣;因此,它一直是 Firefox WebRTC 团队的相对低优先级。在过去几周里,关于 RTCDTMFSender 可用性的查询出现激增,令人惊讶。虽然没有为其实施设定里程碑,但该功能仍然在我们路线图上。

与此同时,有一种合理的权宜之计方法,可以在大多数使用情况下有效。通过使用 WebAudio 振荡器,可以合成 DTMF 音调并将其混合到音频流中。值得注意的是,这会导致与规范中描述的行为略有不同:此方法不是使用 RFC4733 发送 DTMF,而是使用正在使用的音频编解码器对音调进行编码(对于电话网关,这几乎总是 G.711)。在实践中,这在几乎所有情况下都适用。

我在本文末尾包含了此方法的示例实现。对于 44 之前的 Firefox 版本,应用程序需要使用它们要将 DTMF 混合其中的流显式构造 DTMFSender,然后从 DTMFSender 检索一个新的流,并将其添加到 RTCPeerConnection(或任何需要发送 DTMF 音调的地方)。例如:

navigator.mediaDevices.getUserMedia({ audio: true })
  .then(function(micStream) {
    var sender = new DTMFSender(micStream);

    /* Now that we have a stream that represents microphone
       input mixed with the DTMF ("sender.outputStream"), we
       can do whatever we want with it. This example plays
       it locally, but you could just as easily add it to
       a PeerConnection. */

    var audio = document.createElement("audio");
    document.body.appendChild(audio);
    audio.mozSrcObject = sender.outputStream;
    audio.play();

    sender.ontonechange = function(e) {
      console.log(JSON.stringify(e));
    }
    sender.insertDTMF("2145551212,1");
  });

这确实有点笨拙。幸运的是,从 Firefox 44 开始,可以直接从 MediaStreamTrack 构造 MediaStream 的功能得以添加,这为我们提供了一种透明地模拟 DTMF 发送器的方法:我们拦截对 <a href="https://mdn.org.cn/en-US/docs/Web/API/MediaStream.addTrack" target="_blank">addTrack()</a> 的调用,创建一个 DTMFSender,用包含 DTMF 生成器的新的轨道替换原始轨道,并将 DTMFSender 附加到它所属的 RTCRTPSender 对象。

可以通过包含 DTMFSender 对象,然后进行基本的本地音频通话来演示这一点。

/* Note: Requires Firefox 44 or later */
var pc1 = new RTCPeerConnection();
var pc2 = new RTCPeerConnection();

pc2.onaddtrack = function(e) {
  var stream = new MediaStream([e.track]);
  var audio = document.createElement("audio");
  document.body.appendChild(audio);
  audio.mozSrcObject = stream;
  audio.play();
};

pc1.onicecandidate = function(e) {
  if (e.candidate) {
    pc2.addIceCandidate(e.candidate);
  }
};

pc2.onicecandidate = function(e) {
  if (e.candidate) {
    pc1.addIceCandidate(e.candidate);
  };
};


navigator.mediaDevices.getUserMedia({ audio: true })
  .then(function(stream) {
    var track = stream.getAudioTracks()[0];
    var sender = pc1.addTrack(track, stream);

    pc1.createOffer().then(function(offer) {
      pc1.setLocalDescription(offer).then(function() {
        pc2.setRemoteDescription(offer).then(function() {
          pc2.createAnswer().then(function(answer) {
            pc2.setLocalDescription(answer).then(function() {
              pc1.setRemoteDescription(answer).then(function() {
                sender.dtmf.ontonechange = function(e) {
                  console.log(JSON.stringify(e));
                }
                sender.dtmf.insertDTMF("2145551212,1");
              });
            });
          });
        });
      });
    });
  });

如果您希望在本地实现 RTCDTMFSender 的平台工作开始时收到通知,请在 Bug 1012645 上将自己添加到 CC 列表中。我们也很乐意在评论中了解您在应用本文中描述的基于振荡器的方法时遇到的成功和挑战,以及您对改进示例实现的任何建议。

最后,这是 DTMFSender 对象的源代码

/*
 * DTMFSender.js
 *
 * This serves as a polyfill that adds a DTMF sender interface to the
 * RTCRTPSender objects on RTCRTPPeerConnecions for Firefox 44 and later.
 * Implementations simply include this file, and then use the DTMF sender
 * as described in the WebRTC specification.
 *
 * For versions of Firefox prior to 44, implementations need to manually
 * instantiate a version of the DTMFSender object, pass it a stream, and
 * then retreive "outputStream" from the sender object. Implmentations
 * may also choose to attach the sender to the corresponding RTCRTPSender,
 * if they wish.
 *
 * This Source Code Form is subject to the terms of the Mozilla Public License,
 * v. 2.0. If a copy of the MPL was not distributed with this file, You can 
 * obtain one at https://mozilla.org/MPL/2.0/.
 */


// The MediaStream enhancements we need to make a polyfill work landed
// at the same time as the "addTrack" method as added to MediaStream.
// If this is possible, we monkeypatch ourselves into RTCPeerConnection.addTrack
// so thatwe attach a new DTMF sender to each RTP Sender as they are created.
if ("addTrack" in MediaStream.prototype) {

  RTCPeerConnection.prototype.origAddTrack =
    RTCPeerConnection.prototype.addTrack;

  RTCPeerConnection.prototype.addTrack = function(track, stream) {
    var sender = this.origAddTrack(track, stream);
    new DTMFSender(sender);
    return(sender);
  }
}

function DTMFSender(senderOrStream) {
  var ctx = this._audioCtx = new AudioContext();
  this._outputStreamNode = ctx.createMediaStreamDestination();
  var outputStream = this._outputStreamNode.stream;

  var inputStream;
  var rtpSender = null;

  if ("track" in senderOrStream) {
    rtpSender = senderOrStream;
    inputStream = new MediaStream([rtpSender.track]);
  } else {
    inputStream = senderOrStream;
    this.outputStream = outputStream;
  }

  this._source = ctx.createMediaStreamSource(inputStream);
  this._source.connect(this._outputStreamNode);

  this._f1Oscillator = ctx.createOscillator();
  this._f1Oscillator.connect(this._outputStreamNode);
  this._f1Oscillator.frequency.value = 0;
  this._f1Oscillator.start(0);

  this._f2Oscillator = ctx.createOscillator();
  this._f2Oscillator.connect(this._outputStreamNode);
  this._f2Oscillator.frequency.value = 0;
  this._f2Oscillator.start(0);

  if (rtpSender) {
    rtpSender.replaceTrack(outputStream.getAudioTracks()[0])
      .then(function() {
        rtpSender.dtmf = this;
      }.bind(this));
  }
}

/* Implements the same interface as RTCDTMFSender */
DTMFSender.prototype = {

  ontonechange: undefined,

  get duration() {
    return this._duration;
  },

  get interToneGap() {
    return this._interToneGap;
  },

  get toneBuffer() {
    return this._toneBuffer;
  },

  insertDTMF: function(tones, duration, interToneGap) {
    if (/[^0-9a-d#\*,]/i.test(tones)) {
      throw(new Error("InvalidCharacterError"));
    }

    this._duration = Math.min(6000, Math.max(40, duration || 100));
    this._interToneGap = Math.max(40, interToneGap || 70);
    this._toneBuffer = tones;

    if (!this._playing) {
      setTimeout(this._playNextTone.bind(this), 0);
      this._playing = true;
    }
  },

  /* Private */
  _duration: 100,
  _interToneGap: 70,
  _toneBuffer: "",
  _f1Oscillator: null,
  _f2Oscillator: null,
  _playing: false,

  _freq: {
    "1": [ 1209, 697 ],
    "2": [ 1336, 697 ],
    "3": [ 1477, 697 ],
    "a": [ 1633, 697 ],
    "4": [ 1209, 770 ],
    "5": [ 1336, 770 ],
    "6": [ 1477, 770 ],
    "b": [ 1633, 770 ],
    "7": [ 1209, 852 ],
    "8": [ 1336, 852 ],
    "9": [ 1477, 852 ],
    "c": [ 1633, 852 ],
    "*": [ 1209, 941 ],
    "0": [ 1336, 941 ],
    "#": [ 1477, 941 ],
    "d": [ 1633, 941 ]
  },

  _playNextTone: function() {
    if (this._toneBuffer.length == 0) {
      this._playing = false;
      this._f1Oscillator.frequency.value = 0;
      this._f2Oscillator.frequency.value = 0;
      if (this.ontonechange) {
        this.ontonechange({tone: ""});
      }
      return;
    }

    var digit = this._toneBuffer.substr(0,1);
    this._toneBuffer = this._toneBuffer.substr(1);

    if (this.ontonechange) {
      this.ontonechange({tone: digit});
    }

    if (digit == ',') {
      setTimeout(this._playNextTone.bind(this), 2000);
      return;
    }

    var f = this._freq[digit.toLowerCase()];
    if (f) {
      this._f1Oscillator.frequency.value = f[0];
      this._f2Oscillator.frequency.value = f[1];
      setTimeout(this._stopTone.bind(this), this._duration);
    } else {
      // This shouldn't happen. If it does, just move on.
      setTimeout(this._playNextTone.bind(this), 0);
    }
  },

  _stopTone: function() {
    this._f1Oscillator.frequency.value = 0;
    this._f2Oscillator.frequency.value = 0;
    setTimeout(this._playNextTone.bind(this), this._interToneGap);
  }
};

关于 Adam Roach

Adam Roach 是 Mozilla CTO 组织的成员,负责 WebRTC 和相关技术。自 1997 年以来,他一直在通过进行协议标准化、架构、设计和实施来构建基于 IP 的实时通信世界。

Adam Roach 的更多文章…