見出し画像

【Unity】LTC (Linear timecode)を使ってみる

音と映像の同期を取る手法として、音声信号にタイムコードを乗せる方法があります。これによって音声信号をトリガーにすることができるため、サビに合わせた映像を流したり、パーティクルなどの演出を行うことができるようになります。

仕組み的には通常の音声信号と同じように送ることができるため、Unityからはタイムコードにエンコードした音声信号をマイク入力で受け取り、その信号をデコードすることでタイムコードを認識させることができます。では早速やっていきましょう!

環境

Unity 2019.4.5f1

1. 用意するもの

TimeCode Generator
まずタイムコードを生成できる機材を用意します。専用の機材もありますが、非常にニッチであるため高価であったり在庫がなかったりと入手難易度は高めです。

そこで、アプリを使ってタイムコードの生成と送信を行います。アプリはこちらを使用しました。

TimeCode Generator

有料ですが、シンプルで使い勝手も良いのでフリーのものを使うよりかは断然おすすめです。Windows/Android/Mac/iPhone版が用意されているので、利用したいデバイスのアプリを購入します。iPhone版であればApp Store経由になるためMacやiPadでも使えるのでお得かもしれません。本記事ではiPhoneで試していきます。

変換ケーブル
昨今のスマホではイヤホンジャックが廃止されているため、イヤホンジャックがない場合は変換ケーブルを用意してください。WindowsやMacなどイヤホンジャックがある場合は不要です。

オーディオケーブル
タイムコードを送るデバイスと受け取る側のPCをオーディオケーブルで接続します。受け取り側のPCはオーディオインターフェースのマイク入力に接続します。

用意するものたち

2. スクリプト

スクリプトはMobileHackerzのMiro氏のものを流用させていただきました。この場をお借りしてお礼申し上げます。

動作しなかった部分や最適化のため一部改変を行ってあります。以下のスクリプトを適当なオブジェクトにアタッチしてください。

// LTC Timecode Reader for Unity C#
// http://blog.mobilehackerz.jp/
// https://twitter.com/MobileHackerz

using System.Linq;
using UnityEngine;

public class LTCread : MonoBehaviour {

    [SerializeField] private string deviceName;
    private string m_TimeCode = "00:00:00;00";

    // 内部で録音するバッファの長さ
    private const int DEVICE_REC_LENGTH = 10;
    
    private AudioClip m_LtcAudioInput;
    private int m_LastAudioPos;
    private int m_SameAudioLevelCount;
    private int m_LastAudioLevel;
    private int m_LastBitCount;
    private string m_BITPattern = "";

    [SerializeField, Range(0.0f, 1.0f)] private float m_AudioThreshold;
    private string m_Gain;

    private GUIStyle m_TimeCodeStyle;

    void Start() {
        string targetDevice = "";
        
        foreach (var device in Microphone.devices) {
            Debug.Log($"Device Name: {device}");
            if (device.Contains(deviceName)) {
                targetDevice = device;
            }
        }

        Debug.Log($"=== Device Set: {targetDevice} ===");
        m_LtcAudioInput = Microphone.Start(targetDevice, true, DEVICE_REC_LENGTH, 44100);
        m_TimeCodeStyle = new GUIStyle { 
            fontSize = 64,
            normal = { textColor = Color.white }
        };
    }
    
    void Update() {
        DecodeAudioToTcFrames();
    }
    
    private void OnGUI() {
        GUI.Label(new Rect(0, 0, 200, 100), m_TimeCode, m_TimeCodeStyle);
        GUI.Label(new Rect(0, 100, 200, 100), m_Gain, m_TimeCodeStyle);
    }
    
    // 現在までのオーディオ入力を取得しフレーム情報にデコードしていく
    private void DecodeAudioToTcFrames() {
        float[] waveData = GetUpdatedAudio(m_LtcAudioInput);
        
        if (waveData.Length == 0) {
            return;
        }

        var gain = waveData.Select(Mathf.Abs).Sum() / waveData.Length;
        m_Gain = $"{gain:F6}";
        if (gain < m_AudioThreshold) return;

        int pos = 0;
        int bitThreshold = m_LtcAudioInput.frequency / 3100; // 適当
        
        while (pos < waveData.Length) {
            int count = CheckAudioLevelChanged(waveData, ref pos, m_LtcAudioInput.channels);
            if (count <= 0) continue;
            
            if (count < bitThreshold) {
                // 「レベル変化までが短い」パターンが2回続くと1
                if (m_LastBitCount < bitThreshold) {
                    m_BITPattern += "1";
                    m_LastBitCount = bitThreshold; // 次はここを通らないように
                } else {
                    m_LastBitCount = count;
                }
            } else {
                // 「レベル変化までが長い」パターンは0
                m_BITPattern += "0";
                m_LastBitCount = count;
            }
        }

        // 1フレームぶん取れたかな?
        if (m_BITPattern.Length >= 80) {
            int bpos = m_BITPattern.IndexOf("0011111111111101"); // SYNC WORD
            if (bpos > 0) {
                string timeCodeBits = m_BITPattern.Substring(0, bpos + 16);
                m_BITPattern = m_BITPattern.Substring(bpos + 16);
                if (timeCodeBits.Length >= 80) {
                    timeCodeBits = timeCodeBits.Substring(timeCodeBits.Length - 80);
                    m_TimeCode = DecodeBitsToFrame(timeCodeBits);
                }
            }
        }

        // パターンマッチしなさすぎてビットパターンバッファ長くなっちゃったら削る
        if (m_BITPattern.Length > 160) {
            m_BITPattern = m_BITPattern.Substring(80);
        }
    }
    
