🎵

[Unity] 効果音再生を一元管理するSoundPlayer

に公開

次の開発に流用できそうなものが出来たと思ったので覚え書き。

実現したかったこと

  • 効果音(SE)の再生に関する機能(音量調整など)を一元管理すること。
    • AudioSourcePlay()PlayOneShot() 等の再生処理を複数個所にハードコードすると、音量調整などが大変になる。
  • シーン切り替え時に効果音が途切れないこと。

環境

  • Unity 6000.1.14f1
  • MessagePipe
    • 効果音の再生リクエストの送受信に使用しました。
  • VContainer
    • MessagePipe が使用できる DI コンテナなら何でもいいと思います。

処理イメージ

具体的な実装

実装するクラスは以下のとおりです。

  • PlaySoundRequestクラス
    • MessagePipe で送受信するメッセージクラスです。
      • 今回は「 PresentationEvent 」というマーカークラスを継承させました。お好みで。
    • 再生したい効果音の一意 Id(ここでは SoundId と定義)を保持します。
    • イミュータブルだと嬉しい。
      • 今回は record で定義しましたが、readonly struct などでも代替可能のはずです。お好みで。
  • ISoundLoader インタフェース、及びその実装である AddressablesSoundLoader クラス
    • 指定された効果音(AudioClip)を読み込みます。
    • 再生はしません。
  • SoundPlayerクラス
    • MonoBehaviour を継承します。
    • DontDestroyOnLoad 下に常駐させます。
      • シーン横断時に再生した音が途切れないようにするためです。
    • PlaySoundRequestを購読(Subscribe)します。
      • 指定された SoundId を元に ISoundLoader から AudioClip を読み込み、再生します。
  • ボタンなど
    • 効果音を再生したいタイミングで、PlaySoundRequestを送信(Publish)します。

PlaySoundRequest クラス

再生する効果音を指定して送信するメッセージです。

namespace MyGame.Presentation.Events
{
    public record PlaySoundRequest : PresentationEvent
    {
        public SoundId SoundId { get; init; }
    }
}

PresentationEventはマーカー用の基底クラスであり、特に機能は持たせていません。

namespace MyGame.Presentation.Events
{
    public abstract record PresentationEvent
    {
        // 特になし
        // 必要であれば「送信元」「送信日時」などの共通プロパティを実装してもよさそう。
    }
}

SoundIdは効果音の列挙型です。
ここでは Addressables のアドレス等は定義していません。
(Addressables しか使わないと決めてシンプルな作りにするなら、ここで定義してもいいと思います。)

namespace MyGame.Application
{
    public enum SoundId
    {
        Click_OK,
        Click_Cancel,
        ...
    }
}

ISoundLoader インタフェース、AddressablesSoundLoader クラス

Addressables から効果音の AudioClip を読み込んで返却します。
ここでは再生はしません。

namespace MyGame.Application
{
    public interface ISoundLoader : IDisposable
    {
        UniTask<AudioClip> LoadAudioClipAsync(SoundId soundId, CancellationToken cancellationToken = default);
    }
}
namespace MyGame.Infra
{
    public class AddressablesSoundLoader : ISoundLoader
    {
        private static readonly Dictionary<SoundId, string> _address = new()
        {
            // SoundId列挙型とAddressablesのアドレスをマッピングします。
            [SoundId.Click_OK] = "Sounds/Click_OK",
            [SoundId.Click_Cancel] = "Sounds/Click_Cancel",
            ...
        };

        private readonly Dictionary<SoundId, AudioClip> _cache = new();

        public async UniTask<AudioClip> LoadAudioClipAsync(SoundId soundId, CancellationToken cancellationToken = default)
        {
            if(_cache.TryGetValue(soundId, out var cached))
                return cached;

            // SoundIdをAddressに変換
            var address = _address[soundId];

            // AddressablesからAudioClipを取得
            var audioClip = await Addressables.LoadAssetAsync<AudioClip>(address)
                .ToUniTask(cancellationToken: cancellationToken);

            // キャッシュ
            _cache[soundId] = audioClip;

            return audioClip;
        }

        public void Dispose()
        {
            foreach (var clip in _cache.Values)
            {
                if (clip != null) Addressables.Release(clip);
            }
            _cache.Clear();
        }
    }
}

SoundPlayer クラス

実際に AudioClip を再生するクラスです。

namespace MyGame.Presentation
{
    public class SoundPlayer : ... // MonoBehaviourを継承
    {
        // イベント購読
        [Inject] private ISubscriber<PlaySoundRequest> _playSoundRequest = default!;

        [Inject] private ISoundLoader _soundLoader = default!;

        // 再生した効果音(AudioSource)を管理するプール
        private ObjectPool<AudioSource> _audioSourcePool = default!;

