凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

uLipSync 内の AudioClip をトリミング・再生するスクリプトの解説

はじめに

今回は小ネタです。uLipSyncキャリブレーション用にオーディオデータの選択した範囲のループ再生機能を作ったのですが、その内容をまとめておきます。元記事はこちら:

tips.hecomi.com

機能について

こんな感じでドラッグして再生したい箇所を選択、クロスフェード具合を調整して、Play を押すと選択範囲がループ再生されるというものです。uLipSync のキャリブレーションでは現在再生されている音を解析するため、このような仕組みが必要でした。

解説

選択された音をトリミングした AudioClip を生成するランタイムコード部と、どこをトリミングするか選択する UI の部分のエディタコード部があります。コード全文はリポジトリからご参照ください。

github.com

ランタイムコード

まずはランタイムコード部からですが、それほど長くは無いので全文を貼ってみます。

using UnityEngine;

[RequireComponent(typeof(AudioSource))]
public class uLipSyncCalibrationAudioPlayer : MonoBehaviour
{
    public AudioClip clip;
    public float start = 0f;
    public float end = 1f;
    public float crossFadeDuration = 0.05f;

    AudioClip _tmpClip;
    float[] _data;
    int _currentPos = 0;
    bool _audioReadCalled = false;
    int _sampleRate = 0;
    int _sampleCount = 0;
    int _channels = 1;
    int _crossFadeDataCount = 0;
    int playDataSampleCount => _sampleCount - _crossFadeDataCount;

    public bool isPlaying
    {
        get 
        {
            var source = GetComponent<AudioSource>();
            return source ? source.isPlaying : false;
        }
    }

    void OnEnable()
    {
        Apply();
    }

    void OnDisable()
    {
        Destroy(_tmpClip);
    }

    void Update()
    {
        if (!_audioReadCalled) return;

        _sampleRate = AudioSettings.outputSampleRate;
    }

    public void Apply()
    {
        if (!clip) return;

        var source = GetComponent<AudioSource>();
        if (!source) return;

        var startPos = (int)(clip.samples * start);
        var endPos = (int)(clip.samples * end);
        var freq = clip.frequency;
        _sampleCount = endPos - startPos;
        _channels = clip.channels;
        _crossFadeDataCount = (int)(_sampleRate * crossFadeDuration);
        _crossFadeDataCount = Mathf.Min(_crossFadeDataCount, _sampleCount / 2 - 1);

        _data = new float[_sampleCount * _channels];
        clip.GetData(_data, startPos);

        var name = $"{clip.name}-{startPos}-{endPos}";
        _tmpClip = AudioClip.Create(
            name, 
            playDataSampleCount,
            _channels, 
            freq, 
            true, 
            OnAudioRead, 
            OnAudioSetPosition);
        source.clip = _tmpClip;
        source.loop = true;
        source.Play();
    }

    void OnAudioRead(float[] data)
    {
        _audioReadCalled = true;

        for (int i = 0; i < data.Length / _channels; ++i)
        {
            for (int ch = 0; ch < _channels; ++ch)
            {
                int index = i * _channels + ch;

                if (_currentPos < _crossFadeDataCount)
                {
                    float t = (float)_currentPos / _crossFadeDataCount;
                    float sin = Mathf.Sin(Mathf.PI * 0.5f * t);
                    float cos = Mathf.Cos(Mathf.PI * 0.5f * t);
                    int indexS = _currentPos;
                    int indexE = _sampleCount - (_crossFadeDataCount - _currentPos);
                    float dataS = _data[indexS * _channels + ch];
                    float dataE = _data[indexE * _channels + ch];
                    data[index] = dataS * sin + dataE * cos;
                }
                else
                {
                    data[index] = _data[_currentPos * _channels + ch];
                }
            }

            _currentPos = (_currentPos + 1) % playDataSampleCount;
        }
    }

    void OnAudioSetPosition(int newPosition)
    {
        _currentPos = newPosition;
    }

