🔊

【Unity】Wwise のオーディオ付き動画を Unity Recorder でキャプチャしたい!!

2024/12/14に公開

この記事は Akatsuki Games Advent Calendar 2024 13日目の記事です。

はじめに

Unity 公式から提供されている動画キャプチャツールの Unity Recorder というものがあります。

こちらの Unity Recorder ですが、サードパーティのオーディオミドルウェアとして Wwise を採用している場合、オーディオ付きの動画キャプチャを行うことができません。
公式のマニュアルにも明示的にその旨が記載されています(以下、引用)。

https://docs.unity3d.com/Packages/com.unity.recorder@5.1/manual/KnownIssues.html

Audio recording limited support

Limitation: The Recorder currently supports only the recording of samples from the Unity built-in audio engine. As such, it cannot record audio from third-party audio engines such as FMOD Studio or Wwise.

オーディオ記録の限定的サポート

制限事項: 現在、レコーダーは、Unity のビルトインオーディオエンジンからのサンプルレコーディングのみをサポートしています。そのため、FMOD Studio や Wwise などのサードパーティ製オーディオエンジンからオーディオを記録することはできません。

これは不便ですね。実際に Unity Recorder でキャプチャしようとすると

  • Include audioを有効化した場合:エラーが出て動画自体出力できず
  • Include audioを無効化した場合:無音の動画ファイルが出力される

という挙動になってしまいました。

Unity Recorder の動作中はゲームが現実時間で動作しなくなる(例えば60FPS出ない環境で60FPSの動画をキャプチャしようとすると、1フレームの時間を1/60秒より長くしながらキャプチャしていく)ため、Windows のゲームバーや Mac の QuickTimePlayer を使ったオーディオ付きキャプチャも望めません。

試行錯誤した結果、「Wwise 側の API に存在するオーディオキャプチャ機能(wav 出力機能)を Unity Recorder 側から呼び出しつつ、出力された無音の動画と音声のwavファイルを FFmpeg を用いて結合する」という方法で音声付きの動画ファイルを生成することができたため、その手法を公開します。

環境

  • OS: macOS Sonoma 14.7.1
  • Unity Editor: 2022.3.51f1
  • Unity Recorder: 4.0.3
  • Wwise Unity API: 2020.1.0
  • ffmpeg: 7.1(Homebrewでインストール)

各サイドの API について

まずはオーディオ付き動画のキャプチャを実現するにあたって利用するAPIの情報を整理します。

Wwise 側のオーディオキャプチャ用 API

Wwise にはオーディオキャプチャ用にいくつかの API が用意されており、そのうちの一部は Unity 用のプラグインでも利用可能です。

https://www.audiokinetic.com/ja/library/edge/?source=SDK&id=goingfurther_offlinerendering.html

用意された API を使うことで、オーディオの出力結果を wav ファイルとして保存することができます。
具体的には、各タイミングで AkSoundEngine にある次の API を叩くことになります。

  • キャプチャ開始時
    1. SetOfflineRendering(true)
    2. SetOfflineRenderingFrameTime(0.0f)
    3. RenderAudio()
    4. StartOutputCapture(wavファイルの出力パス)
  • キャプチャ中毎フレーム
    1. SetOfflineRenderingFrameTime(フレーム間の経過時間)
    2. RenderAudio()
  • キャプチャ終了時
    1. StopOutputCapture()
    2. SetOfflineRendering(false)
    3. SetOfflineRenderingFrameTime(0.0f)
    4. RenderAudio()

Unity Editor 側の録画用 API

Libary/PackageCache/com.unity.recorder@4.0.3/Editor/Sources/Recorders/MovieRecorder/MovieRecorder.cs に動画のキャプチャを行うコードがあります。
中身を見ると、録画開始時・録画中毎フレーム・録画終了時に呼ばれる以下のメソッドが存在していることがわかります。

  • 録画開始時:BeginRecording
  • 録画中毎フレーム:RecordFrame
  • 録画終了時:EndRecording

