はじめに こんにちは!新卒入社4年目の小松です。主にお客様が初めて@niftyをご利用になる際の無料ID会員登録システム、いろいろなサービスをご利用になる際のログインシステムの開発・運用を担当しています。 今回はリモート会議のリアクションわかりづらい問題を解消するツール「もじこえ」を作ってみたので、紹介したいと思います。社内でも一部の会議では実際に使われています。 リモート会議はリアクションがわかりづらい 対面での会議と違って、参加者のリアクションがわかりづらいと感じたときはありませんか。 カメラ・マイクがオフの時はもちろん、うなずきや笑い声など些細なリアクションがわかりづらく、伝わっているのか不安になることがあります。 また実際にはワイワイとしている会議も、コメントを拾い忘れたり、盛り上がりに欠けるなと感じたこともあります。 そこで「もじこえ」を作りました。 もじこえ AI音声でコメント読み上げてくれる匿名チャットツールWebアプリです。 使い方は、リモート会議時に参加者が「もじこえ」をブラウザで開いてチャットします。 読み上げ音声は現在3種類で、コメント投稿ごとに切り替えできます。 実際に使ってみないと伝わりづらいかと思いますが、画面はこんな感じです。 まだデザインを考え中なので、プロトタイプになっています。 ちなみに名前の由来は、 GoogleMeet拡張機能の「こえもじ」 というものがありまして、Meetで話した内容を文字起こしして、コメント欄に追加してくれるものです。 その逆(テキスト→音声)もあればと思ったのが始まりでした。 試しに社内に公開してみましたが、意外と反応があり、社内のLT大会や勉強会などでも使われています。ちなみに私が所属する サービスインフラチーム では、毎日使っています。 もじこえ の中身 構成 バックエンドはNode.js + Expressで、Socket.IOを使ってリアルタイムチャットできるようにしています。音声変換はAWS SDKのPollyを使っています。 フロントは生HTMLとJQueryです。デザインはあとまわしにしているので、のちのちReactなどで作り直したいと思っています。 これらをAWSのFargateで動かしています。 後ほど紹介するSlack連携では、Slack Appを作成し、 Lambdaも使用しています。 読み上げ音声はAWSのAmazon Pollyを使用 Amazon Polly はテキストを音声に変換してくれるAWSのサービスです。様々な言語に対応し、日本語にも対応しています。 料金は、100万文字ごとに4ドルなので、そこまで気にせず使えるかなと思っています。今までで最高でも3ドル未満でした。 もじこえ には、3種類の音声を採用 日本語が話せる3種類の音声を採用しました。結構リアルです。 一番人気はマシューです。 ミズキ(女性) タクミ(男性) マシュー(外国籍で日本語も話せる男性) マシューの音声サンプル ソース紹介 まだリポジトリを公開できる状態ではないため、一部を紹介していきます。 テキスト→音声変換 部分 Node.js + Expressのサーバ側で、AWS SDKでPollyを使用します。 import AWS from "aws-sdk"; // この形式のファイルを作って、各自の値で埋める。 // { "accessKeyId": "", "secretAccessKey": "", "region": "" } AWS.config.loadFromPath('config.json'); const textToSpeakUrl = async (text, voiceId) =>{ // 決めた文字数以降は「省略」に変換 text = omitLongText(text); // URLは「URL省略」に変換 text = replacementUrl(text); // Create the JSON parameters for getSynthesizeSpeechUrl const speechParams = { OutputFormat: "mp3", SampleRate: "16000", // 読ませるテキスト Text: text, TextType: "text", // "Mizuki"などが入る。 VoiceId: voiceId }; let speakUrl; // Polly準備 const polly = new AWS.Polly({apiVersion: '2016-06-10'}); const signer = new AWS.Polly.Presigner(speechParams, polly); // 音声URLに変換 signer.getSynthesizeSpeechUrl(speechParams, function(error, url) { if (error) { return "" } else { speakUrl = url; } }); return speakUrl; } getSynthesizeSpeechUrl を使って、テキストを音声URLに変換します。 テキストの省略は、長すぎる文章と、URLは考慮して行っています。 そのままだと、URLは律儀に1文字ずつ読み上げてくれます。 ただし、各クライアント側で表示されるテキストは省略せずそのままで、音声変換時のみ省略されたテキストを使います。 チャット部分 Socket.IOを使ったチャット自体はよくある使い方です。単純にテキストと、音声変換した音声URLを各部屋のクライアント側に送信しているだけです。 サーバー側 io.on('connection',function(socket){ // 部屋入室 socket.on('enterTheRoom', function({roomId: roomId}){ socket.join(roomId); }) // テキストを受信したら、テキストと音声URLを送信 socket.on('speakTextRoom',async function(speakInfo){ const {speakText, voiceId, roomId, channelId} = speakInfo; // テキストを音声URLに変換 const speakUrl = await textToSpeakUrl(speakText, voiceId); const speakData = {speakText: speakText, speakUrl: speakUrl}; console.log(JSON.stringify({roomId: roomId, speakData: speakData})); // channelId があれば、Slack送信します。 if (channelId !== '') { // Slackに送信 postSlackMsg(channelId, voiceId, speakData); } io.to(roomId).emit('speakData', speakData); }); }); ここはシンプルですが、受け取ったテキストを音声URLに変換して、クライアント側に送信します。 channelId があれば、Slackにも送信します。 クライアント側 var socketio = io(); $(function () { // 部屋番号取得 const pathname = location.pathname; const roomId = pathname.split("room/").pop(); let channelId = ""; // 部屋入室 socketio.emit("enterTheRoom", {roomId: roomId}); // 送信 $("#message_form").submit(function () { // 空白、空文字は送信しない。 if ($("#input_msg").val().trim().length == 0 ) { $("#input_msg").val(""); return false; } // Slack連携flgがcheckedなら、channel_idを渡す。 if ((document.getElementById('slack_link')).checked) { // 先頭がCではないと、""に(channelId) if (roomId.slice(0, 1) === 'C') { channelId = roomId; } } else { channelId = ""; } // サーバに送信 socketio.emit("speakTextRoom", { speakText: $("#input_msg").val(), voiceId: $("#voiceId").val(), roomId: roomId, channelId: channelId} ); $("#input_msg").val(""); return false; }); // テキストと音声URLを受信 socketio.on("speakData", function (speakData) { $("#messages").append($("<li>").text(speakData["speakText"])); const music = new Audio(speakData['speakUrl']); // 音量 music.volume = Number($("#volume").val()); music.play(); // 自動スクロールにチャックなければ、 スクロールしない if ((document.getElementById('auto_scroll_flg')).checked) { $('.message_area').animate({scrollTop: $('.message_area')[0].scrollHeight}, 'fast'); } }); }); URLパス( /room/{各部屋番号} )ごとに部屋を分けるようにしているので、各部屋に入室させます。 Slack連携については、後述します。 音声URLの再生はシンプルで、 Audio() を使っています。 Slack連携 Slack連携をするようにした背景は、ニフティでは特定のリモート会議(大人数が参加するイベントなど)ではSlack実況チャンネルというものが存在していて、実況の分断をなくすためです。 現時点では、「もじこえ」、Slackの双方向にコメントがそれぞれのコメントが流れるようにしました。 もじこえ → Slackのチャンネル 部屋番号をSlackのチャンネルIDにすることで、( /room/{SlackのチャンネルID} )でチャンネルIDを取得します。Slack連携にチェックをつければ、サーバ側でwebhookを使ってSlackチャンネルに送信します。 SlackにPOSTするデータは以下です。現状おそらくSlackで音声再生はできないのですが、再生ボタンの絵文字を押せば、音声URLが開く仕組みにしています。 const payload = JSON.stringify({ channel: channelId, username: username, icon_emoji: iconEmoji, attachments: [ { color: "#ffdbb7", text: `${speakText}\n<${speakUrl}|:arrow_forward:>`, footer: `<{ドメイン}/room/${channelId}|fromもじこえ>`, }, ], }); Slackのチャンネル → もじこえ こちらは、Slack Appを作成して、メッセージデータを取れるようにしました。 データの受取先は手軽なAWS Lambda + API Gatewayにしました。 Lambda + API Gateway APIGatewayはLambdaのAPI化に使っただけで、特に珍しいこともしていないので、説明は省きます。 Lambdaソースは以下になります。 import json import logging import urllib.request import urllib.parse logger = logging.getLogger() logger.setLevel(logging.INFO) def lambda_handler(data, context): logger.info(json.dumps(data)) if ('challenge' in data): return { "statusCode": 200, "body": json.dumps({'challenge': data['challenge']}) } # もじこえ用 if data.get("event") is not None: subtype = data["event"].get("subtype") # もじこえ など、botから送信されたメッセージは何もせずreturn if subtype == "bot_message": return text = data["event"].get("text") roomId = data["event"].get("channel") mojikoe_direct_posting(roomId, text) return { "statusCode": 200 } def mojikoe_direct_posting(roomId, text): params = urllib.parse.urlencode({'room': roomId, 'text': text}) url = '{ドメイン}/api/direct-posting?%s' %params req = urllib.request.Request(url) urllib.request.urlopen(req) return "ok" SlackのRequestを受け取るにはSlack Appで承認される必要があります。リクエストデータに challenge が含まれていたら、特定のレスポンスを返すようにします。 また双方向の実装するにあたり、考慮する必要があったのが、「もじこえ」→Slackに送信された場合、Slack AppのEventが発火され、「もじこえ」→ Slack → 「もじこえ」のように再び送信しようとすることです。 以下のように、SlackからPOSTされるデータの event の subtype を見ると、 bot_message になっているのがわかったので、ここで判別するようにしました。ちなみに、普通にSlackに投稿した場合は "type": "message" になります。 ~~~ "event": { "type": "message", "subtype": "bot_message", "text": "もじこえ から Slack に", "ts": "1661256966.757159", "username": "Mizuki", "icons": { "emoji": ":woman:" }, ~~~ 「もじこえ」に送信する部分については、「もじこえ」のサーバ側で簡単なAPIを作成したので、それを呼ぶだけです。 まずサーバ側で部屋名とメッセージを受け取り、音声はランダムにして、音声URLを作成し、対象の部屋に送信します。 サーバ側のソース // API app.get('/api/direct-posting' , async function(req, res){ const roomId = req.query.room; const voiceIds = ["Mizuki", "Matthew", "Takumi"] const voiceId = voiceIds[Math.floor(Math.random() * voiceIds.length)]; const speakUrl = await textToSpeakUrl(req.query.text, voiceId); const speakData = {speakText: req.query.text, speakUrl: speakUrl} io.to(roomId).emit('speakData', speakData); console.log(roomId, speakData); res.status(200); res.send("ok"); }); Slack Appの設定 Subscribe to bot events で message.channels を追加すると、作成したSlack Appが追加されているチャンネルでメッセージが投稿されたときに、連携したURLにデータをPOSTしてくれます。 ※外部にSlackメッセージが送信されるので、扱いには気をつける必要があります。 Lambda + API Gatewayで受け取る Request URL に追加して連携します。上で説明した、特定のレスポンスを返す用に実装しておくと承認されます。 準備ができたので、あとは連携したいチャンネルにSlack Appを招待。 「もじこえ」のURLを /room/{SlackのチャンネルID} にして、Slack連携のチェックをいれれば、完成です。 今後やること もともと勉強で作り始めたもので、まだまだやることがいっぱいあります。 フロントをReactなどで書き換える Slack連携のオンオフ制御 社内SSO連携 現在匿名でのコメントになるので、チームで相談してログ整備は必要ということになりました。 Meetの拡張機能にできないか調査 などなど またなにか進展があれば、ブログに書こうかと思ってます! 【告知】NIFTY Tech Dayで「もじこえ」について話すかもしれません! NIFTY Tech Day 2022 We are hiring! ニフティでは、さまざまなプロダクトへ挑戦するエンジニアを絶賛募集中です! ご興味のある方は以下の採用サイトよりお気軽にご連絡ください! ニフティ株式会社採用情報 Tech TalkやMeetUpも開催しております! こちらもお気軽にご応募ください! Event – NIFTY engineering