Unity上で外部デバイスとの同期処理を行うための考察(タイムコード読み取り編)

はじめに

エンジニアの松原です。Unityと外部デバイスの間で同期処理を取ろうとするとき、単純に時間のタイミングだけ測って同期を取ろうとしても、デバイス内部で利用しているクロックジェネレータの仕様によって、時間が経つほどズレが大きくなってくることがあります。
同期ズレをできる限り無くしたい、または同期ズレが起きた場合でも後で修正する仕組み(またはマスタリング)として時間の尺度としてタイムコード(Linear time code、またはSMPTE timecode)を利用するアイデアがあります。
前回の記事に引き続き、今回はマイク入力経由でタイムコード信号生成器から音声データとして受け取ったタイムコードを読み取る方法について記事としてまとめました。

synamon.hatenablog.com

ちなみに、60fps以上のフレームレートに対応したタイムコードについての規格についてはSMPTE ST 12-3:2016やITU-RのRecommendation BT.1366-3のドキュメントで読めますが、SDI信号として送受信するアンシラリータイムコード(ATC)についての記述がメインで、音声信号経由で送受信できるかについては書かれていませんでした。(SDI信号はGHzレベルの周波数で信号を送っているため、音声信号として受け取ることができません)

タイムコード信号を波形画像から読み取る

いきなりUnity側の処理に移ると何をしているか分かりづらいと思うので、まずはタイムコード信号を波形画像として表示し、画像から波形に含まれているデータを読み取る方法について解説します。

事前セットアップ

前回の記事でも紹介しましたスマホからタイムコードの信号を生成してくれる、「TimeCode Generator」を使います。

timecodesync.com

3.5mmオーディオジャックを使ってスマホのイヤフォン出力をPCの音声入力につなげた状態で、タイムコード信号をAudacityで信号をキャプチャします。

www.audacityteam.org

実際のタイムコードの信号の波形

タイムコードの信号をAudacityでキャプチャした際の波形はこのようになります。タイムコード信号はパルス波で送られてきます。

これだけでは分からないので、この信号について解説します。タイムコード信号は長い波長を0、短い波長の正負のペアをとしてみなす、Biphase Mark Code(Differential Manchester encodingのエンコードバリエーション)というデジタルデータのエンコード方式が利用されています。(以下の画像はWikipediaの画像の抜粋になります)

先ほどの波形信号を読み取ると、以下のようになります。今回利用するタイムコードはLinear timecode(SMPTE timecode)の規格に則っているので、一つのタイムコードのデータは80ビットで表現されます。赤の縦線が入っている間に80bit分のタイムコードの波形データが含まれています。それぞれの波形をBiphase Mark Codeとして読み取ったものが赤で書かれた[0, 1]の文字になります。

ビットデータをタイムコードに変換する

Wikipediaにタイムコードのデータフォーマット表があるので、これを元に読み取ってみたいと思います。もっと詳しく知りたい方は、ITU-RのRecommendation BR.780-2に詳しく解説されているので、ご興味があればお読みください。

波形画像からビットデータを取り出し、整理すると以下のようになります。(時間以外のデータの表記については省略しています)

これをタイムコードとして数値表現すると、「06:18:36:28」になります。音声の波形からタイムコードを読み取ることができました。

Unity上でタイムコード信号を読み取る

上記ではAudacityでタイムコード信号を直接波形から読み取る方法について紹介しましたが、これ以降はUnity上で動作するスクリプトからタイムコード信号を処理してタイムコードとして取得する方法について解説します。
今回はGistにソースコードを置いております。このコードをベースに記事を書いておりますので、参考しながら記事を読むのをお勧めします。

Unity上で音声信号からLinear timecodeに変換するヘルパーメソッド · GitHub

事前セットアップ

必要パッケージを追加する

今回のコードに含まれる計算処理の一部に com.unity.mathematicsを使っているため、PackageManagerから追加しておく必要があります。
追加方法はPackageManagerの【Add package from git URL...】から com.unity.mathematics を入力して【Add】ボタンを押してインストールします。

AudioのDSP Buffer Sizeを変更する

Project Settingsから、AudioのDSP Buffer Sizeを【Best latency】に変更しておきます。これはマイク入力の遅延を減らすことができます。

信号をUnityで受け取る

前回の記事でも書きましたが、マイク入力経由でタイムコード信号を受け取る処理ですが、一つのソースコードに書くと全体が長くなりがちなので、信号を処理すること自体は別のコードで行うようにしました。

https://gist.github.com/blkcatman/35fffc8e566a81aad09045ec24ee7bb4#file-microphonegrabber-cs

    private void OnAudioFilterRead(float[] data, int channels) {
        // オーディオデータの処理を外部に移譲する
        OnAudioDataRead?.Invoke(data, channels);

        // そのままだとTimecodeの音声が鳴り響くので、ゲインを0にする
        for (int i = 0; i < data.Length; i++)
        {
            data[i] *= 0f;
        }
    }

