1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Unity】エディタで波形を見ながらオーディオをトリミングするツール

Posted at

はじめに

Unityでオーディオ編集を行う際、外部ツールを使わずにエディタ内で簡単にトリミングできると便利です。この記事では、波形を視覚的に確認しながらオーディオクリップをトリミングできるエディタ拡張を紹介します。

Audio Trimmer.png

機能

このエディタ拡張には以下の機能があります。

  • 波形の視覚化: オーディオクリップの波形をリアルタイム生成・表示
  • ビジュアルな範囲選択: トリム範囲を波形上でカラー表示し、スライダーで調整
  • プレビュー再生: トリム範囲をエディタ内で試聴
  • WAVファイル出力: トリムしたオーディオを新しいWAVファイルとして保存

ファイル構成

Assets/
└── Editor/
    └── AudioTrimmerWindow.cs

使い方

  1. UnityエディタのメニューからTools > Audio > Audio Trimmerを選択
  2. Source Audio Clipにトリミングしたいオーディオクリップをドラッグ&ドロップ
  3. 波形が自動生成される
  4. スライダーでトリム範囲を調整(波形上に緑の開始線・赤の終了線・青の選択範囲が表示される)
  5. Preview Rangeで試聴
  6. Trim and Saveで保存(元ファイルと同じフォルダに_trimmedサフィックス付きで保存)

コード全体

AudioTrimmerWindow.cs(クリックで展開)
using UnityEngine;
using UnityEditor;
using System.IO;

namespace U1W.Editor
{
    /// <summary>
    /// オーディオクリップをトリミングするためのエディターウィンドウ
    /// </summary>
    public class AudioTrimmerWindow : EditorWindow
    {
        private AudioClip _sourceClip;
        private float _startTime = 0f;
        private float _endTime = 1f;
        private bool _isPlaying = false;

        private Texture2D _waveformTexture;
        private const int WAVEFORM_WIDTH = 800;
        private const int WAVEFORM_HEIGHT = 200;

        private Vector2 _scrollPosition;

        [MenuItem("Tools/Audio/Audio Trimmer")]
        public static void ShowWindow()
        {
            var window = GetWindow<AudioTrimmerWindow>("Audio Trimmer");
            window.minSize = new Vector2(850, 400);
        }

