【Unity】効果音(SE)の再生と音割れ防止処理の実装

確認環境

この記事は以下環境で制作・確認を行っています。

  • Unity 2019.4.2f1
  • Windows10
  • VisualStudio2019

効果音再生の基本

Unityの効果音の再生はゲームオブジェクトにAudioSourceコンポーネントをアタッチしそのコンポーネントの PlayOneShot(AudioClip) を呼び出すと再生できます。

効果音は短い音の再生を前提として再生させっぱなしが基本的な考え方なので、BGMのように AudioSource.clip に AudioClip を設定して Play(), Stop(), Pause() などの指定を行い音楽の再生状態を色々管理をしません(逆に効果音も一時停止をしたいならBGMに様に扱わないといけません)

あらかじめ Unity に取り込んだ効果音のファイルを取り込んでその音楽ファイルを以下サンプルコードのコンポーネントの _clip にアタッチしておけば音楽ファイルをとして扱うことができます。(実際の再生には AudioListner というコンポーネントが必要ですが Camera に初期設定で付与されているので以下のようなスクリプトを記述して実行すると音が出せる状態になっています)

// 効果音の再生方法のサンプル
[RequireComponent(typeof(AudioSource))]
public class SoundEffectPlayer : MonoBehaviour
{
    // 再生する効果音
    [SerializeField] private AudioClip _clip = default;
    // 効果音を再生するオブジェクト
    [SerializeField] private AudioSource _audioSource = default;
    
    // 初期化
    public void Awake() => _audioSource = this.GetComponent<AudioSource>();
    
    // 効果音を再生するときに呼び出すメソッド
    public void Play() => _audioSource.PlayOneShot(_clip);
}

PlayOneShot は指定された AudioClip の音声を即座に再生することができ、また同じフレーム中に複数の AudioClip を1つの AudioLipo.PlayOneShot() で呼び出すと複数の音が再生できます。この時 AudioSource.clip に AudioClipの指定は必要ありません。

ネットにあるサンプルコードで複数の効果音を鳴らすために複数の AudioSource を用意しているものがありますが簡単な用途であれば1つ以上は AudioSource は必要ありません。必要なのは複数の AudioClip となります。

[SerializeField] private AudioClip _clip1 = default;
[SerializeField] private AudioClip _clip2 = default;

// 同じフレーム中で1つのAudioSourceで複数の効果音を鳴らす
public void Play()
{
    // 以下同じ AudioSource で PlayOneShot() しても効果音は再生される
    
    // 同じ音を2回同時にならす
    _audioSource.PlayOneShot(_clip1);
    _audioSource.PlayOneShot(_clip1);
    
    // 異なる音を同時にならす
    _audioSource.PlayOneShot(_clip1);
    _audioSource.PlayOneShot(_clip2);
}

効果音の同時複数再生は音割れ問題が起きる

同じフレーム中で同じ効果音を何回も PlayOneShot すると再生される波形が合成されてしまい音量が大きくなります。過剰な効果音再生が発生すると音量が大きくなるどころか音割れしノイズのような音が再生されてしまいます(稀にストアに公開されているゲームアプリで一気に爆発が起きたりコインを取得したときに効果音が普段の何倍もの音量で(もっとすごいと音割れした状態で)再生された経験がある人もいるかと思います)

予防方法として、AudioClip の OutputAudioMixerGropu にノーマライズを指定したミキサーを設定して音量を調整する事方法もあるようですが(実際そのようなユースケースがあるのかはさておき)同時に数十個の効果音を同時再生するとノーマライズの影響でエフェクト効果時間中に後から1つ追加の効果音を再生すると、タイミング次第で音量が非常に小さくなってしまう別の問題が発生する可能性があるため、再生するオーディオの数は何らかの方法で制限できた方がよさそうです。

同じ効果音再生を遅延させるコンポーネント

