Godot 4でSE(効果音)を鳴らしていると、よくある悩みが「同じ音が鳴りすぎてうるさい」「爆発音を連打するとミックスがグチャグチャになる」みたいな問題ですね。
素直に実装すると、各オブジェクトに AudioStreamPlayerAudioStreamPlayer2D を継承したノードを生やして、必要なたびに play() を呼ぶ…という形になりがちです。

でもこのやり方だと:

  • プレイヤー、敵、ギミックごとにプレイヤーを大量に持ってしまう
  • 「同じSEの同時発音数」を一括で制御しづらい
  • シーンツリーが「AudioStreamPlayerだらけ」になって管理がつらい

しかも、同じ音を連打すると、10個20個と重なってミックスが破綻してしまいます。
そこで「継承でAudioStreamPlayerを増やす」のではなく、「音の同時発音数を管理するコンポーネント」をシーンに1つポンと置いて、必要なノードからそこに「再生リクエスト」を投げる方式にしてしまいましょう。

今回紹介する AudioPool (同時発音制限) コンポーネントは、まさにそれをやるための小さなヘルパーです。

【Godot 4】SEの鳴らしすぎを一括制御!「AudioPool」コンポーネント

このコンポーネントは:

  • シーン内に複数の AudioStreamPlayer / AudioStreamPlayer2D / AudioStreamPlayer3D をプールとして持つ
  • 同じ AudioStream ごとに「同時発音数」を制限する
  • すでに上限まで鳴っている音は、条件に応じて「再生をスキップ」または「古いものを止めて差し替え」できる

という、いわゆる「SEマネージャー」をコンポーネントとして切り出したものです。


フルコード:AudioPool.gd


extends Node
class_name AudioPool
## 同時発音数を制御するオーディオプールコンポーネント
##
## - シーンに1つ置いて、他のノードから再生要求を投げる
## - 同じAudioStreamごとに同時再生数を制限する
## - 2D/3D/普通のAudioStreamPlayerを選択可能

## 使用するプレイヤーの種類
## "2D" なら AudioStreamPlayer2D
## "3D" なら AudioStreamPlayer3D
## それ以外は通常の AudioStreamPlayer
@export_enum("Normal", "2D", "3D")
var player_type: String = "Normal"

## プール全体で確保しておくプレイヤー数
## 大きくしすぎると無駄にリソースを食うので、ゲームの規模に合わせて調整
@export_range(1, 128, 1)
var pool_size: int = 16

## 1つのAudioStreamにつき、同時に鳴らしてよい最大数
## 例: 3 にすると、同じSEが4つ以上重ならないように制限される
@export_range(1, 32, 1)
var max_voices_per_stream: int = 4

## 同時発音数を超えたときの挙動
## - "skip" : 追加再生は行わない
## - "steal_oldest" : 一番古く鳴っているプレイヤーを止めて再利用する
@export_enum("skip", "steal_oldest")
var overflow_policy: String = "skip"

## 再生する音量のベース値(dB)
## 個別の再生時にオプションで上書き可能
@export_range(-40.0, 6.0, 0.1)
var base_volume_db: float = 0.0

## 再生するピッチのベース値
## 1.0 が等倍。個別再生でランダムピッチを付けるときの基準
@export_range(0.1, 4.0, 0.01)
var base_pitch_scale: float = 1.0

## デバッグ用: 再生/スキップのログを出すかどうか
@export var debug_log: bool = false


## 内部構造
## プール内のプレイヤー
var _players: Array[Node] = []
## 各プレイヤーが今再生しているAudioStream
var _player_streams: Array[AudioStream] = []
## 各プレイヤーの「最後に再生開始した時間(秒)」 - 古いものから奪うときに使用
var _player_start_times: Array[float] = []

## 同じAudioStreamごとの「現在の同時発音数」をカウントする辞書
## key: AudioStream, value: int
var _stream_voice_count: Dictionary = {}


func _ready() -> void:
    _create_pool()
    if debug_log:
        print("[AudioPool] Initialized with %d players, type=%s" % [pool_size, player_type])


## プールとなるAudioStreamPlayer系ノードを生成する
func _create_pool() -> void:
    _players.clear()
    _player_streams.clear()
    _player_start_times.clear()

    for i in pool_size:
        var player := _instantiate_player()
        add_child(player)
        player.bus = "SFX" if "SFX" in AudioServer.get_bus_list() else player.bus
        player.finished.connect(_on_player_finished.bind(i))
        _players.append(player)
        _player_streams.append(null)
        _player_start_times.append(0.0)