    public void Pause()
    {
        var source = GetComponent<AudioSource>();
        if (!source) return;

        source.Pause();
    }

    public void UnPause()
    {
        var source = GetComponent<AudioSource>();
        if (!source) return;

        source.UnPause();
    }
}

次のような流れになっています。

  • start / end はエディタ側からセット(0.0 ~ 1.0 の値)
  • クロスフェード分を考慮したデータ長をサンプルカウントから計算
  • float[] のバッファを用意し、GetData() で対象の範囲のオーディオデータを取得
  • 再生は AudioClip.Create()OnAudioRead()OnAudioSetPosition() を指定したストリーミング形式で再生
    • ここは事前にデータを含むオーディオクリップを作成してしまっても良いかもしれません。

エディタコード

波形描画については以前記事でも触れましたが、次のように AudioUtil.GetMinMaxData() および AudioCurveRendering.DrawMinMaxFilledCurve() を使います。

public static class EditorUtil
{
    ...
    public class DrawWaveOption
    {
        public System.Func<float, Color> colorFunc;
        public float waveScale;
    }

    public static void DrawWave(Rect rect, AudioClip clip, DrawWaveOption option)
    {
        ...
        var minMaxData = AudioUtil.GetMinMaxData(clip);
        ...
        AudioCurveRendering.AudioMinMaxCurveAndColorEvaluator dlg = delegate(
            float x, 
            out Color col, 
            out float minValue, 
            out float maxValue)
        {
            col = option.colorFunc(x);
            ...
            minValue = ...;
            maxValue = ...;
            ...
        };

        // 描画処理
        AudioCurveRendering.DrawMinMaxFilledCurve(rect, dlg);
    }
}

この上で次のようなエディタ拡張を書きます(こちらも少し長いですがほぼ全文を載せます)。

using UnityEngine;
using UnityEditor;
using System.Collections.Generic;

[CustomEditor(typeof(uLipSyncCalibrationAudioPlayer))]
public class uLipSyncCalibrationAudioPlayerEditor : Editor
{
    uLipSyncCalibrationAudioPlayer player { get { return target as uLipSyncCalibrationAudioPlayer; } }

    bool _requireRepaint = false;
    bool _requireApply = false;
    bool _isDraggingStart = false;
    bool _isDraggingEnd = false;
    bool isDragging => _isDraggingStart || _isDraggingEnd;
    ...

    public override void OnInspectorGUI()
    {
        _requireRepaint = false;

        serializedObject.Update();

        DrawClip();
        DrawPlayAndStop();
        DrawWave();
        EditorGUILayout.Separator();
        DrawParameters();

        if (Application.isPlaying && _requireApply)
        {
            player.Apply();
            _requireApply = false;
        }

        serializedObject.ApplyModifiedProperties();

        if (_requireRepaint)
        {
            Repaint();
        }
        else
        {
            EditorUtility.SetDirty(target);
        }

        if (isDragging)
        {
            player.Pause();
        }
        else
        {
            player.UnPause();
        }

        ...
    }

    ...

    void DrawClip()
    {
        var nextClip = (AudioClip)EditorGUILayout.ObjectField("Clip", player.clip, typeof(AudioClip), true);
        if (nextClip == player.clip) return;
        player.clip = nextClip;
        _requireApply = true;
    }

    void DrawPlayAndStop()
    {
        EditorGUILayout.BeginHorizontal();
        GUILayout.FlexibleSpace();
        if (GUILayout.Button(" Play "))
        {
            AudioUtil.PlayClip(player.clip);
        }
        if (GUILayout.Button(" Stop "))
        {
            AudioUtil.StopClip(player.clip);
        }
        EditorGUILayout.EndHorizontal();
        EditorGUILayout.Separator();
    }

