A.I.VOICE for Gamesを使ってUnityエディタ内でカットシーンの仮当てボイス生成を半自動化

この記事はUnity Advent Calendar 2023の22日目 の記事です。

# Unityゲーム開発におけるカットシーン制作での音声の尺を仮音声で確認したい
A.I.VOICE for Gamesアンバサダーの一條です。

Timelineを使ってカットシーン制作を行っている際、セリフがどのくらいで終わりそうか仮の音声を用意してアタリをつける場面を考えます。

・カメラ切替や演出のタイミングをどうすべきか？セリフを言った後にアニメーション、カメラ切替、エフェクトを切り替えたい
・ボイスありの場合、声優さんの収録がおわらないと長さが決めづらい。実際のボイスデータがどれくらいの長さになる？

といったことを考えています。
合成音声ツールを使って、カットシーンの尺を調整するための仮音声があるといいのですが、自分で用意するのは面倒です。特に、セリフデータが大量にある場合はテキストから変換データを使って配置することが大変です。なんとか自動化したいと考えました。

![unitychanvoice1.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19010/1aa97a65-ce43-9c1b-47fa-ff29e94480f0.gif)

そこで、Unityエディタ拡張の A.I.VOICE for Gamesを使って、
Timelineアセットに配置されたテキストから合成音声生成・Timeline配置までを半自動でやることを考えます。

## A.I.VOICE for Gamesとは
株式会社エーアイが配信しているUnity向けアセットです。
https://aivoice.jp/games/

簡単に言うとUnityエディターにテキストからwavファイル（Audio Clipファイル）を作成するウィンドウを生やします。リアルタイムではなくてあくまでエディタ上生成です。
非商用なら無料で利用でき、　Unity１週間ゲームジャムなどでしたらそのまま使うことも便利そうです。

近年は学習元データの権利がはっきりしないAI生成アセットの利用を禁止するストアもあるのですが、「A.I.VOICE」シリーズは元の音声をゼロから収録しており、声優や音声合成化するキャラクターの権利元と契約の上で開発・販売されているため、安心して利用できます。

このエディタ拡張はテキストの直入力のほか、csv読み込みによる一括生成ができます。
指定のフォーマットにそったcsvを用意してやれば一気に作れますので、これを使って半自動化を試みます。
成果は以下の通りです。

https://twitter.com/Takaaki_Ichijo/status/1733411704695033956/video/1

## 開発環境
 - Unity 2022.3.7f1
 - Default Playables
Timelineの拡張パック
文字データをタイムラインで制御する「TextSwitcherClip」が使えるようになる
 - A.I.VOICE for Games
合成音声をエディタ上で生成できる
- CsvHelper
Csv読み込み・書き出しライブラリ
A.I.VOICE for Gamesの導入時にどのみち入れる

## 実装手順
A.I.VOICE for Games自体の導入は省略します。公式サイトの手順をご覧ください。
導入後の手順は以下の通りです。

1. Timelineアセットを解析してTextSwitcherClipからテキスト一覧を取り出す
2. A.I.VOICE for Gamesフォーマットのcsvファイルを作る
3. csvから合成音声データを一括生成する
4. TextSwitcherClipのタイミング情報（開始時刻）を取り出す
5. テキストと対応する音声データをTimelineに配置する

### Timelineアセットを解析してTextSwitcherClipからテキスト一覧を取り出す
まずは対象のTimelineアセットから、TextSwitcherClipの中身を見てボイスを作りたいテキストをリストとして取り出します。
リストはTextSwitcherClipの名称とセリフテキストのTuppleです。TextSwitcherClipの名称と同じ名前のAudioClipを作ることで、後工程のAudioトラック上の配置に使います。

```csharp
const string timelineFilePath = "Assets/Timeline/TestTimeLine.playable";
const string TEXT_SWITCHER_TRACK_NAME = "Text Switcher Track";

TimelineAsset timelineAsset = AssetDatabase.LoadAssetAtPath<TimelineAsset>(timelineFilePath);

var textDataList = new List<(string clipName, string text)>();

if( timelineAsset != null )
{
   IEnumerable<TrackAsset> tracks= timelineAsset.GetOutputTracks();
  
   var textSwitcherTrack = tracks.FirstOrDefault(track => track.name == TEXT_SWITCHER_TRACK_NAME);

   if (textSwitcherTrack != null)
   {
       foreach (TimelineClip timelineClip in textSwitcherTrack.GetClips())
       {
           TextSwitcherClip tsc = (TextSwitcherClip)timelineClip.asset;
           textDataList.Add((timelineClip.displayName, tsc.template.text));
           Debug.Log(timelineClip.displayName + ", " + tsc.template.text);
       }
   }
}
```

### csvファイルを作る
取り出したテキストから所定フォーマットのcsvをつくります。

#### A.I.VOICE for Gamesフォーマットのcsvファイル

```
ファイルパス,セリフ,話者名,音量,話速,高さ,抑揚,スタイル1,スタイル2,スタイル3
Assets/Voices/Voice1.aivoice,LT大会にようこそ！,ユニティちゃん,1,1,1,0.5,0.5,0,0
Assets/Voices/Voice2.aivoice,ビールを飲んでるよ！,ユニティちゃん,1,1,1,0.5,0.5,0,0
Assets/Voices/Voice3.aivoice,9時間睡眠が大事だね！,ユニティちゃん,1,1,1,0.5,0.5,0,0
```

所定フィーマットは日本語のデータ項目のため、csvHelperで簡単に返還をするために、クラスもフィールド名を日本語にしてしまいます。