## プレイヤーを種類に応じて生成
func _instantiate_player() -> Node:
    match player_type:
        "2D":
            return AudioStreamPlayer2D.new()
        "3D":
            return AudioStreamPlayer3D.new()
        _:
            return AudioStreamPlayer.new()


## 公開API: 効果音を再生する
##
## @param stream: 再生したいAudioStream
## @param volume_db: 省略時はbase_volume_dbを使用
## @param pitch_scale: 省略時はbase_pitch_scaleを使用
## @param position: 2D/3Dプレイヤーの場合のみ使用(グローバル座標)
##
## 使用例:
##   AudioPool.play_sound(hit_sound)
##   AudioPool.play_sound(explosion_sound, volume_db = -3.0, pitch_scale = randf_range(0.9, 1.1))
func play_sound(
        stream: AudioStream,
        volume_db: float = NAN,
        pitch_scale: float = NAN,
        position: Variant = null
    ) -> void:
    if stream == null:
        push_warning("[AudioPool] play_sound called with null stream")
        return

    # 現在の同時発音数を取得
    var current_voices := _stream_voice_count.get(stream, 0)

    if current_voices >= max_voices_per_stream:
        # すでに上限に達している場合の処理
        match overflow_policy:
            "skip":
                if debug_log:
                    print("[AudioPool] Skip playing stream (limit reached): ", stream)
                return
            "steal_oldest":
                var idx := _find_oldest_player_for_stream(stream)
                if idx == -1:
                    # 安全策: 見つからなければ普通にスキップ
                    if debug_log:
                        print("[AudioPool] No player to steal, skipping: ", stream)
                    return
                _stop_player(idx)
                _start_player(idx, stream, volume_db, pitch_scale, position)
                return
            _:
                # 未知のポリシーはスキップ扱い
                return

    # まだ余裕があるので、空いているプレイヤーを探す
    var free_idx := _find_free_player()
    if free_idx == -1:
        # 全部埋まっている場合、ポリシーに関係なく「一番古いプレイヤー」を奪う
        free_idx = _find_oldest_player_global()
        if free_idx == -1:
            if debug_log:
                print("[AudioPool] No available player at all, skipping: ", stream)
            return

    _start_player(free_idx, stream, volume_db, pitch_scale, position)


## 内部: 実際にプレイヤーで再生を開始する
func _start_player(
        idx: int,
        stream: AudioStream,
        volume_db: float,
        pitch_scale: float,
        position: Variant
    ) -> void:
    var player := _players[idx]

    # 型チェックしてキャスト
    var p_normal := player as AudioStreamPlayer
    var p2d := player as AudioStreamPlayer2D
    var p3d := player as AudioStreamPlayer3D

    if p_normal:
        p_normal.stream = stream
        p_normal.volume_db = is_nan(volume_db) ? base_volume_db : volume_db
        p_normal.pitch_scale = is_nan(pitch_scale) ? base_pitch_scale : pitch_scale
        p_normal.play()
    elif p2d:
        p2d.stream = stream
        p2d.volume_db = is_nan(volume_db) ? base_volume_db : volume_db
        p2d.pitch_scale = is_nan(pitch_scale) ? base_pitch_scale : pitch_scale
        if position != null and typeof(position) == TYPE_VECTOR2:
            p2d.global_position = position
        p2d.play()
    elif p3d:
        p3d.stream = stream
        p3d.volume_db = is_nan(volume_db) ? base_volume_db : volume_db
        p3d.pitch_scale = is_nan(pitch_scale) ? base_pitch_scale : pitch_scale
        if position != null and typeof(position) == TYPE_VECTOR3:
            p3d.global_position = position
        p3d.play()
    else:
        push_warning("[AudioPool] Unknown player type at index %d" % idx)
        return

    # 状態更新
    _player_streams[idx] = stream
    _player_start_times[idx] = Time.get_unix_time_from_system()

    # カウンタ更新
    _stream_voice_count[stream] = _stream_voice_count.get(stream, 0) + 1

    if debug_log:
        print("[AudioPool] Play stream: ", stream, " on idx=", idx, " voices=",
            _stream_voice_count[stream])


