Webサイトでマイクの音を扱ったり、動的に音を生成したりできたら、どんなに面白いアプリケーションが作れるでしょうか?
この記事では、特別なツールを一切使わず、Webブラウザだけで完結するリアルタイム音声処理の世界へご案内します。Googleが提供する無料の開発環境 「Google Apps Script」 を使い、マイクの音を録音・再生するウェブアプリケーションをゼロから作り上げます。
最終的なゴールは、Googleの最新AIであるGeminiとリアルタイムで音声対話できるアプリケーションを、あなた自身の手で作り上げること(後編)です。
この記事は、音声処理技術を学ぶ2部構成の【前編】です。ステップ・バイ・ステップ形式で進みます。一つひとつの技術を積み上げていくことで、音声処理の根本的な仕組みを理解できます。さあ、始めましょう!
この記事であなたが得られること:
- サーバー費用ゼロでウェブアプリケーションを公開する方法
- ブラウザでマイクの音を録音・再生する仕組みの深い理解
- UIをブロックしない、高度な音声処理の基礎知識
さあ、あなたのブラウザを最強の開発ツールに変える旅を始めましょう!
はじめに:5分で完了!ブラウザだけでウェブアプリを公開しよう
最初のステップとして、開発環境の準備も兼ねて、Google Apps Scriptを使い簡単なWebページをインターネットに公開してみましょう。
1. Google Apps Script プロジェクトを作成する
- Googleドライブを開き、「新規」 > 「その他」 > 「Google Apps Script」を選択します。
- 無題のプロジェクトが開くので、好きな名前(例:「リアルタイム音声処理入門」)を付けます。
2. Webページを表示するコードを書く
エディタに最初から書かれている function myFunction() { ... } をすべて消して、以下のコードに書き換えてください。
function doGet() {
// 'index.html'という名前のHTMLファイルをWebページとして表示する
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('リアルタイム音声処理入門')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
3. 表示するHTMLファイルを作成する
次に、Webページ本体となるHTMLファイルを作成します。
- エディタの左側にある「ファイル」の横の「+」アイコンをクリックし、「HTML」を選択します。
- ファイル名を「index」と入力してEnterキーを押します(.htmlは不要です)。
- 新しく開かれた index.html の中身を、以下の完成版コードにすべて書き換えてください。 このファイルに、アプリの見た目(HTML/CSS)と動作(JavaScript)のすべてを記述していきます。
index.html(完成版)を開く
<!DOCTYPE html>
<html>
<head>
<base target="_top">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>リアルタイム音声処理入門</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding: 1em 2em;
line-height: 1.6;
background-color: #f4f4f9;
color: #333;
}
.container {
max-width: 800px;
margin: 0 auto;
background-color: #fff;
padding: 2em;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #4a4a4a;
border-bottom: 2px solid #78909c;
padding-bottom: 0.5em;
}
h2 {
color: #546e7a;
border-left: 5px solid #78909c;
padding-left: 0.8em;
}
button {
background-color: #546e7a;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1em;
margin: 5px;
transition: background-color 0.3s, transform 0.1s;
}
button:hover {
background-color: #455a64;
}
button:active {
transform: scale(0.98);
}
button:disabled {
background-color: #b0bec5;
cursor: not-allowed;
}
.status {
margin-top: 1em;
padding: 1em;
background-color: #eceff1;
border-radius: 5px;
min-height: 1.6em;
}
#downloadLinkContainer {
margin-top: 1em;
}
</style>
</head>
<body>
<div class="container">
<h1>ブラウザだけで学ぶ!リアルタイム音声処理入門</h1>
<!-- ステップ1-1 & 1-2: ボイスレコーダーと再生 -->
<section>
<h2>ステップ1:ボイスレコーダー(録音・再生・保存)</h2>
<div>
<button id="startButton">録音開始</button>
<button id="stopButton" disabled>録音停止</button>
<button id="playButton" disabled>再生</button>
</div>
<div class="status" id="statusLabel">待機中...</div>
<div id="downloadLinkContainer"></div>
</section>
<!-- ステップ1-3: ストリーミング再生 -->
<section>
<h2>ステップ2:ストリーミング再生の仕組み</h2>
<button id="streamTestButton" disabled>ストリーミング再生テスト</button>
<div class="status" id="streamingStatusLabel">待機中...</div>
</section>
</div>
<script>
// --- グローバル変数と定数の定義 ---
let audioContext;
let recorderNode;
let audioStream;
let recordedChunks = [];
let recordedBlob = null; // 録音された音声データ(Blob)を保持
let recordedBuffer = null; // デコードされた音声データ(AudioBuffer)を保持
// --- DOM要素の取得 ---
const startButton = document.getElementById('startButton');
const stopButton = document.getElementById('stopButton');
const playButton = document.getElementById('playButton');
const streamTestButton = document.getElementById('streamTestButton');
const statusLabel = document.getElementById('statusLabel');
const downloadLinkContainer = document.getElementById('downloadLinkContainer');
const streamingStatusLabel = document.getElementById('streamingStatusLabel');
// --- イベントリスナーの設定 ---
startButton.addEventListener('click', startRecording);
stopButton.addEventListener('click', stopRecording);
playButton.addEventListener('click', playRecording);
streamTestButton.addEventListener('click', testStreamingPlayback);
/**
* ステップ1-1: 録音を開始する
*/
async function startRecording() {
try {
// 録音の初期化
statusLabel.textContent = 'マイクへのアクセスを許可してください...';
recordedChunks = [];
playButton.disabled = true;
streamTestButton.disabled = true;
downloadLinkContainer.innerHTML = '';
// AudioContextの初期化 (ユーザー操作後に実行する必要がある)
if (!audioContext) {
audioContext = new AudioContext();
}
// マイクへのアクセスを要求し、音声ストリームを取得
audioStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true } // エコーキャンセルを有効化
});
statusLabel.textContent = 'プロセッサーを準備中...';
// AudioWorklet用のプロセッサーを定義
// 本来は別ファイルですが、Apps Scriptの制約上、文字列からBlobを作成して読み込みます
const processorCode = `
class RecorderProcessor extends AudioWorkletProcessor {
// processメソッドはリアルタイムで音声データを受け取る
process(inputs, outputs, parameters) {
// inputs[0][0] にFloat32Array形式の音声データ(PCM)が入っている
const pcmData = inputs[0][0];
if (pcmData) {
// メインスレッドにデータを送る
this.port.postMessage(pcmData);
}
// 処理を続けるためにtrueを返す
return true;
}
}
registerProcessor('recorder-processor', RecorderProcessor);
`;
const processorBlob = new Blob([processorCode], { type: 'application/javascript' });
const processorUrl = URL.createObjectURL(processorBlob);
// AudioWorkletプロセッサーを追加
await audioContext.audioWorklet.addModule(processorUrl);
// AudioWorkletNodeを作成
recorderNode = new AudioWorkletNode(audioContext, 'recorder-processor');
// マイクからのストリームをAudioWorkletNodeに接続
const source = audioContext.createMediaStreamSource(audioStream);
source.connect(recorderNode);
// recorderNode.connect(audioContext.destination); // 自分の声をモニタリングする場合はコメントを外す
// Workletから送られてくる音声データを配列に溜める
recorderNode.port.onmessage = (event) => {
// event.dataはFloat32Array
// メモリを節約するために、ここでは参照をコピーするのではなく、データをコピーする
recordedChunks.push(new Float32Array(event.data));
};
// UIの更新
startButton.disabled = true;
stopButton.disabled = false;
statusLabel.textContent = '録音中...';
} catch (err) {
console.error('録音の開始に失敗しました:', err);
statusLabel.textContent = `エラー: ${err.message}`;
startButton.disabled = false;
}
}
/**
* ステップ1-1: 録音を停止する
*/
function stopRecording() {
if (audioStream) {
// マイクのトラックを停止して、マイク使用中のインジケーターを消す
audioStream.getTracks().forEach(track => track.stop());
}
if (recorderNode) {
// ノードの接続を解除
recorderNode.disconnect();
recorderNode.port.close();
recorderNode = null;
}
statusLabel.textContent = '録音を停止しました。WAVファイルを生成中...';
// 録音されたチャンクからWAVファイル(Blob)を生成
recordedBlob = createWavBlob(recordedChunks, audioContext.sampleRate);
// 生成されたBlobから再生用のAudioBufferも作成しておく
recordedBlob.arrayBuffer().then(arrayBuffer => {
return audioContext.decodeAudioData(arrayBuffer);
}).then(decodedData => {
recordedBuffer = decodedData;
statusLabel.textContent = `録音完了! (${(recordedBlob.size / 1024).toFixed(2)} KB)`;
// UIの更新
playButton.disabled = false;
streamTestButton.disabled = false;
createDownloadLink(recordedBlob);
}).catch(err => {
console.error('デコードエラー:', err);
statusLabel.textContent = 'デコードに失敗しました。';
});
// UIの更新
startButton.disabled = false;
stopButton.disabled = true;
}
/**
* ステップ1-2: 録音した音声を再生する
*/
function playRecording() {
if (!recordedBuffer) {
statusLabel.textContent = '再生するデータがありません。';
return;
}
statusLabel.textContent = '再生中...';
const source = audioContext.createBufferSource();
source.buffer = recordedBuffer;
source.connect(audioContext.destination);
source.start(0);
source.onended = () => {
statusLabel.textContent = '再生終了';
};
}
// --- ヘルパー関数 ---
/**
* ステップ1-1: WAVファイル(Blob)を生成する
*/
function createWavBlob(audioChunks, sampleRate) {
// 複数のFloat32Arrayチャンクを1つのFloat32Arrayに結合
const totalLength = audioChunks.reduce((acc, val) => acc + val.length, 0);
const merged = new Float32Array(totalLength);
let offset = 0;
for (const chunk of audioChunks) {
merged.set(chunk, offset);
offset += chunk.length;
}
// Float32 (-1.0 to 1.0) を Int16 (-32768 to 32767) に変換
const pcm16 = new Int16Array(totalLength);
for (let i = 0; i < totalLength; i++) {
let s = Math.max(-1, Math.min(1, merged[i]));
pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
}
// WAVヘッダを作成
const buffer = new ArrayBuffer(44 + pcm16.length * 2);
const view = new DataView(buffer);
// WAVヘッダの書き込み
// RIFFチャンク
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + pcm16.length * 2, true);
writeString(view, 8, 'WAVE');
// fmtチャンク
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); // fmtチャンクのサイズ
view.setUint16(20, 1, true); // フォーマットID (1=PCM)
view.setUint16(22, 1, true); // チャンネル数 (1=モノラル)
view.setUint32(24, sampleRate, true); // サンプリングレート
view.setUint32(28, sampleRate * 2, true); // データ速度 (Byte/sec)
view.setUint16(32, 2, true); // ブロックサイズ (Byte/sample)
view.setUint16(34, 16, true); // サンプルあたりのビット数
// dataチャンク
writeString(view, 36, 'data');
view.setUint32(40, pcm16.length * 2, true);
// PCMデータを書き込み
for (let i = 0; i < pcm16.length; i++) {
view.setInt16(44 + i * 2, pcm16[i], true);
}
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
return new Blob([view], { type: 'audio/wav' });
}
/**
* ステップ1-1: ダウンロードリンクを作成する
*/
function createDownloadLink(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'recording.wav';
a.textContent = '録音したWAVファイルをダウンロード';
downloadLinkContainer.innerHTML = '';
downloadLinkContainer.appendChild(a);
}
// --- ステップ1-3: ストリーミング再生の仕組み ---
const audioQueue = [];
let isPlayingStream = false;
function addToQueue(audioBuffer) {
audioQueue.push(audioBuffer);
if (!isPlayingStream) {
playFromQueue();
}
}
function playFromQueue() {
if (audioQueue.length === 0) {
isPlayingStream = false;
streamingStatusLabel.textContent = 'ストリーミング再生終了';
return;
}
isPlayingStream = true;
const audioBuffer = audioQueue.shift(); // キューの先頭を取り出す
streamingStatusLabel.textContent = `再生中... (キューの残り: ${audioQueue.length})`;
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.start(0);
// 再生が終わったら、次の再生処理を呼ぶ
source.onended = playFromQueue;
}
/**
* ストリーミング再生をテストする
*/
function testStreamingPlayback() {
if (!recordedBuffer) {
streamingStatusLabel.textContent = 'テスト用のデータがありません。まず録音してください。';
return;
}
streamingStatusLabel.textContent = 'ストリーミングのテストを開始します...';
// 録音したデータを0.5秒ずつのチャンクに分割してキューに追加する
const chunkSize = audioContext.sampleRate * 0.5; // 0.5秒分のサンプル数
const totalSamples = recordedBuffer.length;
let offset = 0;
let chunkIndex = 0;
function processNextChunk() {
if (offset >= totalSamples) {
return; // すべてのチャンクを処理した
}
const remainingSamples = totalSamples - offset;
const currentChunkSize = Math.min(chunkSize, remainingSamples);
// 新しいAudioBufferを作成してチャンクをコピー
const chunkBuffer = audioContext.createBuffer(1, currentChunkSize, audioContext.sampleRate);
const originalData = recordedBuffer.getChannelData(0);
const chunkData = chunkBuffer.getChannelData(0);
for (let i = 0; i < currentChunkSize; i++) {
chunkData[i] = originalData[offset + i];
}
// キューに追加
addToQueue(chunkBuffer);
streamingStatusLabel.textContent = `チャンク ${++chunkIndex} をキューに追加しました。`;
offset += currentChunkSize;
// 300ms後に次のチャンクを処理(APIからの受信をシミュレート)
setTimeout(processNextChunk, 300);
}
processNextChunk();
}
</script>
</body>
</html>
4. ウェブアプリとして公開する
いよいよ公開です。
- 画面右上の青い「デプロイ」ボタンをクリックし、「新しいデプロイ」を選択します。
- 歯車アイコンの横にある「種類の選択」をクリックし、「ウェブアプリ」を選択します。
- 「アクセスできるユーザー」を「全員」に設定します。(重要: これを「全員」にしないと、他の人がアクセスできません。)
- 「デプロイ」ボタンをクリックします。初回はアクセス許可を求められるので、指示に従って許可してください。
- 表示されたウェブアプリのURLをコピーして、ブラウザの新しいタブで開いてみましょう。
これで、すべての機能が実装されたボイスレコーダーアプリが動作するはずです!
遊び方
公開したウェブアプリで、以下の手順を試してみてください。コードの具体的な動作はこの後で説明します。
ステップ1:ボイスレコーダー(録音・再生・保存)
- 「録音開始」ボタンを押す: ブラウザからマイクへのアクセス許可を求められます。「許可」してください。ステータスが「録音中...」に変わります。
- 何か話してから「録音停止」ボタンを押す: ステータスが「WAVファイルを生成中...」に変わり、すぐに「録音完了!」と表示されます。
- 「再生」ボタンを押す: 録音した自分の声が聞こえるはずです。
- ダウンロードリンクをクリックする: recording.wavというファイルがダウンロードされ、PCの音楽プレイヤーなどで再生できます。
ステップ2:ストリーミング再生の仕組み
- 「ストリーミング再生テスト」ボタンを押す: 先ほど録音した音声が、少しずつ途切れ途切れに聞こえ始めるかもしれませんが、最終的にはシームレスに再生されます。ステータス表示が目まぐるしく変わる様子にも注目してください。
原理を学ぶ:コードと仕組みの解説
ここからは、このアプリがどのような仕組みで動いているのか、コードの裏側を詳しく見ていきましょう。
ステップ1:ボイスレコーダーを作る(録音・再生・保存)
このステップでは、マイクで録音した音声を、再生可能な.wavファイルとしてダウンロードできるアプリを作りました。
学ぶ技術
-
navigator.mediaDevices.getUserMedia: マイクへのアクセス -
AudioWorklet: 高効率なリアルタイム音声処理 - 音声フォーマット変換 (Float32 → Int16 PCM)
- WAVファイル生成
1. マイクへのアクセス許可を得る
すべての始まりは、ユーザーにマイクの使用許可を求めることです。startRecording関数内の以下のコードがその役割を担います。
// マイクへのアクセスを要求し、音声ストリームを取得
audioStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true } // エコーキャンセルを有効化
});
navigator.mediaDevices.getUserMediaを使うと、ブラウザに許可ダイアログが表示されます。ユーザーが許可すると、マイクからの音声がストリームとして得られます。このストリームは、マイクから入ってくる音声データがリアルタイムで流れ続ける「川」のようなものです。
2. AudioWorkletで音声データを受け取る
受け取ったストリームを処理するために、AudioWorklet という仕組みを使います。これは、UIの描画などを担当するメインスレッドとは別の、専用のスレッドで音声処理を行うための仕組みです。これにより、重い音声処理を行ってもページの動作がカクつくのを防げます。
今回のコードでは、Apps Scriptの制約上、JavaScriptのコードを文字列として定義し、それをBlobに変換して動的に読み込んでいます。
// AudioWorklet用のプロセッサーを定義
const processorCode = `
class RecorderProcessor extends AudioWorkletProcessor {
process(inputs, outputs, parameters) {
const pcmData = inputs[0][0]; // Float32Array形式の音声データ
if (pcmData) {
this.port.postMessage(pcmData); // メインスレッドにデータを送る
}
return true; // 処理を続ける
}
}
registerProcessor('recorder-processor', RecorderProcessor);
`;
// ... Blobに変換して読み込み ...
processメソッドは、マイクから音声データが届くたびに(通常は128サンプルごとに)繰り返し呼び出されます。ここで受け取った生データ (Float32Array) をpostMessageでメインスレッドに送り返し、recordedChunks配列に溜め込んでいきます。
3. データを蓄積し、WAVファイルに変換
録音が終了すると、stopRecording関数がcreateWavBlobを呼び出します。WAVファイルを作るには、2つの要素が必要です。
- PCMデータ: 音声の波形データ。マイクから得られる
Float32(-1.0~1.0)形式を、WAVで一般的なInt16(-32768~32767)形式に変換する必要があります。 - WAVヘッダ: ファイルの種類、サンプリングレート、データサイズなどを示す情報。
createWavBlob関数では、溜め込んだrecordedChunksを結合し、Int16に変換してから44バイトのヘッダを付与し、Blobオブジェクト(ファイルの実体データ)を生成します。
ステップ2:ストリーミング再生の仕組みを理解する
このステップでは、連続して届く音声データを途切れなく再生する「ストリーミング」の概念を、テスト機能を通じて実装レベルで理解します。
これは【後編】でAIからの応答をリアルタイムで再生するための非常に重要な布石となります。
学ぶ技術
- 再生キューの重要性
-
onendedイベントを利用した再生スケジューリング
1. なぜストリーミングが必要か?
これまでは、録音がすべて完了した「1つの大きな音声データ」を再生していました。しかし、音声対話のように、相手からの応答が断続的に送られてくる場合、すべてのデータが揃うのを待っていてはリアルタイム性が損なわれます。そこで、データが届いたそばから再生していくストリーミング再生の仕組みが必要になります。
2. 再生キューとスケジューリング
ストリーミング再生の鍵は、「再生キュー」と「再生スケジューリング」です。
- 再生キュー (
audioQueue): APIなどから送られてきた音声チャンク(AudioBuffer)を一時的に保持しておくための配列です。「次に再生する音声の待機列」の役割を果たします。 - 再生スケジューリング (
playFromQueue):再生中の音声がない時に、キューから最初のチャンクを取り出して再生します。
AudioBufferSourceNodeには、再生が終了した時に発火するonendedイベントがあります。
このイベントハンドラ内で、「もしキューに次のチャンクがあれば、それを取り出して再生する」という処理を自分自身で再度呼び出します。
この再帰的な呼び出しによって、前の音声が終わり次第、次の音声が再生予約され、シームレスな再生が実現できます。
function playFromQueue() {
if (audioQueue.length === 0) {
// キューが空なら終了
isPlayingStream = false;
return;
}
isPlayingStream = true;
const audioBuffer = audioQueue.shift(); // キューの先頭を取り出す
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioContext.destination);
source.start(0);
// 再生が終わったら、次の再生処理を呼ぶ
source.onended = playFromQueue;
}
この仕組みを理解すれば、【後編】でGeminiから送られてくる音声ストリームを滑らかに再生する準備は万端です。
【前編】のまとめと次回予告
お疲れ様でした!この記事では、Google Apps Scriptだけでウェブアプリを公開する方法を学び、Web Audio APIを使って音声を録音・再生する基本的な仕組みを実装しました。
【前編】で完成したこと:
- Google Apps Scriptによるサーバーレスなウェブアプリの公開
- マイクから音声を取得し、WAVファイルとして保存する機能
- 録音した音声をブラウザ上で再生する機能
- リアルタイム通信の基礎となるストリーミング再生のロジック
これで、音声処理の土台は完璧です。
【後編】では、いよいよAIとの連携に挑戦します。
今回作成した録音・再生の仕組みを応用し、GoogleのGemini APIと接続することで、マイクに向かって話しかけるとAIが音声で応答する、完全なリアルタイム音声対話アプリケーションを完成させます。ご期待ください!
参考情報
