Gemini Live API を使った音声優先のAI写真・文書エディター—その作り方

Dev.to / 2026/3/16

💬 オピニオンDeveloper Stack & InfrastructureTools & Practical Usage

要点

  • Say Edit は、ユーザーが画像を編集し、文書を音声だけでナビゲートできる音声優先の AI ワークスペースであり、スライダーやメニューといった従来の UI への依存を減らします。
  • Gemini Live API への連続的で双方向の WebSocket 接続を使用しており、プッシュ・トゥ・トークなしでリアルタイムにモデルが聴き、反応します。
  • 本プロジェクトは Gemini Live API、Gemini の画像生成、Google Cloud Run を組み合わせて、声で操作する編集・読み取り体験を提供します。
  • この記事では、Gemini Live Agent Challenge を構築する際に直面した課題や驚き、舞台裏の教訓を共有します。

この記事は Gemini Live Agent Challenge ハッカソンに参加する目的で作成されました。 #GeminiLiveAgentChallenge

スライダーを一切触れずに写真を編集するバージョンがあります。画像の変更したい部分をクリックし、何を望むかを声に出して言い、それが起こるのを見ます。それが Say Edit。

ここ数週間で Say Edit — 声を優先する AI ワークスペースを構築しました。それは Gemini Live APIGemini 画像生成、および Google Cloud Run 上で動作します。この記事は、それをどう作ったか、何が壊れたか、そして私を驚かせたことの舞台裏です。

中核となるアイデア

ほとんどの AI ツールは入力を求めます。チャットウィンドウを開き、望むものを説明し、返答を待ち、それをどこかにコピーして、繰り返します。私は、私が実際に痛いと感じた二つのユースケースについて、それらのステップをすべて排除したいと考えました:

  1. 写真の編集 — 変更したい内容は正確に分かっていますが、そこへ到達するにはメニュー、マスク、スライダーを探し回らなければなりません。
  2. 密度の高い文書の読み取り — 質問はあるが、正確な箇所を見つけるには 80 ページをスクロールする必要があります。

両方の答えは同じです:継続的にリスニングし、あなたの意図を理解し、即座に行動する持続的な音声セッションです。

ボイスループ — Gemini Live API

Say Edit の基盤は、Gemini Live APIgemini-2.5-flash-native-audio-preview-12-2025)との双方向 WebSocket です。これはプッシュ・トゥー・トークのボタンではありません。ワークスペースにいる間、モデルは常にリスニングしています。

ブラウザ側のオーディオ・パイプラインがどう見えるか:

const response = await ai.models.generateContent({ model: 'gemini-3.1-flash-image-preview', contents: [ { inlineData: { mimeType: file.type, data: base64 } }, { text: `Edit: "${editPrompt}". Focus around pixel (x: ${x}, y: ${y}). Return ONLY the edited image.` } ], config: { responseModalities: [ 'IMAGE', 'TEXT' ] } });

すべての編集は非破壊的です — 履歴スタックに追加され、完全な元へ戻す/やり直し機能と、元の画像へ戻すために長押しして比較するボタンを備えています。

陳腐化したクロージャの問題

この問題にはしばらく悩まされました。ライブ API の onmessage コールバックはセッション開始時に一度だけ登録されます。クロージャが参照する React の状態は即座に陳腐化します — つまり history[historyIndex] は、適用された編集の数に関係なく常に元の画像を指してしまいます。

解決策は、状態を鏡像する参照を維持し、コールバック内でそれらから常に読み取ることでした:

const historyRef = useRef([initialFile]);
const historyIndexRef = useRef(0);
historyRef.current = history;       // kept in sync on every render
historyIndexRef.current = historyIndex;

// Inside the async tool handler — always gets the live value
const imageFile = historyRef.current[historyIndexRef.current];

これは現在、Live API の上に何かを構築する際の標準的な手法となっています。

ドキュメントナビゲーション — 空間検索 + ライブハイライト

ドキュメントワークスペースは別の領域です。質問をすると、AI は単に答えるだけでなく、回答が文書のどこにあるかを正確に示すべきです。

パイプライン:

1. インジェスト(Google Cloud Run 上の NestJS バックエンド)

PDF をアップロードすると、バックエンドはそれを pdfjs-dist を介して処理し、変換行列を持つテキスト項目を抽出し、Y 座標の近接性で単語を行に、行を文レベルのチャンクに結合します。各チャンクはテキストとともに、ぴったりとした境界ボックス [x, y, width, height] を保持します。

