Gemini & Google Apps Script & Google Driveで音声ファイルを解析する

# はじめに
生成AIのマルチモーダルというと「英語ならできるんだけど、日本語だと...」という微妙な感触だったのですが、GPT-4o（オムニ）の登場でスッと景色が変わりました。GPT-4oの画像認識は、日本語でも十分に実用に堪える印象です。

ただ、あいにくGPT-4oは2024年5月末時点で、音声処理（Speech to Text）をAPI経由で扱うことができません。ドキュメントのSpeech to Textの項を見ても、そこにいるのは古き佳きWhisperのみです。

> Support for audio is coming in the following weeks to a small group of trusted partners.
https://community.openai.com/t/gpt-4o-audio-access-for-api/744549/10

とあるので、いずれ一般ユーザーにもロールアウトされるでしょうが、まだもうちょっとかかりそうです。

マルチモーダルといえば、出遅れ感あったGoogleが活路を見出そうとしている分野です。フラグシップモデルのGemini 1.5 Proに加えて、軽量・安価なGemini 1.5 Flashが出てきたのは個人的に意外でした。「うまい、やすい、はやい」、生成AI界の吉牛みたいなFlashは、大量の通話記録を大雑把に文字起こしするのに便利そうです。しかもGoogle AI Studioなら無料で試せるとな。

ということでGeminiのマルチモーダルのうち、音声入力を試してみるのがこの記事です。「Gemini試してみたまじやばい」系記事はたくさんあるので、この記事ではGoogleになるべく魂を売ることに努めました。すなわち、実行環境にはGoogle Apps Scriptを、ファイルストレージにはGoogle Driveを用いました。

結論として、まじやばい、とは思いませんでしたが、Google Apps ScriptのGoogle Drive連携はマルチモーダルの実行環境としてはやはり手軽でした。Gemini 1.5も、Whisperの出力をLLMで校正するこれまでのアプローチと比べると、シンプルで魅力的に感じました。

# この記事の目標
Google Apps ScriptでGoogle Driveの音声ファイルを読み込み、Gemini 1.5 Pro/Flashにリクエストを送ることを目標にします。

- Google Apps Scriptはclaspを用いて、TypeScriptで記述します。
  - https://github.com/google/clasp
- お試し記事のため、エラー処理などは最低限です。

また、Node.jsの環境設定など一般的な知識については記述を割きません。

:::message
## なぜGoogle Apps Scriptを使うか
Google Apps Scriptを使う理由としては以下です。（個人の感想です）

- 実行環境としてセットアップがとても楽
- Google Driveとの連携が容易
    - Workload Identity認証等をしなくても既定の`DriveApp`クラスが使える。
- 実行トリガの設定が楽
    - WebAppとしてデプロイしてHTTP実行
    - スクリプトトリガで実行
    - 手軽な実行環境である`Google Colaboratory`はここが難しい。
- Claspを使うとTypeScriptで書け、Git管理も容易
- 将来、便利なGemini連携がサポートされるような気がする。`GenAIApp.generate`とか。
  - 現状でもGoogle Apps ScriptからVertex AIの認証は比較的楽です。
      - https://zenn.dev/greek_academy/articles/1c5be03365e92a 
:::

# 環境
- MacBook Air (M1: 2020)
- Node.js: v20.14.0
- エディタ：VSCode
- Google Workspace Business Standard

コードは下記で公開しています。

https://github.com/HosakaKeigo/gemini-drive-audio

# セットアップ
## Claspプロジェクトの作成（VSCode）
まず、VSCodeでGoogle Apps Scriptを記述するためのClaspプロジェクトを作成します。

### Claspのインストール
```
$npm install @google/clasp -g
```

### ディレクトリ作成
```sh
$mkdir gemini-drive-audio
$cd gemini-drive-audio
```

### プロジェクト作成
```sh
$npm init
```

### 依存パッケージのインストール
```sh
$npm install -D @types/google-apps-script
$npm install -D typescript
```

本記事では省きますが、お好みでBiome/eslint&prettierなどをリンター／フォーマッタを導入することもできます。

## Google Apps Scriptプロジェクトの作成
Google Apps Scriptには、スプレッドシートなどと紐づいたコンテナ型とスタンドアローン型があります。
今回は後者でも構わないのですが、拡張性を考えてシートに紐づくコンテナ型にします。

