Audio BGM再生関連の問題整理、影響範囲が広く複数ある修正アプローチの検討
Description
現状の BGM 制御メカニズム
コア state(SoundManager 内グローバル)
_bgmAlias: string | null— 「今再生対象」の alias_bgmInstance: IMediaInstance | null—sound.play()の戻り値_crossfadeDuration = 1000ms- ✕ fade 系 setInterval は どこにも保持されない(
sound.ts:946,sound.ts:1017)
API(外部公開)
| API | 用途 | 副作用 |
|---|---|---|
playBGM(alias, opts) | BGM 再生 / 切替 | 同 alias は短絡 / 別 alias は 1秒 crossfade(orphan timer 発生) |
stopBGM() | BGM 停止 | _bgmInstance.stop() + state クリア。走行中の fade timer は止めない |
setBGMVolume(v) | 音量変更 | 走行中の fadeIn/fadeOut が無視して上書きしうる |
playBGM(alias, { forceRestart: true }) | 強制リスタート | visibility 復帰用 |
playBGM(alias, { skipFadeIn: true }) | 即座にフルボリュームで再生 | 初期 BGM 用 |
呼び出し側パターン(実態)
A. mount 時 usePagePreloader({ bgm }) で再生(標準) — 11箇所
Home, MainQuest, Caravan, Gacha, ChapterResult, StageResultView, Battle, Members, InProgressView, BattleSimulator, BattleEffectPreview
B. mount 時に直接 soundManager.playBGM() を呼ぶ
Start.tsx:454(TITLE)Home.tsx:415,422(BGM-ID 変更時)HomeCharacterSelectModal.tsx:169,179DiaryMainScenario.tsx:292(HOMEに戻す)
C. unmount cleanup で stopBGM() する — 一貫していない
| シーン | unmount で stopBGM? |
|---|---|
| Home | ✗ |
| MainQuest | ✗ |
| Caravan | ✗ |
| Gacha | ✗ |
| ChapterResult | ✓ (ChapterResult.tsx:167) |
| StageResultView | ✓ (StageResultView.tsx:426) |
| Battle | ✓ (Battle.tsx:1458,1609) |
| Start | ✓ (Start.tsx:457) |
| Scenario | ✓ (Scenario.tsx:42,44) |
| InProgressView | ✓ (InProgressView.tsx:100,209) |
D. モーダルで stopBGM 叩く
PartyEditModal.tsx:521, HomeCharacterSelectModal.tsx:146 — モーダル開いただけで親の BGM を止める
問題テーブル
| # | 問題 | 影響 | 関連箇所 |
|---|---|---|---|
| P1 | crossfade の fadeOut/fadeIn setInterval が追跡されておらず、新しい playBGM/stopBGM でキャンセルされない(orphan timer) | 1秒以内のシーン往復で alias 重複時に「鳴り始めてすぐ無音」(INS-803 / ガチャ / キャラバン / MainQuest中断) | sound.ts:946,1017 |
| P2 | playBGM 内の await 中に stopBGM が走ると、await 後に「_bgmAlias !== alias ならインスタンスを停止」する race ハンドリングはあるが、orphan 化した fade timer は無防備 | P1 と複合で症状を悪化 | sound.ts:988-995 |
| P3 | 「シーン BGM の所有権」モデルがない。誰でも stopBGM/playBGM できるグローバル state | モーダルが親の BGM を止めて閉じても元に戻らない/二重呼び出しで音が変になる | C/D の不一致表 |
| P4 | unmount で stopBGM するか否かがシーン毎にバラバラ | 結果シーンは止めるが、Home/MainQuest/Caravan/Gacha は止めない。「同一 alias 短絡」の前提が崩れる状況が発生 | C の表 |
| P5 | setBGMVolume 中に fadeIn/fadeOut が走っていると、interval が音量を上書きする | Battle のダッキング (Battle.tsx:382,386,499) と crossfade が衝突する可能性 | sound.ts:1054-1063 |
| P6 | 「BGM 切替時の遷移方針」が暗黙。crossfade 固定 1秒、skipFadeIn フラグはあるが、「即停止 → 即再生」「フェードなし切替」などのオプションがない | 局所最適化が難しく、各シーンが stopBGM + playBGM を別々に組み合わせる結果、上記 race を生みやすい | API 設計全体 |
| P7 | fadeOutSE (sound.ts:840) も同様に orphan timer 問題あり(SE側) | SE が予期せず再生中の同 alias を止めうる | 同上 |
| P8 | クロスフェード中の volume 制御は sound.volume(alias, v) = alias 単位なので、新インスタンスを再生した瞬間にも適用される | P1 の根本原因の一部 | sound.ts:952 |
| P9 | scenario BGM は別レイヤー(_scenarioBgmFadeTimer で追跡管理) | 通常 BGM レイヤーと設計が不揃い。統一の余地あり | sound.ts:122,1264 |
議論したい論点
- 修正の粒度:
- 最小: P1 + P2 のみ(fade timer 追跡&キャンセル)
- 中: P1+P2+P5+P7 (fade & volume race を一網打尽)
- 大: P3+P4+P6 も含めて「シーン BGM 所有権モデル」を導入
- API 設計:
- 現状
playBGM/stopBGMの二択 - 追加候補:
crossfadeTo(alias, duration)/fadeOutBGM(duration)/setSceneBGM(scene, alias)等
- 現状
- シーン unmount での扱い:
- 全シーン明示的に stopBGM すべき?(呼び損ね・順序問題が出る)
- それとも「次の playBGM が必ず crossfade で置き換える」のを保証する設計?
ーーー
P1 P2 の修正で、どこまでカバーできてどこまでカバーできないか?
ーーー
P1 + P2 修正でカバーできる範囲
P1(fade timer 追跡&キャンセル)+ P2(await 中 race の堅牢化)を、おそらく以下のような実装で:
_activeFadeTimers: Set<ReturnType<typeof setInterval>>を追加playBGM/stopBGM入口で_activeFadeTimersを全 clear- await sound.play() 後の
_bgmAlias !== aliasチェックに加え、「自分の起動した fade timer がまだ生きているか」もトークンで判定
✅ カバーされる
| ケース | 理由 |
|---|---|
| INS-803 (MainQuest中断 → Home) | ChapterResult の crossfade で起きた HOME fadeOut timer が、Home の playBGM(HOME) 時にキャンセルされる |
| ガチャ祈祷 → Home 速攻 | 同上(GACHA への crossfade で起きた HOME fadeOut timer) |
| キャラバン → Home 速攻 | 同上 |
Battle → Home 速攻(Battle が stopBGM する経路) | stopBGM 時にも残存 fade timer を clear すれば OK |
playBGM(A) → playBGM(B) → playBGM(A) 1秒以内連打 | 各 playBGM で前の timer が clear される |
fadeIn 途中での setBGMVolume(実装に含めれば) | setBGMVolume 入口でも fade timer clear すれば、ユーザー設定が確実に反映される |
❌ カバーされない(P3/P4/P6 領域)
| ケース | 残る問題 | 該当 P |
|---|---|---|
「unmount cleanup の stopBGM が、新シーンの playBGM の後に走る」race | React effect cleanup 順序次第で、Scene A の cleanup stopBGM が Scene B の playBGM 後に走ると、B のインスタンスが殺される。これは fade timer ではなく _bgmInstance.stop() 直接呼び。P1 では救えない | P3, P4 |
HomeCharacterSelectModal がモーダル内で stopBGM → 閉じても親 Home の BGM が戻らない | モーダルが BGM 所有権を奪うが、復元責任を負っていない。所有権モデルがない | P3 |
PartyEditModal 同様 | 同上 | P3 |
Battle のダッキング(setBGMVolume(0.3) → setBGMVolume(orig))の最中に crossfade が始まる | setBGMVolume で fade timer をキャンセルする実装にすれば部分的に救えるが、「ダッキング中の volume = _bgmVolume を元に計算」している箇所と整合性確認が必要 | P5(部分カバー可) |
SE 側 (fadeOutSE) の orphan timer | BGM 修正のみだと SE 側は別問題として残る | P7 |
playBGM(HOME) の直後に別シーンが stopBGM を呼ぶ通常順序のケース(fade ではなく直接 stop) | これは race というより設計の意図次第。「stopBGM したい人 vs しない人」のシーン間契約が無いと根絶できない | P3, P4 |
| クロスフェード 1秒固定で UX が硬い | API 設計の限界。skipFadeIn はあるが「skipFadeOut」「即切替」「フェード時間指定」がない | P6 |
| シナリオ BGM レイヤーとの統一 | 別レイヤーなので影響なし | P9 |
意外と P6 整理すると、全体的にカバーできそう? ーーー
P6 を「transition policy = state machine + 明示的API」として整理すると、芋づる式にほぼ全部カバーできます。整理してみます。
P6 を本気でやった場合の波及
新 API(例)
soundManager.bgm.crossfadeTo(alias, { duration?, volume? }) // 旧→新クロスフェード
soundManager.bgm.cut(alias, { volume? }) // 即時切替(フェードなし)
soundManager.bgm.fadeOutAndStop({ duration? }) // 停止
soundManager.bgm.duck(volume, { duration? }) // ダッキング(戻すまで維持)
soundManager.bgm.unduck({ duration? }) // ダッキング解除
soundManager.bgm.push(alias) / pop() // モーダル用「一時退避→復元」
state machine(内部)
- 単一の
_currentTransitionを持ち、新リクエストが来たら前を「クリーンに preempt」 - preempt = fade timer 全 clear、必要なら旧 instance 停止、新リクエストへ
setVolume/ ducking もこの machine を経由
各 P がどうなるか
| P | 解消理由 |
|---|---|
| P1 orphan fade timer | machine が transition 単位で timer を管理し、preempt 時に必ず clear |
| P2 await race | transition がトークンを持ち、await 後にトークン一致を確認 |
| P3 所有権なし | push/pop で modal は「一時退避→復元」できる。stopBGM を直接叩く動機がなくなる |
| P4 unmount 不一致 | 「次シーンが crossfadeTo するので、前シーンは何もしなくてよい」が原則化できる → unmount stopBGM を撤廃可能 |
| P5 setBGMVolume race | setVolume も machine 経由なので fade と整合 |
| P6 ポリシー暗黙 | API でポリシーが明示される |
| P7 SE fade orphan | 同じ state machine パターンを SE 用にも適用(DRY化可能) |
| P9 scenario BGM | 同じ machine の別レイヤー or 別インスタンスとして統合余地あり |
カバーされない / 残るもの
| 残課題 | 内容 |
|---|---|
| 呼び出し側の移行 | 既存の playBGM / stopBGM 呼び出し 20箇所超を新 API に置換する大規模 migration(または 旧 API を新 API のシン薄化ラッパーとして残す) |
| 「シーンが BGM を持つ責任」の合意 | API があっても、各シーンの責務(mount で crossfadeTo / modal は push.pop / unmount は何もしない)の運用ルールはドキュメント化が必要 |
iOS interruption / visibility 復帰(sound.ts:180-209) | 既存の復帰ロジックは温存しつつ machine と統合する必要あり、設計時に注意 |
| 既存テストなし問題 | client にテスト基盤がないので回帰検証は手動再現。新 API なら unit test 入れたい欲が出るが別タスク化 |
メリット・デメリット整理
Pros
- 報告済み3バグ + 潜在バグ全部一網打尽
- API がドキュメント化され、新規シーン追加時の事故が減る
- 「stopBGM を呼ぶか呼ばないか」の判断不要になる(運用負荷↓)
Cons
- スコープ大: sound.ts のコア書き換え + 20+ 呼び出し箇所の移行 + iOS復帰ロジックとの整合確認
- 回帰リスク: BGM はゲーム体験のコア。隅々のシーン遷移を再検証する必要あり
- PR レビュー負荷: 大きい diff になる
- チケット粒度: INS-803 のスコープを超えるので、別チケット「BGM 制御 API リファクタ」を切る方が自然
段階案
A. 一気にやる: 新 API 実装 + 全箇所移行 + 旧 API 削除 を 1 PR
B. 2段: ①新 API 実装 + 旧 API を内部で新 API に委譲(互換維持)→ INS-803 解消確認 → ②各シーン移行
C. 保守的: P1+P2 のみで INS-803 を直し、P6 リファクタは別チケット起票して後日
ーーー
B案で修正検討するか?
ーーー
B: 内部は state machine 化して INS-803 含む race 系を全潰し、外側 API は当面 playBGM/stopBGM のシン互換層を保つ。これだと:
- 1 PR で報告バグ全消し
- 既存呼び出しに手を入れないので回帰リスク最小
- 新 API (
crossfadeTo等) は段階的に切替えていける(モーダル干渉とか所有権の話は後追いでもよい)
ーーー
これなら デグレ回避しながら、逐次 保守性上げる修正していけそう。
ーーー 結論 B案採用として、 SoundManager: BGM transition の state machine 化(fade race 解消 / 互換 API 維持) を起票する