ローカルAPIに繋がらない2つの壁―VOICEVOXで検証

ブログ記事を自動でショート動画にする作業の途中で、私はある一点で完全に手が止まりました。

PCのローカルで動く音声合成エンジン VOICEVOX のAPIに、ブラウザ経由でどうしても繋がらなかったのです。

VOICEVOX | 無料のテキスト読み上げ・歌声合成ソフトウェア

エンジン単体では音声がちゃんと返るのに、ブラウザから叩いた瞬間、応答が返らず無言でハングする。

はじめは音声合成API側の設定を疑いました。

ところが切り分けてみると、犯人はまるで別のレイヤに潜んでいたと分かります。

ローカルで動くはずのAPIに、自分の実行環境から届かず固まった経験はないでしょうか。

今回ぶつかったのは2枚の壁で、どちらもAPI本体とは無関係でした。

結論から言えば、ローカルAPIに繋がらないとき最初に見るべきは、API設定ではなくどのオリジンから叩いているか、そしてデータを運ぶ経路に制限がないかという別レイヤになります。

壁①:ブラウザのオリジン制約 ― localhost APIに「繋がらない」本当の理由

まず押さえたいのは、ブラウザが「どのページから、どこへ要求を出すか」を厳しく見ている点です。

公開されたHTTPSのページから、手元の http://127.0.0.1 のような平文ローカルへ要求を投げると、ブラウザはそれを警戒します。

理由になりうる層は、ざっと3つ。

ひとつは mixed content、HTTPSページに平文HTTPの通信が混ざることへのブロックで、この挙動は混在コンテンツの解説としてMDNに整理されています。

Mixed content - Security | MDN

もうひとつはLocal Network Access(旧Private Network Access)で、公開サイトから 127.0.0.1 や社内ネットへの要求を許可制で止める仕組み。

Chromeはこれをローカルネットワークアクセスの権限として導入しており、拒否や無反応だと要求は静かに失敗します。

New permission prompt for Local Network Access  |  Blog  |  Chrome for Developers

最後が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を、エージェントや隔離環境(サンドボックス)から触るときにも効く視点でした。

生成は内側で完結させ、境界を越えるのは最小限に。

同じ壁の前で固まっている方の回り道を、ひとつでも減らせたなら、それがいちばんの収穫になります。

以上です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA