TL;DR
AudioClipを選択したとき、Inspectorウィンドウ下部に表示される波形プレビュー。
これをカスタムInspectorやEditorWindowの指定したRect内に描画したいこと、ありますよね? 私はあります。
例えばAudioClipのカスタムPropertyDrawer。こんな風に波形を出して、部分ループのプレビューとかしたい。
この波形部分を描画する方法を紹介します。色も変えられます。
※一部間接的に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のプレビューを実装しているのか、覗きにいきましょう。
160行目あたりにそれらしきものが見つかります。
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()
を使いましょう。
- これを用意するのが少し面倒なところですが、我々にはAudioUtilがあります。
-
Rect wantedRect
- 描画領域ですね。これも必要。
-
float scaleFactor
- 振幅方向のスケールのようです。引数名だけで何のスケールか分からないのは頂けませんね。
- 弊プロジェクトの音屋に聞いたところ「必要ない……」との話でしたが、まあせっかくあるのでオプション引数として残しておきましょう。
AudioImporter、何のため?
AudioImporterが使われているのはこの部分のみです。
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)
。
グラデーションをつけたり、振幅に合わせて色を変えたりしたい。楽しいので。
というわけで、色替え用デリゲートを用意しました。
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上に表示する際にこんな感じになります。
Attributeにはしていません。
お好みで色を変えたり、AudioUtil等で再生機能をつけたりしてどうぞ。
※このコードはRenderPreview()
をInternalAudioUtil
クラス内に定義している前提で書かれています。
必要に応じて適当に書き換えてください。
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();
}
}