ABEJA Tech Blog

中の人の興味のある情報を発信していきます

100行で作る2D空間ボイチャツール&入ったら誰も居ない悲しみの解決!

こんにちは、メリークリスマス。株式会社ABEJAのラボチームで日々もがいてる藤本(@peisuke)です。本記事はABEJAアドベントカレンダー2022の25日目の記事です。

仕事では機械学習をやっていますが、機械学習で何かの問題を解いているだけでは中々インパクトがでないなぁ思います。機械学習だけではなく、何かしらのサービスと組み合わせることで価値が出ると思います。そんなこともあり、たまにはサービス開発するための技術も磨いております。今回は、2D空間でのボイチャツールを作ってみているので紹介していきます。

まず最小に作ってみた奴のスクショを貼ります。

作ったツールのキャプチャ

Chrome Extension上にボイチャのログイン状況を可視化

はじめに

コロナ禍に入ってからリモートワークが大分増えたのではないかと思います。リモートワーク最高ですね!でも、リモートワークしているだけだとちょっと寂しい。ちょっとした雑談がなかなかできないよ〜って人は多いんじゃないかと思います。当初はコミュニケーションの形としてzoom飲みなどが流行ったけど、どうしても一人が喋り散らかす感じになってしまって辛いですよね。そこでオンライン空間における多人数コミュニケーションのための多くのサービスが登場しました。

例えばSpatial Chatは、自分の位置を示すアイコンを操作して2次元空間上でコミュニケーションが出来るツールです。近い人の声だけが聞こえる事で、多人数でもそれなりに円滑にコミュニケーションが出来るなと思います。特に飲み会では中々使い勝手が良いと思います(課金が必要なので少し躊躇しますけどね)。Gather townは、ドット絵によるRPG風のインタフェースで自分を操作してコミュニケーションするツールです。色んなギミックがあって面白いです。他にも色々なサービスがあるけど、ここでは割愛します。

Spatial ChatとGather town

こういったツールの悩ましい問題として、例えば入ったら誰もいなかったというのがあります。誰も居ないかもだから入らない、結果として流行らないといった負の連鎖があります。飲み会みたいにある程度時間を指定して「いっせーの」で入れれば良いのですが、ふらっと入って雑談する用途には向かないという問題があります。この問題を、もう少し挙げてみましょう。

  • 課題1:そもそもサービスを誰が使っているかがリアルタイムに分からず、いちいちログインする必要がある
  • 課題2:仮に入ったとしても、声をかけてよいかが分からない

これらに共通する問題として、期待が裏切られる可能性がある、というのがあります。前者においては誰かと話そうと期待して入ってみたら誰もいないという状態、後者においては返事を期待して話しかけたら忙しかった/離席していたという状態。どちらも相手が見えないこと起因して期待して入ったもの話せなかったという問題です。これらの問題を、可視化を頑張る事で解決できないか、というのが今回のテーマです。

ということで、前置きが長くなりましたが、今回はこういったリモート環境におけるコミュニケーションの問題を解決するためのサービスを作っていこうと思います。はじめに「100行で作る2D空間ボイチャツール!」のソースコードの解説をしていきます。続いて、今回の工夫点として「Chrome Extensionで作る利用者チェックの仕組み」と「相手が近づいたら音を鳴らして通知」の説明をします。

100行で作る2D空間ボイチャツール

多人数でのオンラインでボイチャをする際には、参加者全員が全員に対して接続をするMesh方式と、サーバを中継するSFUという方式があります。前者は、サーバが必要ないのですが多人数での参加が難しいという課題があります。後者は、サーバが必要であるものの多人数での会話ができます。SkyWayというサービスがSFUサーバを用意してくれているため、今回はそれを利用してSFU方式を取ります。Community Editionでは無料の代わりに通信制限があり、Enterprise Editionでは通信料金が発生します。今回はブログ用に試しに作ってみるだけなので、Community Editionを選びました。

SkyWayを使ったプログラミングについては色々な所で解説があるので、ここでは細かい解説は割愛し、ボイチャツールに必要となるソースコードの解説に留めておきます。まずは、最もシンプルなバージョンを100行で作りました。まず最初にソースコートをこちらに置いておきます。また、画面は以下になります。シンプルですね。もしかしたら通信制限のため見られないかもしれませんが、以下から一時的に入れるようにしておきます。遊び用なのでセキュリティはガバガバですがご容赦ください。入ったら誰も居ない悲しみを味わえると思います。

