Godotで「セリフが流れている間だけBGMを下げたい」という要件、けっこうありますよね。
素直に実装しようとすると、

  • 各シーンごとに「BGM用AudioStreamPlayer」と「ボイス用AudioStreamPlayer」を用意
  • セリフ再生のたびに、BGMの音量をスクリプトから直接いじる
  • フェード時間や元の音量の管理がシーンごとにバラバラ

…といった「コピペ地獄」になりがちです。
さらに、ボイス付きのキャラが増えたり、ナレーションやSEも絡んでくると、どのスクリプトがどのAudioStreamPlayerを触っているのか、すぐにカオスになります。

そこで登場するのが、コンポーネント方式の「DuckAudio」です。
BGM側にこのコンポーネントを 1 個アタッチしておけば、あとは「セリフが再生されているかどうか」の情報を渡すだけで、自動でBGMをダッキング(音量を下げる)してくれます。

継承ベースで「ダッキング専用BGMプレイヤー」を作るのではなく、
既存のAudioStreamPlayer に DuckAudio コンポーネントをくっつけるだけ、という合成(Composition)スタイルでいきましょう。


【Godot 4】セリフが聞こえるBGMミキサー!「DuckAudio」コンポーネント

この DuckAudio コンポーネントは、

  • BGM側にアタッチしておく
  • 「ダッキング対象(セリフ・ナレーション)のAudioStreamPlayer」を登録する
  • 対象のどれかが再生中なら、BGMの音量を自動的に下げる
  • フェードイン・フェードアウトも自動で行う

という役割を持ちます。
ゲーム側からは「セリフを普通に再生するだけ」でOKです。BGMの音量制御は DuckAudio が全部引き受けてくれます。


フルコード:DuckAudio.gd


extends Node
class_name DuckAudio
## DuckAudio
## 重要なセリフやナレーションが流れている間、
## アタッチ先のBGM(AudioStreamPlayer/2D/3D)の音量を自動で下げるコンポーネントです。
##
## 想定の使い方:
## - BGM用の AudioStreamPlayer(2D/3D) の子としてこのノードを配置
## - inspector で「duck_targets」にセリフ・ナレーション用の AudioStreamPlayer を登録
## - あとはセリフを普通に再生するだけで、自動でBGMがダッキングされます

@export_range(-40.0, 0.0, 0.5)
var duck_db: float = -10.0
## ダッキング時にどれだけ音量を下げるか(dB)。
## 例: -10dB なら、体感で「少し下がった」くらい。
## BGM がうるさい場合は -15〜-20dB くらいまで下げてもOKです。

@export_range(0.01, 5.0, 0.01)
var fade_out_time: float = 0.3
## ダッキング開始時のフェード時間(秒)。
## 数値が小さいほど「スッ」と素早く下がり、大きいとゆっくりBGMが沈みます。

@export_range(0.01, 5.0, 0.01)
var fade_in_time: float = 0.5
## ダッキング終了時のフェード時間(秒)。
## セリフが終わったあと、どれくらいの時間をかけて元の音量に戻すか。

@export var auto_detect_parent_player: bool = true
## true の場合:
##   親ノードから AudioStreamPlayer / AudioStreamPlayer2D / AudioStreamPlayer3D を自動で探します。
## false の場合:
##   inspector から manual_bgm_player に明示的に指定してください。

@export var manual_bgm_player: NodePath
## auto_detect_parent_player = false のときに使う、BGMプレイヤーのパス。
## シーン構造的に親が BGM プレイヤーではない場合に利用します。

@export var duck_targets: Array[NodePath] = []
## ダッキングのトリガーとなる AudioStreamPlayer(2D/3D) の NodePath の配列。
## セリフ用、ナレーション用、重要SEなど、複数登録してOKです。
## どれか 1 つでも「再生中」であればダッキングが有効になります。

@export var use_bus_volume: bool = false
## true の場合:
##   BGMプレイヤーの "bus" に設定された Audio Bus のボリュームを操作します。
## false の場合:
##   BGMプレイヤーの volume_db プロパティを直接操作します。
## バス単位でまとめて制御したい場合は true を推奨します。

