耳だけで学習したくて、TTSの音声を自前ストレージにキャッシュした話
プライベートで、ある資格試験の勉強をしています。手持ちのPDF教材から一問一答を作り、スマホでめくって覚えるだけの小さなアプリを自作しました。
しばらく使ううちに、「移動中や歩いているときなど、手元を見られないタイミングでも勉強したい」という欲が出てきました。そこで、設問と解答を音声で読み上げて自動でめくっていくモードを足すことにしました。この記事は、その読み上げをまともな品質・待ち時間・コストで動かすまでのログです。
まずはポイント
- ブラウザ標準のTTS(Web Speech API)は端末依存で、特に日本語の品質が厳しかった。固有名詞の読み間違いが多く、耳で聞くと内容が頭に入ってこない。
- Google Cloud TTSのニューラル音声に替えたら品質は解決。ただし毎回オンデマンドで合成すると、遅延とAPI課金が気になる。
- 読み上げ対象は静的で有限なので、生成した音声を自前のSupabase Storageにキャッシュし、2回目以降はそれを配信するだけにした。Googleを叩くのは「未生成のテキスト」の初回だけ。
- 全設問を事前ウォームアップするバッチを用意し、初回の待ちも消した。キャッシュのキーをテキストのハッシュにしたことで、設問を追加・編集しても「差分だけ」を回せるようになった。
読み上げの仕様
読み上げモードは、状態機械として素直に組みました。
- 設問を読み上げる
- 読み終えたら3秒待つ
- 解答を読み上げる
- 読み終えたら3秒待って次の設問へ
これを、停止するか最後の設問に着くまで繰り返すだけです。UIとしては(index, revealed)という状態に対して、その状態に対応するテキストを読み、読み終わりに次の状態へ遷移させています。「読み終わり」のフックは、ブラウザTTSを使っていた頃はSpeechSynthesisUtterance.onend、キャッシュした音声を<audio>で鳴らすようにしてからはendedイベント、という具合に変わりました。
ブラウザ標準TTSがキモチワルい
最初はゼロ円で動くブラウザ標準のspeechSynthesis(Web Speech API)を使いました。実装は数十行で済み、動くには動きます。ただ、品質がとにかく厳しかった。日本語は抑揚が不自然でロボット感が強く、端末による当たり外れも大きい。
とどめが英語でした。今回の教材は英語の文献がベースで、解答や専門用語に英語の固有名詞が多く出てきます。これらを日本語の音声で読ませると、英語がまるで読めない崩れた音になります。「耳で覚えたくて作っているのに本末転倒だな」と思い、Google Cloud TTSのニューラル音声に切り替えたところ、日本語も英語もあっさり自然になりました。
Web Speech APIとGoogle TTSは何が違うのか
Web Speech APIはブラウザ自身が声を持つのではなく、OSの音声エンジンを呼んでいるだけです。これにより端末差が生まれます。
iOSはApple製の音声(日本語なら「Kyoko」など)で比較的そろっている一方、高品質版は別ダウンロード。Androidは読み上げエンジン自体を差し替え可能で(標準はGoogle製ですが端末により別物が入る)、バラつきがさらにでやすいようです。
Web Speech APIの強みはオフライン・無料・低遅延で、テキストも外に出ません。日常のちょっとした読み上げには十分です。
弱みとしては、このOS音声は「録音を繋ぐ/規則で合成する」方式で、発音辞書も音の素片も言語ごとに作り込まれています。そのため日本語の音声に英単語を渡すと、英語の読み方を知らないまま日本語風に音にしようとして崩れるというわけです。
Google Cloud TTS(WaveNet/Neural2系)は、大量データから学習したニューラルモデルが波形を生成します。英語混じりでも自然なのは、発音を辞書ではなくデータから学んでいるため。要は**「言語ごとの辞書+音の素片」か「データから学習したモデル」か**の違いです。代わりに通信と文字数課金が要るので、その遅延とコストを次のキャッシュで潰します。
毎回合成 vs. キャッシュ
Google TTSに替えれば品質は解決します。残る懸念は2つでした。
- 遅延:再生のたびにAPIへ往復すると、めくるテンポが悪くなる
- コスト:ニューラル音声は文字数課金(おおよそ100万文字あたり16ドル)。同じ設問を何度も読み返す使い方なので、素直に毎回合成すると地味に積み上がる
ここで効いてくるのが、読み上げ対象が静的で有限という性質です。設問も解答も、一度書いたらそうそう変わりません。だったら「毎回その場で合成」ではなく、一度合成して音声ファイルを使い回すほうが、品質・遅延・コストのすべてで有利です。
そこで、生成したMP3を自前のSupabase Storageに保存し、次回以降はそれを配信するだけにしました。サーバー側の処理はこうなります。
- クライアントからテキストと言語を受け取る
- Storageに該当の音声があるか確認する
- あれば、その公開URLを返す(Googleは呼ばない)
- なければ、Googleで合成 → Storageに保存 → URLを返す
Googleは合成のワンショット処理をするだけで、音声の実体は自分のストレージに置く、という構図です。
キャッシュのキーは「テキストのハッシュ」にする
このキャッシュの肝は、ファイル名の決め方です。音声・言語・テキストから決まる一意な名前にしておきます。
// 音声設定(例)とキャッシュのファイル名
const voice = "ja-JP-Neural2-B";
const fileName = sha256(`${voice}:${text}`) + ".mp3";
// 保存先: <bucket>/cache/<hash>.mp3
これには2つの効き目があります。
ひとつは重複排除。まったく同じテキストは同じハッシュになるので、別々の設問に同じ文が出てきても音声はひとつで済みます。
もうひとつが、後述するウォームアップの差分実行です。テキストが1文字でも変われば別のハッシュになる、という性質がそのまま「変更検知」になります。
事前ウォームアップと、差分だけ回す仕組み
初回再生の待ちを消すため、全設問の音声をあらかじめ生成しておくバッチを書きました。ここで悩みどころが「2回目以降、追加・編集した分だけを生成したい」という要件です。
普通なら「最終更新日時」や処理済みリストのような状態を持って差分を出しますが、今回はキャッシュのキーがテキストのハッシュなので、状態管理そのものが要りません。バッチは「Storageに既に存在するファイル」と「今必要なファイル」を突き合わせ、存在しないものだけを生成します。
- 変更なしの設問 → 同じハッシュが既存 → スキップ
- 新規追加した設問 → 新しいハッシュ → 生成
- 一部を編集した設問 → テキストが変わりハッシュも変わる → その分だけ生成
設問テーブルにupdated_atすら持たせていませんが、内容ハッシュが変更検知を兼ねるので困りませんでした。編集で使われなくなった古い音声ファイルはそのまま残るため、不要になったファイルを消す掃除用のフラグ(--prune)だけ別に用意しています。
実際に全件を回した結果がこちらです。
| 項目 | 値 |
|---|---|
| 設問数 | 約3,100問 |
| ユニーク音声数(設問ja/en+解答) | 9,317件 |
| 生成した件数 / 失敗 | 9,303件 / 0件 |
| ストレージ使用量 | 約350MB |
合成はテキストごとに一度きりで、以降は自前ストレージからの配信になるため、Googleへの課金は増えません。合成した文字数は概算で数十万字規模で、Neural2の月間無料枠(100万文字)に収まりました。ウォームアップ後は「初回でも待ちなし・追加課金なし」で、当初気にしていた遅延もコストも意識しなくてよくなりました。
一方で、350MBという実体は自分のストレージ容量を消費します(Supabase無料枠は1GBで、現状は約35%)。教材は今後まだ5倍ほど増える見込みで、このままだと無料枠を超えます。ここは今後の課題で、超えるときには音声フォーマットをMP3からOGG/Opusに変えてファイルを小さくしようと考えています。
所感
やりたかったのは「移動中に耳だけで復習する」という、ごく個人的なことでした。ただ実際に手を動かすと、TTSの品質、キャッシュの置き場所、事前生成と差分更新と、小さいながら地味に面白い論点がいくつも出てきました。
とくに「対象が静的で有限」という性質を認めてしまえば、キャッシュのキーを内容ハッシュにするだけで、重複排除と差分更新がほぼタダで手に入る、というのは気持ちよかったです。凝った状態管理を足す前に、キーの設計で解けないかを先に考えると筋が良さそうです。
同じように「読み上げを付けたいが品質と待ちとコストが不安」という人には、生成音声を自前ストレージにキャッシュしてウォームアップしておく構成はおすすめできます。
Anycloudでは一緒に働くメンバーを募集しています!
Anycloudは、ユーザーの心を動かす体験を届けることを大切にしています。フルリモート・フルフレックスの環境のもと、ライフスタイルに合わせた働き方を実現しながら挑戦したい方を歓迎します。詳細はこちらをご覧ください。