https://nimble-brigadeiros-8bca6f.netlify.app/

この章で作るツール

以下はhtmlのコードになります。入室と退室のボタン、メディアストリーム用のコンテナを用意しておきます。また、ライブラリとしてp5.jsとskywayのsdkを利用します。

<html>
  <head>
    <script type="text/javascript" src="https://cdn.jsdelivr.net/npm/p5@1.5.0/lib/p5.js"></script>
    <script type="text/javascript" src="https://cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>
  </head>
  <body>
    <div class="controller-container">
      <button id="js-join-trigger">Join</button>
      <button id="js-leave-trigger">Leave</button>
    </div>
    <div id="canvas"></div>
    <div id="js-videos-container" class="videos-container">
      <audio id="js-local-video"></audio>
    </div>
    <script type="text/javascript" src="./script.js"></script>
  </body>
</html>

では、script.jsを見ていきましょう。コードはgithubに置いておくので、ここでは要点を解説していきます。はじめに、SkyWayのサーバに接続するのがこちらです。Peerというインスタンスを作ります。ここでキーを入れるところがありますが、こちらはSkyWayから発行されるAPIキーになります。

const Peer = window.Peer;
const peer = new Peer({key: "xxxxxxxxxx-xxxx-xxxxx-xxxxx-xxxxxxxxxxx"});


p5.jsではsetupという関数が最初に呼ばれます。まずは、音声メディアを設定しましょう。 joinボタンが押されたらルームに入室します。ルーム名は"sample-room"としておきましょう。ここで、接続方式や先ほど作成したメディアを設定します。

const localVideo = document.getElementById('js-local-video');

async function setup() {
  localVideo.srcObject = await navigator.mediaDevices.getUserMedia({video: false, audio: true});
}

//  joinボタンを押すと入室
joinTrigger.addEventListener('click', () => {
  //  部屋に入る
  room = peer.joinRoom("sample-room", {mode: 'sfu', stream: localVideo.srcObject});
  ....
  //  自身が抜ける場合は全てのビデオ情報を削除
  room.once('close', () => {
    const remoteVideos = videosContainer.querySelectorAll('[data-peer-id]');
    Array.from(remoteVideos)
      .forEach(remoteVideo => {
        remoteVideo.srcObject.getTracks().forEach(track => track.stop());
        remoteVideo.srcObject = null;
        remoteVideo.remove();
      });
  });
});

これで自分からの送信が可能になりました。次に他の人の音声を取得しましょう。roomに他の人のメディアが接続されたら、ビデオコンテナにメディアを追加してやります。誰のメディアか分かるように属性を設定しておきます。

const videosContainer = document.getElementById('js-videos-container');

joinTrigger.addEventListener('click', () => {
  ....
  //  他の人のメディアを自分のvideoContainerに追加
  room.on('stream', async stream => {
    const remoteVideo = document.createElement('audio');
    remoteVideo.srcObject = stream;
    remoteVideo.setAttribute('data-peer-id', stream.peerId);
    videosContainer.append(remoteVideo);

    await remoteVideo.play().catch(console.error);
  });
  ....
});

ここまでで、自分および接続相手の入室が可能になりました。次に退室部分を作ります。他人が抜けるとpeerLeaveのイベントが発生しますので、退室者のIDに紐付いたビデオを削除します。自分が抜ける場合、Leaveボタンを押されたらcloseイベントを発行します。closeする際には、相手のメディアを全て削除するのみです。中央でユーザの管理をしてくれるから楽で良いですね。ちなみにusersはIDをキーにした位置情報を入れたデータですが、後ほど説明します。

const videosContainer = document.getElementById('js-videos-container');

joinTrigger.addEventListener('click', () => {
  ....
  //  他の人が抜けたら、ビデオを削除
  room.on('peerLeave', peerId => {
    const remoteVideo = videosContainer.querySelector(`[data-peer-id="${peerId}"]`);
    remoteVideo.srcObject.getTracks().forEach(track => { track.stop(); });
    remoteVideo.srcObject = null;
    remoteVideo.remove();

    delete users[peerId];
  });

  //  自身が抜ける場合は全てのビデオ情報を削除
  room.once('close', () => {
    const remoteVideos = videosContainer.querySelectorAll('[data-peer-id]');
    Array.from(remoteVideos)
      .forEach(remoteVideo => {
        remoteVideo.srcObject.getTracks().forEach(track => track.stop());
        remoteVideo.srcObject = null;
        remoteVideo.remove();
      });
  });

  //  Leaveボタンを押したらルームから離れる
  leaveTrigger.addEventListener('click', () => {
    room.close();
    users = {}
  }, { once: true });
});

