はじめに

TextToSpeech(T2S)のライブラリであるVoiceVoxCoreをAndroidにNativeで組み込む方法について解説します。

対象読者

C++言語の実装が必要不可欠なため、難易度が高い技術の話になります。この記事をスムーズに読むためには下記技術知識を押さえておく必要があります。

  • C++言語の基礎知識
    • 特に生ポインターについて
  • Android開発の基礎知識
  • Android NDKの基礎知識
  • プロセッサーの基礎知識
  • ONNXや自然言語処理などの知識があるとなお良い

開発環境

  • Android Studio Meerkat | 2024.3.1
  • Pixel 6a(プロセッサ: ARM64)

使用する素材

プロジェクト構成概要

組み込むためにAndroid NDKでの実装が必要不可欠なため下記のような構成になります。

  • Kotlin側
    • C++側のコードを呼び出し、受け取ったwavファイルを鳴らす処理
  • C++側(Android NDK)
    • VoiceVoxCoreライブラリを呼び出し、一連のシーケンスを構成し、textをwavデータへ音声合成する処理

プロジェクトの作成

Native C++を選択します。もし、既存のプロジェクトに統合したい場合は
https://developer.android.com/ndk/guides?hl=ja
を参考にプロジェクト設定を調整してください。

assetsの準備

まず、対象とするデバイスのプロセッサーを確認してください。筆者はARM64向けに作成するのでARM64のものを準備します。また、商用/非商用の利用規約が存在するので必ず確認を取った上でアプリを作るようにしてください。

今回は筆者の好み的に”冥鳴ひまり”の音声モデルを利用するため、1.vvmのスタイル14を使用します。クレジットは”VOICEVOX:冥鳴ひまり”と記載します。

各種ダウンロードが完了したら次のようにassetsにファイルを配置します。また、ここでは記事都合で分かりやすいように必要となる最小限のものを配置するため、プロジェクト構成などに従って配置してください。

voicevox_coreを配置する

voicevox_coreのsoファイルをjniLibsに配置します。
pathはapp/src/main/jniLibs/arm64-v8a/となります。
また、Gradleを調整します。

android {
	...
	defaultConfig {  
	    applicationId = "com.example.voicevoxtest"  
	    minSdk = 26  
	    targetSdk = 35  
	    versionCode = 1  
	    versionName = "1.0"  
	  
	    testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"  
	    // 追加
	    // java.lang.UnsatisfiedLinkError: dlopen failed: library "libc++_shared.so" not foundで実行時にコケる時の対策
	    externalNativeBuild {  
	        cmake {  
	            arguments += listOf("-DANDROID_STL=c++_shared")  
	        }  
	    }
    }
    externalNativeBuild {  
        cmake {  
            path = file("src/main/cpp/CMakeLists.txt")  
            version = "3.22.1"  
        }  
    }    // 追加  
    sourceSets {  
        getByName("main") {  
            jniLibs.srcDirs("jniLibs")  
        }  
    }    buildFeatures {  
        viewBinding = true  
    }  
}

headerファイルをcppフォルダに入れてください。
以上の配置をすると次のようになります。

Android NDK側の実装

AndroidNDKの実装で気をつけるべきこととして、ビルド以降はapkとしてまとめられるため、例えば音声ファイル1.vvmをcppフォルダなどに置いてもapkに含まれません。従って、実行時に動的に食わせたいものはassetsなどを経由してアプリの内部ストレージなどに展開しないといけません。そのため、3種の必要ファイルはassetsに配置しました。ただし、このままだとcpp側のコードでassetsを参照するような仕組みにしないといけなく、assets自体はパスを持たないのでvoicevoxに辞書ファイルなりを渡すときに困ります。そこで事前にkotlin側で内部ストレージにコピーしてから内部ストレージのパスをcpp側に渡す必要があります。

// MainActivity.kt
// assetsから内部ストレージにvoicevoxの必要なファイルをコピーする  
private fun copyAssetFiles(assetManager: AssetManager, targetDir: File) {  
    try {  
        val files = assetManager.list("") ?: return  
        for (fileName in files) {  
            if (fileName == "1.vvm" || fileName == "libvoicevox_onnxruntime.so") {  
                assetManager.open(fileName).use { inputStream ->  
                    FileOutputStream(File(targetDir, fileName)).use { outputStream ->  
                        inputStream.copyTo(outputStream)  
                    }  
                }            }  
            if (fileName == "open_jtalk_dic_utf_8-1.11") {  
                val targetFile = File(targetDir, fileName)  
                targetFile.mkdirs()  
                for (dicFile in assetManager.list(fileName) ?: emptyArray()) {  
                    assetManager.open("$fileName/$dicFile").use {  
                        FileOutputStream(File(targetFile, dicFile)).use { outputStream ->  
                            it.copyTo(outputStream)  
                        }  
                    }                }  
            }  
        }  
    } catch (e: Exception) {  
        Log.e("Assets", "Error copying assets", e)  
    }  
}