```csharp
public class AIVOICEData
{
   public string ファイルパス { get; set; }
   public string セリフ { get; set; }
   public string 話者名 { get; set; }
   public float 音量 { get; set; }
   public float 話速 { get; set; }
   public float 高さ { get; set; }
   public float 抑揚 { get; set; }
   public float スタイル1 { get; set; }
   public float スタイル2 { get; set; }
   public float スタイル3 { get; set; }
}
```
#### csv生成
上記クラスのリストを作って、CsvWriterに渡してあげることでcsvを書き出します。

```csharp
var records = new List<AIVOICEData>();

foreach (var textData in textDataList)//Timelineアセットから取り出してきたテキストリスト
{
   var record = new AIVOICEData()
   {
       ファイルパス = voiceExportPath + textData.clipName + ".aivoice",
       セリフ = textData.text,
       話者名 = "ユニティちゃん",
       音量 = 1.0f, 話速 = 1.0f, 高さ = 1.0f, 抑揚 = 0.5f, スタイル1 = 0.5f, スタイル2 = 0f, スタイル3 = 0f
   };
  
   records.Add(record);
}

using (var writer = new StreamWriter(csvExportFilePath))
{
   using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
   {
       csv.WriteRecords(records);
   }
}
AssetDatabase.SaveAssets();
AssetDatabase.Refresh();
```

### csvから合成音声データを一括生成する

A.I.VOICE for Gamesを使ってボイスデータを一括生成します。
任意のフォルダで、右クリックメニューの「A.I.VOICE for GAMES」から
「Import CSV」で先ほどのcsvを選択します。これで、TextSwitcherClipと同じ名前のwavができます。

![スクリーンショット 2023-12-09 234345.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19010/40c6aaa2-a869-b633-2681-15d9dbf83b5e.png)

### AudioClipをTimelineに配置

生成したAudioClipをTimelineに配置する後工程を行います。

#### TextSwitcherClipのタイミング情報（開始時刻）を取り出す
今一度タイムラインアセットの中身を調べ、Clipの名前とタイミング情報のTuppleリストを作ります。

```csharp
TimelineAsset timelineAsset = AssetDatabase.LoadAssetAtPath<TimelineAsset>(timelineFilePath);

//TextSwitcherClipの名前とタイミング情報を取得
var clipNameAndStartTime = new List<(string clipName, double startTime)>();
if( timelineAsset != null )
{
   IEnumerable<TrackAsset> tracks= timelineAsset.GetOutputTracks();
  
   var textSwitcherTrack = tracks.FirstOrDefault(track => track.name == TEXT_SWITCHER_TRACK_NAME);

   if (textSwitcherTrack != null)
   {
       foreach (TimelineClip timelineClip in textSwitcherTrack.GetClips())
       {
           clipNameAndStartTime.Add((timelineClip.displayName, timelineClip.start));
       }
   }
```

#### テキストと対応するAudioClipをTimelineに配置する

作ったリストをもとにAudioトラックへAudio Clipを配置していきます。

```csharp
const string AUDIO_TRACK_NAME = "Audio Track";
const string voiceExportPath = "Assets/Voices/";

//（一部略）

var audioTrack = tracks.FirstOrDefault(track => track.name == AUDIO_TRACK_NAME);

DeleteAllClips(timelineAsset,audioTrack);

foreach (var clipNameAndTime in clipNameAndStartTime)// TextSwitcherClipの名前とタイミングリスト
{
   var clip = audioTrack.CreateDefaultClip();
   var audioPlayableAsset = clip.asset as AudioPlayableAsset;
  
//TextSwitcherClipと同じ名前のwavを探してAudioPlayableAssetのタイミングを同じに
   var audioClip = 
AssetDatabase.LoadAssetAtPath<AudioClip>(voiceExportPath+ clipNameAndTime.clipName + ".wav");

   audioPlayableAsset.clip = audioClip;
  
   clip.start = clipNameAndTime.startTime;
   clip.duration = audioClip.length;
   clip.asset = audioPlayableAsset;
}

//タイムラインをいじったのでリフレッシュかけて保存
TimelineEditor.Refresh(RefreshReason.ContentsAddedOrRemoved);
AssetDatabase.SaveAssets();
```

AudioClipのTimeline Clip版をスクリプトで作る時に
```
   var clip = audioTrack.CreateDefaultClip();
   var audioPlayableAsset = clip.asset as AudioPlayableAsset;
```
とやらないと再生時に参照が消えたりするので注意です。（AudioPlayableAssetを直接newするとダメ）

# まとめと今後
Unityのカットシーン制作で仮当てボイスを半自動生成できました。
「半自動」と言っているのは、現時点ではcsvから合成音声生成のときは手動操作が入るためです。
これは、今後のA.I.VOICEのアプデでスクリプトから操作できるようになる予定で、そうなるとボタン一発で配置までいけるようになります。

> GUIを制御している部分のC#コードを見えるようにし、ある程度のユーザーによる改造を許可する予定です。
IndieGamesJp.dev
「音声合成技術を活用して、Unityゲーム開発のイテレーションを高速化。
　「A.I.VOICE for GAMES」開発の背景と今後の展望」より
https://indiegamesjp.dev/?p=8082　より

とりあえずワンソース版をgistにアップしました。
https://gist.github.com/TakaakiIchijo/ff37f586a217430b35688e90d6c4d14c

A.I.VOICE for GamesはUnityエディターと統合されているところがおもしろい点で、他にもテキストアドベンチャー系アセットから自動で合成音声を作ったり、アクションゲームなどで敵にランダムな音声を割り振ったりといった自動化が行えそうです。ぜひ触ってみてください。