        private void OnGUI()
        {
            EditorGUILayout.Space(10);
            EditorGUILayout.LabelField("Audio Trimmer", EditorStyles.boldLabel);
            EditorGUILayout.HelpBox("オーディオクリップの波形を表示してトリミング範囲を選択できます。", MessageType.Info);
            EditorGUILayout.Space(10);

            // ソースクリップ選択
            EditorGUI.BeginChangeCheck();
            _sourceClip = (AudioClip)EditorGUILayout.ObjectField("Source Audio Clip", _sourceClip, typeof(AudioClip), false);
            if (EditorGUI.EndChangeCheck() && _sourceClip != null)
            {
                _endTime = _sourceClip.length;
                GenerateWaveform();
            }

            if (_sourceClip == null)
            {
                EditorGUILayout.HelpBox("オーディオクリップを選択してください。", MessageType.Warning);
                return;
            }

            EditorGUILayout.Space(10);

            // クリップ情報表示
            EditorGUILayout.LabelField("Clip Info", EditorStyles.boldLabel);
            EditorGUILayout.LabelField($"Length: {_sourceClip.length:F3} sec");
            EditorGUILayout.LabelField($"Frequency: {_sourceClip.frequency} Hz");
            EditorGUILayout.LabelField($"Channels: {_sourceClip.channels}");
            EditorGUILayout.LabelField($"Samples: {_sourceClip.samples}");

            EditorGUILayout.Space(10);

            // 波形表示
            if (_waveformTexture != null)
            {
                EditorGUILayout.LabelField("Waveform", EditorStyles.boldLabel);
                Rect waveformRect = GUILayoutUtility.GetRect(WAVEFORM_WIDTH, WAVEFORM_HEIGHT);
                GUI.DrawTexture(waveformRect, _waveformTexture);

                // トリム範囲の視覚的表示
                float startX = waveformRect.x + (waveformRect.width * (_startTime / _sourceClip.length));
                float endX = waveformRect.x + (waveformRect.width * (_endTime / _sourceClip.length));

                // 選択範囲の背景
                Rect selectionRect = new Rect(startX, waveformRect.y, endX - startX, waveformRect.height);
                EditorGUI.DrawRect(selectionRect, new Color(0.3f, 0.7f, 1f, 0.2f));

                // 開始・終了ライン
                EditorGUI.DrawRect(new Rect(startX - 1, waveformRect.y, 2, waveformRect.height), Color.green);
                EditorGUI.DrawRect(new Rect(endX - 1, waveformRect.y, 2, waveformRect.height), Color.red);
            }
            else if (_sourceClip != null)
            {
                if (GUILayout.Button("Generate Waveform", GUILayout.Height(30)))
                {
                    GenerateWaveform();
                }
            }

            EditorGUILayout.Space(10);

            // トリム範囲設定
            EditorGUILayout.LabelField("Trim Range", EditorStyles.boldLabel);

            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("Start Time (sec):", GUILayout.Width(120));
            _startTime = EditorGUILayout.Slider(_startTime, 0f, _sourceClip.length);
            EditorGUILayout.EndHorizontal();

            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("End Time (sec):", GUILayout.Width(120));
            _endTime = EditorGUILayout.Slider(_endTime, 0f, _sourceClip.length);
            EditorGUILayout.EndHorizontal();

            // 範囲の妥当性チェック
            if (_startTime >= _endTime)
            {
                _endTime = Mathf.Min(_startTime + 0.1f, _sourceClip.length);
            }

            float duration = _endTime - _startTime;
            EditorGUILayout.LabelField($"Duration: {duration:F3} sec");

            EditorGUILayout.Space(10);

            // プレビュー再生
            EditorGUILayout.BeginHorizontal();
            if (GUILayout.Button("Preview Range", GUILayout.Height(30)))
            {
                PreviewTrimmedAudio();
            }
            if (GUILayout.Button("Stop Preview", GUILayout.Height(30)))
            {
                StopAllClips();
            }
            EditorGUILayout.EndHorizontal();

            EditorGUILayout.Space(10);

            // トリム実行
            GUI.backgroundColor = Color.green;
            if (GUILayout.Button("Trim and Save", GUILayout.Height(40)))
            {
                TrimAndSaveAudio();
            }
            GUI.backgroundColor = Color.white;

            EditorGUILayout.Space(10);

            EditorGUILayout.HelpBox(
                "トリム後のオーディオは元のファイルと同じフォルダに '_trimmed' サフィックス付きで保存されます。",
                MessageType.Info
            );
        }

        private void GenerateWaveform()
        {
            if (_sourceClip == null) return;

            // 既存のテクスチャを破棄
            if (_waveformTexture != null)
            {
                DestroyImmediate(_waveformTexture);
            }

            _waveformTexture = new Texture2D(WAVEFORM_WIDTH, WAVEFORM_HEIGHT);

            // 背景を黒で塗りつぶし
            Color[] pixels = new Color[WAVEFORM_WIDTH * WAVEFORM_HEIGHT];
            for (int i = 0; i < pixels.Length; i++)
            {
                pixels[i] = new Color(0.1f, 0.1f, 0.1f, 1f);
            }
            _waveformTexture.SetPixels(pixels);

            // サンプルデータ取得
            float[] samples = new float[_sourceClip.samples * _sourceClip.channels];
            _sourceClip.GetData(samples, 0);

            int samplesPerPixel = samples.Length / WAVEFORM_WIDTH;

            // 波形描画
            for (int x = 0; x < WAVEFORM_WIDTH; x++)
            {
                int startSample = x * samplesPerPixel;
                int endSample = Mathf.Min(startSample + samplesPerPixel, samples.Length);

                float min = 0f;
                float max = 0f;

                // この範囲内の最大・最小値を取得
                for (int i = startSample; i < endSample; i++)
                {
                    float sample = samples[i];
                    if (sample < min) min = sample;
                    if (sample > max) max = sample;
                }

                // 波形を描画
                int yMin = (int)((1f - (min + 1f) / 2f) * WAVEFORM_HEIGHT);
                int yMax = (int)((1f - (max + 1f) / 2f) * WAVEFORM_HEIGHT);

                yMin = Mathf.Clamp(yMin, 0, WAVEFORM_HEIGHT - 1);
                yMax = Mathf.Clamp(yMax, 0, WAVEFORM_HEIGHT - 1);

                for (int y = yMax; y <= yMin; y++)
                {
                    _waveformTexture.SetPixel(x, y, Color.cyan);
                }
            }

            // 中心線を描画
            int centerY = WAVEFORM_HEIGHT / 2;
            for (int x = 0; x < WAVEFORM_WIDTH; x++)
            {
                _waveformTexture.SetPixel(x, centerY, new Color(0.5f, 0.5f, 0.5f, 1f));
            }

            _waveformTexture.Apply();
        }