処理側はTimecodeDecorderクラスのProcessAudioData()メソッド内で行うようにしています。

https://gist.github.com/blkcatman/35fffc8e566a81aad09045ec24ee7bb4#file-timecodedecoder-cs

    private void ProcessAudioData(float[] data, int channels)
    {
        // ここにTimecode取得の処理を記述する
        ...
    }

以降はTimecodeDecorderのProcessAudioData()メソッド内の処理をベースに解説していきます。また、メソッド内ではヘルパー的にLtcHelper.csの処理を利用しています。

https://gist.github.com/blkcatman/35fffc8e566a81aad09045ec24ee7bb4#file-ltchelper-cs

無信号時を検出する

信号が来ていない、または無信号の時も処理が走ってしまうので、ProcessAudioData()のメソッド内でタイムコードの信号の解析をする前に無信号状態を検出しています

        // 信号の振幅が一定未満なら、無信号とみなす
        if (!LtcHelper.HasSignal(maxLevels, 0.2f))
        {
            if (currentBinarySize > 0)
            {
                Span<float> source = new Span<float>(binaryBuffer, 0, currentBinarySize);
                source.Fill(0f);
                currentBinarySize = 0;
            }
            return;
        }

タイムコード信号のチャンネルデータを取り出す

OnAudioFilterRead経由で取得したデータはステレオになっていますが、タイムコード信号は左側のみチャンネルしか受け取っていないため、右側のデータを削除します

        // チャンネル一つぶんの信号を取り出す
        var monauralDataLength = data.Length / channels;
        float[] monauralData = new float[monauralDataLength];
        Span<float> monauralSource = new Span<float>(monauralData);
        LtcHelper.GetMonauralData(data, ref monauralSource, channels, 0);

信号を2値化する

タイムコード信号はマイク入力経由で受け取っているため、一度音声信号としてアナログ変換されます。それをPC側で量子化してデジタルデータ化しているため、ノイズが混入したり、パルス信号の波形がなまる(立ち上がり時間が増える)問題があります。
このため、信号処理ではパルス幅を測定する際に振幅(Amplitude)にしきい値(下限)を設けるか、フィルタリング処理が必要になります。
本来であればパルス整形フィルタ(例:ガウスフィルタ)などを掛けるのがベストですが、Unity側で処理を簡略化するため、雑ですが信号そのものを1.0と-1.0に2値化して、パルス整形フィルタの代わりにします。

        // 信号を2値化する
        float[] binarizedData = new float[monauralDataLength];
        Span<float> binarizedDest = new Span<float>(binarizedData);
        LtcHelper.Binarize(monauralSource, ref binarizedDest);

2値化した後はこんなイメージになります。

2値化した信号をバッファに溜める

OnAudioFilterRead()メソッド経由で受け取るバッファサイズはDSP Buffer Sizeに影響を受けるため、OnAudioFilterRead()から呼び出された1回分の信号データにはタイムコードをデコードできるほどのデータ量が含まれていないことがあります。
このため、2値化したデータをある程度のデータ量になるまでバッファに保存します。一定以上データがたまったら信号をタイムコードとしてデコードする処理を行います。

        // 2値化データをバッファにコピーする
        var storeDest = new Span<float>(binaryBuffer, currentBinarySize, monauralDataLength);
        binarizedDest.CopyTo(storeDest);
        currentBinarySize += monauralDataLength;
        
        // 一定以上データをバッファに蓄積したら処理を行う
        if (currentBinarySize > 2000)
        {
        ...
        }

各パルスのパルス幅を取得する

パルスが0のビットを表現しているのか、それとも1のビットを表現しているかを知るためには、事前に各パルスのパルス幅を計算する必要があります。信号は既に2値化されているので、パルス幅の測り方は信号の符号の入れ替わりで計算することができます。プロセスについては以下のようになります。

  1. 信号のカウントを開始する
  2. 前後の信号を比較し、信号どうしの符号が同じなら、同じパルスと見なせる
  3. 同じ符号を持つ信号をカウントする
  4. 比較対象の符号が異なる時、それまでの信号のカウントをパルス幅として確定し、以降は別のパルスと見なす
  5. 1~4の処理を繰り返す

コードでも同様に、パルス幅の判定に連続する信号の符号を利用し、波長を計算しています。

            // 信号の波長を取得する
            var signalSource = new ReadOnlySpan<float>(binaryBuffer, 0, currentBinarySize);
            var waveLengthArray = 
                LtcHelper.GetWaveLengthArrayFromBinarizedFloats(signalSource, samplingRate);

パルス幅からビットを推定する

タイムコードの信号はBiphase Mark Code方式でエンコードされているので、0か1のビットを判断するにはパルス幅で判断する必要がありますが、タイムコード(Linear time code)はフレームレートによってパルス幅が変わってきます。