    void DrawWave()
    {
        var rect = EditorGUILayout.GetControlRect(GUILayout.Height(100));
        EditorUtil.DrawBackgroundRect(rect);

        if (!player.clip) return;

        EditorUtil.DrawWave(rect, player.clip, new EditorUtil.DrawWaveOption());
        var preWaveStart = player.start;
        var preWaveEnd = player.end;

        DrawTrimArea(rect, true, ref player.start, ref _isDraggingStart, ref _isDraggingEnd);
        DrawTrimArea(rect, false, ref player.end, ref _isDraggingEnd, ref _isDraggingStart);
        DrawCrossFadeArea(rect);

        player.start = Mathf.Clamp(player.start, 0f, preWaveEnd - 0.001f);
        player.end = Mathf.Clamp(player.end, preWaveStart + 0.001f, 1f);
    }

    void DrawTrimArea(Rect rect, bool isStart, ref float range, ref bool isDraggingSelf, ref bool isDraggingOther)
    {
        var trimArea = rect;
        if (isStart)
        {
            trimArea.width *= range;
        }
        else
        {
            trimArea.width *= 1f - range;
            trimArea.x += rect.width - trimArea.width;
        }
        EditorGUI.DrawRect(trimArea, new Color(0f, 0f, 0f, 0.7f));

        var deltaPixels = ProcessDrag(trimArea, ref isDraggingSelf, ref isDraggingOther);
        range += deltaPixels / rect.width;

        var borderRect = trimArea;
        if (isStart)
        {
            borderRect.x += trimArea.width;
        }
        borderRect.width = 1;
        var borderColor = isDraggingSelf ?
            new Color(1f, 0f, 0f, 1f) :
            new Color(1f, 1f, 1f, 0.5f);
        EditorGUI.DrawRect(borderRect, borderColor);
    }

    float ProcessDrag(Rect dragRect, ref bool isDraggingSelf, ref bool isDraggingOther)
    {
        float delta = 0f;

        var mouseRect = dragRect;
        mouseRect.x -= 10;
        mouseRect.width += 20;
        EditorGUIUtility.AddCursorRect(mouseRect, MouseCursor.SplitResizeLeftRight);

        if (!isDraggingOther &&
            Event.current.type == EventType.MouseDrag)
        {
            if (mouseRect.Contains(Event.current.mousePosition))
            {
                isDraggingSelf = true;
            }

            if (isDraggingSelf)
            {
                delta = Event.current.delta.x;
                _requireApply = true;
            }

            _requireRepaint = true;
        }
        else if (Event.current.type == EventType.MouseUp)
        {
            isDraggingSelf = false;
        }

        return delta;
    }

    void DrawCrossFadeArea(Rect rect)
    {
        var range = player.crossFadeDuration / player.clip.length;
        range = Mathf.Min(range, player.end - player.start);
        rect.x += (player.end - range) * rect.width;
        rect.width *= range;
        EditorGUI.DrawRect(rect, new Color(0f, 1f, 1f, 0.2f));
    }

    void DrawParameters()
    {
        float preDuration = player.crossFadeDuration;
        player.crossFadeDuration = EditorGUILayout.Slider("Cross Fade", preDuration, 0f, 0.1f);
        if (player.crossFadeDuration != preDuration)
        {
            _requireApply = true;
        }
    }
}

コードの大部分は UI の構築ですが、キモは ProcessDrag() です。EditorGUIUtility.AddCursorRect() では指定したエリアのカーソル形状を変更できます。

docs.unity3d.com

この上で、Event.current.typeEventType.MouseDrag かどうかのチェックと、Event.current.mousePosition が対象の領域に含まれているかどうかを検証してドラッグ中かどうかの判定を行っています。結構、愚直な実装になっていて、もう少し抽象化された API があると良いのですが...、UI Toolkit の例などをみても結構ゴリッと書いてるので無いのかな、と考えています。

docs.unity3d.com

こうしてドラッグ出来るようにした上で start / end をランタイムコード側へ渡して実行すればトリミングした範囲のオーディオデータの再生が可能となりました。

おわりに

最近少し忙しくネタが枯渇しているので、小さめの記事になりましたが...、来月からはまた新しい内容を色々書いていきたいです。