Linear ArchiveArchived issues viewer
← Back to list
INS-804

Audio BGM再生関連の問題整理、影響範囲が広く複数ある修正アプローチの検討

StatusDone
TeamInstansys
AssigneeUnassigned
PriorityNo priority
Created2026/05/22 07:36
Completed2026/05/22 07:48
Archived2026/05/30 03:54

Description

現状の BGM 制御メカニズム

コア state(SoundManager 内グローバル)

  • _bgmAlias: string | null — 「今再生対象」の alias
  • _bgmInstance: IMediaInstance | nullsound.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,179
  • DiaryMainScenario.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 を止める


問題テーブル

#問題影響関連箇所
P1crossfade の fadeOut/fadeIn setInterval が追跡されておらず、新しい playBGM/stopBGM でキャンセルされない(orphan timer)1秒以内のシーン往復で alias 重複時に「鳴り始めてすぐ無音」(INS-803 / ガチャ / キャラバン / MainQuest中断)sound.ts:946,1017
P2playBGM 内の await 中に stopBGM が走ると、await 後に「_bgmAlias !== alias ならインスタンスを停止」する race ハンドリングはあるが、orphan 化した fade timer は無防備P1 と複合で症状を悪化sound.ts:988-995
P3「シーン BGM の所有権」モデルがない。誰でも stopBGM/playBGM できるグローバル stateモーダルが親の BGM を止めて閉じても元に戻らない/二重呼び出しで音が変になるC/D の不一致表
P4unmount で stopBGM するか否かがシーン毎にバラバラ結果シーンは止めるが、Home/MainQuest/Caravan/Gacha は止めない。「同一 alias 短絡」の前提が崩れる状況が発生C の表
P5setBGMVolume 中に fadeIn/fadeOut が走っていると、interval が音量を上書きするBattle のダッキング (Battle.tsx:382,386,499) と crossfade が衝突する可能性sound.ts:1054-1063
P6「BGM 切替時の遷移方針」が暗黙。crossfade 固定 1秒、skipFadeIn フラグはあるが、「即停止 → 即再生」「フェードなし切替」などのオプションがない局所最適化が難しく、各シーンが stopBGM + playBGM を別々に組み合わせる結果、上記 race を生みやすいAPI 設計全体
P7fadeOutSE (sound.ts:840) も同様に orphan timer 問題あり(SE側)SE が予期せず再生中の同 alias を止めうる同上
P8クロスフェード中の volume 制御は sound.volume(alias, v) = alias 単位なので、新インスタンスを再生した瞬間にも適用されるP1 の根本原因の一部sound.ts:952
P9scenario BGM は別レイヤー(_scenarioBgmFadeTimer で追跡管理)通常 BGM レイヤーと設計が不揃い。統一の余地ありsound.ts:122,1264

議論したい論点

  1. 修正の粒度:
    • 最小: P1 + P2 のみ(fade timer 追跡&キャンセル)
    • 中: P1+P2+P5+P7 (fade & volume race を一網打尽)
    • 大: P3+P4+P6 も含めて「シーン BGM 所有権モデル」を導入
  2. API 設計:
    • 現状 playBGM/stopBGM の二択
    • 追加候補: crossfadeTo(alias, duration) / fadeOutBGM(duration) / setSceneBGM(scene, alias)
  3. シーン 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 の後に走る」raceReact 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 timerBGM 修正のみだと 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 timermachine が transition 単位で timer を管理し、preempt 時に必ず clear
P2 await racetransition がトークンを持ち、await 後にトークン一致を確認
P3 所有権なしpush/pop で modal は「一時退避→復元」できる。stopBGM を直接叩く動機がなくなる
P4 unmount 不一致「次シーンが crossfadeTo するので、前シーンは何もしなくてよい」が原則化できる → unmount stopBGM を撤廃可能
P5 setBGMVolume racesetVolume も 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 維持) を起票する