    // マイク入力から録音データの生データを得る。
    // オーディオ入力が進んだぶんだけ処理して float[] に返す
    private float[] GetUpdatedAudio(AudioClip audioClip) {
        
        int nowAudioPos = Microphone.GetPosition(null);
        
        float[] waveData = new float[0];

        if (m_LastAudioPos < nowAudioPos) {
            int audioCount = nowAudioPos - m_LastAudioPos;
            waveData = new float[audioCount];
            audioClip.GetData(waveData, m_LastAudioPos);
        } else if (m_LastAudioPos > nowAudioPos) {
            int audioBuffer = audioClip.samples * audioClip.channels;
            int audioCount = audioBuffer - m_LastAudioPos;
            
            float[] wave1 = new float[audioCount];
            audioClip.GetData(wave1, m_LastAudioPos);
            
            float[] wave2 = new float[nowAudioPos];
            if (nowAudioPos != 0) {
                audioClip.GetData(wave2, 0);
            }

            waveData = new float[audioCount + nowAudioPos];
            wave1.CopyTo(waveData, 0);
            wave2.CopyTo(waveData, audioCount);
        }

        m_LastAudioPos = nowAudioPos;

        return waveData;
    }
    
    // 録音データの生データから、0<1, 1>0の変化が発生するまでのカウント数を得る。
    // もしデータの最後に到達したら-1を返す。
    private int CheckAudioLevelChanged(float[] data, ref int pos, int channels) {
        
        while (pos < data.Length) {
            int nowLevel = Mathf.RoundToInt(Mathf.Sign(data[pos]));
            
            // レベル変化があった
            if (m_LastAudioLevel != nowLevel) {
                int count = m_SameAudioLevelCount;
                m_SameAudioLevelCount = 0;
                m_LastAudioLevel = nowLevel;
                return count;
            }

            // 同じレベルだった
            m_SameAudioLevelCount++;
            pos += channels;
        }

        return -1;
    }
    
    // ---------------------------------------------------------------------------------
    // フレームデコード
    private int Decode1Bit(string b, int pos) {
        return int.Parse(b.Substring(pos, 1));
    }

    private int Decode2Bits(string b, int pos) {
        int r = 0;
        r += Decode1Bit(b, pos);
        r += Decode1Bit(b, pos + 1) * 2;
        return r;
    }

    private int Decode3Bits(string b, int pos) {
        int r = 0;
        r += Decode1Bit(b, pos);
        r += Decode1Bit(b, pos + 1) * 2;
        r += Decode1Bit(b, pos + 2) * 4;
        return r;
    }

    private int Decode4Bits(string b, int pos) {
        int r = 0;
        r += Decode1Bit(b, pos);
        r += Decode1Bit(b, pos + 1) * 2;
        r += Decode1Bit(b, pos + 2) * 4;
        r += Decode1Bit(b, pos + 3) * 8;
        return r;
    }

    private string DecodeBitsToFrame(string bits) {
        // https://en.wikipedia.org/wiki/Linear_timecode

        int frames = Decode4Bits(bits, 0) + Decode2Bits(bits, 8) * 10;
        int secs = Decode4Bits(bits, 16) + Decode3Bits(bits, 24) * 10;
        int mins = Decode4Bits(bits, 32) + Decode3Bits(bits, 40) * 10;
        int hours = Decode4Bits(bits, 48) + Decode2Bits(bits, 56) * 10;

        return $"{hours:D2}:{mins:D2}:{secs:D2};{frames:D2}";
    }
}

解説の便宜上、1ファイルにまとめてありますが、音声入力処理とLTCのデコード部分はファイルを分けた方が良いでしょう。

3. 実行

スクリプトをアタッチした状態で実行すると、コンソールログにMicrophoneデバイスの一覧が表示されます。

Microphoneデバイス一覧

この一覧からタイムコードを受け取るデバイスを選択して、Device NameをコピーしてインスペクタのDevice Nameに貼り付けます。

Device Nameの指定

ここで指定したデバイスからタイムコードを受け取るようにできたので、実行を止めて再び実行します。

次に、TimeCode Generatorの音声出力が指定したデバイスに接続されていることを確認して、再生ボタンを押すとTimeCode Generatorに表示されているタイムコードがUnity側にも表示されていることが確認することができます。

うまく動作しない場合は、TimeCode Generatorの音量(スマホ側の音量)を上げてみるか、指定したMicrophoneデバイスのゲインが低くなっていないか確認してみてください。

また、インスペクタ上にある「Audio Threshold」で音量レベルの閾値を指定することができます。音量レベルがこの値を下回ると、タイムコードのデコード処理を行わなくすることができるため、入力がないときの誤動作を回避することができます。

現在の音量レベルの値はタイムコードの下に表示するようにしてあるので参考になると思います。

4. おわりに

LTCを使ってUnityと同期を取ることができることがわかりました。Unityを使ったバーチャルライブなどの用途では効果を発揮すると思います。音と映像の同期は演出としてとても重要な要素になるため、作品作りでは常に意識していきたいと思います。🌱

※注記
動画を撮ってコマ送りにして気づいたのですが、よくみるとTimeCode Generator側とUnity側で1フレーム分ズレていることがわかります。

このズレはTimeCode Generator側なのかUnity側なのかまでは検証は行いませんでしたが、実運用ではさほど問題ない範囲だと思います。(専用のLTC機材を使ってズレる場合はおそらくUnity側かもしれません)


この記事が気に入ったらサポートをしてみませんか?