1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【初心者向け】サーバ屋が生成AIのAPIを初めて触る ④まとめ編 〜スマホで使える「ボイスメモ整理アプリ」をGASで作って完結〜

1
Last updated at Posted at 2026-08-16

はじめに

シリーズ最終回です。①セットアップ編、②全機能ためす編、③つまずき解決編と順を追って進めてきました。最終回はこの学びを全部使って、スマホで使える実用ミニアプリを構築します。

各回のタイトル 内容
①セットアップ編 契約〜アカウントトークン発行〜最初の1リクエスト
②全機能ためす編 チャット生成/ベクトル埋め込み/音声の文字起こし/音声の読み上げ
③つまずき解決編 実際に遭遇したエラーと解決法FAQ
④まとめ編(本記事) 30分で作るミニアプリ+無償枠の賢い使い方

構築するのは「ボイスメモ整理アプリ」、使い方は次の通りです。

  1. スマホの録音アプリで思いつきを録音し、Gドライブの「VoiceMemo」フォルダに保存
    初期画面(Gドライブの「VoiceMemo」フォルダに音声ファイルがない場合)
    Screenshot_20260812-210406.png
    ※ページ上部に警告のようなメッセージが表示されるが、Googleがフィッシング対策として表示している警告で、「一般ユーザーが作ったWebアプリですよ」と閲覧者に知らせる仕組みです。エラーではなく仕様で、設定では消せません。
  2. スマホのブラウザで自分専用のWebアプリを開き、音声を選んでタップ
    Screenshot_20260812-210206.png
    ※選択した音声ファイルの処理中のメッセージが表示されます
  3. 数十秒待つと、文字起こし→内容を要約→TODO抽出が表示され、要約を音声で読み上げ(1x/1.5x/2x切替可)。要約はMarkdown形式でGドライブに自動保存
    Screenshot_20260812-210242.png
    ※一連の処理が完了すると、要約とTODO(内容によっては表示されない)、要約の読み上げを再生することができます。読み上げスピードは1.5倍や2倍も選ぶことができます。

最終回がPHPじゃない理由

ここまでPHPを利用してきましたが、最終回は Google Apps Script(GAS) を使います。理由は2つあります。

1つ目は実用性です。「スマホでいつでも使えて、サーバの用意も不要」という条件を満たすものがGASだからです。GoogleアカウントさえあればWebアプリを無料でデプロイでき、Gドライブとの連携も簡単です。

2つ目が本命で、③つまずき解決編のまとめで書いた「生成AIのAPIは、結局HTTPSで話す外部サービス」の実証です。(認証はBearer、送るのはJSON、受け取るのはJSONかバイナリ)この構造さえ分かっていれば、プログラミング言語がPHPからGAS(JavaScript)に変わっても生成AIのAPIが利用できることを、最終回で実際にやってみます

処理の流れ

スマホで録音 → Gドライブ「VoiceMemo」フォルダ
     │
     ▼ GAS Webアプリ(スマホのブラウザで操作)
音声ファイル選択 → processAudio()
     │
     ├─ ①文字起こしAPI (whisper-large-v3-turbo)
     ├─ ②チャットAPI (gpt-oss-120b)「JSONで要約とTODOを返して」
     ├─ ③要約をMarkdown形式でGドライブに保存
     └─ ④読み上げAPI (meimeihimari) → wavをGドライブ保存+画面のプレーヤーへ
     │
     ▼
要約カード表示 + 倍速読み上げプレーヤー

倍速読み上げは、サーバ側で音声を加工するのではなく、 ブラウザの<audio>要素のplaybackRate で実現します。GASでは音声処理ができませんが、再生速度の変更は再生側で実装すれば追加コストはかかりません。

セットアップ(15分)

1. GASプロジェクトの作成

  1. Googleアカウントにログインした状態でscript.google.com を開き「新しいプロジェクト」を作成
  2. コード.gs に後述のCode.gsを貼り付け
    image.png
  3. 「+」→「HTML」で index という名前のファイルを作り、後述のindex.htmlを貼り付け
    image.png

2. アカウントトークンをスクリプトプロパティに保存

②全機能ためす編ではアカウントトークンをconfig.phpに分離しました。GASでの同じ役割がスクリプトプロパティです。コードにキーを直書きしない、という原則はプログラミング言語が変わっても同じです。

  1. 左メニューの歯車「プロジェクトの設定」→「スクリプトプロパティ」
  2. プロパティ名 SAKURA_API_KEY、値に①セットアップ編で発行したアカウントトークンを設定
    image.png

