はじめに
こんにちは!新卒入社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を使用します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
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を各部屋のクライアント側に送信しているだけです。サーバー側
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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); }); }); |
channelId
があれば、Slackにも送信します。
クライアント側
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
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'); } }); }); |
/room/{各部屋番号}
)ごとに部屋を分けるようにしているので、各部屋に入室させます。Slack連携については、後述します。
音声URLの再生はシンプルで、
Audio()
を使っています。Slack連携
Slack連携をするようにした背景は、ニフティでは特定のリモート会議(大人数が参加するイベントなど)ではSlack実況チャンネルというものが存在していて、実況の分断をなくすためです。現時点では、「もじこえ」、Slackの双方向にコメントがそれぞれのコメントが流れるようにしました。
もじこえ → Slackのチャンネル
部屋番号をSlackのチャンネルIDにすることで、(/room/{SlackのチャンネルID}
)でチャンネルIDを取得します。Slack連携にチェックをつければ、サーバ側でwebhookを使ってSlackチャンネルに送信します。SlackにPOSTするデータは以下です。現状おそらくSlackで音声再生はできないのですが、再生ボタンの絵文字を押せば、音声URLが開く仕組みにしています。
1 2 3 4 5 6 7 8 9 10 11 12 |
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ソースは以下になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
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" |
challenge
が含まれていたら、特定のレスポンスを返すようにします。また双方向の実装するにあたり、考慮する必要があったのが、「もじこえ」→Slackに送信された場合、Slack AppのEventが発火され、「もじこえ」→ Slack → 「もじこえ」のように再び送信しようとすることです。
以下のように、SlackからPOSTされるデータの
event
のsubtype
を見ると、bot_message
になっているのがわかったので、ここで判別するようにしました。ちなみに、普通にSlackに投稿した場合は"type": "message"
になります。
1 2 3 4 5 6 7 8 9 10 11 |
~~~ "event": { "type": "message", "subtype": "bot_message", "text": "もじこえ から Slack に", "ts": "1661256966.757159", "username": "Mizuki", "icons": { "emoji": ":woman:" }, ~~~ |
まずサーバ側で部屋名とメッセージを受け取り、音声はランダムにして、音声URLを作成し、対象の部屋に送信します。
サーバ側のソース
1 2 3 4 5 6 7 8 9 10 11 12 |
// 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の拡張機能にできないか調査
- などなど