FORCIA CUBEフォルシアの情報を多面的に発信するブログ

GoogleMeetのチャット欄を読み上げさせてみた

2021.12.24

アドベントカレンダー2021 エンジニア

これは、FORCIA Advent Calendar 2021の24日目の記事です。

こんにちは、DXプラットフォーム事業部の小海です。在宅勤務やオンライン会議が一般的になりつつある世の中ですが、皆様いかがお過ごしでしょうか。 フォルシアでもGoogleMeetによるオンライン会議を普段から使用しています。今回はオンライン会議をより楽しいものにするための工夫を紹介します。

はじめに

フォルシアでは定期的に社内ハッカソンを実施しており、エンジニアやエンジニア以外の社員も開発したいもの・勉強したいことを持ち寄り定期的に報告をしながら共に学ぶ機会を設けています。

数年前までは参加者で合宿所に行っていましたが、最近の情勢ではオンラインで開催をしています。参加者それぞれ自宅で開発・勉強し、定期的にオンライン集まり報告などをしつつ、最終日に発表会を行っています。

合宿所で行われたハッカソンについてはこちらの記事をご覧ください。

今回は2021年のGWに行われたオンラインハッカソンで私が実施した「GoogleMeetのチャット欄を読み上げてみた」という内容について紹介します。

なお、記事は技術的な内容でソースコードの記載もありますが、設計の考え方や課題解決の仕方に主軸を置いているため、プログラミング苦手な方ももう少しブラウザバックは我慢して頂けますと幸いです。

なぜそのテーマを選んだのか

ハッカソンに参加するにあたり「最近不便に感じたことはないか」「最近面白いと思ったことはないか」などを考えていました。

私は技術部の教育チームにも所属しているため、4月からGoogleMeetを使った社内でのオンライン講義の企画・講師・参加をしていました。

その中で参加者がチャット欄に感想や質問を書くことが多々あったのですが、講師が説明に集中しているとき、チャット欄を見ることができなくなったり、講義の内容によってはチャット欄を見ながら講義をすることが難しいということがありました。

また、会議や講義は講師の腕だけじゃなく参加者が能動的に参加することが大事だと思っていたため、オンラインならではの仕組みでより活発で面白い講義にしたいと思っていました。

普段からゲーム配信や実況を楽しんでいた私は、コメント欄の読み上げ機能には馴染みがあり、GoogleMeetのチャット欄を読み上げさせることができたら楽しいのではと思ったのがきっかけです。