Wikipediaではパルス幅は30フレーム時2400Hz (= 416.66マイクロ秒)という表現になっていました。

Made up of 80 bits per frame, where there may be 24, 25 or 30 frames per second, LTC timecode varies from 960 Hz (binary zeros at 24 frames/s) to 2400 Hz (binary ones at 30 frames/s), and thus is comfortably in the audio frequency range.

ITU-Rのドキュメントでは30フレーム時の0ビットのパルス幅は417マイクロ秒、1ビットのパルスは片側208.5マイクロ秒と記述がありました。

流石に今回の記事でパルス幅を自動的に判断する方法までは入れられなかったのと、30fps以下にタイムコードを設定する意味も薄いため、タイムコード信号発生器のフレームレートは30fps固定として、0のビットと1のビットを判定するパルス幅は決め打ちで設定しました。
コードではノイズ等でパルス幅が長くなったり短くなったりすることを想定して、それぞれのビットのパルス幅の判定基準に上限下限のしきい値を設けてビットデータの配列に変換しています。

            // ビットデータ配列に変換する
            var bitArray = LtcHelper.DecodeWaveLengthToBits(waveLengthArray,
                0.0004f, //0ビットとして判定するパルス幅の下限
                0.0005f, //0ビットとして判定するパルス幅の上限
                0.0002f, //1ビットとして判定するパルス幅の下限
                0.00026f //1ビットとして判定するパルス幅の上限
            );

ビットデータ配列からSync wordを探す

Sync wordはタイムコード信号の始まりから終わりまでを区別するための固定の16ビット(0011111111111101)のデータです。Sync wordの前後にタイムコードの実データが含まれているので、ビット変換後はSync wordを頼りにタイムコードのデータの区切りを判別することができます。
Sync wordはタイムコードの実データ部の後ろに付与されているので、Array.LastIndexOf()メソッドを使って要素の最後から検索しています。

            // ビットデータ配列内にSyncWordが含まれているかチェックする
            var syncWordPosition = bitArray.LastIndexOf(LtcHelper.SyncWord, StringComparison.Ordinal);
            // SyncWordが入っていない場合は処理を中断する
            if (syncWordPosition < 0)
            {
                // 1秒以上バッファにデータがたまっているときはバッファを初期化する
                if (currentBinarySize > samplingRate)
                {
                    var clearSource = new Span<float>(binaryBuffer);
                    clearSource.Fill(0f);
                    currentBinarySize = 0;
                }
                return;
            }

Sync wordより前のデータ(64bit)をタイムコードしてデコードする

Sync wordの間にあるビットデータはタイムコードの実データになるので、Sync wordが見つかったビットのインデックス位置よりも前の64bit分のデータをデコードすることでタイムコードを取り出すことができます。
コードではタイムコードのデータフォーマットに従ってデコードしています。また、冗長な処理かもしれませんが、コードではSync word分も取り出して、最後にバリデーションをかけています。

            // SyncWordが含まれている箇所より前にタイムコードを計算できるデータ量が含まれている時
            if (syncWordPosition >= 64)
            {
                // タイムコード80bitぶんのデータを切り出す
                var bitData = bitArray.Substring(syncWordPosition - 64, 80);
                // タイムコードに変換する
                if (LtcHelper.DecodeBitsToTimecode(bitData, out var timecode))
                {
                    CurrentTime = timecode;
                }
            }

2値化した信号データをバッファから削除する

一度デコードしてタイムコード化したデータは不要になるため、タイムコードとして認識した信号データよりも前の2値化した信号データをバッファから削除します。

            // 後処理: バッファの先頭からSyncWordの末尾の位置を取得する
            var segmentPosition = waveLengthArray[syncWordPosition + 16].Position;
            var tempBufferSize = currentBinarySize - segmentPosition;
            if (tempBufferSize > 0)
            {
                // SyncWordの末尾からのデータをバッファの先頭に移動する
                var tempBuffer = new float[tempBufferSize];
                var temp = new Span<float>(tempBuffer);
                // Tempバッファにデータをコピー
                var tempSource = new Span<float>(binaryBuffer, segmentPosition, tempBufferSize);
                tempSource.CopyTo(temp);
                // Tempバッファを2値化データ用のバッファの先頭にコピーする
                var bufferDest = new Span<float>(binaryBuffer, 0, tempBufferSize);
                temp.CopyTo(bufferDest);
                currentBinarySize = tempBufferSize;
            }

できあがったもの

タイムコードの表示がUnity上でできるようになりました。マイク入力にOSネイティブのドライバ(ASIOなど)を使ったり、コードをチューニングすることで遅延をもっと減らすこともできると思います。

まとめ

この記事ではタイムコード信号生成器から発生した信号をマイク入力経由でUnity上でもタイムコードを扱えるようにする内容について紹介しました。次回以降はこのタイムコード表示を使った同期方法の検討や、タイムコード以外の方法を使った同期方法についても記事にできればと思います。