【Unity】PowerPointファイル表示＋音声合成でスライドの動画作成を自動化

先日PowerPointファイルを利用して発表動画を作成する機会があったのですが，何度も噛んだり，制限時間に間に合わなかったり撮り直すことになりました．

すごく喉が痛くなったので，機械に読み上げてもらうようにしようと思った次第です．

やろうとしていることは以下のようなものです．

![構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/1d44cccd-863e-0385-5dac-947ae61974b0.png)

1. PowerPointファイルの読み込み
2. スライドを画像に変換し，ノートに書かれている内容を取得
3. 画像をUnityに読み込み
4. ノートの文を音声合成APIに投げ，音声ファイルを作成
5. スライド画像を表示し，音声を再生させる
6. （アバターを表示し，口パクさせる）

今回は5までのやり方を示そうと思います．
ここまでで<b>ナレーションありのスライド動画</b>をUnityで表示させることができるようになります．ナレーションはスライドのメモを読み上げる形で実現します．

[UnityRecorder](https://docs.unity3d.com/Packages/com.unity.recorder@2.0/manual/index.html)等を利用することで動画をエクスポートすることもできます．

<b>今回の記事で実装した内容は[GitHub](https://github.com/Asalato/UniSlideToMovie)で公開しています．</b>
詳しくはそちらを確認いただけるとありがたいです．

6まで実装すると以下のような動画がPowerPointファイルを投げるだけで作成できるようになりました．
![sample.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/38714332-a388-fd02-01ab-a5fac92eb981.png)
~~YouTubeの怪しい美容広告みたいになりました．~~

# PowerPointファイルの読み込み
[Microsoft.Office.Interop.PowerPoint](https://www.nuget.org/packages/Microsoft.Office.Interop.PowerPoint/)ライブラリを利用することで，C#で.pptxや.pptといったPowerPointファイルを開いたり，修正，画像の書き出しといったことが可能になります．

以下のコードで，スライドを１枚の画像として書き出し，ノートをstringで取得します．

```cs
var SLIDE_PATH = "スライドの場所"
var FILE_PATH = "画像の保存場所"

// ノートと画像保存場所のリスト
var slideList = new List<(string, string)>();

var app = new Microsoft.Office.Interop.PowerPoint.Application();

// スライドを開く
var ppt = app.Presentations.Open(SLIDE_PATH, MsoTriState.msoTrue, MsoTriState.msoFalse,
    MsoTriState.msoFalse);

var width = (int) ppt.PageSetup.SlideWidth;
var height = (int) ppt.PageSetup.SlideHeight;

var slideList = new List<SlideDataRaw>();

for (var i = 1; i <= ppt.Slides.Count; i++)
{
    // 非表示スライドは無視
    if (ppt.Slides[i].SlideShowTransition.Hidden == MsoTriState.msoTrue) continue;

    // ノート
    var note = ppt.Slides[i].NotesPage.Shapes.Placeholders[2].TextFrame.TextRange.Text;
    if (note == "") continue;

    // JPEGとして保存
    var file = FILE_PATH + $"/slide{i:0000}.jpg";
    ppt.Slides[i].Export(file, "jpg", width, height);

    slideList.Add((note, file));
}

ppt.Close();
app.Quit();
```

UnityでPowerPointファイルを利用するのに一番問題となるのは，画像の保存場所です．
一般的にUnityではデータの保存場所として`Application.persistentDataPath`などを利用しますが，これらは実行時Unityがアクセス権を持っており，PowerPointSDKからこの場所にデータの保存が行えないという問題がありました．
なので`FILE_PATH`には`C:\Users\User\Documents`などUnityが触れない場所を設定する必要があります．

## スライド画像をUnityに読み込む
一般的な`.jpg`ファイルの読み込み方法と同じです．
以下のスクリプトで`.jpg`ファイルを`Texture2D`に変換します．

```cs
private static Texture2D LoadImage(string path)
{
    byte[] binary;
    try
    {
        using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
        {
            var length = (int) fs.Length;
            binary = new byte[length];
            fs.Read(binary, 0, length);
            fs.Close();
        }
    }
    catch(IOException exception)
    {
        Debug.Log(exception);
        return null;
    }
    
    var texture = new Texture2D(0, 0);
    texture.LoadImage(binary);
    return texture;
}
```

# テキストを音声合成しAudioClipを作成
先ほど取得できたスライドのノートを音声合成APIに送信し，AudioClipを作成します．
音声合成は必要になった場合にリアルタイムで合成結果を受け取る<b>ストリーミング音声合成</b>が一般的ですが，今回は発言内容が事前にすべて決まっているため，先にリクエストを全て送信しておき，結果をキャッシュするようにします．

今回は音声合成APIとして[Watson Text To Speech API](https://www.ibm.com/jp-ja/cloud/watson-text-to-speech)を利用しました．

## Watson Text To Speechの利用方法
1. [IBM Cloud](https://cloud.ibm.com/login)にログイン（アカウントを持っていない場合は作成します）
2. カタログからText To Speechを選択します．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/9a031f2d-27bc-7668-ef0f-4a8ba9a7952c.png)

3. リージョンを選択し，「作成」を押します．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/4b13c6c0-0b86-9135-66ee-abacf11db595.png)

4. リソースリストから今作成したサービスを選択し，APIキーとURLをメモしておきます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/433211/bb27af89-6bff-f81a-31d5-c1b0ded014a4.png)

これでWatson側の設定は以上です．
Watsonには[IBM Watson SDK for Unity](https://github.com/watson-developer-cloud/unity-sdk)という便利なUnity用SDKが存在し，これを利用することで多くのサンプルやUtilityスクリプトを利用できますが，今回はText To Speechのみを利用するため，自分でWebRequestを実装します．

---

まず，先ほど取得したAPIキーから<b>アクセストークン</b>を取得するリクエストを記述する必要があります．音声合成リクエストにはAPIキーではなくこのトークンを送る必要があります．

トークンを取得するリクエストは以下のように記述します．

```cs
public string GetAccessToken(string apikey)
{
    var form = new WWWForm();
    form.AddField("grant_type", "urn:ibm:params:oauth:grant-type:apikey");
    form.AddField("apikey", apiKey);
    form.AddField("response_type", "cloud_iam");
    using (var request = UnityWebRequest.Post(AUTH_URL, form))
    {
        request.SetRequestHeader("Content-type", "application/x-www-form-urlencoded");
        request.SendWebRequest();
        while(!request.isDone && !_cancelled){}

        if (request.responseCode != 200L)
        {
            Debug.LogError($"[GenerateAudio] Request Failed ({request.responseCode}): {request.error}\nat{request.url}");
            return;
        }

        var json = request.downloadHandler.text;
        return JsonConvert.DeserializeObject<IamTokenResponse>(json).AccessToken;
    }
}

```

今回はJsonのパースに[Newtonsoft Json](https://www.newtonsoft.com/json)を利用しました．どんなものでもよく，AccessTokenパラメータのValueが目的のアクセストークンです．

一応，Newtonsoft Jsonの場合のパースする対象オブジェクトは以下のような構造になっています．

```cs
public class IamTokenResponse
{
    [JsonProperty("access_token", NullValueHandling = NullValueHandling.Ignore)]
    public string AccessToken { get; set; }
    [JsonProperty("refresh_token", NullValueHandling = NullValueHandling.Ignore)]
    public string RefreshToken { get; set; }
    [JsonProperty("token_type", NullValueHandling = NullValueHandling.Ignore)]
    public string TokenType { get; set; }
    [JsonProperty("expires_in", NullValueHandling = NullValueHandling.Ignore)]
    public long? ExpiresIn { get; set; }
    [JsonProperty("expiration", NullValueHandling = NullValueHandling.Ignore)]
    public long? Expiration { get; set; }
}
```

そしてリクエストを送信し，合成結果を取得するのは以下のように記述します．

```cs
public byte[] GetSynthesizeVoice(string text)
{
    var rqStr = JsonConvert.SerializeObject(new JObject {["text"] = text});
    var url = $"{URL}/v1/synthesize?voice=ja-JP_EmiV3Voice";
    using (var request = new UnityWebRequest(url, "POST"))
    {
        request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(rqStr));
        request.downloadHandler = new DownloadHandlerBuffer();
        request.SetRequestHeader("Content-Type", "application/json");
        request.SetRequestHeader("Accept", "audio/wav");
        request.SetRequestHeader("Authorization", $"Bearer {ACCESS_TOKEN}");
        request.SendWebRequest();
        while(!request.isDone && !_cancelled){}
        
        if (request.responseCode != 200L)
            return null;
        return request.downloadHandler.data;
    }
}
```

`ACCESS_TOKEN`には先ほど取得したアクセストークンを，`URL`にはWatsonのページで取得したUrlを記述してください．

---

これで合成音声のバイナリを取得できたので，UnityのAudioClipに変換する作業を行います．方法は私の前回の記事[Google Cloud Text-To-Speechを利用してUnityでキャラクターをフルボイスに！](https://qiita.com/Asalato/items/6aa23c40ff31d9d3f13d)でも使わせていただいた，[WAV](https://answers.unity.com/questions/737002/wav-byte-to-audioclip.html)クラスを利用します．

```cs
var wav = new WAV(GetSynthesizeVoice("こんにちは"));
var audioClip = AudioClip.Create("TextToSpeech", wav.SampleCount, 1, wav.Frequency, false);
```

# スライドを表示
最後に，画像表示と音声再生を同時に行い，音声が終了したら次のスライドを表示し音声も再生させる機構を実装します．

実装は簡単で，音声再生に使うAudioSourceをUpdateで監視しておき，`isPlaying`（再生中）が`false`になったら次の処理を行います．

```cs
public class Presenter : MonoBehaviour
{
    [SerializeField] private RawImage image;
    [SerializeField] private AudioSource source;
    
    private int _currentIndex;
    private SlideData[] _slide;

    public void StartPresentation(SlideData[] data)
    {
        Debug.Log("[Presenter] Start presentation.");
        _slide = data;
        _currentIndex = -1;
    }

    private void Update()
    {
        if (_slide == null || source.isPlaying) return;
        if (_currentIndex >= _slide.Length - 1) return;
        Debug.Log("[Presenter] Next slide.");
        ++_currentIndex;
        image.texture = _slide[_currentIndex].image;
        source.clip = _slide[_currentIndex].clip;
        source.Play();
    }
}
```

これでスライドの読み込みから音声合成，スライド送りが実装できました．

# 終わりに
今回はPowerPointファイルをUnityで読み込み，音声合成を行うことでナレーションありのスライド動画を自動で作成する手順を紹介しました．

今回示したコードのサンプルは，実際には「リクエスト可能サイズに合わせたテキストの分割」や「分割された音声ファイルの結合」が必要です．そのような処理も含めた，<b>UniSlideToMovie</b>というサンプルプロジェクトを[GitHub](https://github.com/Asalato/UniSlideToMovie)で公開していますのでそちらも確認していただけたらと思います．

また，この次の記事として，今回触れられなかった「6. アバターを表示し，口パクさせる」処理について書きたいと思います．そちらもよろしくお願いします．