先に結論
- VOICEVOXの
/audio_queryは、読み上げテキストをURLのクエリ文字列で渡す仕様 - 日本語はURLエンコードで1文字あたり約9バイトに膨らむ
- 実測では1,800文字は通り、3,000文字でHTTP 500になった
- 対処は120文字ごとのチャンク分割+次チャンクの先読み合成。これで1,512文字を途切れなく読み切れた
- そして本当の敵は500エラーではなく、「失敗を黙ってブラウザ標準音声にフォールバックする」自分のコードだった
Claude Codeの応答を自動で読み上げるツールを自作していたときの話。短い文章は問題なくVOICEVOXの声で読み上げるのに、長い文章を渡すと何も言わずにブラウザ標準の声にすり替わる。VOICEVOXには繋がっているはずなのに、なぜか標準音声が出る。
原因はVOICEVOXのAPI仕様と、自分のコードの両方にあった。この記事はその実測結果と対処法をまとめたもの。
症状:長文だけブラウザ標準の声になる
自作のHTMLツールから、ローカルで動かしているVOICEVOXに接続してテキストを読み上げさせていた。短いテキストなら狙い通りVOICEVOXの声が出る。ところが長い文章を入力すると、エラーも出ないまま、いつの間にかブラウザの speechSynthesis(標準音声)に切り替わっている。
コンソールを見てもVOICEVOX側への接続自体は成功しているように見える。原因が読み取れないまま、しばらくこの症状に振り回された。
原因:URLの長さ
VOICEVOXの /audio_query は、読み上げたいテキストをURLのクエリ文字列で渡す仕様になっている。
POST /audio_query?speaker=2&text=<URLエンコードしたテキスト>
ここに日本語の罠がある。日本語1文字はURLエンコードすると約9バイトに膨らむ。たとえば「あ」は %E3%81%82 になる。3バイトの文字が、パーセントエンコードによって9文字(9バイト)の文字列に変わる計算だ。つまり、文字数の9倍程度のバイト数がリクエスト行に乗ることになる。
実測結果
実際に文字数を変えてリクエストを送り、結果を記録した。
| 文字数 | URLのバイト数 | 結果 |
|---|---|---|
| 120 | 1,130 | 200 OK |
| 492 | 4,478 | 200 OK |
| 900 | 8,150 | 200 OK |
| 1,800 | 16,250 | 200 OK |
| 3,000 | 27,050 | HTTP 500 |
| 4,992 | 44,978 | HTTP 500 |
1,800文字までは通り、3,000文字で落ちる。上限はこの間のどこかにあるということしか言えない。正確な値は特定していない。サーバー側やOSのURL長制限、VOICEVOXのバージョンによっても変わりうる数値なので、この記事の数字を絶対的な閾値として扱わないでほしい。
症状が分かりにくかった本当の理由
500エラーが返ること自体は、ある意味では普通の話だ。厄介だったのは、そこから先のコードの作りが症状を分かりにくくしていたこと。
自分のコードには、/audio_query のレスポンスに対する res.ok のチェックが入っていなかった。そのため、サーバーがエラーを返してきても構わずJSONとして読み取り、そのまま次の /synthesis に渡していた。当然 /synthesis 側も失敗し、例外が catch に落ちる。そして catch の中で、何も表示せずにブラウザ標準の音声へフォールバックする処理を書いていた。
// 修正前(症状を隠していた書き方のイメージ)
try {
const queryRes = await fetch(`/audio_query?speaker=${speaker}&text=${encodeURIComponent(text)}`, { method: "POST" });
const query = await queryRes.json(); // res.ok を見ていない
const audio = await fetch(`/synthesis?speaker=${speaker}`, {
method: "POST",
body: JSON.stringify(query),
});
// ...
} catch (e) {
speakWithBrowserVoice(text); // 黙ってフォールバック
}
res.ok を確認していれば、その時点でエラーだと気づけたはずだった。
const queryRes = await fetch(`/audio_query?speaker=${speaker}&text=${encodeURIComponent(text)}`, { method: "POST" });
if (!queryRes.ok) {
throw new Error(`audio_query failed: ${queryRes.status}`);
}
結果として、「VOICEVOXには繋がっているのに標準の声が出る」という、原因を追いにくい症状になっていた。フォールバックは一見親切な設計だが、失敗した理由を隠してしまう。失敗は失敗として画面に出したほうが、結局は早く解決できる。
親切のつもりで挟んだ回避処理が、あとで自分の時間を奪う。これは家の仕組みづくりでもよく起きることで、「とりあえず動くようにしておく」が、原因を突き止める工程をまるごと未来の自分に押し付けている。
対処:分割して合成する
根本の対処は、長文をそのまま /audio_query に投げないこと。短いチャンクに分割してから、順番に合成・再生する。
分割の方針は次の通り。
- 句読点(。!?)を区切り候補として文の切れ目を探す
- 1チャンクは最大120文字。URLエンコードしても約1,100バイトで、上限に対して十分な余裕がある
- 1文がそれ自体で120文字を超える場合は、読点(、)や空白で切る
- ただし切る位置が早すぎると不自然な間ができるので、上限の4割より手前では切らない
擬似コードにすると、おおよそこういう流れになる。
function splitIntoChunks(text, maxLen = 120) {
const chunks = [];
let rest = text;
while (rest.length > 0) {
if (rest.length <= maxLen) {
chunks.push(rest);
break;
}
// maxLenの4割より手前は候補にしない
const searchStart = Math.floor(maxLen * 0.4);
const window = rest.slice(0, maxLen);
const cutAt = findLastBreak(window, searchStart); // 。!?、空白の順で探す
chunks.push(rest.slice(0, cutAt));
rest = rest.slice(cutAt);
}
return chunks;
}
再生側にも工夫がいる。チャンクを1つずつ「合成してから再生」を順に繰り返すと、合成にかかる時間ぶんだけ音が途切れる。そこで、再生中に次のチャンクの合成を先読みしておく。これをしないと、チャンクの繋ぎ目ごとに無音が入ってしまう。
検証結果
1,512文字のテキストを使って、実際にこの分割方式を試した。
- 16チャンクに分割された
- 最大チャンク長は111文字(URLエンコードで923バイト)
- 16チャンクすべてが合成に成功した
- 合成にかかった時間は合計58.4秒、生成された音声は約10MB
- 分割後のテキストを結合すると、元の本文と一致した(欠落なし)
先読みの安全性についても確認できた。合成にかかった時間(58.4秒)は、生成された音声の合計再生時間より短い。つまり、次のチャンクの合成は常に前のチャンクの再生時間内に終わっており、先読みが再生に追いつかれることはない。
まとめ
- VOICEVOXの
/audio_queryはテキストをURLに載せる仕様で、日本語はURLエンコードによって約9倍のバイト数に膨らむ - 1,800文字は通り、3,000文字で500エラーになった。上限の正確な値は特定していないが、この間のどこかにある
- 長文はそのまま送らず、120文字程度のチャンクに分割してから合成・再生する。先読みで再生時間を稼げば途切れない
res.okを確認せず、失敗時に黙ってフォールバックする作りは、原因の分かりにくい症状を生む。失敗は失敗として表に出したほうがいい
この記事のシクミ
潰した工程:「長文を人間が手で区切って、何回も読み上げボタンを押す」
長文がエラーになるなら、人が短く切って渡せばいい——それは解決ではなく、工程を自分に押し付けているだけ。120文字での自動分割と先読み合成を仕込んだことで、こちらは長文をそのまま投げるだけでよくなった。
もうひとつ潰した工程:「なぜ動かないのかを調べる時間」
失敗したときに黙って別の手段へ逃げる作りは、原因調査という重い工程を未来の自分に丸投げする。res.ok を1行見て、失敗をその場で画面に出すようにしておく。仕組みは、うまくいく道より失敗が見える道を先に用意しておいたほうが長持ちする。

コメント