深夜にひとりで詰まっているとき、画面の右半分にエディタを開きながら、左半分にチャット UI を開いて状況を文章で説明し続けるのは、思っている以上に消耗します。私自身、2026年4月に Gemini 2.5 Flash の Live API を本格的に使い始めてから、「画面を見せながら、声で相談できる AI ペアが手元にあれば、最初の30秒で済んだ会話を、毎回3分かけて文字に起こしていたのだな」と気づきました。
この記事は、その気づきを実装に落とし込んだ記録です。ブラウザの getDisplayMedia で取得した画面映像と、マイクからの音声を Gemini Live API に同時に流し込み、応答を音声で受け取る — つまり「肩越しに画面を覗きながら相談に乗ってくれる人」を、TypeScript と Node.js だけで構築するための、動くコードと判断の根拠をまとめます。
公式の Live API ガイド はバラバラの API リファレンスとしては優れているのですが、「ブラウザでの画面共有」「音声の双方向化」「コストの抑え方」「Function Calling での編集補助」を一つの完成形として組み立てる手順は、自分で繋ぎ込む必要があります。ここでは私が実装の中で何度か手戻りしたポイントも含め、最短経路を共有します。
なぜ「画面共有つき音声 AI ペア」が個人開発に効くのか
最初に、なぜこの構成を選んだかを述べます。Gemini API には Vision(画像入力)と Function Calling、そしてテキストでのチャットも揃っているので、「スクリーンショットを撮って投げる」だけでも近いことはできます。実際、私も最初の数日はそのアプローチで動かしていました。
ただ、実装作業を観察してみると、自分が AI に相談したい瞬間は次の二つに集中していたのです。
第一に、画面の状態が 時系列で動いている とき。スタックトレースが流れている、テストランナーがインクリメンタルに走っている、エラーが特定のタイミングだけ赤くなる、など。スクリーンショットを撮るタイミング自体が判断要素になってしまい、「ちょうどいい瞬間」を毎回キャプチャするコストが意外と高い。
第二に、相談の内容が 言葉で説明しづらい とき。「いま動かしている UI のこのボタンを押すと、ここのコンポーネントが一瞬だけチカっとして消えるんですけど、なぜ?」のような問いは、文字に起こすと長くなり、しかも書いている間にもう一度試したくなるものです。
この二つの状況を解決するのが、画面と音声の 同時ストリーミング です。Gemini Live API は WebSocket で映像・音声・テキストを同時に送れる設計になっており、Flash 系モデルなら個人で運用可能なコストレンジに収まります。私の手元では、毎日30分使って月 4〜6 ドル程度の試算で、これは「もう一人いてくれる」価値と比べれば破格と感じています。
全体アーキテクチャの設計
実装に入る前に、全体像を共有します。構成要素は大きく三つです。
ブラウザ側ではユーザーの操作を受け付け、getDisplayMedia で画面、getUserMedia でマイク音声を取得します。映像はフレームをサンプリングして JPEG にエンコードし、音声は 16kHz の PCM に変換して、すべて自社のフロントエンド WebSocket に流します。
中継サーバー(Node.js + ws)は、ブラウザと Gemini Live API の間に立ち、API キーを安全に保ったままメッセージを橋渡しします。ここで音声フォーマットの変換、フレームレートの調整、Function Calling の呼び出し処理、コスト計測などを担います。
Gemini 側は gemini-2.5-flash-preview-native-audio-dialog などの Live API 対応モデルに WebSocket で接続します。応答は Gemini ネイティブの音声で返ってくるため、ブラウザはそれをそのまま再生します。
直接ブラウザから Gemini に繋ぐ実装も可能ですが、API キーが漏洩するため、本番運用では中継サーバー方式を強く推奨します。私自身、最初は CORS とトークン署名で何とか直結を試みましたが、課金漏洩のリスクと、フレーム制御をサーバー側に集約できる利点を比べて、この方式に落ち着きました。
環境のセットアップ
Node.js 20 以上と TypeScript の環境を前提にします。プロジェクト初期化と必要パッケージのインストールから始めます。
mkdir gemini-pair && cd gemini-pair
npm init -y
npm install @google/genai ws zod
npm install -D typescript @types/node @types/ws tsx
npx tsc --init@google/genai は2026年から正式版に切り替わった Gemini 用の公式 SDK で、Live API もこちらで叩きます。旧 @google/generative-ai パッケージとは API が異なるので、既存コードからの移行時は要注意です。古い SDK から移行する手順は Google Gen AI SDK 完全移行ガイド に Python 視点ですがまとめてあります。
API キーは .env に書き、サーバー側のみが読み込みます。
echo "GEMINI_API_KEY=YOUR_API_KEY" > .envブラウザ側:画面と音声を同時に取得する
最初の山場が、ブラウザでの取得処理です。getDisplayMedia は画面(タブ・ウィンドウ・全画面)を、getUserMedia はマイクを取得します。ここで意識すべきは、音声は AudioWorklet で 16kHz PCM に変換しながらストリームする という点です。MediaRecorder で webm に固めると Gemini 側のフォーマット要件と合わず、サンプル単位で切れない問題に当たります。私はここで一度詰まりました。
// src/client/capture.ts
type Sender = (data: ArrayBuffer | string) => void;
export async function startCapture(send: Sender) {
// 画面共有(ユーザー選択ダイアログが出る)
const screen = await navigator.mediaDevices.getDisplayMedia({
video: { frameRate: 1, width: 1280 },
audio: false,
});
// マイク音声
const mic = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true },
video: false,
});
// 1 fps で画面をサンプリングし JPEG に変換して送信
const video = document.createElement("video");
video.srcObject = screen;
await video.play();
const canvas = new OffscreenCanvas(1280, 720);
const ctx = canvas.getContext("2d")!;
const frameTimer = setInterval(async () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0);
const blob = await canvas.convertToBlob({ type: "image/jpeg", quality: 0.6 });
const buf = await blob.arrayBuffer();
send(JSON.stringify({ kind: "frame" }));
send(buf);
}, 1000);
// 音声を 16kHz PCM に変換しながらストリーム
const audioCtx = new AudioContext({ sampleRate: 16000 });
await audioCtx.audioWorklet.addModule("/pcm-worklet.js");
const source = audioCtx.createMediaStreamSource(mic);
const node = new AudioWorkletNode(audioCtx, "pcm-worklet");
node.port.onmessage = (e) => {
send(JSON.stringify({ kind: "audio" }));
send(e.data as ArrayBuffer);
};
source.connect(node);
// 停止ハンドル
return () => {
clearInterval(frameTimer);
screen.getTracks().forEach((t) => t.stop());
mic.getTracks().forEach((t) => t.stop());
audioCtx.close();
};
}pcm-worklet.js は最小実装で次のようになります。process 内で Float32 を Int16 に量子化し、メインスレッドへ転送します。ブラウザの public/ 配下に置きます。
// public/pcm-worklet.js
class PCMWorklet extends AudioWorkletProcessor {
process(inputs) {
const input = inputs[0];
if (!input || !input[0]) return true;
const f32 = input[0];
const i16 = new Int16Array(f32.length);
for (let i = 0; i < f32.length; i++) {
const s = Math.max(-1, Math.min(1, f32[i]));
i16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
this.port.postMessage(i16.buffer, [i16.buffer]);
return true;
}
}
registerProcessor("pcm-worklet", PCMWorklet);ここまでで、ブラウザは「1秒に1枚の JPEG フレーム」と「連続した 16kHz PCM 音声」を中継サーバーへ送り続ける状態になります。frameRate を 1 にしているのは、コードを書く・読む時間軸ではこれで充分なためで、私の検証でもプロセスを観察するには 0.5〜2 fps が現実解でした。
中継サーバー:WebSocket で Gemini Live API を中継する
サーバー側は、ブラウザからの WebSocket と Gemini Live API への WebSocket を双方向にブリッジします。Gemini 側のスキーマは setup メッセージで初期化したあと、realtimeInput で映像・音声を投げ、serverContent で応答を受け取る構造です。
// src/server/bridge.ts
import { WebSocketServer, WebSocket } from "ws";
import { GoogleGenAI, Modality } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
const wss = new WebSocketServer({ port: 8787 });
wss.on("connection", async (browser) => {
const session = await ai.live.connect({
model: "gemini-2.5-flash-preview-native-audio-dialog",
config: {
responseModalities: [Modality.AUDIO],
systemInstruction: PAIR_PROMPT,
tools: [{ functionDeclarations: PAIR_TOOLS }],
},
callbacks: {
onmessage: (msg) => handleGeminiMessage(browser, msg),
onerror: (e) => console.error("[gemini]", e),
onclose: () => browser.close(),
},
});
// ブラウザからのメッセージを Gemini へ
let nextKind: "frame" | "audio" | null = null;
browser.on("message", (data, isBinary) => {
if (!isBinary) {
const ev = JSON.parse(data.toString());
if (ev.kind === "frame" || ev.kind === "audio") nextKind = ev.kind;
if (ev.kind === "text") session.sendClientContent({
turns: [{ role: "user", parts: [{ text: ev.text }] }],
});
return;
}
if (nextKind === "frame") {
session.sendRealtimeInput({
media: { mimeType: "image/jpeg", data: Buffer.from(data as Buffer).toString("base64") },
});
} else if (nextKind === "audio") {
session.sendRealtimeInput({
media: { mimeType: "audio/pcm;rate=16000", data: Buffer.from(data as Buffer).toString("base64") },
});
}
nextKind = null;
});
browser.on("close", () => session.close());
});handleGeminiMessage では、Gemini からの音声チャンクをそのままブラウザに転送し、Function Calling のツール呼び出しが来た場合はサーバー側で実行して結果を戻します。
async function handleGeminiMessage(browser: WebSocket, msg: any) {
if (msg.serverContent?.modelTurn?.parts) {
for (const part of msg.serverContent.modelTurn.parts) {
if (part.inlineData?.mimeType?.startsWith("audio/")) {
const buf = Buffer.from(part.inlineData.data, "base64");
browser.send(JSON.stringify({ kind: "audio_response" }));
browser.send(buf);
}
if (part.text) {
browser.send(JSON.stringify({ kind: "text", text: part.text }));
}
}
}
if (msg.toolCall?.functionCalls) {
const responses = [];
for (const call of msg.toolCall.functionCalls) {
const result = await runTool(call.name, call.args);
responses.push({ id: call.id, name: call.name, response: { result } });
}
// 例外パターンに備え、空配列でもセッションを進めるように送る
}
}ここで一度詰まったのは、バイナリと JSON が交互に届く順序の管理 でした。最初は単純に「直前に来た JSON の kind を覚えておく」方式で実装していたところ、onmessage がイベントループの隙間で稀に逆順で発火するケースがあり、フレームと音声が混じる事故が起きました。最終的にはブラウザ側で「メタデータと本体を 1 つの ArrayBuffer に連結して 1 メッセージで送る」方式に置き換えています(後述)。
システムプロンプトの設計
「ペア」として動かすには、System Instructions に役割定義を入れることが要になります。Gemini Live API はネイティブ音声出力でも、内部的には Gemini の言語能力をそのまま使うため、ここでの設計が応答の質を決めます。
const PAIR_PROMPT = `あなたは画面を肩越しに見ているシニア開発者です。守るべき行動原則は次の通り。
- ユーザーは画面を共有しています。文字が読み取れる範囲は信用し、推測はしない
- 短く話す。一回の応答は 2〜3 文に留め、続きは相手の反応を待つ
- 画面の状態が変化したと感じたら、変化点だけ短く言葉にして良い
- コードや関数名を口にするときは、英語の発音記号ではなく自然な読みで
- 推測や仮定を述べる場合は「たぶん」「画面からは読み切れませんが」と前置きする
- ユーザーが「メモしておいて」「あとで見返したい」と言ったら save_note ツールを使う
- ユーザーが「公式ドキュメントを見せて」と言ったら open_docs ツールを使う
ユーザーの集中を妨げないことが最優先です。沈黙は問題ではなく、安心です。`;私が試行錯誤したのは「短く話す」と「沈黙を許す」の二つでした。標準のままだと Gemini はやや饒舌で、考え事をしているユーザーにも話しかけ続けてしまいます。「沈黙は問題ではなく、安心です」という一文を入れてから、体感上ペアらしくなりました。
Function Calling で「肩越しの提案」を実行に繋げる
ペアプログラマーが本領を発揮するのは、画面を見て「ここ、コメント残しておきましょうか?」と提案して、合意したら実際にメモが残る瞬間です。Function Calling でこの体験を実装します。
const PAIR_TOOLS = [
{
name: "save_note",
description: "現在の議論の要点を Markdown としてローカルファイルに保存する",
parameters: {
type: "object",
properties: {
title: { type: "string", description: "ノートのタイトル" },
body: { type: "string", description: "Markdown 本文" },
},
required: ["title", "body"],
},
},
{
name: "open_docs",
description: "公式ドキュメントの URL を開く",
parameters: {
type: "object",
properties: {
product: { type: "string" },
topic: { type: "string" },
},
required: ["product", "topic"],
},
},
];
async function runTool(name: string, args: any) {
if (name === "save_note") {
const fs = await import("node:fs/promises");
const path = `./notes/${Date.now()}-${args.title.replace(/[^\w\-]/g, "_")}.md`;
await fs.mkdir("./notes", { recursive: true });
await fs.writeFile(path, `# ${args.title}\n\n${args.body}\n`);
return { saved: path };
}
if (name === "open_docs") {
const url = `https://www.google.com/search?q=${encodeURIComponent(args.product + " " + args.topic + " docs")}`;
return { url };
}
return { error: "unknown tool" };
}Function Calling 自体の詳しい挙動は Gemini API の Function Calling 完全実装ガイド で踏み込んでいるので、応答スキーマの細かい話はそちらに譲ります。実装で重要なのは、ツール結果をモデルへ即時に返すこと です。返さないと、Gemini は「自分が呼んだツールの結果を待っている状態」のまま音声を止めるため、会話が不自然に途切れます。
コストを抑える 4 つの設計判断
個人開発で続けるためには、コスト感覚が必須です。私が回している構成では、月 4〜6 ドルの範囲に収まっていますが、初期実装ではこの 5 倍以上かかっていました。差を生んだ判断を共有します。
第一に、画面のフレームレートを 1 fps に固定 したこと。動画でなくスナップショットの連続として扱い、JPEG 品質も 0.6 に落としています。フレームを 5 fps に上げてもペアとしての精度は体感ほぼ同じで、コストだけ 5 倍になります。
第二に、長時間の沈黙時はサーバー側でフレーム送信を止める こと。ユーザーが何も話さず画面も大きく動かない時間が 30 秒続いたら、フレーム送信を一時停止する補助ロジックを入れています。コードを読んでいる時間や、別のテストを走らせて待っている時間は、AI に常に画面を見せておく必要がありません。
第三に、応答モダリティを音声単一に固定 すること。responseModalities をテキストと両方にすると料金が二重に発生します。今回はネイティブ音声に振り切っており、必要なときだけ別途テキスト応答を要求しています。
第四に、会話セッションを意図的に短く区切る こと。Gemini Live API は長時間セッションでもコンテキストを保持しますが、トークン課金は累積します。私は 30 分を目安に「いまの議論を save_note でまとめて、新しいセッションを始める」運用にしています。コスト管理全般の判断軸は Gemini 2.5 Flash 個人開発者向けコスト性能レポート にもまとめました。
私が実装中に詰まった 3 つの落とし穴
公式ドキュメントには書かれていませんが、本番に近い形で動かすうちに何度かハマったポイントを記録しておきます。
一つ目は、Chromium の getDisplayMedia がタブ録画モードだと動的フレームレートになる ことです。タブ録画を選ぶと「画面が変化したフレームだけ」がストリームに乗るため、requestAnimationFrame ベースで取得すると 0 枚しか取れない時間帯が発生します。setInterval で能動的に drawImage する方式に切り替えてようやく安定しました。
二つ目は、音声出力の再生が途切れる問題 です。Gemini からの音声チャンクを decodeAudioData で逐次再生すると、チャンク境界でクリッピングが起きます。AudioBufferSourceNode をキューに積み、前のソースの endedAt を基準に次の start(time) を呼ぶスケジューラを書く必要があります。
三つ目は、Function Calling の引数が 1 万文字を超えると応答が途中で止まる 現象です。save_note の body を長くしすぎたときに発生しました。本文は要約に留め、長文を残したいときはサーバー側のヒューリスティックで分割するようにしてあります。
Gemini に限らず双方向プロトコル全般の落とし穴が体系化されています。
運用に乗せるための小さな仕上げ
毎日使う道具にするために、私が最後に足したのは次の三つです。どれも小さな改善ですが、体感は大きく変わりました。
一つ目は、録音データのローカル保存 です。AI に話しかけた音声と画面を、24 時間だけ手元の SQLite に保存しています。あとで「あの判断、どんな会話で決めたんだっけ」と振り返るのに、テキストの save_note より音声と画面の方が当時の感覚を呼び戻しやすいことが分かりました。
二つ目は、ホットキーでの開始・停止 です。Electron 化までは行っていませんが、Chromium の Site Settings で chrome://flags/#enable-experimental-web-platform-features を有効化し、ローカル PWA としてインストールしておくとキー一発で立ち上がります。
三つ目は、Function Calling 経由での「次に話したいトピック」のキューイング です。「いまは TypeScript の型エラーを直したいから、Rust の話は後回しで」と言うと、後で Gemini 側から「さっきキューに入れた Rust の話、いま聞きますか?」と振ってもらえる仕組みです。地味ですが、思考の流れを崩さない助けになります。
まずは一日、試しに肩越しに置いてみる
ここまでお読みいただきありがとうございました。実装としては中規模ですが、本質はシンプルで、「画面と声をストリーミングで Gemini に流し、ネイティブ音声で返す」ただこれだけです。難しいのは、自分の作業フローのどこに「もう一人」を置くか という設計の方で、これはコードを書いた後に、一日二日使ってみないと見えてきません。
具体的な次の一歩として、まずは gemini-2.5-flash-preview-native-audio-dialog モデルで上記の最小構成を立ち上げ、いつもの作業を 30 分だけ画面共有しながら進めてみてください。「自分は実は何を相談したかったのか」という、ふだん言葉にしないでいた疑問が浮かび上がってきます。私自身、その問いの解像度が上がっただけでも、この実装に投じた時間は元が取れたと感じています。