VoicevoxClientSharp: C#やUnityからVOICEVOXで音声合成するライブラリの紹介

# はじめに

C#およびUnityからVOICEVOXを操作するクライアントライブラリを作成したので紹介します。

## VOICEVOXとは

VOICEVOXはhiroshiba氏が開発した無償の音声合成ソフトウェアです。テキスト入力を音声合成しwavとして得ることができます。
商用・非商用問わずに無償で使うことができ、OSSとして開発されています。

- [VOICEVOX](https://voicevox.hiroshiba.jp/)

# VoicevoxClientSharp

- [VoicevoxClientSharp](https://github.com/TORISOUP/VoicevoxClientSharp)

`VoicevoxClientSharp`はC#から[VOICEVOX](https://voicevox.hiroshiba.jp/)および[voicevox_engine](https://github.com/VOICEVOX/voicevox_engine)を制御するクライアントライブラリです。ライセンスはMITです。

詳細はリポジトリのREADMEにも書いてあるのですが、この場でも紹介します。

## できること

* C#（Unity含む）からVOICEVOXを使って簡単に音声合成ができる
  * VOICEVOXが提供する各APIを実行できるAPIクライアント実装
  * APIをラップして簡単に扱えるようにしたクライアント実装
* Unity上でVOICEVOXの合成結果を使って音声再生できる
* 音声再生と合わせてVRMのリップシンクができる

## 対象プラットフォーム

- `.NET Standard 2.0`
  - .NET Core 2.0以降
  - .NET Framework 4.6.1以降
  - Unity 2018.4以降
  - など

- 対応VOICEVOXエンジンバージョン: `0.21.1`

またUnity向けのサポートプラグインも別途配布しています。そちらのプラグインを持ちることでUnity上の音声再生や[VRM](https://vrm.dev/)との連携を行うことができます。

## 導入方法

NuGetパッケージとして配布しているため、NuGetより導入してください。

```cs
Install-Package VoicevoxClientSharp
```

### Unity向け導入方法

Unityに導入する場合は[NuGetForUnity](https://github.com/GlitchEnzo/NuGetForUnity)の使用を推奨します。

また同時に`UniTask`および`VoicevoxClientSharp.Unity`を導入することで、Unity上での音声再生などを行うことができるようになります。
`VoicevoxClientSharp.Unity`の導入はUPMより次のURLを入力して下さい。

```
https://github.com/TORISOUP/VoicevoxClientSharp.git?path=VoicevoxClientSharp.Unity/Assets/VoicevoxClientSharp.Unity
```

※ `VoicevoxClientSharp.Unity`は要`UniTask`

## 使い方（C#,Unity共通）

1. **`VOICEVOX`をネットワーク上でアクセスできる場所で起動する**
   - ローカルマシンまたはdockerコンテナ上で起動しておいてください

2. **`VoicevoxClientSharp`より次のどちらかのクライアントを生成する**
    - `VoicevoxSynthesizer` : とにかく簡単にVOICEVOXを使いたい人向け
    - `VoicevoxApiClient` : VOICEVOXが提供するAPIを個別に使いたい人向け

3. **生成したクライアントをもとに、VOICEVOXを用いて音声合成を行う**
    - 音声合成した結果は`wav`形式の`byte[]`として得られます

4. **`wav`をC#アプリケーション側で再生する**
   - Unityの場合は`AudioClip`に変換することで再生できます
   - `VoicevoxClientSharp.Unity`を用いると簡単にUnity上で再生ができます


### VoicevoxSynthesizer:とにかく簡単にVOICEVOXを使いたい人向けクライアント

`VoicevoxSynthesizer`を用いることでテキストからの音声合成を簡単に実行することができます。

```cs:VoicevoxSynthesizer
// VoicevoxSynthesizerの初期化
using var synthesizer = new VoicevoxSynthesizer();

// スタイル（発話するキャラクターや種類）のIdを取得する
// StyleIdが既知の場合はこのステップは不要
int styleId = (await synthesizer.FindStyleIdByNameAsync(speakerName: "ずんだもん", styleName: "あまあま"))!.Value;

// 音声合成を実行
// resultに合成結果のwavデータ(byte[])が可能されている
SynthesisResult result = await synthesizer.SynthesizeSpeechAsync(styleId, "こんにちは、世界！");
```

```cs:SynthesisResult
public readonly struct SynthesisResult : IEquatable<SynthesisResult>
{
    /// <summary>
    /// 合成した音声データ
    /// </summary>
    public byte[] Wav { get; }

    /// <summary>
    /// 音声合成に使用したクエリ
    /// </summary>
    public AudioQuery AudioQuery { get; }

    /// <summary>
    /// 音声合成に使用したテキスト
    /// </summary>
    public string Text { get; }
}
```

また、細かく発話時のパラメータを調整して合成することもできます。

```cs
var result = await synthesizer.SynthesizeSpeechAsync(styleId, "こんにちは、世界！",
        speedScale: 1.1M, // 読み上げ速度の倍率
        pitchScale: 0.1M, // ピッチの変化
        intonationScale: 1.1M, // イントネーションの強さ
        volumeScale: 0.5M, // 音量
        prePhonemeLength: 0.1M, // 読み上げ前の待機時間
        postPhonemeLength: 0.1M, // 読み上げ後の時間
        pauseLength: 0.1M, // 読み上げ途中の待機時間
        pauseLengthScale: 1.5M); // 読み上げ途中の待機時間の倍率
```

### VoicevoxApiClient :VOICEVOXが提供するAPIを個別に使いたい人向けクライアント

`VoicevoxApiClient`はVOICEVOXが提供するREST APIと1:1に対応した"シンプルな"クライアントです。上級者向けです。

`VoicevoxSynthesizer`はこの`VoicevoxApiClient`をラップしているだけです。そのため`VoicevoxSynthesizer`でできることはすべて`VoicevoxApiClient`でも実装が可能です。

どのREST APIとメソッドが対応しているかは[対応表](https://github.com/TORISOUP/VoicevoxClientSharp?tab=readme-ov-file#b-1-%E4%BD%BF%E3%81%84%E6%96%B9)があるのでそちらを御覧ください。

```cs:使用例,StyleIdを取得して音声合成を行う
// APIクライアントを生成
using var apiClient = VoicevoxApiClient.Create();

// GET /speakers
// スピーカー一覧を取得
var speakers = await apiClient.GetSpeakersAsync();

// スピーカー名とスタイル名からスタイルIDを取得
var speaker = speakers.FirstOrDefault(s => s.Name == "ずんだもん");
var styleId = speaker?.Styles.FirstOrDefault(x => x.Name == "あまあま")!.Id ?? 0;

// POST /audio_query
// 音声合成用のクエリを作成
var audioQuery = await apiClient.CreateAudioQueryAsync("こんにちは、世界！", styleId);

// POST /synthesis
// 音声合成を実行
byte[] wav = await apiClient.SynthesisAsync(styleId, audioQuery);
```


## Unity向けの使い方

Unityの場合は`VoicevoxClientSharp.Unity`を導入することで次の機能が使用可能になります。

 - `VoicevoxSpeakPlayer`:Unityで合成した音声を再生するコンポーネント
 - `VoicevoxVrmLipSyncPlayer` : VRMアバターをリップシンクするコンポーネント
   - ただしこちらは[VRMパッケージ](https://github.com/vrm-c/UniVRM)の導入も必要
 - エディタ拡張

### VoicevoxSpeakPlayer:Unityで合成した音声を再生するコンポーネント

`VoicevoxSpeakPlayer`は`VoicevoxSynthesizer`より得られる`SynthesisResult`をUnity上で再生するコンポーネントです。
GameObjectにアタッチし、`AudioSource`を割り当てて使用してください（動的に生成して割り当ててもOKです）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/7964d5e5-eb3b-a760-d841-abac4cc3955b.png)



```cs:Unity上で音声再生する
using System.Threading;
using Cysharp.Threading.Tasks; // UniTaskが必須
using UnityEngine;
using VoicevoxClientSharp;
using VoicevoxClientSharp.Unity;

namespace Sandbox
{
    // 使用例
    public class Sample : MonoBehaviour
    {
        // 設定済みのVoicevoxSpeakPlayerをバインドしておく
        [SerializeField] 
        private VoicevoxSpeakPlayer _voicevoxSpeakPlayer;

        // VoicevoxSynthesizerを用いて音声合成する
        private readonly VoicevoxSynthesizer _voicevoxSynthesizer 
            = new VoicevoxSynthesizer();

        private void Start()
        {
            var cancellationToken = this.GetCancellationTokenOnDestroy();

            // テキストを音声に変換して再生する
            SpeakAsync("こんにちは、世界", cancellationToken).Forget();
        }

        // 音声合成して再生する処理
        private async UniTask SpeakAsync(string text, CancellationToken ct)
        {
            // テキストをVoicevoxで音声合成
            var synthesisResult = await _voicevoxSynthesizer.SynthesizeSpeechAsync(
                0, text, cancellationToken: ct);
            
            // 結果を再生する
            await _voicevoxSpeakPlayer.PlayAsync(synthesisResult, ct);
        }

        private void OnDestroy()
        {
            _voicevoxSynthesizer.Dispose();
        }
    }
}
```

### VoicevoxVrmLipSyncPlayer:VRMアバターをリップシンクするコンポーネント

![seedsan2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/ca626b6c-afd2-d431-23c0-dd8fb3ab9024.gif)

`VoicevoxVrmLipSyncPlayer`はVRMアバターをVOICEVOXで合成した音声再生のタイミングに合わせてリップシンクするコンポーネントです。
さきほどの`VoicevoxSpeakPlayer`と組み合わせて使用してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/542d3f68-5b17-cd6f-4a7f-ba95652c7039.png)


```cs:VRMとリップシンクする例かつ、すべて動的に組み立てるパターン
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UniVRM10;
using VoicevoxClientSharp;
using VoicevoxClientSharp.Unity;

namespace Sandbox
{
    /// <summary>
    /// すべてをスクリプトからセットアップする場合
    /// </summary>
    public sealed class LoadVrmAndSpeech : MonoBehaviour
    {
        // 読み込むVRMのパス
        [SerializeField] private string _vrmPath = "";

        private readonly VoicevoxSynthesizer _voicevoxSynthesizer = new VoicevoxSynthesizer();

        private void Start()
        {
            var cancellationToken = this.GetCancellationTokenOnDestroy();

            LoadVrmAndSpeechAsync(_vrmPath, cancellationToken).Forget();
        }

        // pathで指定したVRMを読み込み、リンプシンクして喋らせる
        private async UniTask LoadVrmAndSpeechAsync(string path, CancellationToken ct)
        {
            // バイナリファイルを読み込んでVRMをロード
            var vrm10Instance = await Vrm10.LoadPathAsync(path, ct: ct);
            var vrmGameObject = vrm10Instance.gameObject;

            // AudioSourceをVRMにアタッチ
            var audioSource = vrmGameObject.AddComponent<AudioSource>();

            // VoicevoxSpeakPlayerを追加してAudioSourceを紐づける
            var voicevoxSpeakPlayer = vrmGameObject.AddComponent<VoicevoxSpeakPlayer>();
            voicevoxSpeakPlayer.AudioSource = audioSource;

            // VoicevoxVrmLipSyncPlayerをアタッチし、VRMを紐づけ
            var voicevoxVrmLipSyncPlayer = vrmGameObject.AddComponent<VoicevoxVrmLipSyncPlayer>();
            voicevoxVrmLipSyncPlayer.VrmInstance = vrm10Instance;

            // VoicevoxSpeakPlayerにVoicevoxVrmLipSyncPlayerを追加
            voicevoxSpeakPlayer.AddOptionalVoicevoxPlayer(voicevoxVrmLipSyncPlayer);

            // テキストを音声に変換
            var synthesisResult = await _voicevoxSynthesizer.SynthesizeSpeechAsync(
                0, "こんにちは、世界", cancellationToken: ct);

            // 音声を再生しながらリップシンク
            // voicevoxSpeakPlayerに再生命令を送ればVoicevoxVrmLipSyncPlayerと協調して動作する
            await voicevoxSpeakPlayer.PlayAsync(synthesisResult, ct);
        }
    }
}
```

#### おまけ:VoicevoxVrmLipSyncPlayerの仕組み

本筋とは関係ないので折りたたみ。興味があれば読んで下さい。

<details><summary>VoicevoxVrmLipSyncPlayerの仕組み</summary>

VOICEVOXの音声合成は2段階に別れています。

1. テキストよりクエリ（`AudioQuery`）を作成する
2. 作成したクエリをもとに音声合成を行い`wav`を生成する

`VoicevoxSynthesizer`は内部でこの2つの処理を実行しています。合成結果として返されるデータ構造に`SynthesisResult`に含まれている`AudioQuery`がこの合成に用いたクエリです。


```cs:SynthesisResult
public readonly struct SynthesisResult : IEquatable<SynthesisResult>
{
    /// <summary>
    /// 合成した音声データ
    /// </summary>
    public byte[] Wav { get; }

    /// <summary>
    /// 音声合成に使用したクエリ
    /// </summary>
    public AudioQuery AudioQuery { get; }

    /// <summary>
    /// 音声合成に使用したテキスト
    /// </summary>
    public string Text { get; }
}
```

この`AudioQuery`ですが、VOICEVOXが返すもとのjsonを覗いてみると次のようなデータとなっています。


```json:「こんにちは、世界。」のクエリ
{
  "accent_phrases": [
    {
      "moras": [
        {
          "text": "コ",
          "consonant": "k",
          "consonant_length": 0.07622026652097702,
          "vowel": "o",
          "vowel_length": 0.1315000206232071,
          "pitch": 5.785459518432617
        },
        {
          "text": "ン",
          "consonant": null,
          "consonant_length": null,
          "vowel": "N",
          "vowel_length": 0.058612607419490814,
          "pitch": 5.884223937988281
        },
        {
          "text": "ニ",
          "consonant": "n",
          "consonant_length": 0.029203424230217934,
          "vowel": "i",
          "vowel_length": 0.08954069763422012,
          "pitch": 5.925149917602539
        },
        {
          "text": "チ",
          "consonant": "ch",
          "consonant_length": 0.0847683846950531,
          "vowel": "i",
          "vowel_length": 0.05707687884569168,
          "pitch": 5.91061544418335
        },
        {
          "text": "ワ",
          "consonant": "w",
          "consonant_length": 0.0631946474313736,
          "vowel": "a",
          "vowel_length": 0.15520663559436798,
          "pitch": 5.906591892242432
        }
      ],
      "accent": 5,
      "pause_mora": {
        "text": "、",
        "consonant": null,
        "consonant_length": null,
        "vowel": "pau",
        "vowel_length": 0.2892865836620331,
        "pitch": 0
      },
      "is_interrogative": false
    },
    {
      "moras": [
        {
          "text": "セ",
          "consonant": "s",
          "consonant_length": 0.0821339562535286,
          "vowel": "e",
          "vowel_length": 0.08564445376396179,
          "pitch": 5.917510032653809
        },
        {
          "text": "カ",
          "consonant": "k",
          "consonant_length": 0.07719596475362778,
          "vowel": "a",
          "vowel_length": 0.09904120117425919,
          "pitch": 5.946408271789551
        },
        {
          "text": "イ",
          "consonant": null,
          "consonant_length": null,
          "vowel": "i",
          "vowel_length": 0.10033080726861954,
          "pitch": 5.755062103271484
        }
      ],
      "accent": 1,
      "pause_mora": null,
      "is_interrogative": false
    }
  ],
  "speedScale": 1,
  "pitchScale": 0,
  "intonationScale": 1,
  "volumeScale": 1,
  "prePhonemeLength": 0.1,
  "postPhonemeLength": 0.1,
  "pauseLength": null,
  "pauseLengthScale": 1,
  "outputSamplingRate": 24000,
  "outputStereo": false,
  "kana": "コンニチワ'、セ'カイ"
}
```

ここには各音素とその再生時間が含まれています。`consonant_length`が子音の再生時間、`vowel_length`が母音の再生時間です。つまりこの`AudioQuery`の情報をもとにすれば「どの時間にどの音素を再生しているか」を割り出すことが可能となります。
そこで`VoicevoxVrmLipSyncPlayer`は音声再生と同じタイミングでこの`AudioQuery`を順次解析し、時間が一致するようにVRMのExpressionを制御することでリップシンクを実現しています。

- [VoicevoxVrmLipSyncPlayerの実装](https://github.com/TORISOUP/VoicevoxClientSharp/blob/master/VoicevoxClientSharp.Unity/Assets/VoicevoxClientSharp.Unity/Runtime/VRM/VoicevoxVrmLipSyncPlayer.cs)

また次のような調整も行っています。

 - 「ま行」や「ぱ行」などの発音時に口を閉じる音素はちゃんと口を閉じる
 - Unity上での時間計測では分解能が足りないため、`await`時に誤差が蓄積しないように逐次再生タイミングを補正する

なお動作原理上、「音声が1.0倍速で再生されている」ということを前提にしているため、音声の再生速度やゲーム中の`timeScale`を変更した場合はリップシンクできません。

</details>



### Unityエディタ拡張

[VoicevoxClientSharp -> Open HelperWindow]より、VOICEVOXからスピーカー一覧を取得するエディタ拡張を開くことができます。
StyleIdなどを確認するときに使用してください。


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/914d8f28-6955-4cf0-b8d4-c78aca7aa0ce.png)



## おまけ機能:AvisSpeech連携

[AvisSpeech](https://aivis-project.com/)は`voicevox_engine`をベースとしたVOICEVOX派生の音声合成ソフトウェアです。
`VoicevoxClientSharp`も部分的に対応しており、`VoicevoxApiClient`の一部メソッドが使用できます。


```cs:AvisSpeech向けにApiClientを生成する
// VoicevoxApiClientを生成する際にstaticメソッドでAviSpeechを指定して生成してください
using var avisSpeechApiClient = VoicevoxApiClient.CreateForAvisSpeech();
```

## 全体構成

APIクライントは`IVoicevoxApiClient`インタフェースにより抽象化が行われています。また`VoicevoxSynthesizer`はこの`IVoicevoxApiClient`に依存しています。
もしテストを書くようなことがあればこの抽象化をうまく利用してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/3e6b8a98-1fd3-fc21-e72e-a66f8e390370.png)


# まとめ

`VoicevoxClientSharp`を使うことでC#およびUnityから気軽に音声合成し、再生することができるようになるので是非使ってみてください。（より詳しい使い方は[README](https://github.com/TORISOUP/VoicevoxClientSharp)に書いてあるのでこっちを読んでください。）

余談として、`VOICEVOX`は`OpenAPI Spec`を公開してくれています。が、C#のコードジェネレートをかけたところ上手く動くコードが生成できませんでした。
なのでこの`VoicevoxClientSharp`はREST APIを叩く部分を手動で実装してあったりします。



# Unityでのサンプル実装

`VoicevoxClientSharp`を用いて、VRMを読み込んで発話させるサンプルプロジェクトを作ってみました。

![seedsan.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/47146/d32e6758-50c0-02e8-a197-ed19b6df91ba.gif)


- ローカルのVRMを読み込んで表示
- 背景色を変更できる（OBSなどでクロマキー合成ができる）
- REST APIにより外部から発話命令を送ることができる

**このサンプルプロジェクト、今年のUnityアドベントカレンダーのネタだったりします。** プロダクト規模に見合わないほど「クリーンなアーキテクチャ」をかなり意識して設計して作ってあります。もはや`VoicevoxClientSharp`のサンプル実装というよりも、「Unityにおけるクリーンなアーキテクチャのサンプル実装」の方が主題になってしまっています。~~`VoicevoxClientSharp`のサンプル実装としておそらく適切ではない。~~

この記事はアドベントカレンダーに向けた布石でもありました。ということでサンプルプロジェクトおよびその解説記事を後日（12/23）公開する予定なのでお楽しみに。