10月6日にリリースされたChrome M86(安定版)から、WebRTC Insertable Streams APIが利用できるようになりました。
Insertable Streamsは簡単に言うと、音声や映像のフレーム毎のバイナリデータに任意の値を差し込む技術です。バイナリデータを触れるので、映像データを暗号化(E2EE)する等の用途に利用されている印象です。また、音声や映像とタイミングをズラさずにデータを届けられるという利点もあります。
さっそく、国内のWebRTC PaaSであるSkyWayで試してみました。
javascript SDKの公式チュートリアル(P2P)を少しだけ改造して、任意のデータをやり取りできるようにしてみます。
デモ
CODEPEN
でお試しいただけます。
コード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Insertable Streams on SkyWay</title>
<script src="https://cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>
</head>
<body>
<video id="my-video" width="400px" autoplay muted></video>
<video id="their-video" width="400px" autoplay></video>
<p id="my-id"></p>
<input id="their-id" placeholder="their peer id" />
<button id="make-call">発信</button>
<input id="num" placeholder="send number" />
<p id="receive-num"></p>
<script>
const APIKEY = "YOUR APIKEY";
const senderTransform = (pc) => {
pc.getSenders().forEach((sender) => {
const senderStreams = sender.createEncodedStreams();
const readableStream = senderStreams.readable;
const writableStream = senderStreams.writable;
const transformStream = new TransformStream({
transform: (encodedFrame, controller) => {
const len = encodedFrame.data.byteLength;
if (sender.track.kind === "audio") {
const container = new Uint8Array(len + 1);
container.set(new Uint8Array(encodedFrame.data), 0);
const num = new Uint8Array(1);
num[0] = document.getElementById("num").value;
container.set(num, encodedFrame.data.byteLength);
encodedFrame.data = container.buffer;
}
controller.enqueue(encodedFrame);
},
});
readableStream.pipeThrough(transformStream).pipeTo(writableStream);
});
};
const receiverTransform = (pc) => {
pc.getReceivers().forEach((receiver) => {
const receiverStreams = receiver.createEncodedStreams();
const readableStream = receiverStreams.readable;
const writableStream = receiverStreams.writable;
const transformStream = new TransformStream({
transform: (encodedFrame, controller) => {
if (receiver.track.kind === "audio") {
const mediaData = new Uint8Array(
encodedFrame.data.slice(0, -1)
);
const num = new Uint8Array(encodedFrame.data.slice(-1));
document.getElementById("receive-num").textContent = num[0];
encodedFrame.data = mediaData.buffer;
}
controller.enqueue(encodedFrame);
},
});
readableStream.pipeThrough(transformStream).pipeTo(writableStream);
});
};
navigator.mediaDevices
.getUserMedia({ video: true, audio: true })
.then((stream) => {
const videoElm = document.getElementById("my-video");
videoElm.srcObject = stream;
videoElm.play();
localStream = stream;
})
.catch((error) => {
console.error("mediaDevice.getUserMedia() error:", error);
return;
});
const peer = new Peer({
key: APIKEY,
debug: 2,
config: {
encodedInsertableStreams: true,
},
});
peer.on("open", () => {
document.getElementById("my-id").textContent = peer.id;
});
peer.on("call", (mediaConnection) => {
mediaConnection.answer(localStream);
mediaConnection.on("stream", (stream) => {
document.getElementById("their-video").srcObject = stream;
const pc = mediaConnection.getPeerConnection();
receiverTransform(pc);
senderTransform(pc);
});
});
document.getElementById("make-call").onclick = async () => {
const theirId = document.getElementById("their-id").value;
const mediaConnection = peer.call(theirId, localStream);
mediaConnection.on("stream", (stream) => {
document.getElementById("their-video").srcObject = stream;
});
setTimeout(() => {
const pc = mediaConnection.getPeerConnection();
senderTransform(pc);
receiverTransform(pc);
}, 1000);
};
</script>
</body>
</html>
解説
やっていることの概要は以下のとおりです。
- 送信側:音声バイナリデータの先頭に、ある1byteのデータを差し込む
- 受信側:音声バイナリデータの先頭から、1byte分のデータを取得すると同時に削除する
送信をsenderTransform、受信receiverTransform関数として、最初に用意しています。
では次に、通信が確率するまでの流れを追ってみます。
Peerの作成
const peer = new Peer({
key: APIKEY,
debug: 2,
config: {
encodedInsertableStreams: true,
},
});
peerインスタンスを作成する際に、configというパラメータで、 encodedInsertableStreams: true
を与えます。
これにより、このpeerでInsertable Streams APIを使えるようになります。
発信
// 発信ボタン押下
document.getElementById("make-call").onclick = async () => {
const theirId = document.getElementById("their-id").value;
const mediaConnection = peer.call(theirId, localStream);
// 相手映像が来たらDOMに適用
mediaConnection.on("stream", (stream) => {
document.getElementById("their-video").srcObject = stream;
});
// 1秒待つ
setTimeout(() => {
// RTCPeerConnectionを取得
const pc = mediaConnection.getPeerConnection();
senderTransform(pc); // 送信streamに任意データを差し込み
receiverTransform(pc); // 受信streamから任意データを取得
}, 1000);
};
まず、発信ボタンを押した時に、peer.call()
の戻り値としてMediaConnectionのインスタンスが取得できます。
MediaConnectionが確立する(openプロパティがtrueになる)まで、setTimeoutで1秒間待った上で、getPeerConnectionを実行し、RTCPeerConnection
を取得します。(Insertable Streamsを行うにはこのRTCPeerConnection
が必要です)
RTCPeerConnection
を先のsenderTransform, receiverTransform関数に渡してあげることで、流れているstreamに任意データを差し込み&取得します。
着信
peer.on("call", (mediaConnection) => {
mediaConnection.answer(localStream);
mediaConnection.on("stream", (stream) => {
document.getElementById("their-video").srcObject = stream;
// RTCPeerConnectionを取得
const pc = mediaConnection.getPeerConnection();
senderTransform(pc); // 送信streamに任意データを差し込み
receiverTransform(pc); // 受信streamから任意データを取得
});
});
発信側と同様に、getPeerConnection()でRTCPeerConnection
を取得し、sender/receiverTransform関数に渡してあげます。
任意データの差し込み&取得
sender/receiverTransform関数では、以下を実施しています。
-
sender:numという1バイトのUint8Array型の変数を用意し、DOMから取得した値を入れ、音声バイナリデータの先頭に足す
-
receiver:音声バイナリデータの先頭から値を削除 & 取得しDOMに適用
Insertable Streamsの詳細な使い方についてはここでは割愛します。
(以下に公式サイトや参考にした記事のリンクを貼りましたので、そちらをご参照ください。)