ひとつの注意点: PDF の座標系は左下原点ですが、React PDF ビューアは左上を原点とします。読み込み時にはすべての Y 座標が反転します:

y = pageHeight - transform[5] - (height || 10)

各チャンクは gemini-embedding-001 で埋め込みされ、Supabase pgvector インデックスに保存され、高速なコサイン類似検索のための HNSW インデックスを用います。

2. Live セッション中の検索

質問をすると、Live セッションは search_document を呼び出します。フロントエンドはバックエンドの /query エンドポイントにアクセスし、クエリを埋め込み、文書のチャンクに対してコサイン類似検索を実行します。 上位の結果にはページ番号と境界ボックスが返されます。

3. 空間ハイライト

モデルは、ページと境界ボックス座標の配列を使って focus_document_section を呼び出します。フロントエンドは PDF ビューアを正しいページにジャンプし、関連する文の正確なピクセル位置に黄色のハイライトオーバーレイを描画します:

// Convert stored PDF-point bboxes to percentage overlays
const highlight = {
  pageIndex,
  left:   (bx / pageWidthPx) * 100,
  top:    (by / pageHeightPx) * 100,
  width:  (bw / pageWidthPx) * 100,
  height: (bh / pageHeightPx) * 100,
};

回答を聞くと同時に、画面上でハイライトされているのが見えます。

コンポーズスタジオ

単一画像の編集を超えて、Say Edit には音声で2枚の写真を合成する Compose Studio があります:

"A の人物を B の衣装で着せる。"
"B の背景に商品を置く。"

両方の画像は base64 にエンコードされ、Gemini の画像生成へ一緒に送られます:

const response = await ai.models.generateContent({
  model: \"gemini-3.1-flash-image-preview\",
  contents: [
    { inlineData: { mimeType: imgA.type, data: base64A } },
    { inlineData: { mimeType: imgB.type, data: base64B } },
    { text: `Composition request: \"${prompt}\". Return ONLY the composed image.` }
  ],
  config: { responseModalities: [\"IMAGE\", \"TEXT\"] }
});

結果は通常の編集と同じ履歴スタックに入ります — そのため、2つの画像を組み合わせてから、声で結果をさらに洗練させていくことができます。

Infrastructure — Google Cloud Run

フロントエンド(React + Vite)とバックエンド(NestJS)はどちらもコンテナ化され、Google Cloud Run にデプロイされています。フロントエンドの Dockerfile は、ビルド時に ARG インジェクションを使用して Vite の環境変数を埋め込みます:

ARG VITE_GEMINI_API_KEY
ARG VITE_BACKEND_URL
RUN echo \"VITE_GEMINI_API_KEY=$VITE_GEMINI_API_KEY\" > .env
RUN echo \"VITE_BACKEND_URL=$VITE_BACKEND_URL\" >> .env
RUN npx vite build

デプロイ方法:

gcloud run deploy say-edit \\
  --source . \\
  --region us-central1 \\
  --allow-unauthenticated \\
  --set-build-env-vars "VITE_GEMINI_API_KEY=...,VITE_BACKEND_URL=..."

What I Learned

ライブ API の中断モデルがゲームのすべてである。 私が使ってきた他の音声インターフェースは待たされるだけだった。文の途中で中断できる能力 — クライアントサイドのハックではなく、適切なサーバーサイドの信号に支えられている — が、Say Edit を実際の会話のように感じさせる要因だ。

ツール設計は UX デザインである。 get_current_hotspot ツールは、座標を再度言い直さずにユーザーが「これを編集して」と言えるようにするためだけに存在する。ツールのスキーマを正しく決定すること — モデルが何を呼ぶか、いつ、どんな引数を渡すか — は、UI 要素よりも対話の質を左右する。

Live API コールバックでは参照 (Refs) が必須です。 セッション開始時に登録された非同期コールバックは、旧い React 状態を取り込む。ref-mirror パターンは譲れない。

PDF 抽出は思ったより難しい。 pdfjs-dist は変換行列を提供するが、文は返さない。グルーピングとチャンク化のパイプラインが、全体の中で最も過小評価されている部分である。

Try It

ライブデモ: https://glyph-client-229364276486.us-central1.run.app/
クライアントリポジトリ: https://github.com/greatsage-raphael/say_edit
サーバーリポジトリ: https://github.com/greatsage-raphael/say_edit_server

Gemini Live API、NaNo Banana、Google Cloud Run、そして Supabase を用いて構築。

この記事は Gemini Live Agent Challenge のハッカソンに参加する目的で作成されました。#GeminiLiveAgentChallenge