        private void PreviewTrimmedAudio()
        {
            if (_sourceClip == null) return;

            // Unity エディターでオーディオをプレビュー再生
            var audioUtil = System.Type.GetType("UnityEditor.AudioUtil, UnityEditor");
            if (audioUtil != null)
            {
                var playClipMethod = audioUtil.GetMethod(
                    "PlayPreviewClip",
                    System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public,
                    null,
                    new System.Type[] { typeof(AudioClip), typeof(int), typeof(bool) },
                    null
                );

                if (playClipMethod != null)
                {
                    int startSample = (int)(_startTime * _sourceClip.frequency);
                    playClipMethod.Invoke(null, new object[] { _sourceClip, startSample, false });
                }
            }
        }

        private void StopAllClips()
        {
            var audioUtil = System.Type.GetType("UnityEditor.AudioUtil, UnityEditor");
            if (audioUtil != null)
            {
                var stopMethod = audioUtil.GetMethod(
                    "StopAllPreviewClips",
                    System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public
                );

                if (stopMethod != null)
                {
                    stopMethod.Invoke(null, null);
                }
            }
        }

        private void TrimAndSaveAudio()
        {
            if (_sourceClip == null)
            {
                EditorUtility.DisplayDialog("Error", "ソースクリップが選択されていません。", "OK");
                return;
            }

            // ソースファイルのパスを取得
            string sourcePath = AssetDatabase.GetAssetPath(_sourceClip);
            if (string.IsNullOrEmpty(sourcePath))
            {
                EditorUtility.DisplayDialog("Error", "ソースファイルのパスを取得できません。", "OK");
                return;
            }

            // 保存先パスを生成
            string directory = Path.GetDirectoryName(sourcePath);
            string fileName = Path.GetFileNameWithoutExtension(sourcePath);
            string extension = Path.GetExtension(sourcePath);
            string outputPath = Path.Combine(directory, $"{fileName}_trimmed{extension}");

            // WAV形式でエクスポート
            if (!ExportTrimmedWav(outputPath))
            {
                EditorUtility.DisplayDialog("Error", "オーディオのトリムに失敗しました。", "OK");
                return;
            }

            AssetDatabase.Refresh();

            EditorUtility.DisplayDialog(
                "Success",
                $"トリムされたオーディオを保存しました:\n{outputPath}",
                "OK"
            );
        }

        private bool ExportTrimmedWav(string outputPath)
        {
            try
            {
                // サンプルデータを取得
                float[] samples = new float[_sourceClip.samples * _sourceClip.channels];
                _sourceClip.GetData(samples, 0);

                // トリム範囲のサンプルを抽出
                int startSample = (int)(_startTime * _sourceClip.frequency) * _sourceClip.channels;
                int endSample = (int)(_endTime * _sourceClip.frequency) * _sourceClip.channels;
                int trimmedLength = endSample - startSample;

                float[] trimmedSamples = new float[trimmedLength];
                System.Array.Copy(samples, startSample, trimmedSamples, 0, trimmedLength);

                // WAVファイルとして書き出し
                WriteWavFile(outputPath, trimmedSamples, _sourceClip.frequency, _sourceClip.channels);

                return true;
            }
            catch (System.Exception e)
            {
                Debug.LogError($"Failed to export trimmed audio: {e.Message}");
                return false;
            }
        }

