はじめに
このたび、VOTW(Voice Of The World)という、多言語対応の読み上げブラウザを公開、販売し始めました。
このアプリには前身があり、もともとVowcという名前で無料公開していたのですが、今はそちらの公開は止まっています。
Voice Of The World(GooglePlay)
昨今のAIエージェントのおかげで、最新OSへの追従などメンテナンスコスト、それに複数ロケールに対応するための翻訳コストが劇的に下がったので、有料アプリとして仕切り直して 海外に打って出てみよう! と思い立ちました。
このツールはある意味で 「レガシーな手法の塊」 で、その代わりに端末機能だけでサクサク動く、という姿を目指しています。
生成AIやクラウドサービスの台頭で、世の中は基本的にサブスクが主流になってきているので、逆に 端末だけで閉じた機能でどこまでできるか 、勝負したくなりました。
この記事は、そんな小さなアプリのご紹介です。
どんなアプリ?
シングルタブブラウザを核として、表示中のコンテンツからテキストを抜き出し、読み上げるアプリです。
機能はシンプルなのですが、例えばどこかのポータルサイトにアクセスして、読み上げさせたら、ページ左上のメニューから順番に読み上げられた……となっても嬉しくないので、 一種の統計的フィルタを用いて文章だけ抜きだす機能 を実装しています。
テキストが取り出せたら次は読み上げです。これには本体のTTS(Text To Speech)モジュールを使っています。以前は端末搭載の標準TTSの他にもいろいろなTTSモジュールがGooglePlayに公開されていましたが、公開を終了しているものも複数あるようです。KDDI Labs.のN2 TTS等は、日本語を読ませるには鉄板アプリだったのですが。
ただ、当然音声読み上げが下火になったわけではなく、読み上げSDKやAIベースの再生エンジンにトレンドは移ってきているようですね。
TTSエンジンは設定画面で変更可能なのですが、実質 デフォルトのGoogleの標準TTSが一番安定して動く という、少し皮肉な状況になっています。変更する必要がありません。
VOTWでは、コンテンツの記述言語をテキストの内容から判定して、それに合わせてTTSを初期化するという処理を行っています。日本語コンテンツなら日本語、英語コンテンツなら英語として読んで(発声して)くれるという具合です。現在、 自動判定言語は8か国語 (日本語、英語、中文繁体、中文簡体、韓国語、ドイツ語、フランス語、スペイン語)です。
その他、読み上げをサポートする機能として、ブラウザとしての基本機能(ブックマーク、履歴)、複数ページまとめ読み、メディアプレイヤーライクな再生機能、TTSの制御(読み上げ速度、ピッチ)、読み替え辞書、RSSリーダーとRSS読み上げ、RSS記事まとめ読み、読み上げ範囲選択とキーワードマッチングを使った自動範囲設定などをサポートしています。
なお、今回仕切り直しをするにあたり、 Claude CodeとGemini に大いに手伝ってもらい、SDK35対応や、UIの多言語化(こちらも現在8か国語対応)なども行いました。AI同士で相互レビューさせると、ちょくちょく改善点も出てくるので、助かります。
コンテンツからのテキスト取り出しについて
コンテンツからのテキスト取り出しについては、Androidでは古くからWebViewにJavaScriptをコンテンツに流し込み、 alertウィンドウの表示コールバックで応答を受け取る という手法が存在します。
JavaScriptを流し込む、という発想は、AndroidのWebViewに限ったことではなくて、javascript:スキーマで流し込んでやるだけで、Androidであれば
mWebView.loadUrl(javascriptstring);
これだけです。javascript:スキーマを使うのは標準的な手法なので、ほとんど同じ手法がiOSのWebViewでも使えます。iOSは結果を受け取るフック関数にalertは使えないですが。
で、JavaScriptの中で何をやっているかというと、こんな感じです(主要部分抜粋)。
function TextSearch(node){
// ...
(function trace_node(node,result, parentgrp, mygrp){
for(var elm=node;elm;elm=elm.nextSibling){
if(elm.nodeType == 3){
// ...
result.push( new texttree( parentgrp, mygrp, elm, rubymode ));
}else{
if(elm.hasChildNodes())
{
// 見えないようにサイズで細工しているDIVタグなどは除外
if(elm.nodeType==1 && elm.tagName=="DIV" && elm.clientWidth < 2 && elm.clientHeight < 2) {
continue;
}
groupcnt++;
trace_node(elm.firstChild,result,mygrp,groupcnt);
}
if(elm.tagName=="IFRAME")
{
try {
var subsearch = InjectJS2IFrame(elm);
if(subsearch != null)
{
for(var ii=0; ii < subsearch.result.length; ii++)
{
result.push(subsearch.result[ii]);
}
}
}
catch(e)
{
// ...
}
continue;
}
}
}
})(this.node,this.result,-1,0);
};
function texttree( _parent, _me, _text, _rubymode )
{
this.parent = _parent;
this.me = _me;
this.text = _text;
this.rubymode = _rubymode; /* 0:normal 1:ruby body 2:ruby caption */
}
function InjectJS2IFrame(iframeelement)
{
try {
var win = iframeelement.contentWindow;
var doc = win.document;
var script = doc.createElement("script");
script.type = "text/javascript";
// 自分の関数をtoString()で文字列化して流し込む(分身を作るイメージ)
script.text = InjectJS2IFrame.toString() + TextSearch.toString() + 他に必要な関数全部();
doc.body.appendChild(script);
}
catch(e)
{
// ...
}
return null;
}
function get_text(){
var content=document.body;
var result_str="";
var text_search=new TextSearch(content);
// ...
}
省略しているところもありますが、テキストを取り込む大まかな流れは以下のような感じです。
- エントリーは
get_textで、document.bodyの頂上からスキャンする。 - イテレーションの本体は
TextSearchで、この中のtrace_nodeがDOMを再帰的にたどる。 - テキストを含むエレメントは
nodeType == 3。これを見つけたらテキスト要素として取り込む。 - エレメントに子がいたら、再帰で潜る。実際のコンテンツでは、Visibilityだけではなく、見えないようにサイズで細工しているDIVタグが沢山出てくるので、そういうのは無視する。
-
iframeタグを見つけたら、InjectJS2IFrameで、今走っているスクリプトの各関数の中で、必要なものをtoString()で文字列化して、フレーム内コンテンツにscript要素を作ってから、そこに流し込む。 自分の分身をtoString()で作って渡すイメージ 。実行が終わるまで待って、同期的に結果を受け取ってテキストを取り込み、処理を継続する。
InjectJS2IFrameの中にtry-catchがあるのは、CORS対策。別オリジンのコンテンツは読ませてくれずにエラーとなるためで、このケースでは取り込みを諦める。
集めたデータを alert() に渡し、Java側(そう、KotlinではなくJavaです)で受け取ります。今だったら当然のようにJSON化して構造化したデータで渡すのですが、いろいろ検証していたころは、ちょっと泥臭いプロトコルを作っていました。
なお、集めている情報は以下の通りです。
- エレメントの親子構造
- コンテンツ座標系のエレメント矩形(位置・サイズ)情報
- ルビモード
- テキスト文字列
「読み上げに親子構造や座標情報なんて、何に使うんだ?」と感じる方もいらっしゃるかもしれません。実は実装開始当初は取得していませんでした。
実際にコンテンツからテキストデータを取得すると想像以上に細切れに取れてきて、ある程度最適化しないと、そのあとの 統計的フィルタ がうまくいかないということが起こりました。フィルタがうまく動かないと、必要なテキストまで抜け落ちて、何を読んでいるのかわからない、という具合です。
これを解決するのに必要だったのですが、この方法は次回ご紹介したいと思います。
ではまた。