Loading post
Jul 21, 2026

僕のポートフォリオサイト(meetyudai.com)に、マイクに向かって話しかけると日本語音声で返事が返ってくるボイスチャット機能を作りました。音声合成にはVOICEVOXを使っています。
ポートフォリオのネタとしては印象に残りやすいわりに、VOICEVOXをクラウドで動かす日本語の実践記事は意外と少なかったので、構成・実装・ハマりどころ・コストまでまとめます。
VOICEVOXは無料で使える日本語音声合成ソフトです。「ずんだもん」「四国めたん」などのキャラクターボイスが揃っていて、品質は商用TTSに引けを取りません。重要なのは、GUIアプリとは別にHTTP APIを持つ「エンジン」が公式Dockerイメージで配布されていること。つまりサーバーサイドから普通のREST APIとして叩けます。
speaker ID(スタイルID)で指定します。例: ずんだもん(ノーマル)= 3、四国めたん(ノーマル)= 2 など。IDはGET /speakersで一覧できますVOICEVOX:ずんだもん)が必要です。規約はキャラクターごとに異なるので公式サイトで確認してください登場人物は4つです。ブラウザ、Firebase Functions(API)、OpenAI(音声認識と応答生成)、そしてCloud Run上のVOICEVOXエンジン。
┌──────────┐ ①MP3録音をPOST ┌─────────────────┐
│ ブラウザ │ ───────────────▶ │ Firebase Functions │
│ (Next.js) │ │ /voice-chat │
└──────────┘ └────────┬─────────┘
▲ │
│ ②Whisperで文字起こし
│ ③ChatGPTで応答テキスト生成
│ │
│ ▼
│ ┌─────────────────┐
│ ⑤WAV(base64)を返却 │ VOICEVOX Engine │
└────────────────────── │ (Cloud Run) │
│ ④audio_query │
│ → synthesis │
└─────────────────┘
流れを言葉にすると:
<audio>で再生「なぜ全部クラウドAPIにしないのか?」と思うかもしれません。OpenAIのTTSでも音声は返せます。でも日本語の「キャラクターとして愛せる声」はVOICEVOXの独壇場で、ポートフォリオの個性という目的にはこちらが正解でした。
クラウドに載せる前に、手元でエンジンの挙動を掴んでおくと後がずっと楽です。VOICEVOXエンジンはDocker一発で起動します:
# CPU版エンジンをローカル起動(ポート50021)
docker run --rm -p 50021:50021 voicevox/voicevox_engine:cpu-ubuntu20.04-latest
起動したら、ブラウザで http://localhost:50021/docs を開いてみてください。Swagger UI(OpenAPIドキュメント)が同梱されているので、全エンドポイントをブラウザから試せます。APIの学習はここが最短ルートです。
curlで2段階APIを手で叩くとこうなります:
# ① audio_query: テキスト → 合成パラメータ(JSON)
curl -s -X POST \
"localhost:50021/audio_query?text=こんにちは、ようこそ&speaker=3" \
> query.json
# 中身を覗くと、モーラ・アクセント・話速などが全部JSONで見える
# jq '.speedScale, .accent_phrases[0]' query.json
# ② synthesis: 合成パラメータ → WAV
curl -s -X POST "localhost:50021/synthesis?speaker=3" \
-H "Content-Type: application/json" \
-d @query.json \
> hello.wav
# 再生(macOSならafplay、Linuxならaplayなど)
afplay hello.wav
数秒で「ずんだもんの声」が手元から出ます。この体験を先にやっておくと、パイプライン全体の中でどこが遅いか・何が失敗し得るかの土地勘がつきます。
ローカル検証のコツを3つ:
/docsのSwagger UIから試すか、スクリプト言語からencodeURIComponent相当を通してくださいquery.jsonを手で編集してspeedScaleを2.0にする、pitchScaleを上げる、などを試すと「間のJSONが調整ポイント」という設計が体感できますhttp://localhost:50021を直接向けておけば、クラウド課金ゼロで開発が完結します録音にはmic-recorder-to-mp3を使いました。MediaRecorder APIを生で叩くとブラウザごとの出力フォーマット差(Chromeはwebm、Safariはmp4…)に悩まされるので、「必ずMP3で出てくる」ライブラリに寄せた方が、後段のWhisperに渡すときに楽です。
'use client';
import { useState } from 'react';
import MicRecorder from 'mic-recorder-to-mp3';
const recorder = new MicRecorder({
bitRate: 128,
encoder: 'mp3',
numberOfChannels: 1, // モノラルで十分。ファイルサイズ半減
sampleRate: 44100,
});
const VoiceChat = () => {
const [isRecording, setIsRecording] = useState(false);
const [botAudioURL, setBotAudioURL] = useState('');
const startRecording = () =>
recorder.start().then(() => setIsRecording(true));
const stopRecording = () => {
recorder.stop().getMp3().then(async ([buffer, blob]) => {
const file = new File(buffer, 'input.mp3', { type: blob.type });
const formData = new FormData();
formData.append('audio', file, 'input.mp3');
const response = await fetch(`${API_BASE_URL}/voice-chat`, {
method: 'POST',
body: formData,
});
// base64のWAVをBlob URLにして<audio>で再生する
const base64Audio = await response.text();
const audioBlob = await fetch(
`data:audio/wav;base64,${base64Audio}`
).then((res) => res.blob());
setBotAudioURL(URL.createObjectURL(audioBlob));
setIsRecording(false);
});
};
return (
<div>
<button onClick={isRecording ? stopRecording : startRecording}>
{isRecording ? '録音停止' : '話しかける'}
</button>
{botAudioURL && <audio src={botAudioURL} controls autoPlay />}
</div>
);
};
細かい注意点:
autoPlayが効かない場合はユーザー操作起点でaudio.play()を呼ぶ設計にしますURL.revokeObjectURL()で解放するとメモリに優しいFirebase Functions側は「音声を受け取り、テキストにして、返事を考えて、声にする」パイプラインです。骨格はこうなります。
export const voiceChat = onRequest({ cors: true }, async (request, response) => {
const audioFile = (request as any).file; // multerでmultipartを処理
// ① Whisperで文字起こし
const transcript = await openai.audio.transcriptions.create({
file: fs.createReadStream(audioFile.path),
model: 'whisper-1',
language: 'ja', // 言語を固定すると精度も速度も上がる
});
// ② ChatGPTで応答テキストを生成
const chat = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content:
'あなたはYudaiのポートフォリオサイトの案内役です。' +
'2〜3文で短く、親しみやすく答えてください。' + // ←重要。長文はTTSが遅くなる
'経歴やプロジェクトについて聞かれたら簡潔に紹介してください。',
},
{ role: 'user', content: transcript.text },
],
});
const replyText = chat.choices[0].message.content ?? '';
// ③ VOICEVOXで音声合成(2段階API)
const wav = await synthesizeWithVoicevox(replyText, 3); // 3 = ずんだもん
// ④ base64で返す
response.set('Content-Type', 'audio/wav').status(200).send(wav.toString('base64'));
});
システムプロンプトの「短く答えて」は飾りではありません。応答テキストの長さがそのままTTS時間と再生待ち時間になるので、音声対話では「短く喋らせる」こと自体がレイテンシ設計です。
VOICEVOXのAPIが2段階なのは最初戸惑うところです。
async function synthesizeWithVoicevox(text: string, speaker: number): Promise<Buffer> {
// ステップ1: テキスト → 合成パラメータ(JSON)
const queryRes = await fetch(
`${VOICEVOX_URL}/audio_query?text=${encodeURIComponent(text)}&speaker=${speaker}`,
{ method: 'POST' },
);
const audioQuery = await queryRes.json();
// ここでJSONをいじると声を調整できる
audioQuery.speedScale = 1.1; // 少し早口に(会話のテンポ感)
audioQuery.pitchScale = 0.0; // 音高はデフォルト
audioQuery.intonationScale = 1.2; // 抑揚を少し強めに
audioQuery.prePhonemeLength = 0.1; // 音声先頭の無音を短く
// ステップ2: 合成パラメータ → WAV
const synthesisRes = await fetch(`${VOICEVOX_URL}/synthesis?speaker=${speaker}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(audioQuery),
});
return Buffer.from(await synthesisRes.arrayBuffer());
}
/audio_queryがテキストから「読み・アクセント・話速などの合成パラメータ」を作り、/synthesisがそのJSONから実際のWAVを作ります。間のJSONを触れば話速・抑揚・無音長を調整できる、よくできた設計です。読み間違いを直したいときも、このJSONのkanaやアクセント句を修正して/synthesisに渡せます。
/speakersエンドポイント「どの声にするか」は見た目以上に大事な設計判断です。ボイスチャットの第一印象は、ほぼ声のキャラクターで決まります。
利用できる話者はGET /speakersで一覧できます。返ってくるJSONはこんな形です(抜粋・簡略化):
[
{
"name": "四国めたん",
"speaker_uuid": "7ffcb7ce-....",
"styles": [
{ "name": "ノーマル", "id": 2 },
{ "name": "あまあま", "id": 0 },
{ "name": "ツンツン", "id": 6 },
{ "name": "セクシー", "id": 4 }
]
},
{
"name": "ずんだもん",
"speaker_uuid": "388f246b-....",
"styles": [
{ "name": "ノーマル", "id": 3 },
{ "name": "あまあま", "id": 1 },
{ "name": "ささやき", "id": 22 }
]
}
]
ここで押さえるべき構造は、「話者(キャラクター)」と「スタイル」の2階層になっていることです。APIのspeakerパラメータに渡すのは、実はキャラクターのIDではなくスタイルのIDです。同じずんだもんでも「ノーマル(3)」と「ささやき(22)」ではまったく別の声になります。
選定の実務的なポイント:
/docsから数種類合成して聴き比べるのがおすすめですspeakerを環境変数や設定docに置いておくと、デプロイなしで差し替えられます/speakersの結果はキャッシュしてOK。 エンジンのバージョンを上げない限り変わらないので、毎回叩く必要はありませんVOICEVOX:ずんだもんの表記を置きます。キャラクターごとに利用規約が異なるため、採用を決めた時点で公式の規約ページを確認する習慣をつけると安全ですVOICEVOXエンジンは公式のDockerイメージが配布されているので、これをGCPのArtifact Registryにpushして、Cloud Runで動かしました。
# 公式イメージを取得してArtifact Registryにpush
docker pull voicevox/voicevox_engine:cpu-ubuntu20.04-latest
docker tag voicevox/voicevox_engine:cpu-ubuntu20.04-latest \
us-west1-docker.pkg.dev/MY_PROJECT/voicevox/engine:latest
docker push us-west1-docker.pkg.dev/MY_PROJECT/voicevox/engine:latest
# Cloud Runにデプロイ(CPU版・メモリ多め)
gcloud run deploy voicevox-engine \
--image us-west1-docker.pkg.dev/MY_PROJECT/voicevox/engine:latest \
--memory 2Gi --cpu 2 \
--port 50021 \
--no-allow-unauthenticated \
--region us-west1
<!-- TODO(Yudai): 実際のデプロイ時のリージョン・スペック・認証設定を思い出して確定させる -->
VOICEVOXエンジン自体には認証がありません。Cloud RunのURLを--allow-unauthenticatedで公開すると、誰でもあなたの課金で音声合成し放題になります。--no-allow-unauthenticatedにして、Functions側からIDトークン付きで呼ぶのが正解です:
import { GoogleAuth } from 'google-auth-library';
const auth = new GoogleAuth();
async function callVoicevox(path: string, init: RequestInit) {
// Cloud RunのURLをaudienceにしたIDトークンを自動取得
const client = await auth.getIdTokenClient(VOICEVOX_URL);
const headers = await client.getRequestHeaders();
return fetch(`${VOICEVOX_URL}${path}`, {
...init,
headers: { ...init.headers, ...headers },
});
}
Functionsのサービスアカウントにroles/run.invokerを付与すれば、鍵ファイルの管理なしでサービス間認証が完結します。
VOICEVOXエンジンのイメージは巨大(数GB)で、min-instances=0だと最初のリクエストで数十秒待たされます。選択肢は3つ:
| 対策 | コスト | 効果 |
|---|---|---|
| min-instances=1で常駐 | 月$15〜50程度(スペック次第) | コールドスタート消滅 |
| startup CPU boost + 第2世代実行環境 | ほぼ無料 | 起動が多少速くなる程度 |
| デモ前にwarmupリクエストを打つ | 無料 | ポートフォリオ用途なら現実解 |
僕のような「面接や共有の直前だけ動けばいい」用途なら、Cloud Schedulerで日中だけ15分おきにpingを打つ、あるいは手動でwarmupする運用で十分です。本気で常時提供するならmin-instances=1に課金します。
このパイプラインは外部依存が3つ(Whisper、ChatGPT、VOICEVOX)直列に並んでいるので、どれか1つが不調なら全体が落ちる構造です。しかも一番落ちやすいのが、コールドスタートを抱えたVOICEVOXです。何も対策しないと、エンジンが寝ているだけで「ボタンを押しても1分黙り込むサイト」になります。ポートフォリオで一番見せたい瞬間に、一番壊れて見える。
設計方針は一つです:音声はエンハンスメント、テキストがベースライン。プログレッシブエンハンスメントの考え方そのままで、「声が出せないなら、せめてテキストで返事する」に倒します。
まずタイムアウトを必ず入れます。fetchはデフォルトで際限なく待つので、AbortSignal.timeoutで上限を切ります:
async function synthesizeSafe(
text: string,
speaker: number,
): Promise<Buffer | null> {
try {
// コールドスタート込みでも待てる上限を設定。
// これを超えたら「今回は声なし」と割り切る
return await synthesizeWithVoicevox(text, speaker, {
signal: AbortSignal.timeout(8000),
});
} catch (err) {
// タイムアウトも接続エラーもここに落ちる。
// 音声合成の失敗は「会話の失敗」ではないので、エラーを外に投げない
console.warn('voicevox synthesis failed, falling back to text', err);
return null;
}
}
そしてAPIのレスポンス形式を「WAVそのもの」から「テキスト+音声(あれば)」に変えます:
// 応答は常にテキストを含む。音声は「付いていればラッキー」扱い
interface VoiceChatResponse {
replyText: string; // ChatGPTの応答。これは必ず返す
audioBase64: string | null; // VOICEVOXが失敗したらnull
}
const wav = await synthesizeSafe(replyText, SPEAKER_ID);
response.status(200).json({
replyText,
audioBase64: wav ? wav.toString('base64') : null,
});
フロントエンドはaudioBase64があれば再生し、nullならテキストを吹き出しで表示するだけ。ユーザーから見ると「今日は文字で返ってきた」であって「壊れている」ではない。この差は体験として非常に大きいです。
各段階の失敗の扱いを整理するとこうなります:
| 失敗箇所 | ユーザーへの見せ方 | 実装 |
|---|---|---|
| Whisper(文字起こし) | 「聞き取れませんでした、もう一度どうぞ」 | ここが落ちたら会話が成立しないので、素直にリトライを促す |
| ChatGPT(応答生成) | 同上(または定型の挨拶文を返す) | 応答が作れないのも会話の失敗。1回だけ自動リトライの価値あり |
| VOICEVOX(音声合成) | テキストのみで返答を表示 | 落としてもいい唯一の段階。タイムアウト+フォールバック |
もう一つ実務的な注意として、Functions側の関数タイムアウトを内側の合計より長くしておくこと。内側で8秒のタイムアウトを切っても、関数全体が10秒で強制終了する設定だと、フォールバック処理を実行する前に殺されます。「外側のタイムアウト > 内側のタイムアウトの合計+処理時間」が原則です。
3つのAPIを直列に呼ぶ構成では、「なんか遅い」は必ず起きます。そのときどの段階が遅いのかを即答できるログを最初から仕込んでおくと、調査時間が桁で変わります。
やることは単純で、各段階の所要時間を測って構造化ログで1行にまとめるだけです:
export const voiceChat = onRequest({ cors: true }, async (request, response) => {
const timings: Record<string, number> = {};
const stopwatch = (label: string) => {
const start = Date.now();
return () => (timings[label] = Date.now() - start);
};
let done = stopwatch('whisper');
const transcript = await transcribe(request);
done();
done = stopwatch('chat');
const replyText = await generateReply(transcript.text);
done();
done = stopwatch('voicevox');
const wav = await synthesizeSafe(replyText, SPEAKER_ID);
done();
// 1リクエスト=1行の構造化ログ。Cloud Loggingでそのままフィルタ・集計できる
console.log(JSON.stringify({
event: 'voice_chat_completed',
timings, // { whisper: 900, chat: 1400, voicevox: 1100 }
totalMs: Object.values(timings).reduce((a, b) => a + b, 0),
transcriptLength: transcript.text.length, // 内容ではなく長さだけ記録する
replyLength: replyText.length,
audioSynthesized: wav !== null, // フォールバック率の監視に使う
speaker: SPEAKER_ID,
}));
// ...
});
ログ設計のポイント:
transcriptLengthだけ記録します。プライバシーの観点でも、ログの取り扱いが一段軽くなりますaudioSynthesized: falseの率を見る。 これが上がってきたら、VOICEVOX側のコールドスタートか障害の兆候です。テキストフォールバックのおかげでユーザーからの報告は来ないので、ログだけが異変を知る手段になりますこの構成の面白いところは、監視すべきものが普通のWebアプリと少し違うことです。HTTPステータスは全部200でも、「音声なし応答率30%」なら体験としては半壊しています。ビジネスロジック上の成功(声が出たか)を明示的にログに落とすのが肝です。
このパイプラインは直列に3つのAPIを呼ぶので、体感は「話しかけて2〜5秒後に返事が来る」くらいです。
録音停止 → Whisper(〜1s) → ChatGPT(〜1-2s) → VOICEVOX(〜1s) → 転送・再生
コストの目安(1会話あたり):
会話として自然にするなら、次の改善は「文ごと合成」です。ChatGPTの応答をストリーミングで受け、句点で区切って文単位でVOICEVOXに流し、できたWAVから順次再生する。体感レイテンシは「最初の1文ができるまで」に短縮されます。
// 文単位ストリーミング合成のイメージ
let sentenceBuffer = '';
for await (const chunk of chatStream) {
sentenceBuffer += chunk.choices[0]?.delta?.content ?? '';
const match = sentenceBuffer.match(/(.+?[。!?])/);
if (match) {
enqueueSynthesis(match[1]); // この文だけ先にVOICEVOXへ
sentenceBuffer = sentenceBuffer.slice(match[1].length);
}
}
公開前に一通り見直した項目をチェックリストにしておきます。この構成は「無料で使えるエンジン」の裏に従量課金のAPIが2つ(OpenAI、Cloud Run)ぶら下がっているので、守るべきものは主に自分の財布です。
--no-allow-unauthenticatedになっているか。 エンジン自体は無認証なので、公開した瞬間に誰でも合成し放題になりますroles/run.invokerはFunctionsのサービスアカウントだけに絞ったか。 allUsersや広いロールが紛れ込んでいないかIAMを確認/voice-chatエンドポイントにレート制限を入れたか。 このエンドポイントは1回叩かれるごとにOpenAIの課金が発生します。IPあたり「1分にN回まで」の素朴な制限でも、スクリプトによる連打からは十分守れますcors: true(全許可)は開発中だけにして、本番はhttps://meetyudai.comのみ許可にどれも数分で終わる項目ですが、特にレート制限と課金アラートは「入れておけば事故が事件にならない」保険です。
docker run+curl+/docsのSwagger UIでローカル検証してから載せると、土地勘がついて後が速いaudio_query → synthesisの2段階。間のJSONが声のチューニングポイント/speakersで一覧できる。渡すIDは「キャラクター」ではなく「スタイル」のIDで、設定に逃がしておくと差し替えが楽ポートフォリオは「見る」ものが多いので、「話せる」はそれだけで印象に残りやすいと思います。日本語の音声合成をここまで気軽に使えるVOICEVOXプロジェクトに感謝しつつ、次は文単位ストリーミング合成をやってみる予定です。