        private void Awake()
        {
            // ...検証など(割愛)...

            // プールを初期化
            _audioSourcePool = new ObjectPool<AudioSource>(
                createFunc: () =>
                {
                    var go = new GameObject("Pooled Audio Source");
                    var item = go.AddComponent<AudioSource>();

                    // 初期設定
                    item.playOnAwake = false;

                    // 自身のライフサイクルに合わせる
                    go.transform.SetParent(transform);

                    return item;
                },
                actionOnGet: item =>
                {
                    item.gameObject.SetActive(true);
                },
                actionOnRelease: item =>
                {
                    // オブジェクトをリセット
                    item.Stop();
                    item.clip = null;
                    item.gameObject.SetActive(false);
                },
                actionOnDestroy: item =>
                {
                    if (item != null && item.gameObject != null)
                    {
                        try
                        {
                            Destroy(item.gameObject);
                        }
                        catch (MissingReferenceException)
                        {
                            // 破棄済みの場合は無視
                        }
                    }
                },
                collectionCheck: true,
                defaultCapacity: 10,
                maxSize: 30
            );

            // イベント購読
            _playSoundRequest?.Subscribe(e =>
            {
                // 再生
                _OnSoundPlayRequested(e).Forget();
            }).AddTo(this);
        }

        private void OnDestroy()
        {
            _audioSourcePool?.Clear();
            _audioSourcePool?.Dispose();
        }

        private async UniTask _OnSoundPlayRequested(PlaySoundRequest e)
        {
            var audioSource = _audioSourcePool.Get();
            try
            {
                // AudioClipを読み込み
                audioSource.clip = await _soundLoader.LoadAudioClipAsync(e.SoundId, _cancellationTokenSource.Token);

                // 必要であればここで音量調整など

                // 再生
                await audioSource.PlayAsync(_cancellationTokenSource.Token);
            }
            finally
            {
                // 返却
                if (audioSource != null && _audioSourcePool != null)
                {
                    _audioSourcePool.Release(audioSource);
                }
            }
        }
    }
}

この SoundPlayer クラス(MonoBehaviour コンポーネント)を DontDestroyOnLoad 下に常駐させます。

// VContainerでの例
builder.RegisterComponentOnNewGameObject<SoundPlayer>(Lifetime.Singleton)
    .DontDestroyOnLoad();

(※2025/09/02 追記)

AudioSource.PlayAsync() は自作の拡張メソッドです。

namespace MyGame.Presentation
{
    /// <summary>
    /// UnityEngine.AudioSourceの拡張メソッド
    /// </summary>
    public static class AudioSourceExtensions
    {
        /// <summary>
        /// AudioSourceを非同期再生します。
        /// 主に効果音の再生に使用します。
        /// </summary>
        /// <remarks>
        /// <br/> - ObjectPoolでの使用を想定しています。
        /// <br/> - 1つのAudioSourceにつき1つのAudioClipを再生することを想定しています。
        /// <br/> 1. 呼び出し元で完了を待機する場合
        /// <br/>    await audioSource.PlayAsync();
        /// <br/> 2. 呼び出し元で完了を待機しない場合
        /// <br/>    audioSource.PlayAsync().Forget();
        /// </remarks>
        public static async UniTask PlayAsync(this AudioSource audioSource, CancellationToken cancellationToken = default)
        {
            if (audioSource == null || audioSource.clip == null)
                return;

            try
            {
                // 再生
                audioSource.Play();

                // フレーム遅延を待機
                await UniTask.NextFrame(cancellationToken);

                await UniTask.WaitUntil(
                    () =>
                    {
                        return audioSource == null
                        || audioSource.gameObject == null
                        || !audioSource.isPlaying;
                    },
                    cancellationToken: cancellationToken
                );
            }
            catch (OperationCanceledException)
            {
                // キャンセル時は停止
                if (audioSource != null && audioSource.gameObject != null)
                {
                    audioSource.Stop();
                }
                throw;
            }
        }
    }
}

ボタンなど

効果音を再生したい時に PlaySoundRequest を送信します。

namespace MyGame.Presentation
{
    public class SampleButton : ...
    {
        [SerializeField] private Button _button;
        ...
        [Inject] private IPublisher<PlaySoundRequest> _playSoundRequest = default!;

        private void Awake()
        {
            ...

            // UIイベントハンドラ
            // (R3を使用した例です。標準のOnClickでも可)
            _button.OnClickAsObservable().Subscribe(async _ =>
            {
                ...
                // サウンド再生
                _playSoundRequest?.Publish(new PlaySoundRequest
                {
                    SoundId = SoundId.Click_OK
                });
                ...
            }).AddTo(this);
        }
        ...
    }
}

こうすることで、

  1. ボタンがクリックされる
  2. PlaySoundRequest が送信される
  3. SoundPlayer がそれを受信し、対応する AudioClip を再生する

という流れになりました。

疑問・未検証

同時に 10,000 個の効果音再生が重複してもちゃんと再生されるだろうか?(それが必要なケースを想定していないので検証していません)

所感など

改善点などあればブラッシュアップしていきたいです。
あと、BGM も似たような仕組みを構築しておくと楽そうだなと思いました。

余談

ISoundLoader を経由する(Addressables へのアクセスを外部化する)のが冗長だと感じる場合は、SoundPlayer内で Addressables の AudioClip 読み込み → 再生、としてもいいと思います。お好みで。

Discussion