## 内部: プレイヤーを停止し、カウンタを減らす
func _stop_player(idx: int) -> void:
    if idx < 0 or idx >= _players.size():
        return

    var player := _players[idx]
    var stream := _player_streams[idx]

    # 実際に停止
    var p_normal := player as AudioStreamPlayer
    var p2d := player as AudioStreamPlayer2D
    var p3d := player as AudioStreamPlayer3D
    if p_normal: p_normal.stop()
    if p2d: p2d.stop()
    if p3d: p3d.stop()

    # カウンタを減らす
    if stream != null and _stream_voice_count.has(stream):
        _stream_voice_count[stream] -= 1
        if _stream_voice_count[stream] <= 0:
            _stream_voice_count.erase(stream)

    _player_streams[idx] = null
    _player_start_times[idx] = 0.0


## 各プレイヤーのfinishedシグナルから呼ばれる
func _on_player_finished(idx: int) -> void:
    _stop_player(idx)
    if debug_log:
        print("[AudioPool] Player finished idx=", idx)


## 空いている(再生していない)プレイヤーを探す
func _find_free_player() -> int:
    for i in _players.size():
        var player := _players[i]
        var p_normal := player as AudioStreamPlayer
        var p2d := player as AudioStreamPlayer2D
        var p3d := player as AudioStreamPlayer3D
        var is_playing := false
        if p_normal:
            is_playing = p_normal.playing
        elif p2d:
            is_playing = p2d.playing
        elif p3d:
            is_playing = p3d.playing

        if not is_playing:
            return i
    return -1


## 指定したAudioStreamを鳴らしているプレイヤーのうち、
## 「最も古く再生を開始したもの」のインデックスを返す
func _find_oldest_player_for_stream(stream: AudioStream) -> int:
    var oldest_time := INF
    var oldest_idx := -1
    for i in _players.size():
        if _player_streams[i] == stream:
            var t := _player_start_times[i]
            if t < oldest_time:
                oldest_time = t
                oldest_idx = i
    return oldest_idx


## 全プレイヤーの中で「最も古く再生を開始したもの」のインデックスを返す
func _find_oldest_player_global() -> int:
    var oldest_time := INF
    var oldest_idx := -1
    for i in _players.size():
        if _player_streams[i] != null:
            var t := _player_start_times[i]
            if t < oldest_time:
                oldest_time = t
                oldest_idx = i
    return oldest_idx

使い方の手順

ここからは、実際のシーンにコンポーネントとして組み込む手順を見ていきましょう。

① AudioPoolノードをシーンに追加する

まずは、ゲーム全体をまとめている「メインシーン」や、「ステージシーン」に AudioPool を1個だけ置きます。
(2Dゲームを想定して AudioStreamPlayer2D を使う例にします)

Main (Node)
 ├── Player (CharacterBody2D)
 ├── EnemySpawner (Node)
 ├── AudioPool (Node)  ← このノードにAudioPool.gdをアタッチ
 └── その他...
  • AudioPool ノードを追加し、スクリプトに AudioPool.gd を指定
  • インスペクタで以下を設定
    • player_type: 2D
    • pool_size: 16 くらいからスタート
    • max_voices_per_stream: 3 〜 5 くらいが無難
    • overflow_policy: とりあえず skip 推奨

これで、シーン内に「SEを鳴らすための共通プール」が1つできあがりました。

② スクリプトからAudioPoolを取得する

プレイヤーや敵など、SEを鳴らしたいノードから AudioPool にアクセスします。
パスはプロジェクトの構成次第ですが、例として ../AudioPool にあるとします。


# Player.gd (例)
extends CharacterBody2D

@export var hit_sound: AudioStream

var audio_pool: AudioPool

func _ready() -> void:
    audio_pool = get_node("../AudioPool") as AudioPool

これで Player から audio_pool.play_sound(...) が呼べるようになります。

③ 実際にSEを鳴らす

例えば、プレイヤーが敵に当たったときにヒット音を鳴らす処理はこんな感じです:


func _on_hit_enemy() -> void:
    if audio_pool and hit_sound:
        # 軽くピッチをランダムにして耳障りを減らす
        var pitch := randf_range(0.95, 1.05)
        audio_pool.play_sound(
            hit_sound,
            volume_db = -2.0,
            pitch_scale = pitch,
            position = global_position  # 2DなのでVector2を渡す
        )

敵側も同じように書けます。


# Enemy.gd (例)
extends CharacterBody2D

@export var death_sound: AudioStream
var audio_pool: AudioPool

func _ready() -> void:
    audio_pool = get_node("../AudioPool") as AudioPool

