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: 例えば
-12dB くらいから試すと良いです。 - 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_PlayerVoicePlayer_NarrationBoss/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 をカスタマイズして、
「継承より合成」なオーディオ設計を楽しんでみてください。
