ScalaからVOICEVOX喋らせようとしている人は多分絶対自分しかいないけど、ScalaからVOICEVOXを喋らせるためのラッパーであるvoicevoxcore4s
を作った。
この記事は、はてなエンジニアAdvent Calendar 2022の48日目の記事です。昨日の記事は id:gurrium の
でした。プロダクトをめちゃくちゃ世に送り出していてかっこいいぜ!俺も負けていられないな。
このラッパーはVOICEVOXの中核機能を提供しているlibcore.so
のC言語APIをScalaでラップしている。voicevoxcore4s
という名前はelastic4s
とかhttp4s
といった、Scala系ライブラリの慣例をリスペクトして名付けた。
VOICEVOXとは
流行りまくっているので今更説明不要だと思うが、VOICEVOX(ボイスボックス、ボイボ)とは音声合成ツールである。最近はVOICEVOX解説動画などが広く作られているため、「ずんだもんの声のソフト」と言ったら分かる人もいるだろう。
ありがたいことに、VOICEVOXはオープンソースソフトウェアであり、我々のような一介のエンジニアも触ることができる。そこで今回はこの枝豆に喋ってもらおうというわけである。
一般ユーザはGUI環境で遊ぶと思うのだが、今回はCoreというモジュールを操作する。VOICEVOXの音声合成機能の中心はCoreというメインモジュールから構成されており、そこでONNXを用いたニューラルネットワークモデルが動作するという仕組みになっているのだ(以下図はvoicevox/全体構成.md at main · VOICEVOX/voicevoxより引用)。
そしてVOICEVOX Coreの製品版バイナリはlibcore.so
として提供されており、これをC/C++から呼び出すことで音声合成が可能である。
とはいえこれでは使うのが大変なので、VOICEVOX EngineというPythonのラッパーがHTTP APIを提供し、HTTPリクエストを行うことで音声合成が可能となっている。
Scalaから呼び出したい
さて、自分はzmmという、VOICEVOXを使った解説動画生成ツールを開発していて、音声合成のためにVOICEVOXを使っている(後で紹介させてください)。ScalaからVOICEVOXを呼び出すには今のところVOICEVOX Engineを呼び出す必要があるのだが、VOICEVOX EngineはPython製なので、Pythonも動かすか、これを内包したDocker Imageを動作させる必要がある。
依存が増えるとプロダクトとして大変なので、このlibcore.so
をScalaから直接呼び出したい。そうすれば、zmmを構成するコンポーネントの数を減らすことができる。そこで今回のライブラリを作ったというわけである。
使い方
以下のコードを実行するとずんだもんの声でresult.wav
が保存される。voicevoxcore4sのプロジェクトをダウンロードしてsbt "compile;run"
しても同様の事ができる。
import com.sun.jna.ptr.{PointerByReference, IntByReference} import java.io.FileOutputStream val dictionaryDirectory = Util.extractDictFiles() val libs = Util.extractLibraries() // CAVEAT: Call only once Util.unsafeLoadLibraries() val core = Core() val initialized = core.initialize(use_gpu = false) if (initialized) { val loadDictResult = core.voicevox_load_openjtalk_dict(dictionaryDirectory) if (loadDictResult == 0) { val (length, pbr) = (new IntByReference(), new PointerByReference()) core.voicevox_tts("こんにちは、世界", 3L /* ずんだもん */, length, pbr) println(s"length: ${length.getValue()}") println(s"err: ${core.last_error_message()}") val resultPtr = pbr.getValue() val resultArray = resultPtr.getByteArray(0, length.getValue()) val fs = new FileOutputStream("./result.wav") fs.write(resultArray) fs.close() core.voicevox_wav_free(resultPtr) } core.finalizeCore() }
生成されたwavファイルの内容はこのような感じになっていて、ちゃんと喋ってくれている。
https://ipfs.io/ipfs/QmeGWJTVFnEepsEpVYExbUazbW1rjDw3fEmm1fPSB13tJS?filename=result.wav
面白ポイント
本来VOICEVOXはCのライブラリなので、越えなければならない壁がいくつも存在する。
C APIをラップする
JVMからCのAPIを呼び出す方法はいくつかあるのだが、今回はJNAという仕組みを使った。
sbtで数行書くと、ネイティブライブラリを呼び出せるようになる。
... libraryDependencies ++= Seq( "net.java.dev.jna" % "jna" % "5.12.1", "net.java.dev.jna" % "jna-platform" % "5.12.1", ), ...
JNAではcom.sun.jna.Library
を継承したクラスを定義すれば、外部ライブラリとのインターフェイスを定義できるが、JavaとCとでは型の扱いやメモリの扱いが異なるため、その差を吸収するためのデータ型が用意されている。
com.sun.jna.Pointer
- https://java-native-access.github.io/jna/4.2.0/com/sun/jna/Pointer.html
- ネイティブなポインタ型を抽象化したもの。この型のみであらゆる型のポインタを抽象化する。ジェネリックはない。
- 例えば
int*
もPointer
で表現される。ptr.getInt(0)
などで値を取り出してくるという仕様。
- 例えば
ptr.getほげほげ
が*ptr
に相当する。ptr.read
を使うと、既存の配列へのコピーが可能。
com.sun.jna.Memory
- https://java-native-access.github.io/jna/4.2.0/com/sun/jna/Memory.html
malloc
して得られたメモリ領域を表現する。複雑なポインタまわりの処理で利用する。- 動的にメモリを確保したいときに使う。
Pointer
の子クラスでもある。もうひとつの子クラスはFunction
であり、関数ポインタをJava風に抽象化したもの。
実際のコードでは以下のようにlibcore.so
がラップされている。
package com.github.windymelt.voicevoxcore4s import com.sun.jna.Library import com.sun.jna.Library.OPTION_FUNCTION_MAPPER import com.sun.jna.Native import com.sun.jna.NativeLibrary import com.sun.jna.Pointer import com.sun.jna.ptr.IntByReference import com.sun.jna.ptr.PointerByReference import java.lang.reflect.Method import scala.collection.JavaConverters._ // cf. https://github.com/java-native-access/jna/blob/master/www/Mappings.md // cf. https://github.com/VOICEVOX/voicevox_core/blob/0.13.0/core/src/core.h trait Core extends Library { type VoicevoxResultCode = Int def initialize( use_gpu: Boolean, cpu_num_threads: Int = 0, load_all_models: Boolean = true ): Boolean /* ... */ }
型だけ合わせたtrait
を置いておき、ラッパとしてコンパニオンオブジェクトを配置した。
object Core { private val functionMap = new com.sun.jna.FunctionMapper { def getFunctionName(library: NativeLibrary, method: Method): String = method.getName() match { case "finalizeCore" => "finalize" // to avoid conflicting to reserved keyword case otherwise => otherwise } } private val INSTANCE: Core = Native.load( "core", classOf[Core], Map(OPTION_FUNCTION_MAPPER -> functionMap).asJava ) def apply(): Core = INSTANCE }
こうすることでCore()
と書くだけでlibcore.so
が呼び出せるようになる。
ちなみにわからない箇所はStack Overflowにお願いして回答してもらったりした。ありがとうございました。
ライブラリ類の自動ダウンロード・JARへの同梱
さて、これだけだとlibcore.so
のラッパーだが、coreが動くためにはONNXというニューラルネットワークのランタイムや辞書ファイルなどが必要である。使い勝手を良くするために以下の方針でライブラリを同梱することにした。
libcore.so
やlibonnxruntime.so
などの依存ライブラリは再配布してよいライセンスなので、バイナリのままJARファイルに格納する- これだと特定のプラットフォームでしか動かないので、クロスビルドできるようにプロジェクトを分割しておく
- sbt-buildinfoというプラグインを使って、コンパイル時に確定したライブラリのバージョン情報や場所の情報を受け取っている
- 依存ライブラリや、動作に必要な日本語辞書はビルド時に勝手にダウンロードし、ローカルにキャッシュできるようにした
- sbtの
build.sbt
で全部処理できている。compileしてrunするだけで全て動く仕組みになっている。
- sbtの
- 実行時にはJAR内のライブラリや辞書ファイルを外側のファイルシステムに展開する
- OSは直接JAR内のダイナミックライブラリを読めないためこうするしかない
- なるだけ固定位置に展開するようにして、何度も展開せずにすむ工夫をしておいた
これにより、JARさえあれば(プラットフォームに問題がなければ)動作する夢のVOICEVOXライブラリが完成した。
マルチプラットフォーム
まだ今のところx86_64のLinuxでCPUを使った合成のためのJARしか用意していないが、ビルド設定を変更することで他のアーキテクチャ、他のOSからも呼び出せる仕組みにしている。今後余裕があったらアーキテクチャを増やしたり、CUDA対応させていきたい。
まだできていない事
Maven Centralへの登録はまだやっていない。sbtからは直接Gitからアーティファクトを呼び付けることができるので、もし使いたい酔狂な人がいたらこれを使ってほしい。
また、Cats Effect等へのバインディングはこのライブラリのスコープ外とした。分割プロジェクトとしてvoicevoxcore4s-catseffect
みたいな名前でバインディングを用意しようかと思っている。
zmmをよろしく
このプロジェクトはVOICEVOX解説動画生成ツールであるzmmのために書かれている。
このツールを使うことで、XMLで書いた原稿、キャラクターの立ち絵、背景画像、フォントをインストールするだけで簡単に解説動画を作成できる。今のところコンポーネントがいくつかに分かれている都合上、Docker Composeを動作させなければならないのだが、いずれは単体で起動したり、ウェブサービスとして提供できるクオリティにまで仕上げたいと思っている。
よろしく!
感想
着想したときは何ヶ月もかかるかと思ったけど、ラッパーって意外と数週間で作れるもんですね。これからのVOICEVOXの発展に注目だぜ!
明日は id:rokoucha です。