func die() -> void:
    if audio_pool and death_sound:
        audio_pool.play_sound(
            death_sound,
            volume_db = -4.0,
            pitch_scale = randf_range(0.9, 1.1),
            position = global_position
        )
    queue_free()

④ シーン構成図の具体例

プレイヤー、敵、動く床が全部同じ AudioPool を共有している例です。

Main (Node)
 ├── Player (CharacterBody2D)
 │   ├── Sprite2D
 │   ├── CollisionShape2D
 │   └── Player.gd
 ├── Enemy (CharacterBody2D)
 │   ├── Sprite2D
 │   ├── CollisionShape2D
 │   └── Enemy.gd
 ├── MovingPlatform (Node2D)
 │   ├── Sprite2D
 │   └── MovingPlatform.gd
 └── AudioPool (Node)
     └── AudioPool.gd  ← コンポーネントとしてアタッチ

例えば MovingPlatform.gd では「床が端に到達したときのカチッ音」を鳴らしたいだけですが、
ここでも AudioStreamPlayer2D を持たせるのではなく、AudioPool に投げるだけでOKです。


# MovingPlatform.gd (例)
extends Node2D

@export var move_sound: AudioStream
var audio_pool: AudioPool

func _ready() -> void:
    audio_pool = get_node("../AudioPool") as AudioPool

func _on_reach_edge() -> void:
    if audio_pool and move_sound:
        audio_pool.play_sound(move_sound, volume_db = -6.0, position = global_position)

これで、どのオブジェクトからでも 「AudioPoolに再生を依頼するだけ」 という統一インターフェイスになります。
各ノードがそれぞれAudioStreamPlayerを抱え込む必要はありません。


メリットと応用

継承ベースの「各ノードにAudioStreamPlayerを1個ずつ持たせる」方式 と比べると、このコンポーネント方式にはかなりのメリットがあります。

  • シーン構造がスッキリ
    PlayerやEnemyの子にAudioStreamPlayerが増殖しないので、ツリーが見やすくなります。
    「どのノードがどのバスに出しているのか」も、AudioPool側だけ見ればOKです。
  • 同時発音数の制御が一括でできる
    「爆発SEは3つまで」「足音は5つまで」などを max_voices_per_stream で制御できるので、
    音が重なりすぎてミックスが破綻するのを防げます。
  • 使い回しが効く
    この AudioPool コンポーネントを別プロジェクトに持っていっても、そのまま使えます。
    依存関係が薄いので、プラグイン的に扱えますね。
  • テスト・デバッグがしやすい
    デバッグログをONにしておけば、「今どのSEが何個鳴っているか」を簡単に確認できます。
    「このSEだけやたら多く鳴ってるな?」といった問題も見つけやすくなります。

つまり、「各オブジェクトに音の再生ロジックを継承で抱え込ませる」のではなく、
「音の再生という責務」をAudioPoolというコンポーネントに委譲する ことで、
Godotプロジェクト全体の構造がかなりクリーンになります。

改造案:ストリームごとの個別設定(ボリューム・上限)を持たせる

さらに踏み込んで、「SEごとに上限数や音量を変えたい」という場合は、辞書で設定を持たせるのがおすすめです。
ざっくりした改造例を示します。


# AudioPool.gd 内に追加する例

## SEごとの個別設定
## key: AudioStream
## value: { "max_voices": int, "volume_db": float }
var per_stream_config: Dictionary = {}

func set_stream_config(stream: AudioStream, max_voices: int, volume_db: float) -> void:
    per_stream_config[stream] = {
        "max_voices": max_voices,
        "volume_db": volume_db,
    }

func _get_max_voices_for(stream: AudioStream) -> int:
    if per_stream_config.has(stream):
        return per_stream_config[stream].get("max_voices", max_voices_per_stream)
    return max_voices_per_stream

func _get_base_volume_for(stream: AudioStream) -> float:
    if per_stream_config.has(stream):
        return per_stream_config[stream].get("volume_db", base_volume_db)
    return base_volume_db

あとは play_sound() 内で max_voices_per_streambase_volume_db の代わりに、
_get_max_voices_for(stream)_get_base_volume_for(stream) を参照するように書き換えれば、
「爆発だけは同時発音2つまで」「UIクリック音は小さめ」など、より細かい調整ができるようになります。

こんな感じで、AudioPoolコンポーネントをベースに、自分のゲームに合わせた「音の設計」をどんどん合成していきましょう。