そこで、同じタイミングで複数再生されたときは再生しない、AudioSourceを複数使用してローテーションする方法もありますが、せっかく効果音を複数再生要求が発生しているので「同じフレーム中では同じ効果音を一つだけしか再生させない」「2つ目以降は再生をスケジュールし遅延した再生する」「過剰な再生要求は破棄する」ような再生機能を持つコンポーネントを作成して音割れを防止しつつゲーム体験を向上させたいと思います。

例えば同じ効果音再生が発生した場合、順番にキューイングしたものを再生し「ターン」という効果音が同時に4回再生されたの場合「ターン(大音量)」ではなく「タタタターン」というような連続した効果音再生になります。

使い方

先に使い方を説明です。以下のように準備して使用してください。

// 効果音を再生するコンポーネント
[SerializeField] private SEPlayer _sePlayer = default;
// 再生する音
[SerializeField] private AudioClip _clip1 = default;
[SerializeField] private AudioClip _clip2 = default;

public void Play1()
{
    // 以下のように指定すると「タタターン」と順番に再生される
    _sePlayer("se1", _clip1);
    _sePlayer("se1", _clip1);
    _sePlayer("se1", _clip1);
}

public void Play2()
{
    // 最初の se1, 2は同時に再生され次の se1, 2は遅延再生される
    _sePlayer("se1", _clip1);
    _sePlayer("se2", _clip2);
    _sePlayer("se1", _clip1);
    _sePlayer("se2", _clip2);
}

インスペクターから以下項目が設定できます。

// 同じ音が同時再生数以上リクエストされてキューされた時に再生を遅延させるフレーム数
[SerializeField, Range(1, 10)] private int delayFrameCount = 2;

// SE再生予定キューに登録できる最大要素数
[SerializeField, Range(1, 32)] private int maxQueudItemCount = 4;

delayFrameCount を大きくするとより遅れて再生されるようになります。maxQueudItemCount は1度に4つ以上要求が来た場合5つ目以降は捨てる基準の数ですがこれを増減させることができます。あまり大きいと遅延が大きくなりすぎて不自然になるので様子を見て調整調整することになります。

実装コード例

実装コードは以下の通り。長いですがコピペで行けると思います。

同じ効果音の同時再生数は固定で1で波形がどうのとか再生中かどうかの状態管理などの難しいことはしていません。ただ順番に並べて再生しつつ指定数以上は捨てているだけです。実装がシンプルな割には効果が高いです。

//
// Copyright (c) 2020 Takap.
// This software is released under the MIT License.
//
using Sirenix.OdinInspector;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.Audio;

namespace Takap.Utility.Sounds.SoundEffects
{
    //
    // 1フレームに再生する効果音の数を1種類1つまでに制限する
    //  → 同じタイミングで同じSEを大量にPlayOneShotして爆音になるのを避ける
    //
    // すごく短い時間に要求された効果音はスケジュールして別のフレームで再生する
    //  → それでも最大数が存在する
    //

    /// <summary>
    /// <see cref="Addressables"/> を前提としたゲーム中のサウンド管理を管理するクラス
    /// </summary>
    public class SEPlayer : MonoBehaviour
    {
        //
        // InnerTypes
        // - - - - - - - - - - - - - - - - - - - -

        // 管理対象の効果音の情報
        public class _Info
        {
            // クリップの名前
            public string Name;
            // 再生済みかどうかのフラグ。true : 再生済み / false : まだ
            public bool IsDone;
            // 再生候補になってからの経過フレーム数
            public int FrameCount;
            // 再生する音
            public AudioClip Clip;
        }

        //
        // Constants
        // - - - - - - - - - - - - - - - - - - - -

        // 何でPlayPneshot()なのに複数チャンネル用意する必要があるんだろう?

        // SEチャンネル数
        public const int SE_CHANNEL = 1;
        // デフォルトのチャンネルを表す
        public const int SE_CH_DEFAULT = -1;

        //
        // Inspector
        // - - - - - - - - - - - - - - - - - - - -