ここまでが基本的なビデオチャットの部分でした。次に、自分および相手の位置の同期を行います。部屋に入った段階で、一定時間おきにsync関数を立ち上げます。sync関数の中でサーバ側に位置のデータを送信します。サーバはデータが送信されてきたら全員に転送します。受信時はdataのイベントが発生するので、ここで他のユーザの情報をIDをキーにして記録しておきます。

let position = {"X": 300, "Y": 300};
let users = {}

function sync() {
  room.send({"X": position.X, "Y": position.X});
  setTimeout(sync, 100);
}

joinTrigger.addEventListener('click', () => {
  ...
  room.on('open', () => {
    setTimeout(sync, 100);
  });
  room.on('data', async (data) => { users[data.src] = data.data; });
  ...
});

音量の調整は、2次元空間内の相対距離に基づいて行います。先程のsync関数の中で、各IDに紐付いたプレイヤーとの距離に基づき音量を設定します。近ければ1、遠ければ0になるような関数を作ります。機械学習エンジニアらしいところも見せたいので、シグモイド関数を使いました。

function sync() {
  room.send({"X": position.X, "Y": position.Y});
  const sigmoid = (x) => { return Math.exp(x) / (Math.exp(x) + 1) };
  const remoteVideos = videosContainer.querySelectorAll('[data-peer-id]');
    Array.from(remoteVideos)
      .forEach(remoteVideo => {
        id = remoteVideo.getAttribute("data-peer-id");
        if (id in users)
          remoteVideo.volume = sigmoid(10 - (dist(position.X, position.Y, users[id].X, users[id].Y) / 20));
      });
  setTimeout(sync, 100);
}

最後にUIを作り、自分の位置を動かせるようにします。マウスを押したところからの相対距離を用いて自分を移動させます。細かなお作法はp5.jsを参照ください。

let position = {"X": 300, "Y": 300}; // 自分の位置
let users = {}; // 他人の位置(キーはID)
let dragPosition = null

async function setup() {
  canvas = createCanvas(600, 600);
  canvas.parent(canvasElem);
  ...
}

function mousePressed() {
  dragPosition = {"positionX": position.X, "positionY": position.Y, "mouseX": mouseX, "mouseY": mouseY}
}

function mouseDragged() {
  if (dragPosition !== null && mouseIsPressed) {
    position.X = dragPosition.positionX + (mouseX - dragPosition.mouseX)
    position.Y = dragPosition.positionY + (mouseY - dragPosition.mouseY)
  }
}

function mouseReleased() {
  dragPosition = null;
}

function draw() {
  background(255, 255, 255);
  noStroke();
  fill(0);
  for (const id in users)
    ellipse(users[id].X, users[id].Y, 30, 30);
  ellipse(position.X, position.Y, 50, 50);
}

ここまでで、2次元空間でのボイチャが可能になりました。とっても簡単でしたね。ちなみに実際のシステムにおいては、マイクON/OFFや拡大縮小など、色々な機能が必要になると思います。

Chrome Extensionで作る利用者チェックの仕組み

ここまでは単なるサンプルでした、これだけなら既存サービスを使えば十分。ここから自分が作りたいものを作っていきます。まず、こういったボイチャシステムの問題は、入っても誰がいるか分からない中、手間を割いてログインしたら、誰もいなかった・・・時のガッカリ感は大きいんじゃないかと思います。ガッカリするとテンション下がるのでログインしないと、それが負の連鎖を起こし、使われなくなるのではないかなと考えています。ここでは、誰かがシステムを使っているかどうかをログインせずともチェックできるような仕組みを作ってみましょう。

具体的には以下の構成です。このようにすることによって、人が喋っているから自分も話したい気分だし覗いてみようってのが出来るようになります。

利用状態をChrome Extensionで可視化

今回はFirebaseはRealTime Databseを使うことにしました。シンプルに自分のIDと状態などを一定間隔で登録してあげることとします。例えば以下のコードを一分に一回ほど呼び出してやります。ここではstatusをtalkingとしてますが、実際はマイクのON/OFFに応じたり、雑談エリア内かどうかといった情報を入れてやります。 


const date = new Date();
db.ref(peer.id).set({"status": "talking", "update": date.toUTCString()});