3. ウェブアプリとしてデプロイ

  1. 「デプロイ」→「新しいデプロイ」→ 種類「ウェブアプリ」を選択
    image.png
  2. 「次のユーザーとして実行: 自分」「アクセスできるユーザー: 自分のみ」 を選択し、「デプロイ」ボタンを押す
    image.png
  3. 「アクセスを承認」ボタンを押す
    image.png
  4. 別ウィンドウが開くので、自分のGドライブと外部サービスへのアクセスを選択し続行する
    ※3の画面に戻ったら、「完了」ボタンを押す
    image.png
  5. 表示されたウェブアプリURLをスマホのブラウザで開く(ホーム画面に追加しておくとアプリ感が出ます)
    image.png

コード全文

Code.gs

// Code.gs — ボイスメモ整理 GAS版
// スマホで録音→Gドライブ保存→文字起こし→要約→読み上げ(倍速対応)
//
// 事前準備:
//   1. プロジェクトの設定 → スクリプトプロパティに SAKURA_API_KEY を追加
//      (①セットアップ編で発行したアカウントトークン)
//   2. Gドライブに「VoiceMemo」フォルダを作成(無ければ初回実行時に自動作成)
//      フォルダ構成:
//        VoiceMemo/          … 未処理の録音を入れるフォルダ(一覧に出るのはここだけ)
//        VoiceMemo/出力/     … 生成されたメモ(.md)と読み上げ(.wav)
//        VoiceMemo/処理済み/ … 処理が終わった元の録音(自動で移動)
//   3. デプロイ → 新しいデプロイ → ウェブアプリ
//      「次のユーザーとして実行: 自分」「アクセスできるユーザー: 自分のみ」
//   4. 表示されたURLをスマホのブラウザで開く(ホーム画面に追加すると便利)

const BASE_URL = 'https://api.ai.sakura.ad.jp/v1';
const FOLDER_NAME = 'VoiceMemo';        // 未処理の録音を置く場所
const OUTPUT_FOLDER_NAME = '出力';      // メモとwavの保存先
const DONE_FOLDER_NAME = '処理済み';    // 処理し終えた録音の移動先

// ---- APIキーをスクリプトプロパティから取得 ----
function getApiKey_() {
  const key = PropertiesService.getScriptProperties().getProperty('SAKURA_API_KEY');
  if (!key) {
    throw new Error('スクリプトプロパティ SAKURA_API_KEY が未設定です。プロジェクトの設定から追加してください');
  }
  return key;
}

// ---- 監視フォルダを取得(無ければ作成) ----
function getFolder_() {
  const folders = DriveApp.getFoldersByName(FOLDER_NAME);
  return folders.hasNext() ? folders.next() : DriveApp.createFolder(FOLDER_NAME);
}

// ---- サブフォルダを取得(無ければ親フォルダ内に作成) ----
function getSubFolder_(parent, name) {
  const folders = parent.getFoldersByName(name);
  return folders.hasNext() ? folders.next() : parent.createFolder(name);
}

// ---- Webアプリ ----
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
    .setTitle('ボイスメモ整理')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1');
}

// ---- VoiceMemo直下の音声ファイル一覧(新しい順に最大10件) ----
// getFiles()は直下のファイルだけを返すため、「出力」「処理済み」内のファイルは
// 一覧に混ざらない。処理済みの録音や生成されたwavが再表示されない仕組み。
function listAudioFiles() {
  const files = getFolder_().getFiles();
  const list = [];
  while (files.hasNext()) {
    const f = files.next();
    const mime = f.getMimeType() || '';
    const isAudio = mime.indexOf('audio/') === 0
      || /\.(m4a|mp3|wav|ogg|aac|mp4)$/i.test(f.getName());
    if (isAudio) {
      list.push({
        id: f.getId(),
        name: f.getName(),
        updated: Utilities.formatDate(f.getLastUpdated(), 'Asia/Tokyo', 'MM/dd HH:mm'),
        ts: f.getLastUpdated().getTime(),
      });
    }
  }
  list.sort((a, b) => b.ts - a.ts);
  return list.slice(0, 10);
}