        // 同じ音が同時再生数以上リクエストされてキューされた時に再生を遅延させるフレーム数
        [SerializeField, Range(1, 10)] private int delayFrameCount = 2;
        // SE再生予定キューに登録できる最大要素数
        [SerializeField, Range(1, 32)] private int maxQueudItemCount = 4;

        // SE再生用のオブジェクト(デフォルトch)
        [SerializeField, ReadOnly] private AudioSource defaultSource = default;

        //
        // Fields
        // - - - - - - - - - - - - - - - - - - - -

        //// 管理中のクリップ
        //private readonly List<_Info> clipList = new List<_Info>();

        private readonly Dictionary<string, Queue<_Info>> table = new Dictionary<string, Queue<_Info>>();

        //
        // Props
        // - - - - - - - - - - - - - - - - - - - -

        /// <summary>
        /// オーディオミキサーを設定します。
        /// </summary>
        public AudioMixerGroup AudioMixerGroup { set => this.defaultSource.outputAudioMixerGroup = value; }

        //
        // Runtime
        // - - - - - - - - - - - - - - - - - - - -

        protected void Update()
        {
            foreach (var q in this.table.Values)
            {
                if (q.Count == 0)
                {
                    continue;
                }

                while (true)
                {
                    if (q.Count == 0)
                    {
                        break;
                    }

                    if (q.Peek().IsDone)
                    {
                        var _ = q.Dequeue();
                    }
                    else
                    {
                        break;
                    }
                }

                if (q.Count == 0)
                {
                    continue;
                }

                // 未再生でキューの先頭の1件に対して
                var info = q.Peek();
                info.FrameCount++;
                if(info.FrameCount > this.delayFrameCount)
                {
                    this.defaultSource.PlayOneShot(info.Clip); // 時間が経過していたら再生して捨てる
                    var _ = q.Dequeue();
                }
            }

            // 全部再生し終わったらループを停止する
            if (this.count == 0)
            {
                this.table.Clear();
                this.enabled = false;
            }
        }

        //
        // Public Methods
        // - - - - - - - - - - - - - - - - - - - -

        /// <summary>
        /// オブジェクトを初期化し使用可能な状態にします。
        /// </summary>
        public void Setup()
        {
            if (!this.defaultSource)
            {
                Log.Debug("Create SE(default)");
                var go = new GameObject("SE (default)");
                go.SetParent(this);
                this.defaultSource = go.AddComponent<AudioSource>();
            }
        }

        //
        // Private Methods
        // - - - - - - - - - - - - - - - - - - - -

        /// <summary>
        /// 効果音を再生します。
        /// </summary>
        public void PlaySE(string name, AudioClip clip)
        {
            // 再生要求があったときにループを開始し、それまでは開始しない
            this.enabled = true;

            var info = new _Info() { FrameCount = 0, Clip = clip, };

            if (!this.table.ContainsKey(name))
            {
                this.defaultSource.PlayOneShot(clip);
                info.IsDone = true;

                var q = new Queue<_Info>();
                q.Enqueue(info);
                this.table[name] = q;
            }
            else
            {
                var list = this.table[name];
                if (list.Count <= this.maxQueudItemCount)
                {
                    this.table[name].Enqueue(info);
                }
                else
                {
                    Log.Debug($"効果音の最大登録数を超えています。name={name}");
                }
            }
        }

        // 有効な要素数を取得する
        private int count
        {
            get
            {
                int num = 0;
                foreach (var list in this.table.Values)
                {
                    num += list.Count;
                }
                return num;
            }
        }
    }
}

最後に使用時の注意点ですが、このコンポーネントで効果音を再生する場合、効果音を識別するために AudioClip に対して文字列で名前を付ける必要があります。異なる効果音でも同じ名前を付けると同じものと認識されてグループ化を行うため遅延再生となります。

また不要なUpdateを走らないように調整しましたが、再生とスケジュール処理でテーブルを検索するため普通の再生より少しだけ処理コストがかかっています。ただし画像描画処理などに比べたらほんの微々たるものかとは思いますが…

以上です。