はじめに
シリーズ最終回です。①セットアップ編、②全機能ためす編、③つまずき解決編と順を追って進めてきました。最終回はこの学びを全部使って、スマホで使える実用ミニアプリを構築します。
| 各回のタイトル | 内容 |
|---|---|
| ①セットアップ編 | 契約〜アカウントトークン発行〜最初の1リクエスト |
| ②全機能ためす編 | チャット生成/ベクトル埋め込み/音声の文字起こし/音声の読み上げ |
| ③つまずき解決編 | 実際に遭遇したエラーと解決法FAQ |
| ④まとめ編(本記事) | 30分で作るミニアプリ+無償枠の賢い使い方 |
構築するのは「ボイスメモ整理アプリ」、使い方は次の通りです。
- スマホの録音アプリで思いつきを録音し、Gドライブの「VoiceMemo」フォルダに保存
初期画面(Gドライブの「VoiceMemo」フォルダに音声ファイルがない場合)

※ページ上部に警告のようなメッセージが表示されるが、Googleがフィッシング対策として表示している警告で、「一般ユーザーが作ったWebアプリですよ」と閲覧者に知らせる仕組みです。エラーではなく仕様で、設定では消せません。 - スマホのブラウザで自分専用のWebアプリを開き、音声を選んでタップ

※選択した音声ファイルの処理中のメッセージが表示されます - 数十秒待つと、文字起こし→内容を要約→TODO抽出が表示され、要約を音声で読み上げ(1x/1.5x/2x切替可)。要約はMarkdown形式でGドライブに自動保存

※一連の処理が完了すると、要約と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プロジェクトの作成
- Googleアカウントにログインした状態でscript.google.com を開き「新しいプロジェクト」を作成
-
コード.gsに後述のCode.gsを貼り付け
- 「+」→「HTML」で
indexという名前のファイルを作り、後述のindex.htmlを貼り付け
2. アカウントトークンをスクリプトプロパティに保存
②全機能ためす編ではアカウントトークンをconfig.phpに分離しました。GASでの同じ役割がスクリプトプロパティです。コードにキーを直書きしない、という原則はプログラミング言語が変わっても同じです。
3. ウェブアプリとしてデプロイ
- 「デプロイ」→「新しいデプロイ」→ 種類「ウェブアプリ」を選択
-
「次のユーザーとして実行: 自分」「アクセスできるユーザー: 自分のみ」 を選択し、「デプロイ」ボタンを押す
- 「アクセスを承認」ボタンを押す
- 別ウィンドウが開くので、自分のGドライブと外部サービスへのアクセスを選択し続行する
※3の画面に戻ったら、「完了」ボタンを押す
- 表示されたウェブアプリURLをスマホのブラウザで開く(ホーム画面に追加しておくとアプリ感が出ます)
コード全文
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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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の使い方」だった、ということです。
発展課題
- 自動化: GASの時間設定トリガーでVoiceMemoフォルダを定期監視し、新着音声を自動処理する(例えば、朝の通勤中に録音→昼には要約が確認できるようになっているなど)
- 過去ボイスメモの類似検索: ②全機能ためす編で体験したベクトル埋め込みを利用し「この件、前もメモしなかったっけ?」を検索できるようにする
- 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リクエスト。無償枠の数%で、セットアップからスマホで動く実用アプリまで実践できました。
振り返って一番大きな学びは、生成AIのAPIは「特別な何か」ではなく「HTTPで話す外部サービス」だったことです。つまずきの原因も、証明書・環境変数・設定ファイルといった、いつものサーバ運用と同じようなものでした(③つまずき解決編を参照)。そして最終回、言語をPHPからGASに替えても同じ知識が活用できること、そのことを実践して証明できました。
「AIならでは」だったのは、応答が毎回同じとは限らないこと、思考型モデルのような新しい挙動があること。ただそれも「finish_reasonを確認する」といった事実ベースの対処で乗り越えられました。障害調査と同じ姿勢です。
これから始める方は、①セットアップ編→②全機能ためす編→④まとめ編と進む前に、③つまずき解決編に目を通しておくと、同じトラブルを避けて半日でここまで来られます。この連載が「さくらのAI最初の一歩」として活用してもらえれば嬉しいです。