// ============================================================
// メイン処理: 文字起こし → 要約 → 読み上げ (APIを3回利用)
// ============================================================
function processAudio(fileId) {
  const apiKey = getApiKey_();
  const folder = getFolder_();
  const outputFolder = getSubFolder_(folder, OUTPUT_FOLDER_NAME);
  const doneFolder = getSubFolder_(folder, DONE_FOLDER_NAME);
  const file = DriveApp.getFileById(fileId);

  // ---- Step 1: 文字起こし (multipart送信。blobを含むpayloadは自動でmultipartになる) ----
  const res1 = UrlFetchApp.fetch(BASE_URL + '/audio/transcriptions', {
    method: 'post',
    headers: { Authorization: 'Bearer ' + apiKey },
    payload: {
      file: file.getBlob(),
      model: 'whisper-large-v3-turbo',
    },
    muteHttpExceptions: true,
  });
  check_(res1, '文字起こし');
  const transcript = (JSON.parse(res1.getContentText()).text || '').trim();
  if (!transcript) throw new Error('文字起こし結果が空でした。音声ファイルを確認してください');

  // ---- Step 2: 要約とTODO抽出 (結果を機械処理するため JSONのみ を指定) ----
  const prompt = [
    '以下はボイスメモの文字起こしです。内容を整理して、次の形式のJSONのみを返してください。',
    '説明文やコードブロック記法(```)は一切不要です。',
    '',
    '{"title": "メモの内容を表す短いタイトル", "summary": "3行以内のわかりやすい要約", "todos": ["やるべきことがあれば列挙(なければ空配列)"]}',
    '',
    '--- 文字起こし ---',
    transcript,
  ].join('\n');

  const res2 = UrlFetchApp.fetch(BASE_URL + '/chat/completions', {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: 'Bearer ' + apiKey },
    payload: JSON.stringify({
      model: 'gpt-oss-120b',
      messages: [{ role: 'user', content: prompt }],
      max_tokens: 3000,
      temperature: 0.2,
    }),
    muteHttpExceptions: true,
  });
  check_(res2, '要約');
  const content = JSON.parse(res2.getContentText()).choices[0].message.content || '';
  const clean = content.replace(/```json|```/g, '').trim();
  let memo;
  try {
    memo = JSON.parse(clean);
  } catch (e) {
    throw new Error('要約結果をJSONとして解析できませんでした。AIの応答: ' + content.slice(0, 300));
  }

  // ---- Step 3: メモをMarkdownで「出力」フォルダに保存 ----
  const stamp = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyyMMdd_HHmm');
  const todoLines = (memo.todos && memo.todos.length)
    ? memo.todos.map(function (t) { return '- [ ] ' + t; }).join('\n')
    : '(なし)';
  const md = [
    '# ' + memo.title,
    '',
    '元ファイル: ' + file.getName() + ' (' + stamp + ')',
    '',
    '## 要約', '', memo.summary, '',
    '## TODO', '', todoLines, '',
    '## 文字起こし全文', '', transcript, '',
  ].join('\n');
  outputFolder.createFile('memo_' + stamp + '.md', md, MimeType.PLAIN_TEXT);

  // ---- Step 4: 要約を読み上げ音声に変換 ----
  const res3 = UrlFetchApp.fetch(BASE_URL + '/audio/speech', {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: 'Bearer ' + apiKey, accept: 'audio/wav' },
    payload: JSON.stringify({
      model: 'meimeihimari',
      input: 'メモを整理しました。' + memo.summary,
      voice: 'normal',
      response_format: 'wav',
    }),
    muteHttpExceptions: true,
  });
  check_(res3, '読み上げ');
  const wavBlob = res3.getBlob().setName('memo_' + stamp + '.wav');
  outputFolder.createFile(wavBlob);

  // ---- Step 5: 処理し終えた元の音声を「処理済み」へ移動 ----
  file.moveTo(doneFolder);

  // 画面のプレーヤーで再生できるよう、音声はbase64で返す
  return {
    title: memo.title,
    summary: memo.summary,
    todos: memo.todos || [],
    transcript: transcript,
    wavBase64: Utilities.base64Encode(wavBlob.getBytes()),
    requests: 3,
  };
}

// ---- 共通: APIエラーを理由付きで表示させる ----
function check_(res, label) {
  const code = res.getResponseCode();
  if (code !== 200) {
    throw new Error(label + 'APIエラー (HTTP ' + code + '): ' + res.getContentText().slice(0, 300));
  }
}

