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>用於接收和顯示日誌文字,以顯示狀態資訊。
<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 程式碼。請記住,這裡建立連線的過程有些牽強;通常您不會在同一文件中構建連線的兩端。
全域性變數
首先,我們建立全域性變數。
let dialString = "12024561111";
let callerPC = null;
let receiverPC = null;
let dtmfSender = null;
let hasAddTrack = false;
let mediaConstraints = {
audio: true,
video: false,
};
它們依次是:
dialString-
當點選“撥號”按鈕時,呼叫者將傳送的 DTMF 字串。
callerPC和receiverPC-
分別代表呼叫者和接收者的
RTCPeerConnection物件。它們將在呼叫開始時在我們的connectAndDial()函式中初始化,如下面的啟動連線過程所示。 dtmfSender-
連線的
RTCDTMFSender物件。這將在設定連線時在gotStream()函式中獲取,如將音訊新增到連線中所示。 hasAddTrack-
由於某些瀏覽器尚未實現
RTCPeerConnection.addTrack(),因此需要使用過時的addStream()方法,我們使用這個布林值來確定使用者代理是否支援addTrack();如果不支援,我們將回退到addStream()。這將在connectAndDial()中確定,如啟動連線過程中所示。 mediaConstraints-
一個物件,指定啟動連線時要使用的約束。我們希望是僅音訊連線,因此
video為false,而audio為true。
初始化
我們獲取撥號按鈕和日誌輸出框元素的引用,並使用 addEventListener() 向撥號按鈕新增一個事件監聽器,以便單擊它時呼叫 connectAndDial() 函式開始連線過程。
const dialButton = document.querySelector("#dial");
const logElement = document.querySelector(".log");
dialButton.addEventListener("click", connectAndDial);
啟動連線過程
單擊撥號按鈕時,會呼叫 connectAndDial()。這會開始構建 WebRTC 連線,為傳送 DTMF 程式碼做準備。
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。track 和 addstream 事件在媒體新增到連線時傳送。
最後,我們呼叫 getUserMedia() 以獲取對呼叫者麥克風的訪問許可權。如果成功,則呼叫函式 gotStream(),否則我們記錄錯誤,因為呼叫失敗。
將音訊新增到連線
如上所述,當獲取麥克風的音訊輸入時,會呼叫 gotStream()。其任務是構建傳送給接收者的流,以便實際的傳輸過程可以開始。它還可以訪問我們將用於在連線上發出 DTMF 的 RTCDTMFSender。
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。然後,我們獲取 RTCRtpSender 的 dtmf 屬性,這是一個 RTCDTMFSender 物件,可以在連線上從呼叫者傳送 DTMF 給接收者。
如果 getSenders() 不可用,我們轉而呼叫 RTCPeerConnection.createDTMFSender() 來獲取 RTCDTMFSender 物件。儘管此方法已過時,但此示例支援它作為回退,以讓較舊的瀏覽器(以及尚未更新以支援當前 WebRTC DTMF API 的瀏覽器)執行該示例。
最後,我們設定 DTMF 傳送器的 ontonechange 事件處理程式,以便在每次 DTMF 音調播放完畢時收到通知。
您可以在文件底部找到日誌函式。
當一個音調播放完畢時
每次 DTMF 音調播放完畢時,都會向 callerPC 傳遞一個 tonechange 事件。這些事件的事件監聽器實現為 handleToneChangeEvent() 函式。
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> 元素中分離出來。
然後,最後,透過呼叫每個 RTCPeerConnection 的 close() 方法來關閉它們。
將候選者新增到呼叫者
當呼叫者的 RTCPeerConnection ICE 層提出新的候選者時,它會向 callerPC 發出 icecandidate 事件。icecandidate 事件處理程式的任務是將候選者傳輸給接收者。在我們的示例中,我們直接控制呼叫者和接收者,因此我們可以透過呼叫接收者的 addIceCandidate() 方法直接將候選者新增到接收者。這由 handleCallerIceEvent() 處理
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 事件具有非 null 的 candidate 屬性,我們從 event.candidate 字串建立一個新的 RTCIceCandidate 物件,並透過呼叫 receiverPC.addIceCandidate()(提供新的 RTCIceCandidate 作為其輸入)將其“傳輸”給接收者。如果 addIceCandidate() 失敗,則 catch() 子句將錯誤輸出到我們的日誌框中。
如果 event.candidate 為 null,則表示沒有更多可用候選者,我們會記錄該資訊。
連線開啟後撥號
我們的設計要求連線建立後,立即傳送 DTMF 字串。為此,我們監控呼叫者是否收到 iceconnectionstatechange 事件。當 ICE 連線過程狀態發生一系列變化時(包括成功建立連線),會發送此事件。
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 事件實際上不包含新狀態,因此我們從 callerPC 的 RTCPeerConnection.iceConnectionState 屬性獲取連線過程的當前狀態。記錄新狀態後,我們檢視狀態是否為 "connected"。如果是,我們記錄即將傳送 DTMF 的事實,然後我們呼叫 dtmf.insertDTMF() 以在音訊資料方法所在的 RTCDTMFSender 物件上傳送 DTMF,該物件我們之前儲存在 dtmfSender 中。
我們的 insertDTMF() 呼叫不僅指定要傳送的 DTMF(dialString),還指定每個音調的持續時間(400 毫秒)以及音調之間的時間量(50 毫秒)。
協商連線
當呼叫的 RTCPeerConnection 開始接收媒體時(在將麥克風流新增到其中之後),會向呼叫者傳遞一個 negotiationneeded 事件,告知它需要開始與接收者協商連線。如前所述,由於我們同時控制呼叫者和接收者,我們的示例有所簡化,因此 handleCallerNegotiationNeeded() 能夠透過呼叫呼叫者和接收者的方法快速構建連線,如下所示。
// 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,我們可以像這樣將它們連結起來:
- 呼叫
callerPC.createOffer()以獲取一個 offer。 - 然後獲取該 offer,並透過呼叫
callerPC.setLocalDescription()設定呼叫者的本地描述以匹配該 offer。 - 然後透過呼叫
receiverPC.setRemoteDescription()將 offer“傳輸”給接收者。這會配置接收者,使其知道呼叫者是如何配置的。 - 然後接收者透過呼叫
receiverPC.createAnswer()建立一個 answer。 - 然後接收者透過呼叫
receiverPC.setLocalDescription()設定其本地描述以匹配新建立的 answer。 - 然後透過呼叫
callerPC.setRemoteDescription()將 answer“傳輸”給呼叫者。這讓呼叫者知道接收者的配置是什麼。 - 如果在任何時候發生錯誤,
catch()子句會將錯誤訊息輸出到日誌中。
跟蹤其他狀態變化
我們還可以觀察信令狀態的變化(透過接受 signalingstatechange 事件)和 ICE 收集狀態(透過接受 icegatheringstatechange 事件)。我們沒有將這些用於任何目的,所以我們所做的只是記錄它們。我們本可以根本不設定這些事件監聽器。
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 事件處理程式類似。
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.candidate 為 null,則表示沒有更多可用候選者,我們會記錄該資訊。
將媒體新增到接收者
當接收者開始接收媒體時,事件將傳遞到接收者的 RTCPeerConnection,即 receiverPC。如啟動連線過程中所述,當前 WebRTC 規範使用 track 事件來實現此目的。由於某些瀏覽器尚未更新以支援此功能,因此我們還需要處理 addstream 事件。這將在下面的 handleReceiverTrackEvent() 和 handleReceiverAddStreamEvent() 方法中進行演示。
function handleReceiverTrackEvent(event) {
audio.srcObject = event.streams[0];
}
function handleReceiverAddStreamEvent(event) {
audio.srcObject = event.stream;
}
track 事件包含一個 streams 屬性,其中包含該軌道所屬的流陣列(一個軌道可以是許多流的一部分)。我們獲取第一個流並將其附加到 <audio> 元素。
addstream 事件包含一個 stream 屬性,該屬性指定新增到軌道的一個流。我們將其附加到 <audio> 元素。
日誌記錄
整個程式碼中使用了一個簡單的 log() 函式,用於將文字附加到 <div> 框中,以向用戶顯示狀態和錯誤。
function log(msg) {
logElement.innerText += `${msg}\n`;
}
結果
您可以在此處嘗試此示例。當您單擊“撥號”按鈕時,您應該會看到一系列日誌訊息輸出;然後撥號將開始。如果您的瀏覽器將音調作為其使用者體驗的一部分可聽地播放,您應該在傳輸時聽到它們。
一旦音調傳輸完成,連線將關閉。您可以再次單擊“撥號”以重新連線併發送音調。
另見
- WebRTC API
- WebRTC 會話的生命週期
- 信令與視訊通話(一個更詳細地解釋信令過程的教程和示例)
- WebRTC 協議簡介