ffmpeg 側の動画とオーディオを結合する API

次のコマンドで動画とオーディオを結合した新しい動画を生成することができます。

ffmpeg -i 動画パス -i オーディオパス -c:v copy -c:a aac 出力先動画パス

実際に組み合わせてみる

Unity Recorder のパッケージをカスタムできるようにする

Package Manager 経由で導入した Unity Recorder は、そのままではコードの書き換えができません。コードの書き換えを行うためには「カスタムパッケージ」化する必要があります。

https://docs.unity3d.com/ja/2022.3/Manual/CustomPackages.html

具体的には、以下のような手順となります。

  1. Libary/PackageCache/com.unity.recorder@4.0.3 を Packages フォルダに移動
  2. Packages/manifest.json から "com.unity.recorder": "4.0.3", の行を削除

これから  Packages/com.unity.recorder@4.0.3/Editor/Sources/Recorders/MovieRecorder/MovieRecorder.cs を編集していくことになりますが、保守性などの観点からこちらのファイルの編集は最小限に留め、実際に行う処理の詳細は別クラスに切り出して MovieRecorder からそれを呼び出すようにしていきます。

MovieRecorder からコールする処理を書く

Packages/com.unity.recorder@4.0.3/Editor/Customize フォルダを作成し、録画開始時・毎フレーム・録画終了時に実行する処理を記述した CustomRecordingProcessor クラスと、FFmpeg の処理をラップした FfmpegPipe クラスを作成しました。

CustomRecordingProcessor.cs

using System.IO;

namespace UnityEditor.Recorder.Customize
{
    internal class CustomRecordingProcessor
    {
        // 前フレームとの時間差分を取得するためのフィールド
        private float _prevRecorderTime;
        // Wwiseによるwavファイル出力先パス
        private string _audioPath;
        // UnityRecorderによる無音の動画ファイル出力先パス
        private string _videoPath;
        // FFmpegによるオーディオ付き動画の出力先パス
        private string _outputPath;

        // 録画開始時に呼ぶ処理
        public void OnBeginRecording(RecordingSession session)
        {
            // ファイルパスの生成
            (_audioPath, _videoPath, _outputPath) = BuildFilePaths(session);

            // オフラインレンダリングの有効化
            AkSoundEngine.SetOfflineRendering(true);
            AkSoundEngine.SetOfflineRenderingFrameTime(0.0f);
            AkSoundEngine.RenderAudio();

            // オーディオキャプチャ開始
            AkSoundEngine.StartOutputCapture(_audioPath);
        }

        // 録画中毎フレーム呼ぶ処理
        public void OnRecordFrame(RecordingSession session)
        {
            // 時間差分を取得
            var deltaTime = session.recorderTime - _prevRecorderTime;
            _prevRecorderTime = session.recorderTime;

            // 前フレームとの差分の分だけオーディオをレンダリング
            AkSoundEngine.SetOfflineRenderingFrameTime(deltaTime);
            AkSoundEngine.RenderAudio();
        }

        // 録画終了時に呼ぶ処理
        public void OnEndRecording(RecordingSession session)
        {
            // オーディオキャプチャ終了
            AkSoundEngine.StopOutputCapture();

            // オフラインレンダリングを無効化
            AkSoundEngine.SetOfflineRendering(false);
            AkSoundEngine.SetOfflineRenderingFrameTime(0.0f);
            AkSoundEngine.RenderAudio();

            // オーディオとビデオを結合するコマンド
            var args =
                "-loglevel error"
                + " -i \"" + _videoPath + "\""
                + " -i \"" + _audioPath + "\""
                + " -c:v copy -c:a aac"
                + " \"" + _outputPath + "\"";

            // FFmpegのラッパーを使ってコマンドを叩き、出力を取得
            var pipe = new FfmpegPipe(args);
            var error = pipe.CloseAndGetOutput();
            if (!string.IsNullOrEmpty(error))
            {
                // 処理に失敗したらエラー出力
                UnityEngine.Debug.LogWarning("FFmpegによる動画とオーディオの結合処理に失敗しました。詳細は以下を確認してください。\n" + error);
            }
            else
            {
                // 処理に成功したら動画と音声のファイルを削除
                File.Delete(_videoPath);
                File.Delete(_audioPath);

                // リネーム
                File.Move(_outputPath, _videoPath);
            }

            pipe.Dispose();
        }