index.html

<!DOCTYPE html>
<!-- index.html — ボイスメモ整理 スマホ用画面 -->
<html>
<head>
  <base target="_top">
  <style>
    body { font-family: sans-serif; margin: 0; padding: 16px; background: #f5f0fa; }
    h1 { font-size: 1.2em; color: #5a2e8f; }
    .file-btn {
      display: block; width: 100%; box-sizing: border-box; text-align: left;
      margin: 8px 0; padding: 14px; font-size: 1em;
      background: #fff; border: 1px solid #d5c6ea; border-radius: 10px;
    }
    .file-btn small { color: #888; }
    .card {
      background: #fff; border-radius: 10px; padding: 16px; margin-top: 16px;
      border: 1px solid #d5c6ea;
    }
    .card h2 { font-size: 1.1em; margin-top: 0; color: #5a2e8f; }
    .todo { margin: 4px 0; }
    .speed-btn {
      padding: 8px 14px; margin-right: 8px; font-size: 1em;
      border: 1px solid #5a2e8f; border-radius: 8px; background: #fff; color: #5a2e8f;
    }
    .speed-btn.active { background: #5a2e8f; color: #fff; }
    #status { color: #5a2e8f; font-weight: bold; }
    details { margin-top: 12px; }
    .error { color: #b00020; white-space: pre-wrap; }
    audio { width: 100%; margin-top: 8px; }
  </style>
</head>
<body>
  <h1>🎙️ ボイスメモ整理</h1>
  <p>Gドライブ「VoiceMemo」フォルダの音声を選んでください。</p>

  <div id="status">読み込み中...</div>
  <div id="fileList"></div>
  <div id="result"></div>

  <script>
    // ---- 起動時: 音声ファイル一覧を取得 ----
    google.script.run
      .withSuccessHandler(showFiles)
      .withFailureHandler(showError)
      .listAudioFiles();

    function showFiles(files) {
      const status = document.getElementById('status');
      const list = document.getElementById('fileList');
      list.innerHTML = '';
      if (!files.length) {
        status.textContent = '音声ファイルが見つかりません。録音してVoiceMemoフォルダに保存してください。';
        return;
      }
      status.textContent = '';
      files.forEach(function (f) {
        const btn = document.createElement('button');
        btn.className = 'file-btn';
        btn.innerHTML = '' + f.name + '<br><small>' + f.updated + '</small>';
        btn.onclick = function () { process(f.id, f.name); };
        list.appendChild(btn);
      });
    }

    // ---- 処理実行 ----
    function process(fileId, name) {
      document.getElementById('status').textContent =
        '' + name + '」を処理中... (文字起こし→要約→読み上げで30秒ほどかかります)';
      document.getElementById('result').innerHTML = '';
      google.script.run
        .withSuccessHandler(showResult)
        .withFailureHandler(showError)
        .processAudio(fileId);
    }

    // ---- 結果表示 ----
    function showResult(r) {
      const doneMsg = '完了! (消費リクエスト: ' + r.requests + '回)';
      document.getElementById('status').textContent = doneMsg;
      // 処理した録音は「処理済み」フォルダへ移動しているので、一覧を取り直して表示を最新化
      google.script.run.withSuccessHandler(function (files) {
        showFiles(files);
        document.getElementById('status').textContent = doneMsg;
      }).listAudioFiles();
      const todos = r.todos.length
        ? r.todos.map(function (t) { return '<div class="todo">☐ ' + escapeHtml(t) + '</div>'; }).join('')
        : '(なし)';
      document.getElementById('result').innerHTML =
        '<div class="card">' +
        '<h2>' + escapeHtml(r.title) + '</h2>' +
        '<p>' + escapeHtml(r.summary) + '</p>' +
        '<h2>TODO</h2>' + todos +
        '<h2>読み上げ</h2>' +
        '<div>' +
        '<button class="speed-btn active" onclick="setSpeed(this, 1.0)">1x</button>' +
        '<button class="speed-btn" onclick="setSpeed(this, 1.5)">1.5x</button>' +
        '<button class="speed-btn" onclick="setSpeed(this, 2.0)">2x</button>' +
        '</div>' +
        '<audio id="player" controls src="data:audio/wav;base64,' + r.wavBase64 + '"></audio>' +
        '<details><summary>文字起こし全文</summary><p>' + escapeHtml(r.transcript) + '</p></details>' +
        '<p><small>メモ(.md)と音声(.wav)は「VoiceMemo/出力」に保存し、元の録音は「VoiceMemo/処理済み」へ移動しました</small></p>' +
        '</div>';
    }

    // ---- 倍速切替: ブラウザのplaybackRateで実現 ----
    function setSpeed(btn, rate) {
      const player = document.getElementById('player');
      player.playbackRate = rate;
      document.querySelectorAll('.speed-btn').forEach(function (b) {
        b.classList.remove('active');
      });
      btn.classList.add('active');
    }

    function showError(e) {
      document.getElementById('status').textContent = '';
      document.getElementById('result').innerHTML =
        '<div class="card error">エラー: ' + escapeHtml(e.message || String(e)) + '</div>';
    }

    function escapeHtml(s) {
      return String(s).replace(/[&<>"']/g, function (c) {
        return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
      });
    }
  </script>
</body>
</html>

コード解説: PHPで学んだことがGASではどう対応しているか

②全機能ためす編のPHPコードと見比べると、やっていることが1対1で対応していることがわかります。

やりたいこと PHP(②全機能ためす編) GAS(今回)
HTTPリクエスト curl_init + curl_setopt_array UrlFetchApp.fetch
multipartで音声データ送信 CURLFile payloadにblobを入れる(自動でmultipart化)
JSON組み立て/分解 json_encode / json_decode JSON.stringify / JSON.parse
アカウントトークンの分離 config.php スクリプトプロパティ
エラー時に理由も表示 HTTPステータス確認+本文表示 muteHttpExceptions: true+check_()
思考型モデル対策 max_tokens: 3000 同じ
JSON応答の整形 ```除去→decode 同じ

プログラミング言語の名前が変わるだけで、構造は同じです。このシリーズで身につけたのは「PHPの書き方」だけではなく「HTTP APIの使い方」だった、ということです。

発展課題

  1. 自動化: GASの時間設定トリガーでVoiceMemoフォルダを定期監視し、新着音声を自動処理する(例えば、朝の通勤中に録音→昼には要約が確認できるようになっているなど)
  2. 過去ボイスメモの類似検索: ②全機能ためす編で体験したベクトル埋め込みを利用し「この件、前もメモしなかったっけ?」を検索できるようにする
  3. LINEやメールへの通知: 要約をGmailAppで自分宛てに送信する

無償枠の賢い使い方(連載のまとめ)

機能 上限/月 感覚
Chat生成 3,000回 学習用途ではまず使い切れない
ベクトル埋め込み 10,000回 前処理に使えるが、使い方は要検討
音声の文字起こし 50回 要管理。このアプリなら1日1〜2本ペース
音声の読み上げ 50回 要管理。生成した音声の取り扱いはライセンスに注意

このアプリは1回の実行で「文字起こし1+チャット1+読み上げ1」の3リクエスト。音声枠から逆算すると月50本のメモを処理できる計算で、毎日使ってもぎりぎり収まります。読み上げが不要な場合はスキップする機能を(ボタンを分ける等)追加するのも良いかもしれません。

シリーズ総括: サーバ屋が生成AIのAPIを触ってみて

4記事の累計消費リクエストはチャット生成:34リクエスト、ベクトル埋め込み:4リクエスト、音声文字起こし:13リクエスト、音声の読み上げ:12リクエスト。無償枠の数%で、セットアップからスマホで動く実用アプリまで実践できました。

image.png

振り返って一番大きな学びは、生成AIのAPIは「特別な何か」ではなく「HTTPで話す外部サービス」だったことです。つまずきの原因も、証明書・環境変数・設定ファイルといった、いつものサーバ運用と同じようなものでした(③つまずき解決編を参照)。そして最終回、言語をPHPからGASに替えても同じ知識が活用できること、そのことを実践して証明できました。

「AIならでは」だったのは、応答が毎回同じとは限らないこと、思考型モデルのような新しい挙動があること。ただそれも「finish_reasonを確認する」といった事実ベースの対処で乗り越えられました。障害調査と同じ姿勢です。

これから始める方は、①セットアップ編→②全機能ためす編→④まとめ編と進む前に、③つまずき解決編に目を通しておくと、同じトラブルを避けて半日でここまで来られます。この連載が「さくらのAI最初の一歩」として活用してもらえれば嬉しいです。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?