@export var debug_print: bool = false
## true にすると、状態遷移や検出ログをprintします。デバッグ用。

# 内部状態管理用
var _bgm_player: Node = null
var _original_volume_db: float = 0.0
var _current_volume_db: float = 0.0

var _is_ducking: bool = false
var _duck_factor: float = 0.0
## 0.0 = 通常音量, 1.0 = 完全ダッキング状態
## 実際の音量は lerp(original_volume_db, original_volume_db + duck_db, _duck_factor)

var _fade_velocity: float = 0.0

func _ready() -> void:
    _resolve_bgm_player()
    if _bgm_player == null:
        push_warning("DuckAudio: BGMプレイヤーが見つかりませんでした。設定を確認してください。")
        return

    _original_volume_db = _get_bgm_volume_db()
    _current_volume_db = _original_volume_db
    _apply_volume()

    if debug_print:
        print("[DuckAudio] Ready. original_volume_db = ", _original_volume_db)

func _physics_process(delta: float) -> void:
    if _bgm_player == null:
        return

    var should_duck := _check_any_target_playing()

    if should_duck and not _is_ducking:
        # ダッキング開始
        _is_ducking = true
        if debug_print:
            print("[DuckAudio] Ducking START")
    elif not should_duck and _is_ducking:
        # ダッキング終了
        _is_ducking = false
        if debug_print:
            print("[DuckAudio] Ducking END")

    # 目標の duck_factor を決定
    var target_factor := _is_ducking ? 1.0 : 0.0

    # 対応するフェード時間を選択
    var fade_time := _is_ducking ? fade_out_time : fade_in_time
    fade_time = max(fade_time, 0.001)

    # 線形補間で duck_factor を更新
    var diff := target_factor - _duck_factor
    var step := delta / fade_time

    if abs(diff) <= step:
        _duck_factor = target_factor
    else:
        _duck_factor += step * sign(diff)

    # duck_factor に基づいて音量を更新
    var target_db := _original_volume_db + duck_db
    _current_volume_db = lerp(_original_volume_db, target_db, _duck_factor)
    _apply_volume()

# ---------------------------------------------------------
# BGM プレイヤーの解決まわり
# ---------------------------------------------------------

func _resolve_bgm_player() -> void:
    if not auto_detect_parent_player:
        if manual_bgm_player != NodePath():
            var node := get_node_or_null(manual_bgm_player)
            if _is_audio_player(node):
                _bgm_player = node
            else:
                push_warning("DuckAudio: manual_bgm_player が AudioStreamPlayer系ではありません。")
        else:
            push_warning("DuckAudio: manual_bgm_player が未設定です。")
        return

    # 親から自動検出
    var parent := get_parent()
    if parent and _is_audio_player(parent):
        _bgm_player = parent
    else:
        # 親がAudioStreamPlayer系でない場合、子ノードも一応探してみる
        for child in get_children():
            if _is_audio_player(child):
                _bgm_player = child
                break

    if _bgm_player == null:
        push_warning("DuckAudio: 親や子から AudioStreamPlayer 系ノードを検出できませんでした。")

func _is_audio_player(node: Node) -> bool:
    return node is AudioStreamPlayer \
        or node is AudioStreamPlayer2D \
        or node is AudioStreamPlayer3D

# ---------------------------------------------------------
# 音量取得・設定まわり
# ---------------------------------------------------------

func _get_bgm_volume_db() -> float:
    if _bgm_player == null:
        return 0.0

    if use_bus_volume:
        # Audio Bus のボリュームを参照
        var bus_name := ""
        if "bus" in _bgm_player:
            bus_name = _bgm_player.bus
        var bus_idx := AudioServer.get_bus_index(bus_name)
        if bus_idx < 0:
            return 0.0
        return AudioServer.get_bus_volume_db(bus_idx)
    else:
        # プレイヤー個別の volume_db を参照
        if "volume_db" in _bgm_player:
            return _bgm_player.volume_db
        return 0.0