// 都合の良いタイミングで
// copyAssetFiles(assets, filesDir)
// をinvokeする

これを踏まえた上でvoicevox_coreのサンプルを見ながら次のように実装します。
スタイルIDやVVMなどはそれぞれの環境に合わせて調整してください。

// native-lib.cpp
#include <jni.h>  
#include <string>  
#include "voicevox_core.h"  
#include <android/log.h>  
#include <filesystem>  
  
#define STYLE_ID 14 // VOICEVOX のスタイルIDを指定  
#define LOG_TAG "NativeLib"  
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__)  
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)  
  
extern "C" JNIEXPORT jbyteArray JNICALL  
Java_com_example_voicevoxtest_MainActivity_voicevox(  
        JNIEnv* env,  
        jobject /* this */,  
        jstring word,  
        jstring assetsPath) {  
    const char *assetsPathCStr = env->GetStringUTFChars(assetsPath, nullptr);  
    std::string assetsPathStr(assetsPathCStr);  
    env->ReleaseStringUTFChars(assetsPath, assetsPathCStr);  
  
    try {  
        LOGE("assetsPathStr: %s", assetsPathStr.c_str());  
        for (const auto &entry : std::filesystem::directory_iterator(assetsPathStr)) {  
            const auto &path = entry.path();  
            LOGE("File: %s", path.c_str());  
        }  
    } catch (const std::filesystem::filesystem_error &e) {  
        LOGE("Filesystem error: %s", e.what());  
    }  
  
    std::string open_jtalk_path = assetsPathStr +"/open_jtalk_dic_utf_8-1.11";  
    LOGE("open_jtalk_path: %s", open_jtalk_path.c_str());  
    std::string open_jtalk_dict_path(open_jtalk_path);  
    const char *wordCStr = env->GetStringUTFChars(word, nullptr);  
    std::string text(wordCStr);  
    env->ReleaseStringUTFChars(word, wordCStr);  
  
    auto initialize_options = voicevox_make_default_initialize_options();  
  
    const VoicevoxOnnxruntime* onnxruntime;  
    auto load_ort_options = voicevox_make_default_load_onnxruntime_options();  
    std::string ort_filename = assetsPathStr + "/";  
    ort_filename += voicevox_get_onnxruntime_lib_versioned_filename();  
    LOGE("load onnxruntime filename: %s", ort_filename.c_str());  
    load_ort_options.filename = ort_filename.c_str();  
    auto result = voicevox_onnxruntime_load_once(load_ort_options, &onnxruntime);  
    if (result != VOICEVOX_RESULT_OK){  
        LOGE("load onnxruntime error: %s", voicevox_error_result_to_message(result));  
        return nullptr;  
    }  
  
    OpenJtalkRc* open_jtalk;  
    result = voicevox_open_jtalk_rc_new(open_jtalk_dict_path.c_str(),&open_jtalk);  
    if (result != VOICEVOX_RESULT_OK){  
        LOGE("load openjtalk error: %s", voicevox_error_result_to_message(result));  
        return nullptr;  
    }  
  
    VoicevoxSynthesizer* synthesizer;  
    result = voicevox_synthesizer_new(onnxruntime,open_jtalk,initialize_options,&synthesizer);  
    if (result != VOICEVOX_RESULT_OK) {  
        LOGE("load synthesizer error: %s", voicevox_error_result_to_message(result));  
        return nullptr;  
    }  
    voicevox_open_jtalk_rc_delete(open_jtalk);  
  
    VoicevoxVoiceModelFile *model;  
    std::string path = assetsPathStr + "/1.vvm";  
    result = voicevox_voice_model_file_open(path.c_str(), &model);  
    if (result != VoicevoxResultCode::VOICEVOX_RESULT_OK) {  
        LOGE("load voicevox_voice_model_file_open error: %s",  
             voicevox_error_result_to_message(result));  
        return nullptr;  
    }  
    result = voicevox_synthesizer_load_voice_model(synthesizer, model);  
    if (result != VoicevoxResultCode::VOICEVOX_RESULT_OK) {  
        LOGE("load voicevox_synthesizer_load_voice_model error: %s",  
             voicevox_error_result_to_message(result));  
        return nullptr;  
    }  
    voicevox_voice_model_file_delete(model);  
  
    LOGE("音声ファイル作成中");  
  
    size_t output_wav_size = 0;  
    uint8_t *output_wav = nullptr;  
  
    result = voicevox_synthesizer_tts(synthesizer,text.c_str(), STYLE_ID,  
                                      voicevox_make_default_tts_options(),  
                                      &output_wav_size, &output_wav);  
  
    if (result != VOICEVOX_RESULT_OK) {  
        LOGE("voicevox_synthesizer_tts error: %s", voicevox_error_result_to_message(result));  
        voicevox_synthesizer_delete(synthesizer);  
        return nullptr;  
    }  
  
  
    // jbyteArray に変換  
    jbyteArray byteArray = env->NewByteArray(static_cast<jsize>(output_wav_size));  
    if (byteArray == nullptr) {  
        LOGE("Failed to allocate jbyteArray");  
        voicevox_synthesizer_delete(synthesizer);  
        return nullptr;  
    }  
    env->SetByteArrayRegion(byteArray, 0, static_cast<jsize>(output_wav_size),  
                            reinterpret_cast<jbyte *>(output_wav));  
  
    voicevox_wav_free(output_wav);  
    voicevox_synthesizer_delete(synthesizer);  
    LOGE("音声ファイル作成完了");  
  
    return byteArray;   
}

