Next.js App Router × ONNX Runtime で 声質変換AI(RVC)のサーバレス音声推論APIを動かしてみた
どうもこんにちは、就活終わりの中尾です。
Next.js(App Router)と Vercel Serverless Functions を用いて、声質変換 AI「RVC」をサーバーレスの環境で動かすまでの試行錯誤をまとめました。開発の過程で大きく3つの壁に衝突しましたが、無事に追加コストなしで運用できる構成に到達したので、知見を共有します。
1. GLIBC バージョン不整合によるビルド失敗
1‑1 事象
next build
実行中に次のエラーメッセージが表示され、ビルドが中断されました。
/lib64/libm.so.6: version `GLIBC_2.27' not found
1‑2 原因
Vercel のビルドイメージは Node.js のメジャーバージョンで決まります。Node 18.x を指定した場合は Amazon Linux 2(glibc 2.26)が使用されますが、onnxruntime‑node 1.21 系は glibc 2.27 以上を前提にビルドされています。このためライブラリをロードできず、ビルドが失敗しました。
1‑3 対策
-
package.json
のengines.node
を "22.x" に変更しました。 - Vercel の Project Settings でも Node.js 22.x を選択しました。
Vercelにおけるビルドイメージが Amazon Linux 2023(glibc 2.34)に切り替わり、エラーが解消しました! onnxruntime‑nodeはデプロイの面で悩まされるので、参考にしてください。
2. 関数サイズ制限と量子化に伴う調整
2‑1 事象
デプロイ時に Serverless Function のサイズが上限(250 MB)を超えたため、以下のエラーが発生しました。
Function "api/infer" is 356.78 MB which exceeds the maximum size limit of 300 MB
2‑2 ボトルネックの分析
何も対策をしていないと...
要素 | 容量 |
---|---|
onnxruntime‑node/bin(GPU バイナリ) | 約 404 MB |
HuBERT モデル | 約 370 MB |
RVC モデル | 53 MB |
2‑3 とった対策のまとめ
手順 | 内容 | 効果 |
---|---|---|
GPU バイナリの除外 | 環境変数 ONNXRUNTIME_NODE_INSTALL=skip を設定し、CUDA/TensorRT 用ライブラリのダウンロードを回避しました。 |
約 380 MB 削減 |
モデルのバンドル外配置 |
postinstall スクリプトで S3 からモデルを取得し、/var/task/server_model に配置しました。 |
バンドルへ含めずに済む |
HuBERT の動的量子化 | Conv を除外して MatMul/Gemm だけを INT8 化しました。ConvInteger ノードが生成されず、モデルサイズが 98 MB に縮小しました。 |
約270 MB 削減 |
2‑3 GPUバイナリ除外の詳細
KeyにONNXRUNTIME_NODE_INSTALL Valueにskipを設定することで、大幅な容量削減を図れる。
![]() |
---|
図1: Vercelの環境変数設定画面 |
2‑5 HuBERT 動的量子化の詳細
量子化に際して畳み込み層(Conv)まで INT8 化すると、ONNX グラフに ConvInteger
ノードが挿入されます。しかし公式の onnxruntime‑node バイナリには CPU 向け ConvInteger
カーネルが含まれていません。そのため実行時に次のエラーが発生します。
Could not find an implementation for ConvInteger(10)
この制約を回避するため、下記のように動的量子化スクリプトで Conv を対象外 に指定しました。
from onnxruntime.quantization import quantize_dynamic, QuantType
quantize_dynamic(
model_input="hubert_base.onnx",
model_output="hubert_base_int8_nomconv.onnx",
weight_type=QuantType.QInt8,
op_types_to_quantize=["MatMul", "Gemm"] # Conv を除外
)
3. 推論パイプラインの実装
3‑1 構成方針
ステップ | 処理内容 |
---|---|
1 | 16 kHz PCM を HuBERT‑INT8 へ入力し 768 次元特徴量を抽出します。 |
2 | 特徴量を時間方向に二倍補間して RVC の期待形状に合わせます。 |
3 | Pitch‑mark 法で F0 を推定し、要求された半音数だけシフトします(±24 半音)。 |
4 | 特徴量と F0 を RVC‑v2(FP16)へ入力し、40 kHz の音声を生成します。 |
5 | 生成波形を 16‑bit WAV として返却します。 |
3‑2 実装のポイント
- HuBERT と RVC は CPU Execution Provider で動作します。INT8 量子化は CPU カーネルのみサポートされるため、GPU を使用しません。
- RVCv1では入力が任意のピッチ推定もコードに含めました。pm,dio,RMVPEなどいろいろアルゴリズムがあり、推奨はRMVPEだが、容量を喰うのと、dioは歌声の変換に向かない、などの理由でpmを選択しました。
ピッチを入力することによって、歌声などの推論が可能になります。
3‑3 コア実装(抜粋)
以下は推論サーバ src/server/utils/onnxServer.ts
の主要部分です。エラーハンドリングやユーティリティ関数を含めても 350 行程度に収まります。
/* HuBERT‑INT8 + PM‑F0 + RVC‑v2 推論サーバ */
import path from "path";
import fs from "fs";
import * as ort from "onnxruntime-node";
import { decodeWav, encodeWav } from "./audio";
// モデル配置
const MODEL_DIR = path.join(process.cwd(), "server_model");
const HUBERT_INT8 = path.join(MODEL_DIR, "hubert_base_int8_nomconv.onnx");
const RVC_ONNX = path.join(MODEL_DIR, "tsukuyomi-chan.onnx");
// セッションキャッシュ
let hubertSession: ort.InferenceSession | null = null;
let rvcSession : ort.InferenceSession | null = null;
/** HuBERT セッション生成 */
async function getHubertSession(): Promise<ort.InferenceSession> {
if (!hubertSession) {
if (!fs.existsSync(HUBERT_INT8))
throw new Error(`HuBERT model not found: ${HUBERT_INT8}`);
hubertSession = await ort.InferenceSession.create(HUBERT_INT8, {
executionProviders: ["cpu"],
graphOptimizationLevel: "all",
});
}
return hubertSession;
}
/** RVC セッション生成 */
async function getRvcSession(): Promise<ort.InferenceSession> {
if (!rvcSession) {
if (!fs.existsSync(RVC_ONNX))
throw new Error(`RVC model not found: ${RVC_ONNX}`);
rvcSession = await ort.InferenceSession.create(RVC_ONNX, {
executionProviders: ["cpu"],
graphOptimizationLevel: "all",
});
}
return rvcSession;
}
/** 推論本体 */
export async function inferRvc(
wavBytes: Uint8Array,
transpose: number = 0,
): Promise<Uint8Array> {
// 0. 入力 WAV を 16 kHz PCM に変換
const { pcm, sampleRate } = await decodeWav(wavBytes);
const pcm16 = sampleRate === 16000 ? pcm : resampleLinear(pcm, sampleRate, 16000);
// 1. HuBERT 特徴抽出
const hubert = await getHubertSession();
const { data: feat0, dims: d0 } = await extractHubertFeatures(pcm16, hubert);
const { data: feats, dims: d1 } = interpolate2x(feat0, d0);
// 2. Pitch‑mark F0 推定
const f0Arr = extractPitchPm(pcm16);
if (transpose !== 0) {
const ratio = 2 ** (transpose / 12);
for (let i = 0; i < f0Arr.length; ++i) f0Arr[i] *= ratio;
}
// 3. RVC 推論
const rvc = await getRvcSession();
const raw = await runRvc(feats, d1, f0Arr, coarseQuantize(f0Arr), rvc);
// 4. 40 kHz WAV へ変換
const pcm40 = raw instanceof Uint16Array ? halfToFloat32(raw) : raw;
return encodeWav(pcm40, 40000);
}
4. まとめ
4-1 達成できたこと
- AWS SageMakerの高い料金を使わずに、RVCをWebで動かすことに成功した。
- onnxという見慣れない拡張子のモデルに詳しくなった。
- 技術的にはAIずんだWebも無料範囲で動かせるようになった。
- Next.jsやVercelなどについてちょっとだけ習熟度が上がった。
4-2 今後の課題
- RVCv2のモデルを動かせていない。😭
- AppRouterだと5秒程度の推論が限界である点。サーバーレスで無料なのはよいが問題🥲
- せめてクライアントサイドのonnxruntime-webについてはWeb-GPUなどを使った高速な処理を実装したかったが、これも謎のエラーにより阻止😡
Discussion