func _apply_volume() -> void:
    if _bgm_player == null:
        return

    if use_bus_volume:
        var bus_name := ""
        if "bus" in _bgm_player:
            bus_name = _bgm_player.bus
        var bus_idx := AudioServer.get_bus_index(bus_name)
        if bus_idx < 0:
            return
        AudioServer.set_bus_volume_db(bus_idx, _current_volume_db)
    else:
        if "volume_db" in _bgm_player:
            _bgm_player.volume_db = _current_volume_db

# ---------------------------------------------------------
# ダッキング対象の監視
# ---------------------------------------------------------

func _check_any_target_playing() -> bool:
    for path in duck_targets:
        if path == NodePath():
            continue
        var node := get_node_or_null(path)
        if node == null:
            continue
        if _is_audio_player(node):
            if _is_player_playing(node):
                return true
    return false

func _is_player_playing(player: Node) -> bool:
    # AudioStreamPlayer(2D/3D) は is_playing() を持つ
    if "is_playing" in player:
        return player.is_playing()
    return false

# ---------------------------------------------------------
# 公開API(任意): スクリプトから直接ダッキングを強制したい場合
# ---------------------------------------------------------

func force_duck(enable: bool) -> void:
    ## 任意でダッキング状態を強制するAPI。
    ## 例: カットシーン全体でBGMを下げたいときなどに利用できます。
    _is_ducking = enable
    if debug_print:
        print("[DuckAudio] force_duck: ", enable)

使い方の手順

ここでは 2D ゲームを例に、「プレイヤーのセリフが流れている間だけBGMを下げる」構成を作ってみます。

① シーン構成を用意する

まずは、シンプルなシーン構成の例です。

Main (Node2D)
 ├── BGMPlayer (AudioStreamPlayer2D)
 │    └── DuckAudio (Node)  ← このコンポーネントをアタッチ
 ├── VoicePlayer (AudioStreamPlayer2D)
 └── Player (CharacterBody2D)
      ├── Sprite2D
      └── CollisionShape2D
  • BGMPlayer … ループ再生するBGM用
  • VoicePlayer … セリフ・ナレーションを再生するためのプレイヤー
  • DuckAudio … BGMPlayer の子として追加

DuckAudio.gd をプロジェクトの addons でも scripts でも好きな場所に置き、
DuckAudio ノードにこのスクリプトをアタッチしてください。

② DuckAudio のインスペクタ設定

エディタで DuckAudio ノードを選択し、インスペクタから以下を設定します。

  • duck_db: 例えば -12 dB くらいから試すと良いです。
  • fade_out_time: 0.2〜0.3 秒くらいにすると「スッ」と下がる自然な感じになります。
  • fade_in_time: 0.4〜0.6 秒くらいで、ゆるやかに戻すのがオススメです。
  • auto_detect_parent_player: BGMPlayer の子に置いているなら true のままでOK。
  • use_bus_volume: BGM専用のAudioBusを作っているなら true にしても便利です。
  • duck_targets: VoicePlayer ノードをドラッグ&ドロップで登録します。

この時点で、「VoicePlayer が再生中なら BGM が下がる」準備が完了しています。

③ 実際にセリフを再生してみる

例えば、プレイヤーが会話ボタンを押したときにセリフを再生するコードはこんな感じです。


# Main.gd など、シーンのルートに付けるスクリプト例
extends Node2D

@onready var bgm_player: AudioStreamPlayer2D = $BGMPlayer
@onready var voice_player: AudioStreamPlayer2D = $VoicePlayer

func _ready() -> void:
    # BGM をループ再生
    bgm_player.play()

func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("ui_accept"):
        _play_sample_voice()

func _play_sample_voice() -> void:
    if voice_player.is_playing():
        return # 再生中ならスキップ

    # 適当なセリフ音声を設定して再生
    # 実際にはAudioStreamRandomizerやキューシステムなどに差し替えてOK
    var voice_stream: AudioStream = preload("res://voices/sample_voice.ogg")
    voice_player.stream = voice_stream
    voice_player.play()

このコードでは、DuckAudio に一切触れていません。
それでも、VoicePlayer が再生された瞬間に BGM がスッと下がり、再生終了後にゆっくり戻るはずです。

④ 複数のセリフプレイヤーをまとめてダッキング

シーンが大きくなってくると、プレイヤーとは別に「ナレーション用」「ボスのセリフ用」など複数の AudioStreamPlayer が増えてきます。