### スプレッドシート作成
GoogleアカウントでログインしたChromeで[マイドライブ](https://drive.google.com/drive/home)を開き、スプレッドシートを新規作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/2912669a-8baa-ebca-f75a-1f871428d8b5.png)


### 「拡張機能」からApps Script作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/0f2777b8-8b8a-1b8a-faa4-c148e584560d.png)

### プロジェクトID取得
URLからプロジェクトIDを取得します。

`https://script.google.com/u/0/home/projects/<Your Project ID>/edit`

### Apps Script APIの有効化 （Claspを初めて使う場合）
Claspを使うためにApps Script APIの有効化を行います。

https://script.google.com/home/usersettings

### clasp login -> clasp clone （VSCode）
VSCodeのターミナルで以下を実行します。

```sh
$clasp login
```
ブラウザが開きますので、Google Apps Scriptのオーナーでログイン。

```sh
$clasp clone <Your Project ID>
```

この時点で以下のファイルツリーができます。

```
gemini-drive-audio/
┣ node_modules/
┣ .clasp.json
┣ appsscript.json
┣ package-lock.json
┣ package.json
┗ コード.js
```

### TypeScript化
せっかくなので、 TypeScriptを使います。

- `コード.js`を`コード.ts`にリネーム
- `コード.ts`と同じ階層に`tsconfig.json`を追加

```json
{
	"compilerOptions": {
		"lib": ["esnext"],
		"experimentalDecorators": true,
		"target": "ES2021",
		"noImplicitAny": true
	}
}
```

お好みでカスタマイズして構いませんが、`target`は"ES2021"としてください。（詳細は分かりませんが、"ES2022"などだと、class構文でエラーが出ます。）

https://qiita.com/shinout/items/ab69b78205a2c44e1360

これで型補完の恩恵を受けながらGoogle Apps Scriptをかけるようになりました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/e66df74b-b3db-e5bd-1d06-efd15f9cfdc6.png)

## Google AI StudioでAPIキーを取得
Geminiを使うためのAPIキーを取得します。Vertex AI経由でも使えますが、今回は（まだ）無料で使えるGoogle AI Studio経由でAPIキーを取得します。
https://aistudio.google.com/app/prompts/new_chat

上記リンクから開くと`Get API Key`ボタンがあるので、進み、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/2039d6b4-dcf4-d754-aef0-f7c476626dd0.png)

「**キーAPIキーを作成**」（ママ）からキーを作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/844a5790-ac97-a986-2194-5e8af3551487.png)


セットアップは以上です。

# Google Apps Scriptを書く
今回やりたいことは以下です。

- Google Driveからファイル（通話音声）を取得
  - ここは本質に関わらない詳細部分なので、ひとまず「ファイル名で取得」を目指します。
- ファイルを加工し、Gemini APIのリクエストボディを作る
- Geminiのレスポンスをパースする

これをドキュメントを眺めながら実装していきます。

## スクリプトプロパティの設定
その前にAPIキーをスクリプトプロパティに格納しておきます。

作成したGoogle Apps Scriptを開き、歯車をクリック。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/a8972c6c-12d6-0422-c1c8-5183d9ef42e4.png)


以下をセットします。

- GOOGLE_API_KEY
- DEFAULT_MODEL
  - `gemini-1.5-pro-latest`/ `gemini-1.5-flash-latest`
  
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2722903/092ffa51-6cdf-bee8-d8b2-d2f693c690c8.png)


## Gemini用クラスの作成
`gemini.ts`にgemini関連の処理をまとめます。

まず、ドキュメントからinterfaceを作成します。

https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/gemini?hl=ja#request

完璧は期せず、必要な箇所のみ抽出しています。

```ts:interface/gemini.ts
interface GeminiRequest {
  contents: {
    role: "USER" | "MODEL";
    "parts": Part[];
  }[],
  "systemInstruction": {
    "parts": Part[];
  },
  "generationConfig"?: {
    "temperature": number;
    "responseMimeType": "text/plain" | "application/json";
  }
}

interface Part {
  "text"?: string;
  /**
   * 画像、音声クリップ、動画クリップのシリアル化されたバイトデータ。
   */
  "inlineData"?: {
    "mimeType": string,
    "data": string
  };
  "fileData"?: {
    "mimeType": string,
    "fileUri": string
  };
}

interface GeminiResponse {
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": string
          }
        ]
      },
      "finishReason": string,
      "citationMetadata": {
        "citations": [
          {
            "startIndex": number,
            "endIndex": number,
            "uri": string,
            "title": string,
            "license": string,
            "publicationDate": {
              "year": number,
              "month": number,
              "day": number
            }
          }
        ]
      }
    }
  ],
  "usageMetadata": {
    "promptTokenCount": number,
    "candidatesTokenCount": number,
    "totalTokenCount": number
  }
}
```

