👅

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 対策

  1. package.jsonengines.node を "22.x" に変更しました。
  2. 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を設定することで、大幅な容量削減を図れる。

Vercelの環境変数設定画面
図1: Vercelの環境変数設定画面

2‑5 HuBERT 動的量子化の詳細

量子化に際して畳み込み層(Conv)まで INT8 化すると、ONNX グラフに ConvInteger ノードが挿入されます。しかし公式の onnxruntime‑node バイナリには CPU 向け ConvInteger カーネルが含まれていません。そのため実行時に次のエラーが発生します。

Could not find an implementation for ConvInteger(10)

この制約を回避するため、下記のように動的量子化スクリプトで Conv を対象外 に指定しました。

title="quantize_hubert_int8_nomconv.py"
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