Voice Of The World開発記 第2回です。
前回のエントリはこちら:多言語読み上げブラウザVOTWをAndroid向けに作った話
Voice Of The World Android版販売中です。
Voice Of The World(GooglePlay)
早速ごめんなさい
前回のラストで「座標情報も使ってテキストの最適化を行います」と書きましたが、これは誤りでした。
というか、コード上は、非常に試行錯誤した痕跡が残っていて、座標を使った最適化実装もあったのですが、最終的には外れていました。ごめんなさい。
現在は要素の親子構造を使って最適化を行っているので、今回はその手法をご紹介します。
コンテンツから抜き出したテキストを処理する
前回、HTMLのコンテンツからテキストをJavaScriptで抜き出す方法をご紹介しました。
抜き出したテキストに対して、VOTWでは以下のような処理を行っています。
- リスト形式で抜き出したテキストを全部つなげて、何語で書いてあるか判定する。
- テキストリスト(結合前)のデータを最適化する。
- 最適化済みのテキストリストのそれぞれの要素に対して、文章か否か、のマーキングをする。
コンテンツが何語で書いてあるかを判定する
まずは、抜き出したコンテンツを取り出した全てのテキストを1つに結合した文字列を作成します。
VOTWでは、表示中のページ単位で「何語で書かれているか」を判断して、言語モードを設定するようになっています。
テキスト単位でこまめに判定するほうがよいという方針ももちろんあり得ます。が、以下の3つの理由で、ページ単位としています。
- AndroidのTTSは、TTSエンジンとロケール(に紐づく音声データ)の指定をセットで行う必要がある。言語が変わるとTTSモジュールのインスタンスを作り直す必要があり、オーバーヘッドがとても大きい。端末によっては、数秒待たされる。
- VOTWはListViewでテキストリストを表示できて、テキストをタップすることで、再生位置を変更することもできるが、ここで言語変更のためにしばらく黙り込むと、ユーザー体験としては「うっ!?」とひっかかる感じになる。
- テキスト単位だと、例えば「OK」のように短い単語に対して言語判定をすると、間違える可能性が高くなる。この場合日本語でも英語でも間違いとは言えない。
- 言語学習用途のように、複数言語が1つのコンテンツに存在する場合もあるが、ほとんどの場合は、1ページ1言語で問題がない。
判定には、Cybozu Labs, Inc.の Nakatani Shuyoさんが作られた language-detection を、Android向けにポートされたもの language-detection のプロフィールデータをさらに手直ししたものを使っています。
テキストリストのデータを最適化する
今回の記事の本題です。
コンテンツにもよるのですが、ニュース記事のサイトや、Wikiなどの百科事典系サイトの場合、本文中にハイパーリンクが沢山張られていることがあります。
典型的なのが、以下のような2パターンです。
<div>
VOTW の販売サイトは<a href="https://play.google.com/store/apps/details?id=jp.iyas.votw">こちら</a>です。<br>
VOTW の販売サイトはこちらです。<br>
</div>
この2行、前回のアルゴリズムでテキストを取得すると、以下のように取得されます。
- 1行目: ["VOTW の販売サイトは","こちら","です"]
- 2行目: ["VOTW の販売サイトはこちらです"]
取得したデータを全部読み上げてよいのであれば、順番さえ間違えなければ、この2つは同じように読み上げできます。
しかし、この後の「文字列が文章か否かのマーキング」処理をする前提だと、テキストを細かく切られると不都合が起きる可能性が大きくなります。ハイパーリンクは通常小さな単語にだけ設定してあることが多いので、前後が十分に文章らしいと判断できても「こちら」だけが抜け落ちたりすると、「VOTWの販売サイトはです」となってしまい、読み上げたときに意味がわからなくなります。
このようなケースを極力減らすために、テキストリストに含まれたデータを最適化します。
さて前回の処理で、テキストのリストを取得できたわけですが、それぞれのリストアイテムは以下のような情報を持っています。
- ノードの親子構造
- 親ノードのノードインデックス
- 自ノードのノードインデックス
- ルビモード
- テキスト文字列
前回の記事では「エレメントの親子構造」と書いてましたが、これも不正確でしたね。再びごめんなさい。正確にはノードです。
エレメント(Element)はタグに対応する要素情報で、厳密にはエレメントノードです。取得したいテキスト情報はテキストノードとなります。これらのノード全部ひっくるめてノードのツリー構造にしたものがDOMツリーとなります。
上記のコンテンツをDOMツリーで表すと、こんな感じです。
ElementNode(div)
+- TextNode("VOTW の販売サイトは")
+- ElementNode(a href="xxxxx")
| +- TextNode("こちら")
+- TextNode("です")
+- ElementNode(br)
|
+- TextNode("VOTW の販売サイトはこちらです")
+- ElementNode(br)
- ElementNode は NodeType==1
- TextNode は NodeType==3
こんな感じのツリー構造を表現した配列になっていて、配列の要素はノードに対応し、JavaScriptの中で親子構造がわかるようにしてTextNodeだけを一覧にしてJavaコードに渡します。
配列内の各要素の並び方もミソで、前回ご紹介したJavaScriptでは、DOMツリーの上から順番に見ていき、子を見つけたら子を順番に再帰的スキャンするという方法でした。
この方法は、htmlのようにタグで親子構造を表現したドキュメントの場合、ドキュメント上で出現した順番に要素が拾いだされるという前提条件を満たすので、上記の配列がJavaScriptから渡された時点で、出現順にソートされていることになります。
実際のHTMLコンテンツでは、サイドメニューなどの配置をCSSで制御していることも多いので、必ずしもコンテンツに書いてある順に要素が出現するわけではないのですが、実用上はこの方針で問題はありません。
TextNode同士に直接の親子関係はないため、親子インデックスを扱う実装はElementNodeとTextNodeを対にした階層管理になっていて、単純なノードインデックスではなく、レイヤーインデックスの性格を持つのですが、ここでは詳細を省略します。
頭からテキスト配列を見ていくと、親->子、子...の順番に隣り合って出現することを利用して、おおむね以下のことを行っています。
- カレントテキストの直前に親が出てきて、かつカレントに子がいなかったら(親の孫がいない)、カレントのテキストを親テキストにくっつけて、自身を空にする。
- カレントテキストの前に同じ親を持つ兄弟がいたら、そのカレントのテキストを前方の兄弟テキストにくっつけて、自身を空にする。
- 以下は親子や兄弟の出現順序がイレギュラーな場合の救済措置
- カレントテキストの直前に出てきたノードが親ではない(上記2条件を先に処理しているので、兄弟でもない)なら、カレントを親の兄弟に昇格してしまう(あとから親が出てきたら、くっついてくれるだろう)
- カレントテキストの親はいるのに、まだ出てきていなかったら、カレントを親の兄弟に昇格してしまう(あとから親が出てきたら、くっついてくれるだろう)
サンプルコンテンツの例でいえば、配列を頭から順番にたどって、以下のように処理されていきます。
- 最初に "VOTW の販売サイトは"を見つける。
- 1階層下がって"こちら" を見つける。親テキストは "VOTW の販売サイトは" なので、親にくっつけて、親は "VOTW の販売サイトは こちら" となる。
- 元の階層に戻って "です" を見つける。このテキストは最初のテキストの兄弟扱いなので、最初のテキストは "VOTW の販売サイトは こちら です"となる。
- このテストコンテンツでは、さらに次の"VOTW の販売サイトはこちらです" も兄弟テキストとみなされるので、"VOTW の販売サイトは こちら です VOTW の販売サイトはこちらです" となる。
- 最終的に、最初のテキスト以外は、全部空になる。
この最適化ルーチンを通ると、もともと
- 1行目: ["VOTW の販売サイトは","こちら","です"]
- 2行目: ["VOTW の販売サイトはこちらです"]
こうだったテキストが
- 1,2行目: ["VOTW の販売サイトは こちら です VOTW の販売サイトはこちらです"]
となります。
なお、テキスト結合時には、必ず空白文字を1文字挟むようになっています。これがないと、英単語の場合にくっついてしまい、読み上げに影響するためです。逆に漢字やハングル文字の読み上げには影響を与えません。
処理内容を見て「子がいないのを確認しているのはなぜだろう」と思う方もいらっしゃるかもしれません。これは、くっつける処理を再帰的に行っているわけではないので、くっつく相手(親や兄弟)から見て、直下のノードが単体のテキストを持っている場合だけを処理しているためです。子がいる場合は、結合はスキップされます。
実際のコンテンツでは、aタグなどのリンク、spanタグやpタグなどのインライン要素を結合できれば、ほぼ十分です。divタグの階層をまたいだ1つの文章などは基本的にあり得ないため、このような処理となっています。
なお、VOTWはルビタグにも対応していて、設定画面で本体、読み仮名、両方を読み上げできるようになっています。ルビタグは被装飾テキストの直後に来るものなので、読み上げモードに応じて、直前のテキストにくっつけるようになっています。この処理も上記の処理の中でやってしまいます。
今回は、JavaScriptから取り出したテキストの最適化処理をご紹介しました。
テキストの最適化の次は、各テキストが文章かどうかを判定してマーキングする処理です。これについては、次回お話したいと思います。
ではまた。