【Unity】AudioClipの波形プレビューを描画する【Editor拡張】

# TL;DR
AudioClipを選択したとき、Inspectorウィンドウ下部に表示される波形プレビュー。
これをカスタムInspectorやEditorWindowの指定したRect内に描画したいこと、ありますよね？　私はあります。

例えばAudioClipのカスタムPropertyDrawer。こんな風に波形を出して、部分ループのプレビューとかしたい。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84499/661a248d-a1f7-81ef-7402-8a087cf1c8fd.png)
この波形部分を描画する方法を紹介します。色も変えられます。

※一部間接的にReflectionを使用、2019.2.6および2019.3.0f5で動作確認済。

# AssetPreview.GetAssetPreview()（ダメな例）
任意のAssetについて、プレビューテクスチャを取得するAPIは存在します。
それがこの[AssetPreview.GetAssetPreview()](https://docs.unity3d.com/ja/2017.4/ScriptReference/AssetPreview.GetAssetPreview.html)なんですが、ダメな点がいくつか。

- 不透過
 - 灰色背景がセットになってついてきます。これでは目盛りなどを裏に入れることができません。
- 解像度が低い
 - なんとびっくり**128x128**。そのせいなのか（？）、波形の両端はぼやけて消えてしまっています。
- 色を変えられない
 - ダメですね。これは面白くありません。

# 実現までの流れ
過程に興味が無い方は飛ばしてどうぞ。

## UnityEditor.AudioUtil
まず、エディタ上でAudioClipを扱うための内部ユーティリティ、AudioUtilクラスが存在します。
内部ユーティリティなので当然公開APIではありませんが、[Reflectedなラッパークラスを作ってあります。](https://qiita.com/Rijicho_nl/items/c7e6a5cb9cf56e52588a)詳細はリンク先の別記事で。

波形を表示するだけなら恐らく頑張ればAudioUtilなしでもできるんですが、使うと楽なので使っていきましょう。


## AudioClipInspector.csでの実装を~~パクろう~~参考にしよう
まずはUnity公式がどうAudioClipのプレビューを実装しているのか、覗きにいきましょう。
> [Editor/Mono/Inspector/AudioClipInspector.cs](https://github.com/Unity-Technologies/UnityCsReference/blob/master/Editor/Mono/Inspector/AudioClipInspector.cs#L160)

160行目あたりにそれらしきものが見つかります。

```cs:AudioClipInspector.csより抜粋
        private void DoRenderPreview(bool setMaterial, AudioClip clip, AudioImporter audioImporter, Rect wantedRect, float scaleFactor)
        {
            scaleFactor *= 0.95f; // Reduce amplitude slightly to make highly compressed signals fit.
            float[] minMaxData = (audioImporter == null) ? null : AudioUtil.GetMinMaxData(audioImporter);
            int numChannels = clip.channels;
            int numSamples = (minMaxData == null) ? 0 : (minMaxData.Length / (2 * numChannels));
            float h = (float)wantedRect.height / (float)numChannels;
            for (int channel = 0; channel < numChannels; channel++)
            {
                Rect channelRect = new Rect(wantedRect.x, wantedRect.y + h * channel, wantedRect.width, h);
                Color curveColor = new Color(1.0f, 140.0f / 255.0f, 0.0f, 1.0f);

                AudioCurveRendering.AudioMinMaxCurveAndColorEvaluator dlg = delegate(float x, out Color col, out float minValue, out float maxValue)
                {
                    col = curveColor;
                    if (numSamples <= 0)
                    {
                        minValue = 0.0f;
                        maxValue = 0.0f;
                    }
                    else
                    {
                        float p = Mathf.Clamp(x * (numSamples - 2), 0.0f, numSamples - 2);
                        int i = (int)Mathf.Floor(p);
                        int offset1 = (i * numChannels + channel) * 2;
                        int offset2 = offset1 + numChannels * 2;
                        minValue = Mathf.Min(minMaxData[offset1 + 1], minMaxData[offset2 + 1]) * scaleFactor;
                        maxValue = Mathf.Max(minMaxData[offset1 + 0], minMaxData[offset2 + 0]) * scaleFactor;
                        if (minValue > maxValue) { float tmp = minValue; minValue = maxValue; maxValue = tmp; }
                    }
                };

                if (setMaterial)
                    AudioCurveRendering.DrawMinMaxFilledCurve(channelRect, dlg);
                else
                    AudioCurveRendering.DrawMinMaxFilledCurveInternal(channelRect, dlg);
            }
        }
```

つまり、`AudioCurveRendering.DrawMinMaxFilledCurve()`というAPIが用意されていて、波形の内容を定義するためのデリゲートである`AudioCurveRendering.AudioMinMaxCurveAndColorEvaluator`でコールバックを生成してRectと一緒に渡していると。
そして嬉しいことに、これらは公開APIなのでReflectionしなくて済みます。

- [Unity - Scripting API: AudioCurveRendering](https://docs.unity3d.com/ScriptReference/AudioCurveRendering.html)


### 引数多くない？
`DoRenderPreview`メソッド、引数が５つもあります。

- `bool setMaterial`
 - メソッドの最後の分岐で使われています。
 - が、`true`を前提にしてInternalじゃない`DrawMinMaxFilledCurve()`を決め打ちで呼んでも結果的に問題なかったので、とりあえずスルーします。
- `AudioClip clip`
 - はい、これは必要ですね。
- `AudioImporter audioImporter`
 - これを用意するのが少し面倒なところですが、我々にはAudioUtilがあります。`GetImporterFromClip()`を使いましょう。
- `Rect wantedRect`
 - 描画領域ですね。これも必要。
- `float scaleFactor`
 - 振幅方向のスケールのようです。引数名だけで何のスケールか分からないのは頂けませんね。
 - 弊プロジェクトの音屋に聞いたところ「必要ない……」との話でしたが、まあせっかくあるのでオプション引数として残しておきましょう。

### AudioImporter、何のため？
AudioImporterが使われているのはこの部分のみです。

```cs
float[] minMaxData = (audioImporter == null) ? null : AudioUtil.GetMinMaxData(audioImporter);
```
出ましたAudioUtil。表示に使うデータはAudioImporterが持っているようです。

ところでAudioClipには`GetData()`というpublicメソッドがあります。
これを使えばAudioUtilに頼らずAudioClipだけでいけるんじゃないかと思い試してみましたが、ダメでした。
ちゃんとキャッシングしないと重すぎて動かないのはともかく、吐き出されるデータも`GetMinMaxData()`とは形式が違います。

### 色を変えたい
`AudioMinMaxCurveAndColorEvaluator`の`out Color col`に渡せばよさそうです。
デフォルトでは何が渡っているのかといえば、決め打ちの`new Color(1.0f, 140.0f / 255.0f, 0.0f, 1.0f)`。
グラデーションをつけたり、振幅に合わせて色を変えたりしたい。楽しいので。

というわけで、色替え用デリゲートを用意しました。

```cs
public delegate Color AudioCurveColorSetter(int channel, float t, float min, float max, float minOfAll, float maxOfAll);
```

#できたもの
## 波形表示メソッド
ここまでの情報をもとに実装したのがこちら。
※[別の記事](https://qiita.com/Rijicho_nl/items/c7e6a5cb9cf56e52588a)で作った`InternalAudioUtil`クラスを前提にしています。
　InternalAudioUtilクラス内に実装しちゃってもいいんじゃないでしょうか。


```cs
/// <summary>
/// Invoked in RenderPreview() to define colors of AudioClip preview curves
/// </summary>
/// <param name="channel"></param>
/// <param name="t">Time (x value) between 0f(left-end), 1f(right-end)</param>
/// <param name="min">Minimum y value of the curve at the time</param>
/// <param name="max">Maximum y value of the curve at the time</param>
/// <param name="minOfAll">Minimum y value of the curve</param>
/// <param name="maxOfAll">Maximum y value of the curve</param>
/// <returns>Color of the curve at the time</returns>
public delegate Color AudioCurveColorSetter(int channel, float t, float min, float max, float minOfAll, float maxOfAll);

/// <summary>
/// Render waveform preview of the clip in given rect. If clip is null, do nothing.
/// </summary>
/// <param name="rect">Rect in which the wave will be rendered</param>
/// <param name="clip">AudioClip source</param>
/// <param name="colorSetter">Delegate for coloring the wave. Default: Color(1,0.54902,0)</param>
/// <param name="amplitudeScale">Y-scale amplification of the wave</param>
public static void RenderPreview(Rect rect, AudioClip clip, AudioCurveColorSetter colorSetter = null, float amplitudeScale = 1)
{
    if (!clip) return;

    //公式実装に倣って補正
    amplitudeScale *= 0.95f;

    //データ取得
    var audioImporter = InternalAudioUtil.GetImporterFromClip(clip);
    float[] minMaxData = (audioImporter == null) ? null : InternalAudioUtil.GetMinMaxData(audioImporter);

    //全体の最大値・最小値を計算
    float minOfAll = 0;
    float maxOfAll = 0;
    for(int i=0; i<minMaxData.Length; i++)
    {
        minOfAll = Mathf.Min(minMaxData[i], minOfAll);
        maxOfAll = Mathf.Max(minMaxData[i], maxOfAll);
    }
    minOfAll *= amplitudeScale;
    maxOfAll *= amplitudeScale;

    //チャンネル数・サンプル数
    int numChannels = clip.channels;
    int numSamples = (minMaxData == null) ? 0 : (minMaxData.Length / (2 * numChannels));
    //１チャンネルごとの専有height
    float h = rect.height / numChannels;

    //各チャンネルについて波形描画
    for (int channel = 0; channel < numChannels; channel++)
    {
        //描画範囲計算
        Rect channelRect = new Rect(rect.x, rect.y + h * channel, rect.width, h);

        //描画内容定義
        AudioCurveRendering.AudioMinMaxCurveAndColorEvaluator dlg = delegate (float x, out Color col, out float minValue, out float maxValue)
        {
            if (numSamples <= 0)
            {
                minValue = 0.0f;
                maxValue = 0.0f;
            }
            else
            {
                //minMaxDataの現在のx座標に対応する値を取得
                float p = Mathf.Clamp(x * (numSamples - 2), 0.0f, numSamples - 2);
                int i = (int)Mathf.Floor(p);
                int offset1 = (i * numChannels + channel) * 2;
                int offset2 = offset1 + numChannels * 2;
                minValue = Mathf.Min(minMaxData[offset1 + 1], minMaxData[offset2 + 1]) * amplitudeScale;
                maxValue = Mathf.Max(minMaxData[offset1 + 0], minMaxData[offset2 + 0]) * amplitudeScale;
                if (minValue > maxValue) { float tmp = minValue; minValue = maxValue; maxValue = tmp; }
            }
            //色を指定
            col = colorSetter?.Invoke(channel, x, minValue, maxValue, minOfAll, maxOfAll) ?? new Color(1, 0.54902f, 0, 1);
        };

        //描画
        AudioCurveRendering.DrawMinMaxFilledCurve(channelRect, dlg);
    }
}

//単色指定ver
public static void RenderPreview(Rect rect, AudioClip clip, Color color, float amplitudeScale = 1)
{
    RenderPreview(rect, clip, (_, __, ___, ____, _____, ______) => color, amplitudeScale);
}

//時間経過でグラデーションver
public static void RenderTimeAwarePreview(Rect rect, AudioClip clip, Color start, Color finish, float amplitudeScale = 1)
{
    RenderPreview(rect, clip, (_, t, ___, ____, _____, ______) => Color.Lerp(start, finish, t), amplitudeScale);
}

//振幅の大小でグラデーションver
public static void RenderAmplitudeAwarePreview(Rect rect, AudioClip clip, Color lowAmp, Color highAmp, float amplitudeScale = 1)
{
    RenderPreview(rect, clip, (channel, _, min, max, minOfAll, maxOfAll) =>
        Color.Lerp(lowAmp, highAmp, Mathf.Clamp01((max - min) / (maxOfAll - minOfAll))), amplitudeScale);
}
```

## おまけ：AudioClipのカスタムPropertyDrawer
今回作成した波形表示APIを使ってAudioClipのカスタムPropertyDrawerを簡単に作ってみました。
↓AudioClipをInspector上に表示する際にこんな感じになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/84499/9547de2d-314b-c257-0776-0b48d15dd4d1.png)
Attributeにはしていません。
お好みで色を変えたり、AudioUtil等で再生機能をつけたりしてどうぞ。

※このコードは`RenderPreview()`を`InternalAudioUtil`クラス内に定義している前提で書かれています。
　必要に応じて適当に書き換えてください。

```cs:AudioClipPropertyDrawer.cs
using UnityEditor;
using UnityEngine;
using System;
using System.Collections.Generic;

[CustomPropertyDrawer(typeof(AudioClip))]
public class AudioClipPropertyDrawer : PropertyDrawer
{
    readonly float headerHeight = EditorGUIUtility.singleLineHeight + 2;
    const float waveHeight = 100;
    const float amplitudeScale = 1;

    //ProjectWindowの型キャッシュ。Ping時にProjectWindowを開くために使用。
    readonly static Type tProjectWindow = typeof(Editor).Assembly.GetType("UnityEditor.ProjectBrowser");
    
    //ObjectPicker使用時の、対応するプロパティごとのControlID
    static Dictionary<string, int> objectPickerControls = new Dictionary<string, int>();

    //プロパティごとのユニークID生成器
    static string MakeUniquePropKey(SerializedProperty prop)
        => prop.serializedObject.targetObject.GetInstanceID().ToString() + "::" + prop.propertyPath;

    public override float GetPropertyHeight(SerializedProperty prop, GUIContent label)
    {
        return headerHeight + waveHeight;
    }

    public override void OnGUI(Rect position, SerializedProperty prop, GUIContent label)
    {
        EditorGUI.BeginProperty(position, label, prop);

        var evt = Event.current;
        var clip = prop.objectReferenceValue as AudioClip;

        /* v------- Colors -------v */
        Color frameColor = new Color(0.2f, 0.2f, 0.2f);
        Color bgColor = new Color(0.25f, 0.25f, 0.25f);
        Color objectFieldBGColor = new Color(0.3f, 0.3f, 0.3f);
        Color objectFieldButtonHoveredColor = new Color(0.4f, 0.4f, 0.4f);
        Color lineColor = new Color(1, 1, 1, 0.5f);
        Color labelColor = Color.white;
        /* -------- Colors -------- */

        /* v------- Styles -------v */
        var labelStyle = new GUIStyle(GUI.skin.label) { alignment = TextAnchor.MiddleLeft };
        labelStyle.normal.textColor = labelColor;
        var centerLabelStyle = new GUIStyle(labelStyle) { alignment = TextAnchor.MiddleCenter };
        /* -------- Styles -------- */

        /* v------- Structures -------v */
        var defaultIndentLevel = EditorGUI.indentLevel;
        var indentWidth = defaultIndentLevel * 16;
        position.x += indentWidth;
        position.width -= indentWidth;

        var mainRect = new Rect(position) { height = headerHeight + waveHeight };
        var y = mainRect.y;

        var headerRect = new Rect(mainRect) { height = headerHeight };
        var labelRect = new Rect(headerRect) { x = headerRect.x + 10, width = Mathf.Clamp(labelStyle.CalcSize(label).x, Mathf.Min(80, headerRect.width * 0.4f), headerRect.width * 0.4f) };
        var objectFieldRect = new Rect(headerRect) { x = labelRect.xMax, width = headerRect.xMax - labelRect.xMax };
        var objectFieldButtonRect = new Rect(objectFieldRect) { x = objectFieldRect.xMax - objectFieldRect.height, width = objectFieldRect.height };
        var lineRect = new Rect(headerRect.x + 5, headerRect.yMax - 1, headerRect.width - 10, 1);
        y += headerHeight;

        var waveRect = new Rect(mainRect) { y = y, height = waveHeight };
        /* -------- Structures -------- */

        /* v------- Draw -------v */
        //ヘッダ
        EditorGUI.DrawRect(headerRect, frameColor);
        GUI.Label(labelRect, label, labelStyle);
        EditorGUI.DrawRect(objectFieldRect, objectFieldBGColor);
        GUI.Label(objectFieldRect, new GUIContent(prop.objectReferenceValue ? prop.objectReferenceValue.name : "None (Audio Clip)", AssetPreview.GetMiniTypeThumbnail(typeof(AudioClip))), labelStyle);
        EditorGUI.DrawRect(objectFieldButtonRect, objectFieldButtonRect.Contains(evt.mousePosition) ? objectFieldButtonHoveredColor : Color.clear);
        GUI.Label(objectFieldButtonRect, "〇", centerLabelStyle);
        EditorGUI.DrawRect(lineRect, lineColor);

        //背景
        EditorGUI.DrawRect(waveRect, bgColor);
        //波形表示
        InternalAudioUtil.RenderAmplitudeAwarePreview(waveRect, clip, Color.yellow, Color.red, amplitudeScale);
        /* -------- Draw -------- */

        /* v------- D&D Control, ObjectPicker -------v */
        switch (evt.type)
        {
            case EventType.DragUpdated:
                if (mainRect.Contains(evt.mousePosition))
                {
                    DragAndDrop.visualMode = DragAndDropVisualMode.Copy;
                    DragAndDrop.activeControlID = GUIUtility.GetControlID(FocusType.Passive);
                    evt.Use();
                }
                break;
            case EventType.DragPerform:
                if (mainRect.Contains(evt.mousePosition))
                {
                    DragAndDrop.AcceptDrag();
                    if (DragAndDrop.objectReferences.Length > 0 && DragAndDrop.objectReferences[0] is AudioClip ac)
                    {
                        prop.objectReferenceValue = ac;
                    }
                    DragAndDrop.activeControlID = 0;
                    Event.current.Use();
                }
                break;
            case EventType.MouseDown:
                if (objectFieldButtonRect.Contains(evt.mousePosition) || !clip && mainRect.Contains(evt.mousePosition))
                {
                    var propKey = MakeUniquePropKey(prop);
                    objectPickerControls[propKey] = GUIUtility.GetControlID(FocusType.Passive);
                    EditorGUIUtility.ShowObjectPicker<AudioClip>(prop.objectReferenceValue, false, "", objectPickerControls[propKey]);
                    Event.current.Use();
                }
                else if (mainRect.Contains(evt.mousePosition))
                {
                    var focused = EditorWindow.focusedWindow;
                    EditorWindow.FocusWindowIfItsOpen(tProjectWindow);
                    EditorGUIUtility.PingObject(clip);
                    focused.Focus();
                }
                break;
            case EventType.ExecuteCommand:
                if (evt.commandName == "ObjectSelectorUpdated")
                {
                    var propKey = MakeUniquePropKey(prop);
                    if (objectPickerControls.TryGetValue(propKey, out int tmp) && EditorGUIUtility.GetObjectPickerControlID() == tmp)
                    {
                        prop.objectReferenceValue = EditorGUIUtility.GetObjectPickerObject();
                        foreach (var editor in ActiveEditorTracker.sharedTracker.activeEditors)
                        {
                            if (editor.serializedObject == prop.serializedObject)
                            {
                                editor.Repaint();
                                return;
                            }
                        }
                    }
                }
                else if (evt.commandName == "ObjectSelectorClosed")
                {
                    var propKey = MakeUniquePropKey(prop);
                    if (objectPickerControls.TryGetValue(propKey, out int tmp) && EditorGUIUtility.GetObjectPickerControlID() == tmp)
                    {
                        prop.objectReferenceValue = EditorGUIUtility.GetObjectPickerObject();
                        objectPickerControls[propKey] = -1;
                    }
                }
                break;
        }
        /* -------- D&D Control, ObjectPicker -------- */

        EditorGUI.EndProperty();
    }
}
```