WebUtauをローカル構築して闇音レンリで日本語歌声合成を試してみた

初めに

今回は、ブラウザ上で動作する仮想シンガーワークステーション WebUtau をソースからビルドし、日本語DiffSingerボイスバンクである闇音レンリ(Yamine Renri)を導入して、実際に「さくらさくら」を歌わせるまでの手順を紹介します。

WebUtauはMIDIをインポートして歌詞を入力し、OpenUtau/DiffSingerエンジンで歌声合成を行うブラウザベースのツールです。バックエンドの.NETサーバーがDiffSingerモデルを実行し、フロントエンドはVanilla JS + Viteで構築されています。リポジトリにビルド済みランタイムは付属していないため、.NETソースからビルドする必要があります。

オリジナル版は中国語UIで提供されているため、本記事では筆者が日本語UIにローカライズしたフォーク版(feat/japanese-ui ブランチ)を使用します。

github.com

開発環境

  • OS: Windows 11
  • シェル: bash(Git Bash)
  • .NET SDK: 10.0.104(.NET 8ターゲットと互換性あり)
  • Node.js: v22.14.0
  • npm: 11.4.2
  • 推論: CPU(GPUがあればCUDA/DirectMLも可)

WebUtauの全体構成

WebUtauは3層構成のアプリケーションです。

