瞑想の誘導ナレーションを配信する小さなアプリを個人開発で回しています。ある朝、Gemini TTS の利用明細を眺めていて、手が止まりました。
同じ音声が、何度も生成されていたのです。
ユーザーが「おやすみ前の3分」を再生するたびに、まったく同じテキストを TTS に投げ、まったく同じ音声を作り直していました。台本は固定です。声も固定です。それでも再生のたびに課金が積み上がっていく。冷静に考えれば当たり前の設計ミスなのですが、動いてしまっていたので気づくのが遅れました。
このとき腑に落ちたことがあります。動的な会話エージェントと違い、癒し系や学習系のアプリで流れる音声の多くは「決まった台本を繰り返し聴く」ものです。ならば、生成した音声そのものを保存し、二度目からは作り直さない。それだけで課金は劇的に下がります。
問題は、その「保存」を安全にやるための鍵の設計と、モデルや声が変わったときの失効でした。ここを雑にやると、声だけ変えたつもりが古い音声を返し続けたり、逆にキャッシュが効かず課金が戻ったりします。本稿は、その二点を実装と実測でまとめた運用ノートです。
都度生成がいくらになるか、まず桁を掴む
対策の前に、放置した場合の桁を掴んでおきます。ここが曖昧だと、どこまで手をかけるべきか判断できません。
TTS の課金は多くの場合、入力テキストの文字数に比例します。ここでは説明のための仮の単価として、1文字あたり ¥0.02 と置きます(実際の単価はお使いのモデルと契約で異なりますので、必ず自分の明細で置き換えてください)。
私のアプリの構成で試算します。
| 項目 | 値 |
| ナレーション本数 | 30 本 |
| 1本あたりの台本 | 約 1,500 文字 |
| アクティブユーザー | 8,000 DAU |
| 1人あたりの再生回数 | 1日 6 回 |
都度生成した場合、1日の生成文字数は 8,000 × 6 × 1,500 = 7,200 万文字です。単価 ¥0.02 なら、1日で ¥1,440,000。とても払える額ではありません。
一方、音声ライブラリ全体を一度だけ生成すると、30 × 1,500 = 45,000 文字。単価 ¥0.02 で ¥900 です。台本を書き換えない限り、この ¥900 は二度と発生しません。
同じ音声を配るのに、¥1,440,000/日 と ¥900/一度きり。この差が、キャッシュに手をかける理由のすべてです。
キャッシュキーは「テキスト」だけでは足りない
最初に私がやりかけた失敗は、台本テキストをそのままキーにすることでした。これは本番で必ず事故ります。
同じテキストでも、声(voice)を変えれば別の音声になります。モデルを差し替えても、話速を変えても、出力フォーマットを mp3 から wav に変えても、中身は変わります。テキストだけのキーでは、これらの違いを取り違えます。「声を女性から男性に変えたのに、古い女性の声が返ってくる」という不具合は、まさにこれが原因でした。
キャッシュキーには、音声の同一性を決める要素をすべて含めます。
- 正規化した台本テキスト(前後の空白除去・改行コード統一)
- 音声名(voice)
- モデルID(例: gemini-3.1-flash-tts)
- 出力フォーマット(mp3 / wav / opus など)
- 話速などの生成パラメータ
これらを正準的な順序で並べ、ハッシュ化します。
import { createHash } from "node:crypto";
interface TtsRequest {
text: string;
voice: string;
model: string;
format: "mp3" | "wav" | "opus";
speakingRate: number;
}
// 音声の同一性を決める要素だけを、順序を固定して鍵にする
export function buildCacheKey(req: TtsRequest): string {
const normalizedText = req.text
.replace(/\r\n/g, "\n")
.replace(/\s+$/gm, "")
.trim();
// キー順を固定した正準 JSON にしてからハッシュ化する
const canonical = JSON.stringify({
t: normalizedText,
v: req.voice,
m: req.model,
f: req.format,
r: req.speakingRate,
});
return createHash("sha256").update(canonical).digest("hex");
}
ポイントは、モデルIDを鍵に含めることです。ここを省くと、あとで述べるモデル切り替えの失効が一切効かなくなります。私はこのキー設計を、迷ったら「音声のバイト列が変わりうる要素は全部入れる」という基準で決めています。
サーバ生成キャッシュと端末キャッシュの二層で守る
キャッシュは一層では足りません。私は二層に分けています。役割がまったく違うからです。
| 層 | 置き場所 | 防ぐ無駄 |
| 生成キャッシュ | オブジェクトストレージ | TTS の再生成(課金の発生) |
| 端末キャッシュ | 端末のローカル領域 | 音声ファイルの再ダウンロード(通信量) |
第1層: サーバ側の生成キャッシュ
サーバ側では、鍵で先にストレージを引き、あればそれを返し、なければ生成して保存します。全ユーザーで一度だけ生成すれば済むので、課金は本数分に収束します。
import { GoogleGenAI } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
async function getOrGenerateAudio(
req: TtsRequest,
storage: ObjectStorage,
): Promise<string> {
const key = buildCacheKey(req);
const objectPath = `tts/${key}.${req.format}`;
// 1. 生成済みなら、そのまま配信 URL を返す
const existing = await storage.exists(objectPath);
if (existing) {
return storage.publicUrl(objectPath);
}
// 2. 未生成のときだけ TTS を呼ぶ(ここが唯一の課金ポイント)
const res = await ai.models.generateContent({
model: req.model,
contents: req.text,
config: {
responseModalities: ["AUDIO"],
speechConfig: {
voiceConfig: { prebuiltVoiceConfig: { voiceName: req.voice } },
},
},
});
const audio = res.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
if (!audio) {
// 生成失敗時はキャッシュに空を書かない。次回リトライさせる
throw new Error("TTS returned no audio");
}
await storage.put(objectPath, Buffer.from(audio, "base64"), {
contentType: `audio/${req.format}`,
// モデルIDをメタデータに残す。あとの失効判断で使う
metadata: { model: req.model, voice: req.voice },
});
return storage.publicUrl(objectPath);
}
ここで一つ、本番でハマった落とし穴を共有します。生成に失敗したときに「空のオブジェクト」をキャッシュに書いてしまうと、以後ずっと無音を返し続けます。失敗時はキャッシュへ書かず、必ず次回にリトライさせる。この一行の判断が、無音のクレームを未然に防ぎました。
第2層: 端末側のキャッシュ
配信 URL が同じでも、毎回ダウンロードすれば通信量がかかり、オフラインでは再生できません。端末側では、同じ鍵をファイル名にして端末内に保存します。
import Foundation
// 端末内キャッシュ。鍵をそのままファイル名に使う
final class AudioCache {
static let shared = AudioCache()
private let dir: URL
private init() {
let base = FileManager.default.urls(for: .cachesDirectory,
in: .userDomainMask)[0]
dir = base.appendingPathComponent("tts", isDirectory: true)
try? FileManager.default.createDirectory(at: dir,
withIntermediateDirectories: true)
}
func localURL(forKey key: String, format: String) -> URL {
dir.appendingPathComponent("\(key).\(format)")
}
func cached(key: String, format: String) -> URL? {
let url = localURL(forKey: key, format: format)
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}
func store(key: String, format: String, data: Data) throws {
try data.write(to: localURL(forKey: key, format: format))
}
}
端末キャッシュを caches ディレクトリに置くのは意図的です。OS が容量逼迫時に自動で掃除してくれるため、自前で肥大化を心配しなくて済みます。消えても、サーバの生成キャッシュから再取得するだけで、再生成の課金は発生しません。
モデルや声が変わったときにどう失効させるか
ここが本稿でいちばん伝えたい運用の勘所です。キャッシュは「効かせる」よりも「正しく捨てる」設計のほうが難しいのです。
Gemini では画像生成モデルが 2026-08-17 に停止予定と告知されています。TTS を含め、依存しているモデルにはいつか終了や差し替えが来ます。そのとき、保存済みの音声をどう扱うか。判断軸を先に決めておくと、期限に慌てずに済みます。
私は次の三択で切り分けています。
- 凍結する — モデルが終了しても、既存の音声はそのまま使い続ける。声の一貫性を保ちたい既存トラックはこれです
- 遅延再生成する — モデルIDを鍵に含めてあるので、モデルを新しくすると鍵が変わり、次に再生された瞬間に新モデルで作り直されます。自然に移行できます
- 一括再生成する — 声質を全トラックで揃えたいときだけ、事前にまとめて作り直します
多くの場合、私は「凍結」と「遅延再生成」を組み合わせます。既存の音声はストレージに残っている限り再生できるので、モデルが止まっても即座に無音にはなりません。新しいトラックや、声を変えたトラックだけが、新しい鍵で新モデルの音声を作ります。
失効を安全に回すためのチェックリストを載せておきます。
| 確認項目 | なぜ必要か |
| モデルIDを鍵に含めているか | 含めないとモデル差し替えが失効に反映されない |
| 旧モデルの音声を即削除していないか | 凍結して再生を継続させるため、確認まで残す |
| 生成メタデータにモデル名を残しているか | どの音声がどのモデル由来かを後から棚卸しできる |
| 失敗時に空をキャッシュしていないか | 無音配信を防ぐ |
古い音声を消すのは、新しい音声が全トラックで再生確認できてからにします。先に消すと、移行期に無音が出ます。私は移行の最後に、旧モデルのメタデータを持つオブジェクトだけを対象に掃除するようにしています。
実測してわかったこと
導入から3週間、実際の数字を見てみます。
| 指標 | 導入前 | 導入後 |
| 1日あたり TTS 生成文字数 | 約 7,200 万 | 約 4,500(新規・変更分のみ) |
| 端末キャッシュのヒット率 | — | 97〜99% |
| 初回再生までの待ち時間(2回目以降) | 1.5〜3 秒 | 0.1 秒未満 |
生成文字数が5桁以上落ちたのは、当然といえば当然です。台本を変えない限り、新規生成はほぼ発生しません。数字以上に効いたのは体感でした。2回目以降の再生が待ち時間ゼロで始まるので、アプリの手触りが明らかに軽くなりました。TTS のコスト削減を狙った施策が、そのまま体験の改善になったのは嬉しい誤算でした。
副次的な効果として、広告収益とのバランスも整いました。無料版は AdMob で運用していますが、TTS の変動費が実質ゼロになったことで、1再生あたりの採算を気にせず再生回数を伸ばせるようになりました。App Store と Google Play の両方で、セッションあたりの再生数が伸びています。
何をキャッシュし、何をキャッシュしないか
最後に、状況別の判断をまとめます。すべてをキャッシュすればよいわけではありません。
固定台本の繰り返し再生は、迷わずキャッシュします。瞑想の誘導、語学の例文、絵本の読み聞かせ。これらは台本が固定で、同じ音声を大量に配るので、キャッシュの効果が最大化します。
ユーザーごとに文面が変わるものは、キャッシュしても効きません。名前入りの読み上げや、その場で生成する要約は、鍵がほぼ毎回変わるため、ヒットしません。こうした動的な音声は、むしろストリーミング配信で初回の待ち時間を縮めるほうに投資します。私はこの二つを、台本が固定かどうかの一点で切り分けています。
判断に迷ったら、「同じ入力が今後どれだけ繰り返されるか」を見積もってください。繰り返しが多いほど、キャッシュの投資対効果は大きくなります。逆に一度きりの音声にキャッシュ機構を作り込むのは、複雑さだけが増えて割に合いません。
私自身、最初はすべての TTS 呼び出しをキャッシュしようとして、動的な音声で鍵が散らばり、かえって設計が複雑になりました。固定台本だけに絞ってからのほうが、コードも運用もずっと素直になりました。
もし同じように「同じ音声を作り直している」心当たりがあれば、まずは一番よく再生されるトラックひとつだけ、生成キャッシュを挟んでみてください。明細の変化がすぐに見えるはずです。実装の足がかりになれば幸いです。お読みいただきありがとうございました。