8
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-01-28

TL;DR

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

例えばAudioClipのカスタムPropertyDrawer。こんな風に波形を出して、部分ループのプレビューとかしたい。
image.png
この波形部分を描画する方法を紹介します。色も変えられます。

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

AssetPreview.GetAssetPreview()(ダメな例)

任意のAssetについて、プレビューテクスチャを取得するAPIは存在します。
それがこのAssetPreview.GetAssetPreview()なんですが、ダメな点がいくつか。

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

実現までの流れ

過程に興味が無い方は飛ばしてどうぞ。

UnityEditor.AudioUtil

まず、エディタ上でAudioClipを扱うための内部ユーティリティ、AudioUtilクラスが存在します。
内部ユーティリティなので当然公開APIではありませんが、Reflectedなラッパークラスを作ってあります。詳細はリンク先の別記事で。

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

AudioClipInspector.csでの実装をパクろう参考にしよう

まずはUnity公式がどうAudioClipのプレビューを実装しているのか、覗きにいきましょう。

Editor/Mono/Inspector/AudioClipInspector.cs

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

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しなくて済みます。

引数多くない?

DoRenderPreviewメソッド、引数が5つもあります。

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

AudioImporter、何のため?

AudioImporterが使われているのはこの部分のみです。

float[] minMaxData = (audioImporter == null) ? null : AudioUtil.GetMinMaxData(audioImporter);

出ましたAudioUtil。表示に使うデータはAudioImporterが持っているようです。

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

色を変えたい

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

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

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

できたもの

波形表示メソッド

ここまでの情報をもとに実装したのがこちら。
別の記事で作ったInternalAudioUtilクラスを前提にしています。
 InternalAudioUtilクラス内に実装しちゃってもいいんじゃないでしょうか。

/// <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));
    //1チャンネルごとの専有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
Attributeにはしていません。
お好みで色を変えたり、AudioUtil等で再生機能をつけたりしてどうぞ。

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

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();
    }
}
8
8
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?