Main (Node2D)
 ├── BGMPlayer (AudioStreamPlayer2D)
 │    └── DuckAudio (Node)
 ├── VoicePlayer_Player (AudioStreamPlayer2D)
 ├── VoicePlayer_Narration (AudioStreamPlayer2D)
 └── Boss (Node2D)
      └── VoicePlayer_Boss (AudioStreamPlayer2D)

この場合もやることは同じで、DuckAudio の duck_targets に、

  • VoicePlayer_Player
  • VoicePlayer_Narration
  • Boss/VoicePlayer_Boss

の 3 つを登録しておくだけです。
どれか 1 つでも再生が始まれば、自動でBGMがダッキングされます。


メリットと応用

DuckAudio コンポーネントを使うメリットは、単に「BGMが下がる」だけではありません。

  • シーン構造がシンプル
    BGMプレイヤーの子に DuckAudio を 1 個置くだけ。
    どのキャラのスクリプトも「BGMのことを知らなくていい」状態になります。
  • 再利用性が高い
    別シーンでも BGM プレイヤーに DuckAudio をペタッと貼って、duck_targets を設定するだけで同じ挙動を再現できます。
  • 調整ポイントが 1 箇所にまとまる
    ダッキング量やフェード時間を、シーンごと・BGM ごとに簡単に変えられます。
    「このマップはセリフが多いから、もう少しBGMを下げよう」といった調整がしやすいです。
  • コンポーネント思考でカオスを防ぐ
    「セリフ再生のたびにBGMを直接操作する」ような密結合を避けられます。
    BGMはBGM、セリフはセリフ、ダッキングはDuckAudioと、それぞれ責務が分離されている状態ですね。

コンポーネント化しておくことで、

  • ボイスの実装を変えても(キューシステム、Timeline、DialogueManagerなど)、DuckAudio はそのまま使い回せる
  • 「特定のシーンだけ、ダッキングを少し弱めたい」といった要件にも柔軟に対応できる

という、将来の拡張にも強い設計になります。

改造案:ダッキング中だけBGMをローパスフィルタに通す

「セリフが流れている間は、BGMをこもった感じにしたい」という場合、Audio Bus のエフェクトと組み合わせるのが定番です。
以下は「ダッキング中だけローパスフィルタのカットオフを下げる」改造案の一例です。

前提:

  • BGM 用の Audio Bus に AudioEffectLowPassFilter を 1 つ追加しておく
  • そのバスを BGM プレイヤーが使用している

DuckAudio に、次のような関数を追加してみましょう。


@export var control_lowpass: bool = false
@export var lowpass_bus_name: StringName = &"Music"
@export_range(200.0, 22000.0, 10.0)
var lowpass_min_cutoff: float = 2000.0
@export_range(200.0, 22000.0, 10.0)
var lowpass_max_cutoff: float = 20000.0

func _update_lowpass() -> void:
    if not control_lowpass:
        return

    var bus_idx := AudioServer.get_bus_index(lowpass_bus_name)
    if bus_idx < 0:
        return

    # 0番目のエフェクトが LowPassFilter だと仮定
    var effect := AudioServer.get_bus_effect(bus_idx, 0)
    if effect == null:
        return

    # duck_factor に応じてカットオフ周波数を補間
    var cutoff := lerp(lowpass_max_cutoff, lowpass_min_cutoff, _duck_factor)
    if "cutoff_hz" in effect:
        effect.cutoff_hz = cutoff

そして、_physics_process() の最後あたりで _update_lowpass() を呼び出します。


func _physics_process(delta: float) -> void:
    # ...(既存の処理)...

    _current_volume_db = lerp(_original_volume_db, target_db, _duck_factor)
    _apply_volume()

    # 追加:
    _update_lowpass()

これで、セリフ中はBGMが小さくなるだけでなく、少しこもった音になって「前に出てくるのは声だけ」という雰囲気を作れます。
こういった「音の演出」も、コンポーネント 1 個で完結するのが気持ちいいですね。

ぜひ、自分のプロジェクト用に DuckAudio をカスタマイズして、
「継承より合成」なオーディオ設計を楽しんでみてください。