        private static (string audioPath, string videoPath, string outputPath) BuildFilePaths(RecordingSession session)
        {
            var videoPath = session.settings.FileNameGenerator.BuildAbsolutePath(session);
            var directory = Path.GetDirectoryName(videoPath);
            var fileName = Path.GetFileNameWithoutExtension(videoPath);
            var extension = Path.GetExtension(videoPath);

            var audioPath = directory + "/" + fileName + "_audio.wav";
            var outputPath = directory + "/" + fileName + "_mux" + extension;

            return (audioPath, videoPath, outputPath);
        }
    }
}

FfmpegPipe.cs

using System;
using System.Diagnostics;
using System.IO;
using UnityEngine;

namespace UnityEditor.Recorder.Customize
{
    internal class FfmpegPipe : IDisposable
    {
        // 実行ファイルがある可能性のあるパス
        private static readonly string[] _executablePathCandidatesWindows =
        {
            @"C:\ffmpeg\bin\ffmpeg.exe",
            @"C:\Program Files\ffmpeg\bin\ffmpeg.exe",
            @"C:\Program Files (x86)\ffmpeg\bin\ffmpeg.exe",
            @"C:\ProgramData\chocolatey\bin\ffmpeg.exe",
            @"C:\Users\" + Environment.UserName + @"\ffmpeg\bin\ffmpeg.exe",
            @"C:\Tools\ffmpeg\bin\ffmpeg.exe"
        };

        private static readonly string[] _executablePathCandidatesMac =
        {
            "/usr/local/bin/ffmpeg",
            "/opt/homebrew/bin/ffmpeg",
            "/opt/local/bin/ffmpeg",
            "/usr/bin/ffmpeg"
        };

        private Process _process;
        private bool _closed;

        public FfmpegPipe(string arguments)
        {
            _process = Process.Start(new ProcessStartInfo
            {
                FileName = ResolveExecutablePath(),
                Arguments = arguments,
                UseShellExecute = false,
                CreateNoWindow = true,
                RedirectStandardInput = true,
                RedirectStandardOutput = true,
                RedirectStandardError = true
            });
        }

        public string CloseAndGetOutput()
        {
            _closed = true;

            _process.StandardInput.Close();
            _process.WaitForExit();

            var outputReader = _process.StandardError;
            var error = outputReader.ReadToEnd();

            _process.Close();
            _process.Dispose();

            outputReader.Close();
            outputReader.Dispose();

            _process = null;

            return error;
        }

        public void Dispose()
        {
            if (!_closed)
            {
                CloseAndGetOutput();
            }
        }

        ~FfmpegPipe()
        {
            if (!_closed)
            {
                UnityEngine.Debug.LogError(
                    "An unfinalized FFmpegPipe object was detected. " +
                    "It should be explicitly closed or disposed before being garbage-collected."
                );
            }
        }

        private static string ResolveExecutablePath()
        {
            var candidates = Application.platform == RuntimePlatform.WindowsEditor
                ? _executablePathCandidatesWindows
                : _executablePathCandidatesMac;

            foreach (var candidate in candidates)
            {
                if (File.Exists(candidate))
                {
                    return candidate;
                }
            }

            throw new FileNotFoundException("FFmpegの実行ファイルが見つかりませんでした。以下のパスにインストールされているか確認してください。\n" + string.Join("\n", candidates));
        }
    }
}