ブラウザ (フロントエンド)        ← Vanilla JS + Vite, port 3000
        ↓ /api/* (Vite proxy)
バックエンド (.NET)              ← ASP.NET Core 8 + OpenUtau, port 38510
        ↓ ONNXモデル読み込み
DiffSingerボイスバンク + ボコーダー

歌声合成を動かすには3層すべてのセットアップが必要です。フロントエンドだけ起動した状態ではピアノ音源でのプレビュー再生になり、歌声は出ません。

環境構築

リポジトリのクローン

WebUtauはオリジナル版(Marigold1122/WebUtau)が中国語UIで提供されていますが、本記事では日本語UIにローカライズしたフォーク版(feat/japanese-ui ブランチ)を使用します。UIラベル・ダイアログ・エラーメッセージがすべて日本語化されています。

git clone -b feat/japanese-ui https://github.com/ayutaz/WebUtau.git
cd WebUtau

フロントエンドのセットアップ

依存関係をインストールします。@tonejs/miditonewanakana@sglkc/kuromojivite など89パッケージがインストールされます。

npm install

開発サーバーを起動します。

npx vite

成功すると以下のように表示されます。

  VITE v6.4.1  ready in 1090 ms
  ➜  Local:   http://localhost:3000/

npm run dev でも起動できますが、Git Bashで 'vite' は認識されていません エラーになることがあるため、その場合は npx vite を使います。

この時点ではフロントエンドのみ起動しており、まだ歌声合成はできません。

バックエンドのビルド

バックエンドは.NET 8で書かれたASP.NET Core APIです。dotnet --version で.NET 8以降のSDKがインストールされていることを確認します。

dotnet --version
# 10.0.104

NuGetパッケージを復元します。OpenUtau.Plugin.BuiltinDiffSingerApiOpenUtau.Core の3プロジェクトが復元されます。

cd server/DiffSingerApi
dotnet restore

リリースビルドを実行します。

dotnet build -c Release

ビルド時間は約17秒です。1567個の警告が出ますが、エラー0で成功します。出力は server/DiffSingerApi/bin/Release/net8.0/ に配置されます。

ボイスバンクとボコーダーのダウンロード

DiffSinger形式のボイスバンクと、対応するボコーダーが必要です。今回は日本語対応の 闇音レンリ(Yamine Renri)DiffSinger を使用します。

ボイスバンクディレクトリの準備

mkdir -p server/voicebanks
cd server/voicebanks

闇音レンリ DiffSingerのダウンロード

GitHubのリリースページから直接ダウンロードします。hop512版(約301MB)はNSF-HiFiGANボコーダーに対応しています。

curl -L -o yamine-renri-hop512.zip \
  "https://github.com/colstone/Yamine_Renri_DiffSinger/releases/download/Beta_Version/Multi-langs.yamine-renri.Normal.hop512.zip" \
  --progress-bar

github.com

ボイスバンクには hop256 版と hop512 版があり、それぞれ対応するボコーダーが異なります。

バージョン サイズ 対応ボコーダー
hop256 253MB Kouon_RefineGAN
hop512 301MB NSF-HiFiGAN(公式標準)

NSF-HiFiGANはOpenUtau公式が配布している標準ボコーダーのため、hop512版を選びました。

NSF-HiFiGANボコーダーのダウンロード

ボコーダーは .oudep という独自パッケージ形式で配布されています(実体はzipファイル)。約51MBです。

curl -L -o nsf_hifigan.oudep \
  "https://github.com/xunmengshe/OpenUtau/releases/download/0.0.0.0/nsf_hifigan.oudep" \
  --progress-bar

ボイスバンクの展開

ダウンロードしたzipは内部が二重ネスト構造になっているため、一旦テンポラリディレクトリに展開してから移動します。

unzip -o yamine-renri-hop512.zip -d _tmp
mkdir -p yamine-renri
cp -r "_tmp/Multi-langs yamine-renri Normal hop512/Multi-langs yamine-renri Normal hop512/"* yamine-renri/
rm -rf _tmp

展開後の yamine-renri/ の構成は以下のようになります。

yamine-renri/
├── 0910_multi_langs_V5_2nd_fhyy_ds1000_1.phonemes.txt
├── 0910_multi_langs_V5_2nd_fhyy_ds1000_1.renri_normal.onnx  (260MB)
├── character.txt
├── character.yaml
├── dictionary.txt
├── dsconfig.yaml
├── dsdict_JPN.txt   ← 日本語辞書
├── dsdict_CnJ.txt
├── dsdict_ENG.txt
└── dsdur/           ← 音価予測モデル

dsconfig.yaml の中身はシンプルです。

phonemes: "0910_multi_langs_V5_2nd_fhyy_ds1000_1.phonemes.txt"
acoustic: "0910_multi_langs_V5_2nd_fhyy_ds1000_1.renri_normal.onnx"
vocoder: nsf_hifigan

ボコーダーをボイスバンク内に配置

OpenUtauのコードを読むと、DiffSingerSinger.csgetVocoder() がボイスバンク直下の dsvocoder/ を優先的にチェックする仕様になっています。PathManager.Inst.DependencyPath を使う方法もありますが、ボイスバンク内に直接配置する方が簡単です。

mkdir -p yamine-renri/dsvocoder
cd yamine-renri/dsvocoder
unzip -o "../../nsf_hifigan.oudep" nsf_hifigan.onnx vocoder.yaml

dsvocoder/vocoder.yaml の中身。

name: "nsf_hifigan"
model: "nsf_hifigan.onnx"
num_mel_bins: 128
hop_size: 512
sample_rate: 44100

ここで重要なのは hop_size です。ボイスバンクの hop512 とボコーダーの hop_size: 512 が一致している必要があります。一致しないとレンダリング時にエラーになります。

バックエンドの起動

起動コマンド

MELODY_ONNX_RUNNER 環境変数で推論ランタイムを指定し、--VoicebanksPath絶対パス を渡します。GPUがない環境では CPU を指定します。

cd server/DiffSingerApi
MELODY_ONNX_RUNNER=CPU dotnet run -c Release -- \
  --VoicebanksPath="C:/Users/yuta/Desktop/AIHUB/WebUtau/server/voicebanks"

ハマりポイントが2つあります。

  1. ボイスバンクパスは絶対パス必須。相対パスを指定すると Found 0 singer(s). になってしまい、ボイスバンクが認識されません。
  2. GPU環境変数の指定。GPUがない環境で MELODY_ONNX_RUNNER を指定しないとCUDAを探そうとして失敗することがあります。CPUの場合は明示的に指定するのが安全です。

起動成功の確認

成功すると以下のログが出力されます。

[INF] ONNX runner set to CPU (from MELODY_ONNX_RUNNER)
[INF] ONNX runner: CPU
[INF] DiffSinger API starting on http://localhost:38510
[INF] Now listening on: http://0.0.0.0:38510
[INF] Searching singers.
[INF] Found 1 singer(s).
[INF]   yamine-renri (DiffSinger)
[INF] SynthesisService worker initialized.

Found 1 singer(s).yamine-renri (DiffSinger) が表示されればOKです。

API動作確認

curlでボイスバンク一覧APIを叩いて確認します。

curl http://localhost:38510/api/voicebanks

レスポンス。

[{"id":"yamine-renri","name":"Multi-langs yamine-renri Normal hop512","singerType":"DiffSinger"}]

Vite開発サーバー経由(/apiプロキシ)でも動作することを確認します。

curl http://localhost:3000/api/voicebanks

同じレスポンスが返れば、フロントエンドからバックエンドへの通信経路ができています。

デモMIDIの作成

WebUtauにはサンプルMIDIが付属していないため、テスト用に歌詞付きの「さくらさくら」MIDIを作成します。

MIDIに歌詞を埋め込む際の注意点

WebUtauのMIDI読み込み(src/modules/MidiImporter.js)は、MIDIの lyrics または text メタイベントから歌詞を抽出します。Node.js上でMIDIを生成する場合、midi-file パッケージの writeMidi を使うのが手軽ですが、このライブラリはマルチバイト文字を正しく扱えません。

midi-filewriteString は内部で codePointAt を使って1バイトずつ書き込むため、日本語のような多バイト文字をそのまま渡すと文字化けします。

// NG: 文字化けする
track.push({ deltaTime: 0, type: 'lyrics', text: 'さ' })

回避策として、UTF-8エンコードしたバイト列を1バイト=1文字のLatin1風文字列に変換してから渡します。WebUtau側の decodeMidiText.js がLatin1として読み込まれたUTF-8バイト列を自動的に検出してデコードし直す仕組みになっているため、これで正しく日本語が読み込まれます。

function utf8Str(str) {
  const bytes = Buffer.from(str, 'utf8')
  return String.fromCharCode(...bytes)
}

track.push({
  deltaTime: 0,
  type: 'lyrics',
  text: utf8Str('さ'),
})

さくらさくらMIDI生成スクリプト

scripts/create-demo-midi.cjs として以下のスクリプトを作成します。

const { writeMidi } = require('midi-file')
const { writeFileSync, mkdirSync } = require('fs')
const { join } = require('path')

const BPM = 80
const TICKS_PER_BEAT = 480
const US_PER_BEAT = Math.round(60_000_000 / BPM)

const Q = TICKS_PER_BEAT / 2   // 8分音符
const H = TICKS_PER_BEAT        // 4分音符
const W = TICKS_PER_BEAT * 2    // 2分音符

function utf8Str(str) {
  const bytes = Buffer.from(str, 'utf8')
  return String.fromCharCode(...bytes)
}

const melody = [
  { note: 69, dur: H, lyric: 'さ' },
  { note: 69, dur: H, lyric: 'く' },
  { note: 71, dur: W, lyric: 'ら' },
  { note: 69, dur: H, lyric: 'さ' },
  { note: 69, dur: H, lyric: 'く' },
  { note: 71, dur: W, lyric: 'ら' },
  // ... さくらさくらの全歌詞・全ノート(45ノート)
]

const track0 = [
  { deltaTime: 0, type: 'timeSignature', numerator: 4, denominator: 4, metronome: 24, thirtyseconds: 8 },
  { deltaTime: 0, type: 'setTempo', microsecondsPerBeat: US_PER_BEAT },
  { deltaTime: 0, type: 'trackName', text: 'Conductor' },
  { deltaTime: 0, type: 'endOfTrack' },
]

const track1 = [
  { deltaTime: 0, type: 'trackName', text: utf8Str('さくらさくら') },
]

let pendingDelta = 0
for (const n of melody) {
  track1.push({ deltaTime: pendingDelta, type: 'lyrics', text: utf8Str(n.lyric) })
  track1.push({ deltaTime: 0, channel: 0, type: 'noteOn', noteNumber: n.note, velocity: 90 })
  const noteDur = Math.round(n.dur * 0.95)
  track1.push({ deltaTime: noteDur, channel: 0, type: 'noteOff', noteNumber: n.note, velocity: 0 })
  pendingDelta = n.dur - noteDur
}
track1.push({ deltaTime: pendingDelta, type: 'endOfTrack' })

const midiData = {
  header: { format: 1, numTracks: 2, ticksPerBeat: TICKS_PER_BEAT },
  tracks: [track0, track1],
}

const output = writeMidi(midiData)
mkdirSync(join(__dirname, '..', 'public', 'demo'), { recursive: true })
writeFileSync(join(__dirname, '..', 'public', 'demo', 'sakura-sakura.mid'), Buffer.from(output))
console.log(`Created with ${melody.length} notes`)

実行します。

node scripts/create-demo-midi.cjs
# Created with 45 notes

public/demo/sakura-sakura.mid が生成されます。

歌声合成の実行

ブラウザで開く

http://localhost:3000/ をブラウザで開きます。

デモMIDIをロード

空のプロジェクト画面に表示される「さくらさくら」ボタンをクリックします(src/host/ui/ShellLayoutView.js_parseDemoMidiFiles() で組み込みデモを返すように改修した場合)。

「タイミング情報のインポート」ダイアログが表示されます。インポート元80 BPMと現在のプロジェクト120 BPMが比較表示されるので、「同期して適用」をクリックします。

ピアノロールを開く

トラックリストにインポートされたトラックが表示されます。トラックを ダブルクリック するとピアノロールエディタが開きます。

ボーカルレンダリングを開始

ピアノロール上部の「このトラックをボーカルとしてレンダリング」ボタンをクリックします。言語選択ダイアログが表示されるので、以下を選択します。

  • 言語: 日本語
  • ボイスバンク: yamine-renri

続行」をクリックすると、ピッチ予測 → オーディオレンダリングが順番に開始されます。

レンダリングの進行

CPU推論なので少し時間がかかります。進行状況はノートの色で確認できます。

状態
グレー 未レンダリング
オレンジ レンダリング中
ティール(青緑) レンダリング完了

すべてのノートがティール色になればレンダリング完了です。

実行結果

再生ボタン(▶)を押すと、闇音レンリの歌声で「さくらさくら」が再生されます。

合成された歌声は、以下のような特徴があります。

  • ピッチは正確にメロディーラインに沿う
  • 「さ」「く」「ら」など各音節の子音・母音が明確に発音される
  • ノート間の音程変化はDiffSingerが自然な遷移を生成する

歌詞をMIDIに埋め込まずに合成すると母音だけの「ハミング」のような出力になるため、必ず歌詞を入れることがポイントです。

トラブルシューティング

実際にハマったポイントをまとめます。

Found 0 singer(s) と表示される

--VoicebanksPath が相対パスになっていないか確認します。../../server/voicebanks のような相対パスではボイスバンクが認識されません。絶対パスで指定する必要があります

鼻歌のような音しか出ない

ノートに歌詞が割り当てられていません。歌詞を埋め込んだMIDIを使うか、ピアノロール上部の「クイック歌詞入力」から手動で歌詞を入力します。

ポート38510が使用中

前回起動したバックエンドプロセスがゾンビ化している場合があります。

netstat -ano | grep 38510
taskkill //PID <PID> //F

ボコーダー設定の不一致エラー

ボイスバンクの hop_size とボコーダーの hop_size が一致しているか確認します。本記事の構成では両方 512 で揃えています。

CPU推論が遅い

GPUがある環境では MELODY_ONNX_RUNNERCUDA または DirectML に変更します。

MELODY_ONNX_RUNNER=DirectML dotnet run -c Release -- --VoicebanksPath="..."

CUDAを使う場合は事前にCUDA Toolkit + cuDNNのインストールが必要です。

所感

WebUtauは「ブラウザ上で歌声合成できる仮想シンガーワークステーション」という珍しい立ち位置のツールで、OpenUtau/DiffSingerをバックエンドにラップすることで、複雑なネイティブGUIなしに歌声合成を試せるのが魅力です。一方で、リポジトリにビルド済みランタイムやサンプルMIDI・ボイスバンクが付属していないため、初回セットアップは自力で全コンポーネントを揃える必要があります。

DiffSingerモデルの推論はCPUでも動作しますが、闇音レンリのような260MBクラスの音響モデルでは数十秒〜分単位の待ち時間が発生します。実用的に試すならNVIDIA GPU(CUDA)またはWindows環境のDirectMLを使うのが望ましいです。

歌詞付きMIDIの生成では、midi-file ライブラリのマルチバイト非対応問題に遭遇しました。WebUtau側の decodeMidiText.js がLatin1として読み込まれたUTF-8を自動デコードする仕組みを持っているため、UTF-8バイト列を文字単位の「擬似Latin1文字列」に変換して渡すというトリックで回避できます。MIDIの仕様自体がテキストエンコーディングを規定していないこともあり、日本語歌詞を扱う際は注意が必要なポイントです。

合成された歌声は短いフレーズなら十分実用的な品質で、デモ・趣味用途であれば「ブラウザだけで歌声合成ができる」体験を提供できる興味深いプロジェクトでした。