Loading post
Jul 21, 2026

NotebookLMのAudio Overview——アップロードしたドキュメントについて、2人のAIホストがちゃんと聴ける会話をしてくれるあれ——を初めて聴いたとき、正直に言うと「すごい」より先に「これ、美味しいところの8割は自分のコンテンツ向けに自作できるのでは」と思ってしまいました。
前提を少し。僕はベイエリアで働く日本人ソフトウェアエンジニアです(PayPalで決済、いまはフィンテックスタートアップのAtlas)。個人の学習パイプラインを回していて、LLMが毎日1本の学習記事を書いてポートフォリオサイトに公開してくれます(これは別記事で書きました)。記事の出来は悪くないのですが、僕のまとまった学習時間は通勤とジム——つまり読めない時間帯です。欲しかったのはまさにNotebookLMがやっていること。すべての記事が自動的に2人対話のポッドキャストになり、自然な日本語で、記事ページのオーディオプレイヤーに置かれている状態です。
というわけで作りました。Firebase Functions(TypeScript)+ Firestore + Cloud Storage + OpenAI APIです。LLMが記事をホスト/ゲストの対話脚本に書き直し、各セリフを別々のOpenAI TTSボイスで合成し、MP3を連結してCloud Storageにアップロードし、記事ドキュメントに音声メタデータを書き込みます。Firestoreトリガーで完全自動化されているので、新しい記事が現れると、数分後にはポッドキャストが付いています。
この記事ではパイプラインを端から端まで説明し、特に重要だったエンジニアリングの詳細——コンテンツハッシュによる再生成スキップ、小さなステータス・ステートマシン、キャッシュ破棄の入力としてのプロンプトバージョニング——と、これだけ単純に音声を扱うことの正直な限界を書きます。
study_articles/{id} 作成 (ja, published)
│ Firestore onDocumentCreated トリガー
▼
runAudioGeneration(articleId, template)
│
├── コンテンツハッシュ一致? ──▶ スキップ (音声は最新)
▼
article.audioStatus = "generating"
│
▼
LLM: 記事 → HOST/GUEST 対話脚本
(テンプレート: tech / concept / book)
│ 「HOST: …」「GUEST: …」行をパース
▼
セリフ単位のTTS (OpenAI)
host → voice "alloy" ──┐
guest → voice "nova" ───┤ セリフごとにMP3バッファ
▼
Buffer.concat
│
▼
Cloud Storage アップロード (公開, immutableキャッシュ)
│ 旧音声ファイルがあれば削除
▼
記事ドキュメント更新:
audio { url, duration, contentHash, template,
model, promptVersion, hostVoice, guestVoice }
audioStatus = "ready"
記事1本あたりのLLM系呼び出しは2種類。脚本を書くためのチャット系呼び出しが1回、そして対話のセリフ1行につきTTS呼び出しが1回です。残りはすべて配管——ただし面白い設計判断は配管の中にあります。
記事をそのままTTSに流すと講義になります。技術的には音声ですが、好んで聴くものではありません。NotebookLMがうまいのは、対話にしたところです。質問が入ると集中が戻るし、やり取りのリズムが情報を自然な塊に区切ってくれます。
脚本ジェネレーターは構造化された記事(タイトル、要約、導入、セクション、まとめ、要点)をプレーンテキストに平坦化し、モデルに厳格なフォーマット——HOST:とGUEST:の行のみ、それ以外一切なし——で脚本に書き直させます。コンテンツタイプに応じて3つのプロンプトテンプレートがあります。
プロンプトに入っているルールは、どれもダメな出力を実際に聴いてから足したものです。
出力のパースは、あえて地味に正規表現でやって、検証します。
export function parseScript(raw: string): ScriptLine[] {
const lines: ScriptLine[] = [];
// 半角「:」と全角「:」の両方を受ける — モデルは両方使ってくる
const re = /^\s*(HOST|GUEST)\s*[::]\s*(.+)$/gim;
let m: RegExpExecArray | null;
while ((m = re.exec(raw)) !== null) {
const text = m[2].trim();
if (text) lines.push({ speaker: m[1].toLowerCase() as Speaker, text });
}
return lines;
}
const lines = parseScript(raw);
if (lines.length < 4) {
// 3行の「脚本」はモデルがフォーマットを無視した証拠。
// ゴミ音声を合成する前に、大声で失敗する。
throw new Error(`Script must contain at least 4 dialogue turns (got ${lines.length})`);
}
JSON出力にすることも考えましたが、この行フォーマットは壊れ方が穏やかなのが良いところです。モデルが余計な前置きを付けても、正規表現は単に読み飛ばします。最低行数チェックが破滅的なケースを捕まえます。
パースされた各セリフは別々に合成されます。ホストとゲストで違う声を使うからです——ホストはalloy、ゲストはnova。対話が対話に聴こえるのは2つの異なる声があってこそで、1つの声だと質問と回答の構造がただの独り言の口論に聴こえます。
日本語出力で最も効果が大きかったのは、TTSのinstructionsパラメータです。OpenAIの汎用ボイスは日本語を話せますが、デフォルトでは英語話者訛りと平坦なイントネーションが目立ちます。各ロールに日本語で明示的な話し方指示を与えたことで、品質が「ロボット翻訳」から「許容できるポッドキャスト」に上がりました。
const VOICES = { host: "alloy", guest: "nova" } as const;
const JA_STYLE = {
host:
"日本語ネイティブのポッドキャストMCのように、自然で聞き取りやすく、" +
"明るく親しみやすいトーンで話す。テンポは少し速め、句読点で軽く間を置く。" +
"英語のアクセントは出さない。",
guest:
"日本語ネイティブの解説者として、落ち着いた知的な口調で話す。" +
"断定は柔らかく、専門用語は丁寧に発音する。英語のアクセントは出さない。",
} as const;
async function synthesizeDialogue(lines: ScriptLine[]) {
const chunks: Buffer[] = [];
for (const { speaker, text } of lines) {
const res = await openai.audio.speech.create({
model: TTS_MODEL,
voice: VOICES[speaker],
input: text,
response_format: "mp3",
instructions: JA_STYLE[speaker],
});
chunks.push(Buffer.from(await res.arrayBuffer()));
}
const mp3 = Buffer.concat(chunks);
// 再生時間は約32kbps (~4000 bytes/秒) と仮定してサイズから推定。
// UIのラベルには十分。サンプル精度ではない。
const estimatedDuration = Math.round(mp3.length / 4000);
return { mp3, estimatedDuration };
}
このコード、突っ込まれそうな点が2つあると思いますが、どちらも意図的です。
「連結」は文字通りBuffer.concatです。 ffmpegなし、クロスフェードなし、ラウドネス正規化なし——MP3バッファをバイト連結しているだけ。これが意外なほどうまく動きます。大半のデコーダーは連結されたMP3ストリームを普通に再生しますし、話者交代の境目は多少の不連続があっても自然な場所です。音声のプロなら顔をしかめるでしょう。個人パイプラインとしては、Functionsのバンドルにffmpegバイナリを入れずに出荷できることが正しいトレードオフでした。
再生時間はバイト数からの推定です。 プレイヤーのラベルのためにMP3フレームヘッダーをパースして正確な長さを出す価値はありませんでした。想定ビットレートで割って、丸めて、終わり。
このパイプラインで一番効いているエンジニアリングは、実は「いつ実行しないか」の判断です。長い記事のTTSは高コストなステップです——実時間で数分、APIコストもそれなり。誰かがボタンを2回押した、トリガーが再発火した、という理由で同一の音声を再生成するのは純粋な無駄です。
生成された音声はすべてcontentHashを保存します。出力を決定するすべての要素にわたるSHA-256です。
function buildHashInput(article: Article, template: Template, model: string): string {
const body = [
article.title,
article.summary,
article.introduction,
...article.sections.map((s) => `${s.title}\n${s.content}`),
article.conclusion,
...article.keyTakeaways,
].join("\n---\n");
// 出力を変えうるものは全部ハッシュに入れる:
// 記事本文、対話テンプレート、プロンプトバージョン、モデル
return [body, template, `v${PROMPT_VERSION}`, model].join("|");
}
// runAudioGeneration の冒頭:
if (!force && article.audio?.template === template) {
const hash = sha256(buildHashInput(article, template, article.audio.model));
if (article.audio.contentHash === hash) {
return { audio: article.audio, skipped: true,
reason: "この内容とテンプレートに対して音声は最新" };
}
}
この仕組みの気持ちいいところは、どれかの入力が変わったときに現れます。記事のセクションを編集した? ハッシュが変わり、次の実行で再生成。脚本モデルを切り替えた? 再生成。そして個人的に一番気に入っているのが、プロンプトバージョン定数を上げると、既存のすべての音声が静かに「古い」扱いになることです。
// prompts.ts
// プロンプト内容が意味的に変わったときにインクリメントする。
// contentHashに参加するので、既存音声がstaleとして検出され再生成される。
export const PROMPT_VERSION = 3;
ここでのプロンプトバージョニングはドキュメントではなく、キャッシュキーです。techテンプレートを書き直して例え話の発明を禁止したとき、PROMPT_VERSIONを2から3に上げただけで、すべての記事の音声が次に触られたタイミングで改善された脚本で再生成されました——マイグレーションスクリプトなし、手動の無効化なし。「とにかく作り直したい」瞬間のために、チェックを完全にバイパスするforceフラグもあります。
生成には数分かかり、フロントエンドはその間ずっと意味のある表示をする必要があります。記事ドキュメントは小さなステートマシンを持ちます。audioStatus ∈ {generating, ready, failed}と、オプションのaudioErrorです。
await articleRef.update({
audioStatus: "generating",
audioError: FieldValue.delete(), // 古いエラーをクリア
});
try {
const script = await generateDialogueScript(article, template);
const { mp3, estimatedDuration } = await synthesizeDialogue(script.lines);
// 新しい音声をアップロードする前に旧ファイルを削除 —
// ファイル名はタイムスタンプなので、放置すると古いものが永遠に漏れる
if (article.audio?.storagePath) {
await deleteAudio(article.audio.storagePath).catch(() => {
/* ベストエフォート:孤児ファイル1個のために実行を失敗させない */
});
}
const uploaded = await uploadAudio(articleId, mp3); // 公開, cache: immutable
await articleRef.update({
audio: {
storageUrl: uploaded.url,
storagePath: uploaded.path,
duration: estimatedDuration,
script: script.raw, // 表示・デバッグ用に保存
template,
contentHash,
model: script.model,
promptVersion: PROMPT_VERSION,
hostVoice: "alloy",
guestVoice: "nova",
generatedAt: now(),
},
audioStatus: "ready",
});
} catch (err) {
await articleRef.update({
audioStatus: "failed",
audioError: String(err).slice(0, 500), // 切り詰める:エラー文はUIのデータで
}); // あってログのダンプ置き場ではない
throw err;
}
小さいけれど効いている選択をいくつか。
study-article-audio/{articleId}/配下にタイムスタンプ名で置かれるので、明示的に削除しないと再生成のたびに孤児が静かに溜まります。削除はベストエフォート——ファイル1個の漏れは、実行を失敗させる理由としては弱すぎます。Cache-Control: public, max-age=31536000, immutableが安全で、プレイヤーがCDNと喧嘩することもありません。最後のピースで、人間の手を工程から外します。記事コレクションへのonDocumentCreatedトリガーが、日次の記事ジェネレーターが新しい記事を公開するたびに発火します。
export const onArticleCreated = onDocumentCreated(
"study_articles/{articleId}",
async (event) => {
const article = event.data?.data();
if (!article) return;
if (article.status !== "published") return; // 下書きに音声は付けない
if (article.language !== "ja") return; // 当面は日本語のみ
if (article.audio) return; // すでに音声がある
try {
await runAudioGeneration(event.params.articleId, "tech", false);
} catch {
// 再スローしない:失敗はすでに記事ドキュメントに
// audioStatus="failed" として永続化されている。再スローすると
// プラットフォームが決定論的な失敗をリトライしてしまう。
}
}
);
ガード条件そのものが設計です。公開済みの記事のみ、日本語のみ(話し方指示が今のところ日本語特化なので)、二重生成はしない。そしてcatchブロックは意図的にエラーを握りつぶします——runAudioGenerationがすでにaudioStatus: "failed"をドキュメントに書いており、失敗は見るべき場所で見えるようになっているからです。トリガーをエラーで終わらせても、プラットフォームが決定論的な失敗を再試行するだけです。前回の記事のパイプラインと合わせると、全体のチェーンはこうなります:Linear issue → スケジュール記事生成 → Firestore書き込み → トリガー → ポッドキャスト。スマホからissueを起票すると、翌朝には記事とエピソードがあるわけです。
instructionsパラメータは大いに効きますが、汎用の多言語TTSは専用の日本語エンジンにはやはり及びません。技術系カタカナ語のピッチアクセントの誤りはすり抜けてきます。「通勤で聴ける」基準はクリアしていますが、「本物のポッドキャストとして公開する」基準ではありません。techテンプレートを使います。concept/bookを選ぶには手動エンドポイントが必要です。記事タイプの自動分類は、やりたいことリストには入っています。将来のアイデアを魅力順に:同時実行数制限つきの並列TTS。エピソードが本物のポッドキャストアプリに届くRSSフィード(正直、これが一番欲しい)。つなぎ目とラウドネスのための軽いffmpeg後処理。記事内容からの対話テンプレート自動選択。英語向けの話し方指示を付けた英語音声。
audioStatusステートマシンと切り詰めたエラーが、数分かかる生成の間もUIを正直に保つ。旧音声ファイルの削除がストレージの静かな漏れを防ぎ、生成ごとの新ファイル名がimmutableキャッシュを安全にする。Buffer.concatもバイト数の再生時間も厳密には「正しくない」が、毎日問題なく動いている。効いたのはどれか1つの技術ではなくて、「通勤で読む」と「通勤で聴く」は別のプロダクトで、後者は週末ひとつ分の配管作業で届く距離にある——そう気づいたことでした。