        private void WriteWavFile(string path, float[] samples, int frequency, int channels)
        {
            using (FileStream fs = new FileStream(path, FileMode.Create))
            using (BinaryWriter writer = new BinaryWriter(fs))
            {
                int sampleCount = samples.Length;
                int byteRate = frequency * channels * 2; // 16bit = 2 bytes

                // WAV ヘッダー
                writer.Write(new char[4] { 'R', 'I', 'F', 'F' });
                writer.Write(36 + sampleCount * 2); // ファイルサイズ - 8
                writer.Write(new char[4] { 'W', 'A', 'V', 'E' });

                // fmt チャンク
                writer.Write(new char[4] { 'f', 'm', 't', ' ' });
                writer.Write(16); // fmtチャンクサイズ
                writer.Write((short)1); // フォーマット (1 = PCM)
                writer.Write((short)channels);
                writer.Write(frequency);
                writer.Write(byteRate);
                writer.Write((short)(channels * 2)); // ブロックアライン
                writer.Write((short)16); // ビット深度

                // data チャンク
                writer.Write(new char[4] { 'd', 'a', 't', 'a' });
                writer.Write(sampleCount * 2);

                // サンプルデータを16bit PCMに変換して書き込み
                for (int i = 0; i < sampleCount; i++)
                {
                    short sample = (short)(samples[i] * 32767f);
                    writer.Write(sample);
                }
            }
        }

        private void OnDisable()
        {
            StopAllClips();

            if (_waveformTexture != null)
            {
                DestroyImmediate(_waveformTexture);
            }
        }
    }
}

コードの解説

1. EditorWindowの基本設定

public class AudioTrimmerWindow : EditorWindow
{
    private AudioClip _sourceClip;
    private float _startTime = 0f;
    private float _endTime = 1f;

    private Texture2D _waveformTexture;
    private const int WAVEFORM_WIDTH = 800;
    private const int WAVEFORM_HEIGHT = 200;

    [MenuItem("Tools/Audio/Audio Trimmer")]
    public static void ShowWindow()
    {
        var window = GetWindow<AudioTrimmerWindow>("Audio Trimmer");
        window.minSize = new Vector2(850, 400);
    }
}
  • EditorWindowを継承してカスタムエディタウィンドウを作成
  • [MenuItem]でメニューからウィンドウを開けるように設定
  • 波形表示用のテクスチャサイズを定数で定義(800x200px)

2. 波形生成アルゴリズム

private void GenerateWaveform()
{
    // サンプルデータ取得
    float[] samples = new float[_sourceClip.samples * _sourceClip.channels];
    _sourceClip.GetData(samples, 0);

    int samplesPerPixel = samples.Length / WAVEFORM_WIDTH;

    // 波形描画
    for (int x = 0; x < WAVEFORM_WIDTH; x++)
    {
        int startSample = x * samplesPerPixel;
        int endSample = Mathf.Min(startSample + samplesPerPixel, samples.Length);

        float min = 0f;
        float max = 0f;

        // この範囲内の最大・最小値を取得
        for (int i = startSample; i < endSample; i++)
        {
            float sample = samples[i];
            if (sample < min) min = sample;
            if (sample > max) max = sample;
        }

        // 波形をシアン色で描画
        int yMin = (int)((1f - (min + 1f) / 2f) * WAVEFORM_HEIGHT);
        int yMax = (int)((1f - (max + 1f) / 2f) * WAVEFORM_HEIGHT);

        for (int y = yMax; y <= yMin; y++)
        {
            _waveformTexture.SetPixel(x, y, Color.cyan);
        }
    }

    _waveformTexture.Apply();
}