後はvoicevox_coreのリンカを設定してあげる必要があるため、次のようにCMakeを調整します。

cmake_minimum_required(VERSION 3.22.1)  
  
project("voicevoxtest")  
  
add_library(${CMAKE_PROJECT_NAME} SHARED  
        native-lib.cpp)  
  
target_link_libraries(${CMAKE_PROJECT_NAME}  
        ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libvoicevox_core.so  
        android  
        log)

Kotlin側の実装

AndroidNDKからwavのバイナリーデータを貰えるため、それを再生する機能を実装しながら統合します。

override fun onCreate(savedInstanceState: Bundle?) {  
    super.onCreate(savedInstanceState)  
  
    binding = ActivityMainBinding.inflate(layoutInflater)  
    setContentView(binding.root)  
  
    copyAssetFiles(assets, filesDir)  
    val wavData = voicevox("こんにちは", filesDir.absolutePath)  
    playAudio(wavData)  
}

private fun playAudio(  
    audioData: ByteArray  
) {  
    // wavバイナリー音声データを再生する  
    val wavHeaderSize = 44  
    if (audioData.size <= wavHeaderSize) return  
  
    val pcmData = audioData.copyOfRange(wavHeaderSize, audioData.size)  
  
    val sampleRate = 24000  
    val channelConfig = AudioFormat.CHANNEL_OUT_MONO  
    val audioFormat = AudioFormat.ENCODING_PCM_16BIT  
  
    val audioTrack = AudioTrack(  
        AudioManager.STREAM_MUSIC,  
        sampleRate,  
        channelConfig,  
        audioFormat,  
        pcmData.size,  
        AudioTrack.MODE_STATIC  
    )  
  
    audioTrack.write(pcmData, 0, pcmData.size)  
    audioTrack.play()  
}

筆者と同じ構成で組み上げると実行時に冥鳴ひまりさんがこんにちはと喋ってくれると思います。これほど簡単に可愛い音声モデルでT2Sできるのは最高ですね!

T2S環境をより良くする

ここからはおまけです。
音声音量を上げたいなどの希望が出てくると思います。そのような悩みを解決する機能はVOICEVOXにしっかりあります。その使い方をおまけとして紹介します。
公式ドキュメントによると音声処理は次のような形になります。
https://github.com/VOICEVOX/voicevox_core/blob/main/docs/guide/user/tts-process.md
合成音声の設定データはAudioQueryにあり、これはjsonで定義されています。これを調整すると音声音量をあげたりなど出来ます。またアクセントなどの調整は更に深い層で処理されていることもわかります。今回は音声音量を上げたいのでAudioQueryの調整を目的とします。
voicevox_synthesizer_ttsvoicevox_synthesizer_create_audio_queryvoicevox_synthesizer_synthesisのショートハンドになります。
そのため、先ほどのvoicevox_synthesizer_ttsを2つに分解する必要があります。また、jsonを扱いたいため、nlohmannも導入します。
まずはCmakeから調整します。

cmake_minimum_required(VERSION 3.22.1)  
  
project("voicevoxtest")  
  
include(FetchContent)  
# nlohmann/json をダウンロード  
FetchContent_Declare(  
        nlohmann_json  
        GIT_REPOSITORY https://github.com/nlohmann/json.git  
        GIT_TAG v3.12.0 # 必要なバージョンを指定  
)  
FetchContent_MakeAvailable(nlohmann_json)  
  
# ヘッダーファイルのディレクトリを指定  
include_directories(${CMAKE_SOURCE_DIR}/)  
  
add_library(${CMAKE_PROJECT_NAME} SHARED  
        # List C/C++ source files with relative paths to this CMakeLists.txt.  
        native-lib.cpp)  
  
target_link_libraries(${CMAKE_PROJECT_NAME}  
        PRIVATE nlohmann_json::nlohmann_json  
        ${CMAKE_SOURCE_DIR}/../jniLibs/arm64-v8a/libvoicevox_core.so  
        android  
        log)

また、includeも忘れずに追記してください。

#include <nlohmann/json.hpp>

これで実行するとさきほどと比べて音声音量が大きくなったと思います。音量スケールを5あたりにするとかなりの爆音で再生されるので気をつけてください。

おわりに

実装自体はかなりの技術力を要求されますが、VoiceVoxCoreはかなり優れた便利なライブラリなため、サービスに組み込むことで表現の幅が広がるため、是非試してみてください。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる