はじめに
Google I/O 2025 で色々新しい発表があったので、それを試して記事を書いているシリーズがあり、この記事はそのうちの 1つです。
- Gemini API + Node.js で音声合成(TTS)
- 無料枠で Gemini・Gemma の新モデルの API利用(Node.js で)
- Gemini API & SDK の MCP 対応を Node.js でお試し
あと記事は書けてないですが、「Node.js で API を使った音楽生成(Lyria RealTime による)」も試してたりします。
今回の内容
今回はタイトルに書いてある、Live API の「Gemini 2.5 Flash Native Audio」を使った、「テキストに対する音声での応答」を試します。モデルは「gemini-2.5-flash-preview-native-audio-dialog」を使います。
公式情報
今回試す「Gemini 2.5 Flash Native Audio」の公式情報として、以下があります。
https://ai.google.dev/gemini-api/docs/models#gemini-2.5-flash-native-audio
指定できるモデルは、以下 2つがあるようです。
- gemini-2.5-flash-preview-native-audio-dialog
- gemini-2.5-flash-exp-native-audio-thinking-dialog
また入出力は、以下に対応しているようです。
- 入力: Audio, video, text
- 出力: Audio and text
また、Gemini 2.5 Flash Native Audio の情報を含む Live API の情報がのっている公式ドキュメントのページは、以下があります。
●Live API | Gemini API | Google AI for Developers
https://ai.google.dev/gemini-api/docs/live
現状、プレビュー版の API です。
モデルの違い
Live API用のモデルについてツールも利用可能です。ただし、モデルによって使えるツールに違いがあるようで、それについては以下に掲載されています。
https://ai.google.dev/gemini-api/docs/live#tools-overview
サンプルコード
今回のお試しを進めるための、参照用の情報にするサンプルコードを探します。
今回は、モデルを使ったやりとりが試せる以下の Google AI Studio でコードを確認しました。
●Stream | Google AI Studio
https://aistudio.google.com/live
具体的には、Live API のモデルを試せる画面に進み、その画面内で、以下で示したアイコンをクリックします。
そうすると、記事執筆時点では Python と TypeScript のサンプルコードを見ることができます。
自分は、以下の TypeScript のコードを参照用の情報として使いました。
// To run this code you need to install the following dependencies:
// npm install @google/genai mime
// npm install -D @types/node
import {
GoogleGenAI,
LiveServerMessage,
MediaResolution,
Modality,
Session,
} from '@google/genai';
import mime from 'mime';
import { writeFile } from 'fs';
const responseQueue: LiveServerMessage[] = [];
let session: Session | undefined = undefined;
async function handleTurn(): Promise<LiveServerMessage[]> {
const turn: LiveServerMessage[] = [];
let done = false;
while (!done) {
const message = await waitMessage();
turn.push(message);
if (message.serverContent && message.serverContent.turnComplete) {
done = true;
}
}
return turn;
}
async function waitMessage(): Promise<LiveServerMessage> {
let done = false;
let message: LiveServerMessage | undefined = undefined;
while (!done) {
message = responseQueue.shift();
if (message) {
handleModelTurn(message);
done = true;
} else {
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
return message!;
}
const audioParts: string[] = [];
function handleModelTurn(message: LiveServerMessage) {
if(message.serverContent?.modelTurn?.parts) {
const part = message.serverContent?.modelTurn?.parts?.[0];
if(part?.fileData) {
console.log(`File: ${part?.fileData.fileUri}`);
}
if (part.inlineData) {
const fileName = 'ENTER_FILE_NAME';
const inlineData = part.inlineData;
audioParts.push(inlineData.data || '');
const buffer = convertToWav(audioParts, inlineData.mimeType || '');
saveBinaryFile(`${fileName}.wav`, buffer);
}
if(part?.text) {
console.log(part?.text);
}
}
}
function saveBinaryFile(fileName: string, content: Buffer) {
writeFile(fileName, content, 'utf8', (err) => {
if (err) {
console.error(`Error writing file ${fileName}:`, err);
return;
}
console.log(`Appending stream content to file ${fileName} .`);
});
}
interface WavConversionOptions {
numChannels : number,
sampleRate: number,
bitsPerSample: number
}
function convertToWav(rawData: string[], mimeType: string) {
const options = parseMimeType(mimeType);
const dataLength = rawData.reduce((a, b) => a + b.length, 0);
const wavHeader = createWavHeader(dataLength, options);
const buffer = Buffer.concat(rawData.map(data => Buffer.from(data, 'base64')));
return Buffer.concat([wavHeader, buffer]);
}
function parseMimeType(mimeType : string) {
const [fileType, ...params] = mimeType.split(';').map(s => s.trim());
const [_, format] = fileType.split('/');
const options : Partial<WavConversionOptions> = {
numChannels: 1,
bitsPerSample: 16,
};
if (format && format.startsWith('L')) {
const bits = parseInt(format.slice(1), 10);
if (!isNaN(bits)) {
options.bitsPerSample = bits;
}
}
for (const param of params) {
const [key, value] = param.split('=').map(s => s.trim());
if (key === 'rate') {
options.sampleRate = parseInt(value, 10);
}
}
return options as WavConversionOptions;
}
function createWavHeader(dataLength: number, options: WavConversionOptions) {
const {
numChannels,
sampleRate,
bitsPerSample,
} = options;
// http://soundfile.sapp.org/doc/WaveFormat
const byteRate = sampleRate * numChannels * bitsPerSample / 8;
const blockAlign = numChannels * bitsPerSample / 8;
const buffer = Buffer.alloc(44);
buffer.write('RIFF', 0); // ChunkID
buffer.writeUInt32LE(36 + dataLength, 4); // ChunkSize
buffer.write('WAVE', 8); // Format
buffer.write('fmt ', 12); // Subchunk1ID
buffer.writeUInt32LE(16, 16); // Subchunk1Size (PCM)
buffer.writeUInt16LE(1, 20); // AudioFormat (1 = PCM)
buffer.writeUInt16LE(numChannels, 22); // NumChannels
buffer.writeUInt32LE(sampleRate, 24); // SampleRate
buffer.writeUInt32LE(byteRate, 28); // ByteRate
buffer.writeUInt16LE(blockAlign, 32); // BlockAlign
buffer.writeUInt16LE(bitsPerSample, 34); // BitsPerSample
buffer.write('data', 36); // Subchunk2ID
buffer.writeUInt32LE(dataLength, 40); // Subchunk2Size
return buffer;
}
async function main() {
const ai = new GoogleGenAI({
apiKey: process.env.GEMINI_API_KEY,
});
const model = 'models/gemini-2.5-flash-preview-native-audio-dialog'
const config = {
responseModalities: [
Modality.AUDIO,
],
mediaResolution: MediaResolution.MEDIA_RESOLUTION_MEDIUM,
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: 'Zephyr',
}
}
},
contextWindowCompression: {
triggerTokens: '25600',
slidingWindow: { targetTokens: '12800' },
},
};
session = await ai.live.connect({
model,
callbacks: {
onopen: function () {
console.debug('Opened');
},
onmessage: function (message: LiveServerMessage) {
responseQueue.push(message);
},
onerror: function (e: ErrorEvent) {
console.debug('Error:', e.message);
},
onclose: function (e: CloseEvent) {
console.debug('Close:', e.reason);
},
},
config
});
session.sendClientContent({
turns: [
`INSERT_INPUT_HERE`
]
});
await handleTurn();
session.close();
}
main();
これを書きかえて使っていきます。
今回試した内容
今回試した内容について書いていきます。
コード
今回、元のサンプルコードを JavaScript のものに変えて使いました。また、プロンプトの部分は、日本語の内容に変えています(あと、出力する wavファイルのファイル名も test という内容で設定しました)。
具体的には以下の内容です。
import { GoogleGenAI, MediaResolution, Modality } from "@google/genai";
import mime from "mime";
import { writeFile } from "fs";
const responseQueue = [];
let session;
async function handleTurn() {
const turn = [];
let done = false;
while (!done) {
const message = await waitMessage();
turn.push(message);
if (message.serverContent && message.serverContent.turnComplete) {
done = true;
}
}
return turn;
}
async function waitMessage() {
let message;
while (true) {
message = responseQueue.shift();
if (message) {
handleModelTurn(message);
return message;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
const audioParts = [];
function handleModelTurn(message) {
const parts = message.serverContent?.modelTurn?.parts;
if (!parts) return;
const part = parts[0];
if (part.fileData) {
console.log(`File: ${part.fileData.fileUri}`);
}
if (part.inlineData) {
const fileName = "test";
audioParts.push(part.inlineData.data || "");
const buffer = convertToWav(audioParts, part.inlineData.mimeType || "");
saveBinaryFile(`${fileName}.wav`, buffer);
}
if (part.text) {
console.log(part.text);
}
}
function saveBinaryFile(fileName, content) {
writeFile(fileName, content, "utf8", (err) => {
if (err) {
console.error(`Error writing file ${fileName}:`, err);
} else {
console.log(`Appended stream content to ${fileName}.`);
}
});
}
function convertToWav(rawData, mimeType) {
const options = parseMimeType(mimeType);
const dataLength = rawData.reduce((sum, chunk) => sum + chunk.length, 0);
const header = createWavHeader(dataLength, options);
const body = Buffer.concat(rawData.map((d) => Buffer.from(d, "base64")));
return Buffer.concat([header, body]);
}
function parseMimeType(mimeType) {
const [fileType, ...params] = mimeType.split(";").map((s) => s.trim());
const [, format] = fileType.split("/");
const opts = {
numChannels: 1,
bitsPerSample: 16,
sampleRate: 48000,
};
if (format && format.startsWith("L")) {
const bits = parseInt(format.slice(1), 10);
if (!isNaN(bits)) opts.bitsPerSample = bits;
}
for (const param of params) {
const [key, val] = param.split("=").map((s) => s.trim());
if (key === "rate") {
opts.sampleRate = parseInt(val, 10);
}
}
return opts;
}
function createWavHeader(
dataLength,
{ numChannels, sampleRate, bitsPerSample }
) {
const byteRate = (sampleRate * numChannels * bitsPerSample) / 8;
const blockAlign = (numChannels * bitsPerSample) / 8;
const buf = Buffer.alloc(44);
buf.write("RIFF", 0);
buf.writeUInt32LE(36 + dataLength, 4);
buf.write("WAVE", 8);
buf.write("fmt ", 12);
buf.writeUInt32LE(16, 16);
buf.writeUInt16LE(1, 20);
buf.writeUInt16LE(numChannels, 22);
buf.writeUInt32LE(sampleRate, 24);
buf.writeUInt32LE(byteRate, 28);
buf.writeUInt16LE(blockAlign, 32);
buf.writeUInt16LE(bitsPerSample, 34);
buf.write("data", 36);
buf.writeUInt32LE(dataLength, 40);
return buf;
}
async function main() {
const ai = new GoogleGenAI({
apiKey: process.env.GEMINI_API_KEY,
});
const model = "models/gemini-2.5-flash-preview-native-audio-dialog";
const config = {
responseModalities: [Modality.AUDIO],
mediaResolution: MediaResolution.MEDIA_RESOLUTION_MEDIUM,
speechConfig: {
voiceConfig: {
prebuiltVoiceConfig: {
voiceName: "Zephyr",
},
},
},
contextWindowCompression: {
triggerTokens: "25600",
slidingWindow: { targetTokens: "12800" },
},
};
session = await ai.live.connect({
model,
callbacks: {
onopen() {
console.debug("Opened");
},
onmessage(msg) {
responseQueue.push(msg);
},
onerror(e) {
console.debug("Error:", e.message);
},
onclose(e) {
console.debug("Close:", e.reason);
},
},
config,
});
session.sendClientContent({
turns: ["自己紹介をしてください。4つの文章で。"],
});
await handleTurn();
session.close();
}
main().catch(console.error);
あとは、上記を試すための下準備をします。
下準備
上記のコードを実行するため、パッケージのインストールと APIキーの設定を行います。
以下でパッケージをインストールします。
npm i @google/genai mime
それと環境変数 GEMINI_API_KEY に、Gemini の APIキーをセットしておきます。
処理を実行してみる
今回用意したコードを実行して、こちらの日本語の文に対する応答を音声データ(wavファイル)として得ます。その音声データを再生した時の様子は以下の動画のとおりです。
プロンプトは「自己紹介をしてください。4つの文章で。」という内容にしていたのですが、それに対する音声での応答を確認できました。