ブログ記事を自動でショート動画にする作業の途中で、私はある一点で完全に手が止まりました。
PCのローカルで動く音声合成エンジン VOICEVOX のAPIに、ブラウザ経由でどうしても繋がらなかったのです。
エンジン単体では音声がちゃんと返るのに、ブラウザから叩いた瞬間、応答が返らず無言でハングする。
はじめは音声合成API側の設定を疑いました。
ところが切り分けてみると、犯人はまるで別のレイヤに潜んでいたと分かります。
ローカルで動くはずのAPIに、自分の実行環境から届かず固まった経験はないでしょうか。
今回ぶつかったのは2枚の壁で、どちらもAPI本体とは無関係でした。
結論から言えば、ローカルAPIに繋がらないとき最初に見るべきは、API設定ではなくどのオリジンから叩いているか、そしてデータを運ぶ経路に制限がないかという別レイヤになります。
壁①:ブラウザのオリジン制約 ― localhost APIに「繋がらない」本当の理由
まず押さえたいのは、ブラウザが「どのページから、どこへ要求を出すか」を厳しく見ている点です。
公開されたHTTPSのページから、手元の http://127.0.0.1 のような平文ローカルへ要求を投げると、ブラウザはそれを警戒します。
理由になりうる層は、ざっと3つ。
ひとつは mixed content、HTTPSページに平文HTTPの通信が混ざることへのブロックで、この挙動は混在コンテンツの解説としてMDNに整理されています。
もうひとつはLocal Network Access(旧Private Network Access)で、公開サイトから 127.0.0.1 や社内ネットへの要求を許可制で止める仕組み。
Chromeはこれをローカルネットワークアクセスの権限として導入しており、拒否や無反応だと要求は静かに失敗します。
最後がCORS、オリジンをまたぐ要求そのものへの制限です。
私の環境では、4秒のタイムアウトを仕掛けたfetchがことごとくabortしました。
つまりVOICEVOX本体は正常で、止めていたのはブラウザ側のこの層だったわけです。
どのブロックが効いたかを正確に名指すなら、コンソールのエラー文言を読むのが近道。
壁①の越え方:実行オリジンをAPIに合わせる
壁の正体がオリジンなら、越え方は単純で、要求元のオリジンをAPIと同じにそろえるだけです。
具体的には、合成コードを動かすタブ自体を http://127.0.0.1:50021、つまりVOICEVOXのアドレスで開いて実行します。
こうすると、mixed contentもCORSも最初から発生しません。
同一オリジンなので、ブラウザにとっては「自分の家の中での会話」に過ぎないからです。
実際のコードは、拍子抜けするほどの短さ。
const base = location.origin; // 127.0.0.1:50021 を開いて実行
const q = await fetch(`${base}/audio_query?speaker=3&text=${encodeURIComponent(t)}`, { method: 'POST' });
const query = await q.json();
query.outputSamplingRate = 24000; query.outputStereo = false;
const s = await fetch(`${base}/synthesis?speaker=3`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(query) });
const wav = new Uint8Array(await s.arrayBuffer()); // ← これをローカルで保存
鍵は audio_query で組んだクエリを synthesis に渡す二段構えで、間に outputSamplingRate=24000 を挟めば出力wavも軽くなります。
合成が通ったかは、返ってきたwavのバイト数を見れば一目で分かるはず。
私の手元では、短い「テスト」という発話が 38,444バイト のwavで返ってきました。
肝心なのは、ここまででVOICEVOX側の設定は一切いじっていない点です。
壁②:データを運ぶ経路の制限 ― 戻り値の遮断と切り詰め
オリジンを合わせて合成は通るようになったものの、そこで2枚目の壁が現れました。
合成した音声データを、動画を組み立てる環境まで運べないのです。
私が使っていたブラウザツールは、長いbase64文字列を「BLOCKED: Base64 encoded data」として丸ごと遮断しました。
しかも戻り値は、サイズでも切り詰められます。
6文字のトークンを連番で返して測ったところ、約166個・およそ1,000文字で打ち切られました。
つまり戻り値は実質1KB程度が上限、という地味で硬い制約です。
16bitのPCM音声は、gzipしても1〜2割しか縮まず、hex化に至ってはサイズが倍になります。
小さな郵便受けに分厚い小包を押し込もうとして詰まる、あの感覚に近いものでした。
結局、どう運搬を工夫しても、1KBの壁の前では転送回数が現実的でなくなりました。
現実解:生成は同一オリジン、重い処理はローカルへ寄せる
2枚の壁から導かれる落としどころは、案外シンプルでした。
生成はAPIと同一オリジンで、重い後工程はAPIと同じマシンの中で完結させる、これに尽きます。
具体的には、音声合成からスライド描画、ffmpegでの動画合成までを、ローカルPCの中で一気通貫に流す構成。
ブラウザを経由しなければ、base64遮断も1KB制限も最初から無縁になります。
ローカルでwavを保存する処理なら、標準ライブラリだけでも十分に書けるはず。
import json, urllib.request, urllib.parse
VV = 'http://127.0.0.1:50021'; SP = 3
q = urllib.request.Request(f'{VV}/audio_query?speaker={SP}&text=' + urllib.parse.quote(text), method='POST')
query = json.loads(urllib.request.urlopen(q, timeout=30).read())
s = urllib.request.Request(f'{VV}/synthesis?speaker={SP}', data=json.dumps(query).encode(), headers={'Content-Type': 'application/json'}, method='POST')
open('audio_00.wav', 'wb').write(urllib.request.urlopen(s, timeout=60).read())
このスクリプトはVOICEVOXとffmpegが同じマシンにいる前提なので、データを外へ持ち出す手間がありません。
もうひとつの割り切りとして、字幕をスライドに焼き込んでいるショートなら、音声が無くても内容は通じます。
だから合成が間に合わなくても、無音トラックに差し替えれば動画自体は完成、という逃げ道も残せました。
最後に
今回の詰まりを一言でまとめると、犯人はAPI本体ではなく、その手前の「オリジン」と「経路」だった、に尽きます。
ローカルAPIに繋がらないときは、設定ファイルを睨む前に、まず「どのオリジンから叩いているか」を確認すると早いです。
そして大きなデータを境界の外へ運ぼうとする設計そのものを、一度疑ってみる価値があります。
この2点は、VOICEVOXに限らず、ローカルで動くどんなAPIを、エージェントや隔離環境(サンドボックス)から触るときにも効く視点でした。
生成は内側で完結させ、境界を越えるのは最小限に。
同じ壁の前で固まっている方の回り道を、ひとつでも減らせたなら、それがいちばんの収穫になります。
以上です。











コメントを残す