MovieRecorder から処理をコールする

フィールドを宣言し、BeginRecording, RecordFrame, EndRecordingCustomRecordingProcessor の処理を呼び出すようにします。

// ..........省略..........

namespace UnityEditor.Recorder
{
    class MovieRecorder : BaseTextureRecorder<MovieRecorderSettings>
    {
        // ..........省略..........

        // コード追加:フィールドを宣言して初期化
        private readonly Customize.CustomRecordingProcessor _customRecordingProcessor = new();

        // ..........省略..........

        protected internal override bool BeginRecording(RecordingSession session)
        {
            // ..........省略..........

            s_ConcurrentCount++;
            m_RecordingStartedProperly = true;
            m_RecordingAlreadyEnded = false;

            // コード追加:カスタムの処理を呼び出し
            _customRecordingProcessor.OnBeginRecording(session);

            return true;
        }

        protected internal override void RecordFrame(RecordingSession session)
        {
            if (m_Inputs.Count != 2)
                throw new Exception("Unsupported number of sources");

            if (!m_RecordingStartedProperly)
                return; // error will have been triggered in BeginRecording()

            base.RecordFrame(session);

            // コード追加:カスタムの処理を呼び出し
            _customRecordingProcessor.OnRecordFrame(session);
        }

        protected internal override void EndRecording(RecordingSession session)
        {
            if (asyncReadback != null)
            {
                asyncReadback.Dispose();
                asyncReadback = null;
            }

            if (m_Encoder != null)
            {
                m_Encoder.CloseStream();
                m_Encoder = null;
            }

            base.EndRecording(session);

            if (m_RecordingStartedProperly && !m_RecordingAlreadyEnded)
            {
                s_ConcurrentCount--;
                if (s_ConcurrentCount < 0)
                    ConsoleLogMessage($"Recording ended with no matching beginning recording.", LogType.Error);
                if (s_ConcurrentCount <= 1 && s_WarnedUserOfConcurrentCount)
                    s_WarnedUserOfConcurrentCount = false; // reset so that we can warn at the next occurence
                m_RecordingAlreadyEnded = true;
            }

            // コード追加:カスタムの処理を呼び出し
            _customRecordingProcessor.OnEndRecording(session);
        }
        
        // ..........省略..........
    }
}

Wwise 側で自動で呼ばれる RenderAudio を呼ばれないようにする

今までの対応だけでは実は不十分です。
というのも通常のフローで Wwise を Unity に導入した場合、AkInitializer.LateUpdate 経由で毎フレーム RenderAudio が呼ばれるのですが、これと CustomRecordingProcessor.OnRecordFrame 内で呼ぶ RenderAudio と合わせて 1フレーム間に合計2回の RenderAudio が呼ばれることになり、結果的に出力されるwavファイルが動画とズレてしまいます。

これを防ぐためにはいくつかの方法がありますが、私はシンプルに AkInitializer.LateUpdate 経由で呼ばれる方をコメントアウトしました。
RenderAudio を呼ぶ処理が書かれているのは AkSoundEngineController.LateUpdate 内なので、該当箇所をコメントアウトしておきます。

代わりに非録画時は別の経路で毎フレーム RenderAudio を呼ぶ必要があるので、そこはプロジェクト側で用意した MonoBehaviour の LateUpdate 辺りで呼んでおくと良いかと思います。

対応は以上となります。

動作確認

実際にやってみると、確かに出力先に指定したフォルダに音声付きの動画ファイルが生成されることを確認できました。嬉しい。

おわりに

今回の対応は FFmpeg を使った動画と音声の結合、ということになりますが、Wwise の API の中には AkSoundEngine.GetCaptureSamples というメソッドがあるので、これで取得したオーディオサンプルを動画のエンコーダーに渡すようにすれば FFmpeg なしで音声付き動画を出力できるようになる気がしています。

進展があればまた記事を書こうかと思います。

Discussion