ブラウザの中にサーバーを、Hono Service Worker Adapter + Wasmで実現する真のスタンドアロンなVOICEVOX
こちらは Hono Advent Calendar 2025 11日目の記事です。
はじめに
WebAPI、叩いていますか?
私の普段利用している音声合成アプリケーションのVOICEVOXも、VOICEVOX ENGINEと特定の規約で通信を行っています。
このVOICEVOXですが、Electronアプリケーションとして提供されていて、起動時に特に設定がなければEngineをバックグラウンドで立てて、それと通信するようになっています。
けどふと思うのです、思い立った時にブラウザでシュッと立ち上げてシュッと合成したい、したくない?
ってことで過去にブラウザでVOICEVOXが動作するようにしました。
いやそれじゃ足りない…このスポットで立ち上がって、ENGINEも特に認証とか面倒だし本家に膨大なパッチいりそうだし…なんか…楽にできない?
と困っていたところ、最近こんな記事を目にしました。
VOICEVOXはonnxruntimeを使っていて、onnxruntime-webにもWebGPU EPがあるわけです。推論部分はこれを使えばいいし、あれっ、APIがそのままWebにあれば求めた姿に近づくのでは?
WebAPIをサーバーで動作させずに、それっぽいWebAPIっぽい振る舞いがブラウザ上で動作すればいけそうです。
……そうだ、Service Workerだ!
ということで、Service Workerでfetchの横取りをして、いい感じにRoutingをやってくれるものを探したところ、Honoに出会いました。
ということで(前置き長い)Hono Service Woker AdapterとWasmを使ってAPIをそれっぽくでっち上げて、スタンドアロンなVOICEVOXを動作させた話をします。
成果物
動作している様子が以下で確認できます。
Service Workerのinstall処理が走っているので、一度開いて何度かくるくるした後、リロードすると動作すると思います。だめだったらforce-refreshの後にくるくるを待ってリロードすると動作すると思います。
実際のコードは、この web/ の箇所が今回のメインの実装となっていて、Honoが使われているのは web/src/sw.ts です。
アーキテクチャ: ブラウザの中に「サーバー」を作る
とある理由で複雑になってしまっていますが、以下が今回のアーキテクチャ図となります。
VOICEVOXのUIからのENGINEへのリクエストをServiceWorkerでハンドリングして、Honoを使用してWasmとして動作する.NETのENGINEにRoutingしています。
Honoはどう使われているのか
公式ドキュメントにあるように、ServiceWorkerにregisterされる側のスクリプトで利用しています。
ここで重要なのは、普段HonoでWebAPI書いてる時となんの違いもなく書けるということです。
import { Hono } from "hono";
import { handle } from "hono/service-worker";
declare const self: ServiceWorkerGlobalScope;
const maybeBase = import.meta.env.BASE_URL
? new URL(import.meta.env.BASE_URL).pathname
: "/";
const app = new Hono().basePath(maybeBase + "/sw");
app.get("/engine_manifest", (c) => c.json(dummyEngineManifest));
app.post("/synthesis", async (c) => {
const styleId = c.req.query("speaker");
if (styleId === undefined) {
throw new InvalidRequestFieldError("speaker");
}
const numericStyleId = Number(styleId);
if (Number.isNaN(numericStyleId)) {
throw new InvalidRequestFieldTypeError("speaker", "number");
}
const audioQueryJson = await c.req.text();
// WASMとして動作している.NET RuntimeのVOICEVOX ENGINEへ処理を委譲
const result = await sendToMainThread<{ buffer: ArrayBuffer }>("synthesis", {
audioQueryJson,
styleId: numericStyleId,
});
return new Response(new Blob([result.buffer], { type: "audio/wav" }));
});
// 他にいっぱいVOICEVOX ENGINEとして動作するために必要なエンドポイントを生やす
self.addEventListener("fetch", (event: FetchEvent) => {
const url = new URL(event.request.url);
console.log(`SW Fetch: ${url.pathname}`);
const baseAppended = (maybeBase + "/sw").replace(/\/+/g, "/");
// /sw で始まるリクエストのみ処理
if (url.pathname.startsWith(baseAppended)) {
handle(app)(event);
}
// その他のリクエストはネットワークにフォールバック
});
普段と違うところは、fetchに対するEventListerを生やしている箇所ですが、それ以外は本当に普段通りです。
Honoの設計やPortabilityがこういうところで生きているのか…と非常に感心する箇所でもあります。
ここで特筆すべきは、Service Worker特有の書きづらさをHonoが完全に隠蔽してくれている点です。
通常、Service Workerでルーティングを書こうとすると、event.request.url をパースして if 文で分岐させ、クエリパラメータを URLSearchParams で取得し...といった手続き的なコードになりがちです(本当に面倒でした)。
しかしHonoを使えば、普段Cloudflare WorkersやNode.jsで書いているのと全く同じメンタルモデルで記述できるのです。
加えて以下の点が割とお気に入りです。
-
basePathで実際のデプロイ箇所に依存したプレフィックスを吸収出来るため、ルーティングが本当にクリーンに保てる - パラメータの扱い、直感さ
このように、HonoでWebAPI(エンドポイントだけでも)が書ければ、適当なMockサーバーだって作り放題なのです。
VOICEVOX ENGINEとしてまとめ上げるために何が必要だったか
前述したように、HonoのWebAPIを作成し、ServiceWorkerで動作させれば、VOICEVOXからのENGINEへのリクエストはハンドリングできそうです。
あとは実際の処理をひたすら実装していくだけに落とし込むことが出来ます。
今回はHonoのアドベントカレンダーなので詳細は割愛しますが、ENGINEの実装にはVoicevoxEngineSharpというC#実装のVOICEVOX ENGINEを使用しました。
数年前からC#のBuild TargetとしてWebAssemblyを指定できるようになっています。
そのため、ローカルで動作している実績のあるこのEngine実装をwasm化し、Honoから向ければ容易にスタンドアロン構成にすることが出来ました。
アーキテクチャ図にもありますが、.NET RuntimeはService Worker上では動作していません。
これは.NET RuntimeでDynamicImportが多用されていて、ブラウザ制約によってService Worker上で動作させることが出来ないためこうなっています。
そのため、若干複雑な形になっていますが、これを別の言語でいい感じに実装したら、よりシンプルな形でスタンドアロン構成を実現できるかと思います。
また今回はService Workerの方の処理がメインとなるため、本家のUIにほぼ手を加えていないで実現することが出来ています。
Service Workerのregister処理だけ分岐で導入することができれば、なんだって出来てしまいそうです。
おわりに
今回はHono Service Worker Adapterを活用し、WebAPIをでっち上げてスタンドアロン構成なVOICEVOXを実現することが出来ました。
この例のように既にあるアプリケーションをWASM化して、Honoでくるんであげればスタンドアロン構成のアプリケーションが出来るという可能性を示すことが出来たと思います。
Honoはアイデア次第で様々な活用が出来るライブラリであるため、ぜひとも今後も活用していきたいですね!
Discussion