Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

ScalaからVOICEVOX呼んでずんだもんに喋らせる2023

ScalaからVOICEVOX喋らせようとしている人は多分絶対自分しかいないけど、ScalaからVOICEVOXを喋らせるためのラッパーであるvoicevoxcore4sを作った。

github.com

この記事は、はてなエンジニアAdvent Calendar 2022の48日目の記事です。昨日の記事は id:gurrium

giarrium.hatenablog.com

でした。プロダクトをめちゃくちゃ世に送り出していてかっこいいぜ!俺も負けていられないな。

developer.hatenastaff.com

このラッパーはVOICEVOXの中核機能を提供しているlibcore.soのC言語APIをScalaでラップしている。voicevoxcore4sという名前はelastic4sとかhttp4sといった、Scala系ライブラリの慣例をリスペクトして名付けた。

VOICEVOXとは

流行りまくっているので今更説明不要だと思うが、VOICEVOX(ボイスボックス、ボイボ)とは音声合成ツールである。最近はVOICEVOX解説動画などが広く作られているため、「ずんだもんの声のソフト」と言ったら分かる人もいるだろう。

ずんだもん。イラストは坂本アヒル氏

voicevox.hiroshiba.jp

ありがたいことに、VOICEVOXはオープンソースソフトウェアであり、我々のような一介のエンジニアも触ることができる。そこで今回はこの枝豆に喋ってもらおうというわけである。

一般ユーザはGUI環境で遊ぶと思うのだが、今回はCoreというモジュールを操作する。VOICEVOXの音声合成機能の中心はCoreというメインモジュールから構成されており、そこでONNXを用いたニューラルネットワークモデルが動作するという仕組みになっているのだ(以下図はvoicevox/全体構成.md at main · VOICEVOX/voicevoxより引用)。

https://github.com/VOICEVOX/voicevox/raw/main/docs/res/%E5%85%A8%E4%BD%93%E6%A7%8B%E6%88%90_%E6%A7%8B%E6%88%90.svg

そして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という仕組みを使った。

github.com

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にお願いして回答してもらったりした。ありがとうございました。

stackoverflow.com

ライブラリ類の自動ダウンロード・JARへの同梱

さて、これだけだとlibcore.soのラッパーだが、coreが動くためにはONNXというニューラルネットワークのランタイムや辞書ファイルなどが必要である。使い勝手を良くするために以下の方針でライブラリを同梱することにした。

  • libcore.solibonnxruntime.soなどの依存ライブラリは再配布してよいライセンスなので、バイナリのままJARファイルに格納する
    • これだと特定のプラットフォームでしか動かないので、クロスビルドできるようにプロジェクトを分割しておく
    • sbt-buildinfoというプラグインを使って、コンパイル時に確定したライブラリのバージョン情報や場所の情報を受け取っている
  • 依存ライブラリや、動作に必要な日本語辞書はビルド時に勝手にダウンロードし、ローカルにキャッシュできるようにした
    • sbtのbuild.sbtで全部処理できている。compileしてrunするだけで全て動く仕組みになっている。
  • 実行時にはJAR内のライブラリや辞書ファイルを外側のファイルシステムに展開する
    • OSは直接JAR内のダイナミックライブラリを読めないためこうするしかない
    • なるだけ固定位置に展開するようにして、何度も展開せずにすむ工夫をしておいた

これにより、JARさえあれば(プラットフォームに問題がなければ)動作する夢のVOICEVOXライブラリが完成した。

マルチプラットフォーム

まだ今のところx86_64のLinuxでCPUを使った合成のためのJARしか用意していないが、ビルド設定を変更することで他のアーキテクチャ、他のOSからも呼び出せる仕組みにしている。今後余裕があったらアーキテクチャを増やしたり、CUDA対応させていきたい。

まだできていない事

Maven Centralへの登録はまだやっていない。sbtからは直接Gitからアーティファクトを呼び付けることができるので、もし使いたい酔狂な人がいたらこれを使ってほしい。

stackoverflow.com

また、Cats Effect等へのバインディングはこのライブラリのスコープ外とした。分割プロジェクトとしてvoicevoxcore4s-catseffectみたいな名前でバインディングを用意しようかと思っている。

zmmをよろしく

このプロジェクトはVOICEVOX解説動画生成ツールであるzmmのために書かれている。

www.3qe.us

このツールを使うことで、XMLで書いた原稿、キャラクターの立ち絵、背景画像、フォントをインストールするだけで簡単に解説動画を作成できる。今のところコンポーネントがいくつかに分かれている都合上、Docker Composeを動作させなければならないのだが、いずれは単体で起動したり、ウェブサービスとして提供できるクオリティにまで仕上げたいと思っている。

www.nicovideo.jp

よろしく!

感想

着想したときは何ヶ月もかかるかと思ったけど、ラッパーって意外と数週間で作れるもんですね。これからのVOICEVOXの発展に注目だぜ!

明日は id:rokoucha です。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?