0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

多言語読み上げブラウザVOTWをAndroid向けに作った話

0
Posted at

はじめに

このたび、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化して構造化したデータで渡すのですが、いろいろ検証していたころは、ちょっと泥臭いプロトコルを作っていました。

なお、集めている情報は以下の通りです。

  • エレメントの親子構造
  • コンテンツ座標系のエレメント矩形(位置・サイズ)情報
  • ルビモード
  • テキスト文字列

「読み上げに親子構造や座標情報なんて、何に使うんだ?」と感じる方もいらっしゃるかもしれません。実は実装開始当初は取得していませんでした。

実際にコンテンツからテキストデータを取得すると想像以上に細切れに取れてきて、ある程度最適化しないと、そのあとの 統計的フィルタ がうまくいかないということが起こりました。フィルタがうまく動かないと、必要なテキストまで抜け落ちて、何を読んでいるのかわからない、という具合です。

これを解決するのに必要だったのですが、この方法は次回ご紹介したいと思います。

ではまた。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?