【Google I/O 2025】 Live API の「Gemini 2.5 Flash Native Audio（モデル： gemini-2.5-flash-preview-native-audio-dialog）」を試す

## はじめに
Google I/O 2025 で色々新しい発表があったので、それを試して記事を書いているシリーズがあり、この記事はそのうちの 1つです。


- [Gemini API ＋ Node.js で音声合成（TTS）](https://qiita.com/youtoy/items/433b567bb84672b264c7)
- [無料枠で Gemini・Gemma の新モデルの API利用（Node.js で）](https://qiita.com/youtoy/items/714a1bd58a80f856663c)
- [Gemini API ＆ SDK の MCP 対応を Node.js でお試し](https://qiita.com/youtoy/items/fd1b123c3f7fc3516264)

あと記事は書けてないですが、「Node.js で API を使った音楽生成（Lyria RealTime による）」も試してたりします。

https://x.com/youtoy/status/1925523938089869697

### 今回の内容

今回はタイトルに書いてある、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
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/9eef5d4e-d291-42de-a28f-f9bf929d4c30.png)

指定できるモデルは、以下 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 の情報](https://ai.google.dev/gemini-api/docs/live#native-audio-output
)を含む Live API の情報がのっている公式ドキュメントのページは、以下があります。

●Live API  |  Gemini API  |  Google AI for Developers
　https://ai.google.dev/gemini-api/docs/live
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/679f2e8e-2019-4331-80e5-45b72b41a846.png)

現状、プレビュー版の API です。

### モデルの違い

Live API用のモデルについてツールも利用可能です。ただし、モデルによって使えるツールに違いがあるようで、それについては以下に掲載されています。

https://ai.google.dev/gemini-api/docs/live#tools-overview
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/bf3ddd38-36cb-4ef4-a68a-7a647470ba18.png)


## サンプルコード

今回のお試しを進めるための、参照用の情報にするサンプルコードを探します。

今回は、モデルを使ったやりとりが試せる以下の Google AI Studio でコードを確認しました。

●Stream | Google AI Studio
　https://aistudio.google.com/live


具体的には、Live API のモデルを試せる画面に進み、その画面内で、以下で示したアイコンをクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/60be5c3f-5526-4b65-a3a2-fd60d938f7b3.png)


そうすると、記事執筆時点では Python と TypeScript のサンプルコードを見ることができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/50868/fabbaec9-ebe4-4628-85cd-61cd383c0872.png)


自分は、以下の 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 という内容で設定しました）。

具体的には以下の内容です。

```javascript
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キーの設定を行います。

以下でパッケージをインストールします。

```zsh
npm i @google/genai mime
```

それと環境変数 GEMINI_API_KEY に、Gemini の APIキーをセットしておきます。


### 処理を実行してみる

今回用意したコードを実行して、こちらの日本語の文に対する応答を音声データ（wavファイル）として得ます。その音声データを再生した時の様子は以下の動画のとおりです。

<iframe width="560" height="315" src="https://www.youtube.com/embed/AwwBvA1_HGA?si=8k9FbnYzML-NUtMh" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

プロンプトは「自己紹介をしてください。4つの文章で。」という内容にしていたのですが、それに対する音声での応答を確認できました。