アルゴリズムの流れ:

  1. オーディオクリップから全サンプルデータを取得
  2. 画面幅(800px)に合わせてサンプルをグループ化
  3. 各グループの最大値・最小値を取得
  4. 最大値・最小値を縦位置に変換
  5. 縦線として描画(ピーク波形表示)

利点:

  • 長時間のオーディオでも軽量に描画
  • 全体の音量変化が一目瞭然

3. トリム範囲の視覚化

// トリム範囲の視覚的表示
float startX = waveformRect.x + (waveformRect.width * (_startTime / _sourceClip.length));
float endX = waveformRect.x + (waveformRect.width * (_endTime / _sourceClip.length));

// 選択範囲の背景(青の半透明)
Rect selectionRect = new Rect(startX, waveformRect.y, endX - startX, waveformRect.height);
EditorGUI.DrawRect(selectionRect, new Color(0.3f, 0.7f, 1f, 0.2f));

// 開始・終了ライン(緑・赤)
EditorGUI.DrawRect(new Rect(startX - 1, waveformRect.y, 2, waveformRect.height), Color.green);
EditorGUI.DrawRect(new Rect(endX - 1, waveformRect.y, 2, waveformRect.height), Color.red);
  • 開始位置を緑の線で表示
  • 終了位置を赤の線で表示
  • 選択範囲を青の半透明で塗りつぶし

4. エディタ内でのプレビュー再生

private void PreviewTrimmedAudio()
{
    if (_sourceClip == null) return;

    // エディター用の AudioUtil を使用(リフレクション)
    var audioUtil = System.Type.GetType("UnityEditor.AudioUtil, UnityEditor");
    if (audioUtil != null)
    {
        var playClipMethod = audioUtil.GetMethod(
            "PlayPreviewClip",
            System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public,
            null,
            new System.Type[] { typeof(AudioClip), typeof(int), typeof(bool) },
            null
        );

        if (playClipMethod != null)
        {
            int startSample = (int)(_startTime * _sourceClip.frequency);
            playClipMethod.Invoke(null, new object[] { _sourceClip, startSample, false });
        }
    }
}

ポイント:

  • UnityEditor.AudioUtilは公式ドキュメントに載っていない内部API
  • リフレクションを使ってエディタ内でのオーディオプレビューを実現
  • PlayPreviewClipで指定したサンプル位置から再生

注意点:

  • 内部APIのため、Unityバージョンによっては動作しない可能性がある
  • 本番コードではなく、エディタツール限定で使用すること

5. WAVファイルの書き出し

private bool ExportTrimmedWav(string outputPath)
{
    // サンプルデータを取得
    float[] samples = new float[_sourceClip.samples * _sourceClip.channels];
    _sourceClip.GetData(samples, 0);

    // トリム範囲のサンプルを抽出
    int startSample = (int)(_startTime * _sourceClip.frequency) * _sourceClip.channels;
    int endSample = (int)(_endTime * _sourceClip.frequency) * _sourceClip.channels;
    int trimmedLength = endSample - startSample;

    float[] trimmedSamples = new float[trimmedLength];
    System.Array.Copy(samples, startSample, trimmedSamples, 0, trimmedLength);

    // WAVファイルとして書き出し
    WriteWavFile(outputPath, trimmedSamples, _sourceClip.frequency, _sourceClip.channels);

    return true;
}

処理の流れ:

  1. オーディオクリップから全サンプルを取得
  2. トリム範囲のサンプルインデックスを計算
  3. 該当範囲のサンプルを新しい配列にコピー
  4. WAVファイルとして書き出し

6. WAVファイルフォーマット