使用したツールや言語と選定理由

  • GoogleMeet
    • 普段からFORCIAでは社内オンライン会議にGoogleMeetを使用しています
  • GoogleChromeの拡張機能
    • GoogleMeetと読み上げツールを使用するため
  • 棒読みちゃん(Ver0.1.11.0 Beta21
  • 仮想サウンドドライバ

設計

まず考えるべき壁は3点あります。

  • GoogleMeetのチャット欄の情報をどのように取得するか
  • 取得したチャット欄の情報をどのように読み上げツールに渡すか
  • 読み上げツールで読み上げた音声をどのようにGoogleMeetで再生させるか

GoogleMeetのチャット欄の情報をどのように取得するか

これは検討段階から答えを持っており、自作のGoogleChrome拡張を作成することで、特定のWebページを開いているときにWebページの情報をJavaScriptで取得できます。

自作GoogleChromeの拡張のソースコードは後述します。

GoogleMeetのチャット欄の情報をどのように読み上げツールに渡すか

ここは非常に悩みました。 基本線で考えていたのは「GoogleChrome拡張から読み上げツールのAPIを呼ぶ」という方式です。ただし、GoogleChrome拡張から読み上げツールのAPIを叩くことの難易度が高く、想定よりも時間がかかってしまいそうと感じていました。

GW期間という限られた時間だったこともあり、あまり時間をかけたくなかったため、もっと楽な方法がないか調べていたところ、読み上げツールには『クリップボード監視』という機能がありました。読み上げツール側で『クリップボード監視』をONにしておくと、クリップボードの変更を検知して内容を自動で読み上げるというものです。

この『クリップボード監視』をONにしておき、GoogleChrome拡張からクリップボードに文字列を保存するようにすれば、読み上げが可能となるので、この方向で進めることとしました。

読み上げツールで読み上げた音声をどのようにGoogleMeetで再生させるか

GoogleMeetではGoogleChromeのタブ共有で音声を共有できますが、なるべく画面共有などは使わずに、会議の運用に影響しない形で読み上げさせる方法を模索していました。

最終的には、仮想サウンドドライバを使用し、読み上げツールの出力先を仮想サウンドドライバに設定し、GoogleMeetの入力を仮想サウンドドライバに設定することで連携を可能にしました。

ただし、そのように設定した場合、読み上げツールがマイクを使用してしまうため、同じPCで私がマイクを使用できなくなります。仮想ミキサーなどを使用することで解消できますが、時間短縮のため、私と読み上げツールは別のPCで会議に参加することにしました。

実装

ここまで来れば、後はGoogleChrome拡張を作成して、チャット欄をクリップボードに保存すれば実現ができます。

少々記事が長くなりつつあるので、細かい説明は省きますが興味がある方向けにソースコードを共有します。

ファイル構造

拡張を作成するときのファイル構造は下記のようになります。 今回は gmeetClipborder という名前で作成することにしました。

gmeetClipborder
 ├ content
 │ ├ content.js
 │ └ jquery-3.6.0.min.js
 ├ images
 │ └ icon.png
 ├ js
 │ └ background.js
 ├ popup
 │ ├ jquery-3.6.0.min.js
 │ ├ popup.html
 │ └ popup.js
 └ manifest.json

manifest.json

GoogleChrome拡張作成の構成ファイルです。

{
	"name": "GoogleMeet ClipBoarder",
	"version": "1.0",
	"description": "GoogleMeet ClipBoarder",
	"permissions": ["declarativeContent"],
	"background": {
		"scripts": ["js/background.js"],
		"persistent": false
	},
	"content_scripts": [
		{
			"matches": ["https://meet.google.com/*"],
			"js": ["content/jquery-3.6.0.min.js", "content/content.js"]
		}
	],
	"page_action": {
		"default_popup": "popup/popup.html",
		"default_icon": {
			"32": "images/icon.png"
		}
	},
	"icons": {
		"48": "images/icon.png"
	},
	"manifest_version": 2
}

background.js

GoogleChrome拡張の本体といえるJavaScriptです。

// 基本的なルール
chrome.runtime.onInstalled.addListener(() => {
	chrome.declarativeContent.onPageChanged.removeRules(undefined, () => {
		chrome.declarativeContent.onPageChanged.addRules([{
			conditions: [
				new chrome.declarativeContent.PageStateMatcher({
					pageUrl: {hostEquals: 'meet.google.com'},
				})
			],
			actions: [new chrome.declarativeContent.ShowPageAction()]
		}]);
	});
});

// 有効化・無効化のStatus管理
const Enable = (() => {
	let isEnabled = false;
	const that = this;
	this.get = () => {
		return isEnabled;
	};
	this.set = (bool) => {
		isEnabled = Boolean(bool);
		return that; // for method chaining
	};
	this.toggle = () => {
		isEnabled = !isEnabled;
		return that; // for method chaining
	};
	return this;
})();

// popupやcontentの世界との連携
const CommandHandler = {
	GetEnabled: () => Enable.get(),
	ToggleEnabled: () => Enable.toggle().get()
};
chrome.runtime.onMessage.addListener(
	(request, _sender, sendResponse) => sendResponse(CommandHandler[request.cmd]())
);

popup.html

拡張アイコンのクリック時に表示されるHTMLです。

<!DOCTYPE html>
<html>
	<head>
		<style>
			button {
				height: 30px;
				width: 30px;
				outline: none;
				font-weight: bold;
			}
			button.is-active {
				background-color: red;
				color: white;
			}
		</style>
	</head>
	<body>
		<button id="toggleButton"></button>
		<script src="jquery-3.6.0.min.js"></script>
		<script src="popup.js"></script>
	</body>
</html>

popup.js

上記popup.htmlで読み込まれるJavaScriptです。

(() => {
// static
const SELECTOR_TOGGLE_BUTTON = "#toggleButton";
const CLASS_ACTIVE = "is-active";
// functions
const renderStatus = (isEnabled, $button) => {
	if(isEnabled){
		$button.addClass(CLASS_ACTIVE);
		$button.html("on");
	}else{
		$button.removeClass(CLASS_ACTIVE);
		$button.html("off");
	}
};
// on loaded
$(() => {
	const $button = $(SELECTOR_TOGGLE_BUTTON);
	// initial setting
	chrome.runtime.sendMessage(
		{ cmd: "GetEnabled" },
		(isEnabled) => renderStatus(isEnabled, $button)
	);
	// attach event
	$("#toggleButton").on("click", () => {
		chrome.runtime.sendMessage(
			{ cmd: "ToggleEnabled" },
			(isEnabled) => renderStatus(isEnabled, $button)
		);
	});
});
})();

content.js

拡張によってページ内で読み込まれるクライアント側のJavaScriptです。

'use strict';
(() => {
// static
const CLASS_READED = "gmc-readed";
const SELECTOR_ALL_MESSAGES = ".GDhqjd div div";
const SELECTOR_NEW_MESSAGES = SELECTOR_ALL_MESSAGES + ":not(." + CLASS_READED + ")";
const MAX_TEXTS_AT_ONCE = 5;
// on loaded
$(() => {
	initialize();
	setInterval(MainLoop, 1000);
});
// initialize
const initialize = () => {
	const $elm = $(SELECTOR_ALL_MESSAGES);
	$elm.addClass(CLASS_READED);
};
// get new text from HTML
const getMessage = () => {
	const $elm = $(SELECTOR_NEW_MESSAGES);
	$elm.addClass(CLASS_READED);
	const messages = [];
	$elm.each((_i, e) => {
		messages.push($(e).data("message-text"));
	});
	return messages;
}
// save text to clipboard
const saveClipboard = (str) => {
	if(!str) return;
	const listener = (e) => {
		e.clipboardData.setData("text/plain", str);
		e.preventDefault();
		document.removeEventListener("copy", listener);
	}
	document.addEventListener("copy", listener);
	document.execCommand("copy");
}
// main
const MainLoop = (() => {
	// closure
	let prevEnabled = false;
	const callback = (isEnabled) => {
		if (isEnabled) {
			if(!prevEnabled){
				initialize();
			}else{
				const messages = getMessage();
				if(messages && messages.length > 0 && messages.length <= MAX_TEXTS_AT_ONCE){
					saveClipboard(messages.join("\n"));
				}
			}
		}
		prevEnabled = isEnabled;
	};
	return () => {
		chrome.runtime.sendMessage({ cmd: "GetEnabled" }, callback);
	};
})();
})();

GoogleChromeに自作の拡張機能を読み込ませる

下記の手順で、作成した拡張機能を読み込ませることができます。

  • chrome://extensions/ にアクセス
  • デベロッパーモードをONにする
  • 『パッケージ化されていない拡張を読み込む』を選択
  • 開発したフォルダ「gmeetClipborder」を選択

これで、GoogleMeetのチャット欄の読み上げができるようになりました(拍手)

おわりに

今回はオンライン会議を面白くするアイデアを技術で実現した例をお話しさせていただきました。いかがだったでしょうか。ブラウザバックを我慢して読み進めて頂いたプログラミングが苦手な方、ここまで読んで頂いてありがとうございます。おそらく後半は存分にスクロールして頂いたかと思います。

日常でふと思いついたアイデアを形にすることは、とても楽しいしワクワクします。今回作成したGoogleMeetの読み上げ機能はハッカソンでの発表会でもとても好評で、参加者みんな読み上げツールに好きな言葉を読み上げさせて楽しんでくれていました。チャット欄で大喜利が発生し、読み上げツールの発言に皆さん気を取られ、私の発表はあまり聞いてもらえなかったのが良い思い出です。アイデアって難しいですね。オンライン飲み会専用ツールとなりそうです。

この記事の内容に興味をもった方は是非弊社採用ページもご確認ください!

この記事を書いた人

小海 研太

2012年4月新卒入社のエンジニア。
大手旅行会社の検索サイト開発を経て、現在はEC/MRO業界の検索サイトを担当。
尊敬している人はラーメンズの小林賢太郎さん。

フォルシアではフォルシアに興味をお持ちいただけた方に、社員との面談のご案内をしています。
採用応募の方、まずはカジュアルにお話をしてみたいという方は、お気軽に下記よりご連絡ください。


採用お問い合わせフォーム 募集要項

※ 弊社社員に対する営業行為などはお断りしております。ご希望に沿えない場合がございますので予めご了承ください。