Firebase上には例えば以下のように登録されます。

この情報を定期的にチェックしてアイコンで表現するChrome Extensionを作っていきましょう。Chrome Extensionに入れる機能としては、Firebase Realtime Databaseの読み込み、一定間隔での動作実行、アイコン変更の3つです。background.jsを以下のようにしました。具体的には1分に一度dbを読みに行き、利用中のユーザが存在しているかどうかに応じてアイコンを設定します。

...
const app = firebase.initializeApp(config)
const db = database.getDatabase(app);

chrome.alarms.create("notify",
  { delayInMinutes: 0, periodInMinutes: 1 }
);

chrome.alarms.onAlarm.addListener((alarm) => {
  if (alarm.name === "notify") {
    var userRef = database.ref(db, '/')
    database.get(userRef)
      .then((snapshot) => {
        const data = snapshot.val();
        if (data === null || data === undefined || Object.keys(data).length == 0) {
          chrome.action.setIcon({path:"red.png"});
        } else {
          chrome.action.setIcon({path:"green.png"});
        }
      })
      .catch((err) => {
        console.error(err);
    });
  }
});

manifest.jsonは以下のようにしました。alarmsの権限をつけるのを忘れないようにしましょう。

{
  "name": "Voice Room Checker",
  "description": "Voice Room Checker",
  "version": "1.0",
  "manifest_version": 3,
  "background": {
    "service_worker": "background",
    "type": "module"
  },
  "permissions": ["alarms"],
  "action": {
    "default_icon": "green.png"
  }
}

これによって、ブラウザを見れば会話が起きているかがひと目で分かりますし、バッジを使ってやれば人数を出すことも可能です。クリックしたら一発でログインできても良いですね。なお、実サービスで使う場合は、Firebase Authentification等と組み合わせ、ユーザごとに入れる部屋を管理するなどが必要になってきます。

相手が近づいたら音を鳴らして通知

ボイスチャットツールでコミュニケーションがしづらいもう一つの理由として、相手がどういう状況かわからない、というのを挙げました。これまでのツールの最初の会話の起こりの流れとしては、(1)一人で先にツールに入って作業をしている、(2)後から入った人が声をかける、というものです。ここで、後から入った人からは先に入った人の状況が分からないため、声をかけづらい状況でした。仮に先に入っている人が「声かけて良いよ」と出していたとしても、なかなか難しいですよね。

後から入った人の気持ち

そこで、今回は逆に、後から人が入ると、先に入っている人に通知が飛ぶというものを考えてみます。通知が飛んだ時点で、先に入った人はタブをシステムに移動させると、後からの人が画面上を動いているのが見えるので声を掛けやすい、といった流れを作ることができます。実は通知を飛ばす仕組み自体はzoomやdiscordのような画面上で相手の状況が見えないツールにはすでに入っています。見えないからこそ通知を飛ばす仕組みになっており、2Dのボイチャシステムは相手の動きが見えるから不要という設計になっているのでしょう。実際の所は、皆が画面を見ず別の画面で作業をしている状況においては、やはり通知は必要ということと思います。

ということで、以下のように近づくとチャイムがなる仕組みを作ってみました。自分が先に入って待っている立場であれば、誰かが入ってきた事に気づけるので挨拶しましょう!

近づくとチャイムが鳴る仕組み

具体的なロジックはシンプルで、前回の位置までの距離が300pixel以上離れていて、最新の距離が300pixel以内になったらチャイムを鳴らすというだけのものです。至ってシンプルです。声を出して会話していたり、複数人が纏まっているときは、あえて鳴らさなくても良いかもしれませんね。

room.on('data', async (data) => {
  if (dist(data.data.X, data.data.Y, position.X, position.Y) < 300) {
    if (!(data.src in users)) {
      chime.play();
    } else if (dist(users[data.src].X, users[data.src].Y, position.X, position.Y) > 300) {
      chime.play();
    }
  }
  users[data.src] = data.data;
});

その先へ

ツールは作っただけでは使われません、ちゃんと使って改善を回すのが大事!作ったら飽きちゃうのが問題!折角作ったので社内で使っていきたいなと思います。本当は運用までやってみてどうだったってレポートまでやりたかったけど、作るので精一杯でした!

採用情報

株式会社ABEJAでは共に働く仲間を募集しています!

面白いものを作ってみたいエンジニアの方々!こちらの採用ページから是非ご応募くださいませ!

careers.abejainc.com