Geminiクラスは以下のように作成してみました。なお、`import/export`は不要です。

```ts:gemini.ts
class GeminiService {
  private endpoint: string;
  constructor(private apiKey?: string, private model?: string) {
    if (!this.apiKey) {
      this.apiKey = this.getGoogleAPIKey()
    }

    if (!this.model) {
      this.model = this.getDefaultModel()
    }

    this.endpoint = `https://generativelanguage.googleapis.com/v1beta/models/${this.model}:generateContent`
  }

  generate(payload: GeminiRequest) {
    const options: GoogleAppsScript.URL_Fetch.URLFetchRequestOptions = {
      'method': 'post',
      'contentType': 'application/json',
      'payload': JSON.stringify(payload),
      'muteHttpExceptions': true
    };

    const response = UrlFetchApp.fetch(`${this.endpoint}${this.apiKeyParam()}`, options)
    const json = JSON.parse(response.getContentText()) as GeminiResponse;
    return json.candidates[0].content.parts[0].text
  }

  private apiKeyParam() {
    return `?key=${this.apiKey}`
  }

  private getGoogleAPIKey() {
    const scriptProperties = PropertiesService.getScriptProperties();
    try {
      return scriptProperties.getProperty('GOOGLE_API_KEY');
    } catch (e) {
      throw new Error("Error getting Google API key from script properties.");
    }
  }

  private getDefaultModel() {
    const scriptProperties = PropertiesService.getScriptProperties();
    try {
      return scriptProperties.getProperty('DEFAULT_MODEL');
    } catch (e) {
      throw new Error("Error getting DEFAULT_MODEL from script properties.");
    }
  }
}
```

## 音声解析関数
次に引数にドライブのファイルオブジェクトを取り、プロンプトに従って解析を行う関数を作成します。

```ts:analyze.ts
function analyzeAudio(audioFile: GoogleAppsScript.Drive.File) {
  const service = new GeminiService()
  const bytes = audioFile.getBlob().getBytes()
  try {
    const response = service.generate({
      contents: [
        {
          "role": "USER",
          "parts": [{
            "inlineData": {
              "mimeType": "audio/mp3",
              "data": Utilities.base64Encode(bytes)
            }
          },
          ],
        }
      ],
      "systemInstruction": {
        "parts": [
          {
            "text": SYSTEM_PROMPT
          }
        ]
      },
      "generationConfig": {
        "temperature": 0,
        "responseMimeType": "application/json"
      }
    })

    const json = JSON.parse(response) as AnalyzeAudioResponse
    console.log(json)
    return json
  } catch (e) {
    console.error(e)
    throw new Error("Failed to analyze audio.", e.message)
  }
}
```

ポイントは以下です。

- `audioFile.getBlob().getBytes()` -> `Utilities.base64Encode(bytes)`でBase64エンコーディングし、`inlineData`プロパティに渡す。
  - `fileData` にCloud StorageのURLを渡すこともできます。
- mimeTypeの指定。指定可能なmimeTypeはドキュメントに記載があります。
  - https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/gemini?hl=ja#parameters
- `generationConfig.responseMimeType`を"application/json"に指定
  - いわゆるJSONモード。戻り値がJSON形式になります。

## promptの指定
最後にプロンプトを指定します。

まず、欲しいレスポンスの型を定義します。

```ts:interface/response.ts
interface AnalyzeAudioResponse {
  title: string
  summary: string
  speakers: {
    name: string,
    profile: string // info about the speaker
  }[],
  topics: {
    name: string,
    description: string
  }[]
  index: string // string of keywords separated by space
}
```

これを返すように適当なプロンプトを作成します。

```ts:prompt.ts
const SYSTEM_PROMPT = `あなたは音声解析アシスタントです。与えられた音声の内容を解析してください。

回答は下記のJSONスキーマにしたがってください。値は全て日本語としてください。

\`\`\`
{
    title: <Title should capture the summary of audio. Must be under 30 characters.>,
    summary: <summary of the provided audio>,
    speakers: {
        name:string,
        profile:string // info about the speaker
    }[],
    topics: {
      name:string,
      description:string
    }[],
    index: <index> // string of keywords separated by space
}
\`\`\`
`
```

細かな挙動の調整はこのプロンプトの修正を通じて行っていくことになります。

## main関数の作成
以上を`main.ts`関数にまとめます。
```ts:main.ts
function main() {
  const fileName = "sample.mp3"
  const file = DriveApp.getFilesByName(fileName).next()

  try {
    const response = analyzeAudio(file)
    console.log(response)
    Utilities.sleep(5000) // rate limit
  } catch (e) {
    console.error(e)
  }
}
```

なお、`DriveApp.getFilesByName`は複数のファイルを返す場合や該当がない例外が考えられますが、今回は1ファイルのみ該当すると仮定して進めます。実際には、所定のフォルダ以下のファイルをiterateしたりするなど、調整が必要です。

## clasp push
ここまでできたらコードを反映させます。

現在のファイルツリーを掲示します。

```
gemini-drive-audio/
┣ interface/
┃ ┣ gemini.ts
┃ ┗ response.ts
┣ node_modules/
┣ .clasp.json
┣ analyze.ts
┣ main.ts
┣ appsscript.json
┣ gemini.ts
┣ package-lock.json
┣ package.json
┣ prompt.ts
┗ tsconfig.json
```
もしコードを./src以下にまとめる場合は、

```json:.clasp.json
"rootDir": "./src"
```

とし、

```
gemini-drive-audio/
┣ node_modules/
┣ src/
┃ ┣ interface/
┃ ┣ appsscript.json
┃ ┣ analyze.ts
┃ ┣ gemini.ts
┃ ┣ main.ts
┃ ┗ prompt.ts
┣ .clasp.json
┣ package-lock.json
┣ package.json
┗ tsconfig.json
```

となります。

`gemini-drive-audio`に移動し、
```
$clasp push
```
を実行すればコードが反映されます。

# Driveにファイルを追加
MyDriveに音声ファイルをアップロードします。ファイル名はユニークで、`main.ts`に指定したものに揃えてください。

サンプル音源として、Librivoxから夏目漱石の『二百十日』の第1章を選びました。圭さんと碌さんの掛け合いがあるため、複数話者の聞き分けをテストできます。

https://librivox.org/nihyakutouka-by-natsume-soseki/

# 実行
Google Apps Scriptを開き、`main.ts`を実行します。初回は権限の認証が必要です。

以下のような結果が得られました。

```json
{
  "title": "豆腐屋の息子",
  "summary": "Kさんと六さんが、田舎で聞こえる鍛冶屋の「カンカン」という音について話しています。Kさんは、その音が幼い頃に実家の豆腐屋の近くにあったお寺の鐘の音に似ていると感じています。\n\nKさんは、豆腐屋の息子でありながら、豆腐屋らしくない生き方をしてきたと六さんに指摘されます。Kさんは、世の中が不公平だからだと反論し、自分が理想とする豆腐屋について熱く語ります。\n\nその後、Kさんは、世の中を変えるためには、自分が豆腐屋として成功し、周りの人間を変えていくしかないと結論付けます。",
  "speakers": [
    {
      "name": "Kさん",
      "profile": "豆腐屋の息子。都会で暮らしている。"
    },
    {
      "name": "六さん",
      "profile": "Kさんの友人。聞き役が多い。"
    }
  ],
  "topics": [
    {
      "name": "鍛冶屋の「カンカン」という音",
      "description": "田舎で聞こえる鍛冶屋の音が、Kさんの幼少期の記憶を呼び起こす。"
    },
    {
      "name": "豆腐屋であること",
      "description": "Kさんは豆腐屋の息子だが、豆腐屋らしくない生き方をしてきたことを自覚し、葛藤を抱えている。"
    },
    {
      "name": "世の中の不公平さ",
      "description": "Kさんは世の中が不公平だと感じており、それを変えたいと思っている。"
    },
    {
      "name": "理想の豆腐屋",
      "description": "Kさんは、周りの人間を変えていけるような影響力を持った豆腐屋になりたいと思っている。"
    }
  ],
  "index": "豆腐屋 息子 鍛冶屋 音 世の中 不公平 理想"
}
```

speakersは、漢字はもちろん違いますが、圭さんと碌さんの2名を取れています。topicsに関しては、「周りの人間を変えていけるような影響力を持った豆腐屋になりたいと思っている。」というところなどイマイチですが、方向性は間違っていないかな、といった精度です。


# おわりに
Google Apps Script、Google Drive、GeminiとGoogleどっぶりで音声解析をしてみました。

本当は通話データでテストしたかったのですが、ここに掲載できる好適なサンプルデータがありませんでした。
興味があればコードを参考にぜひ、ご自身でお試しください。[^1]

[^1]:無料版の場合は、個人情報や機密情報を含んだ情報は送信しないように気をつけてください。 https://ai.google.dev/gemini-api/terms?hl=ja#data-use-unpaid

GitHubのリポジトリを公開していますので、よろしければお使いください。

https://github.com/HosakaKeigo/gemini-drive-audio