private void WriteWavFile(string path, float[] samples, int frequency, int channels)
{
    using (FileStream fs = new FileStream(path, FileMode.Create))
    using (BinaryWriter writer = new BinaryWriter(fs))
    {
        int sampleCount = samples.Length;
        int byteRate = frequency * channels * 2; // 16bit = 2 bytes

        // WAV ヘッダー(RIFFチャンク)
        writer.Write(new char[4] { 'R', 'I', 'F', 'F' });
        writer.Write(36 + sampleCount * 2); // ファイルサイズ - 8
        writer.Write(new char[4] { 'W', 'A', 'V', 'E' });

        // fmt チャンク
        writer.Write(new char[4] { 'f', 'm', 't', ' ' });
        writer.Write(16); // fmtチャンクサイズ
        writer.Write((short)1); // フォーマット (1 = PCM)
        writer.Write((short)channels);
        writer.Write(frequency);
        writer.Write(byteRate);
        writer.Write((short)(channels * 2)); // ブロックアライン
        writer.Write((short)16); // ビット深度

        // data チャンク
        writer.Write(new char[4] { 'd', 'a', 't', 'a' });
        writer.Write(sampleCount * 2);

        // サンプルデータを16bit PCMに変換して書き込み
        for (int i = 0; i < sampleCount; i++)
        {
            short sample = (short)(samples[i] * 32767f);
            writer.Write(sample);
        }
    }
}

WAVフォーマットの構造:

  1. RIFFヘッダー: ファイル識別子("RIFF" + ファイルサイズ + "WAVE")
  2. fmtチャンク: 音声フォーマット情報(PCM、サンプリングレート、チャンネル数など)
  3. dataチャンク: 実際の音声データ(16bit PCM)

サンプルの変換:

  • Unity内部では-1.0~1.0のfloat値
  • WAVファイルでは-32768~32767のshort値
  • sample * 32767fで変換

カスタマイズ例

1. 波形の色変更

// 波形を緑色に
_waveformTexture.SetPixel(x, y, Color.green);

// グラデーション表示
float intensity = Mathf.Abs(sample);
Color waveColor = Color.Lerp(Color.yellow, Color.red, intensity);
_waveformTexture.SetPixel(x, y, waveColor);

2. マルチチャンネル対応

// ステレオの場合、左右チャンネルを個別に描画
for (int channel = 0; channel < _sourceClip.channels; channel++)
{
    int channelHeight = WAVEFORM_HEIGHT / _sourceClip.channels;
    int channelOffset = channel * channelHeight;

    // チャンネルごとに波形を描画
    // ...
}

3. フェードイン/フェードアウト

トリミング時にフェード処理を追加:

float fadeInDuration = 0.1f; // 0.1秒
float fadeOutDuration = 0.1f;

for (int i = 0; i < trimmedSamples.Length; i++)
{
    float time = (float)i / (_sourceClip.frequency * _sourceClip.channels);
    float totalDuration = (float)trimmedSamples.Length / (_sourceClip.frequency * _sourceClip.channels);

    // フェードイン
    if (time < fadeInDuration)
    {
        trimmedSamples[i] *= time / fadeInDuration;
    }

    // フェードアウト
    if (time > totalDuration - fadeOutDuration)
    {
        trimmedSamples[i] *= (totalDuration - time) / fadeOutDuration;
    }
}

応用例

  • ループ音素材の作成: ゲーム用BGMのループポイント切り出し
  • 効果音の無音除去: 録音した効果音の前後の無音部分をトリミング
  • ボイス編集: 会話の一部を抽出してキャラクターのセリフとして使用
  • サンプリング: 長いオーディオから特定のフレーズを切り出し

まとめ

このエディタ拡張により、Unity内でオーディオ編集が完結します。

主な利点:

  • 外部ツール不要で作業効率UP
  • 波形を見ながら正確にトリミング
  • エディタ内でプレビュー再生可能
  • WAV形式で高品質に保存

技術的な学び:

  • Texture2Dを使った波形の視覚化
  • WAVファイルフォーマットの理解
  • Unityエディタ内部APIの活用(リフレクション)
  • EditorWindowを使ったカスタムツール作成

ぜひプロジェクトに導入して、オーディオ編集を効率化してみてください!

動作環境

  • Unity 6000.0.60f1 以降
  • .NET Standard 2.1

参考リンク

1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?