SoundManager: BGM transition の state machine 化(fade race 解消 / 互換 API 維持)
Description
親: INS-804 で整理した方針 B案 の実装チケット。
背景
INS-804 で、複数の BGM 再生不具合(INS-803 含む)の根本が「crossfade の setInterval が
orphan 化し、新インスタンスを後追いで殺す」race にあると整理した。
詳細は INS-804 のスレッド参照。
ゴール
SoundManager の BGM 制御を内部で state machine 化し、以下を達成する:
- crossfade fadeOut/fadeIn の setInterval を transition 単位で追跡・preempt
- playBGM/stopBGM の同時/連続呼び出しを race なく処理
- setBGMVolume も machine 経由で fade と整合
- 既存 API (playBGM/stopBGM/setBGMVolume) はシン互換層として温存(呼び出し側無変更)
スコープ外(後追いチケット候補)
- モーダル系の BGM 干渉 (push/pop API 化) — P3
- シーン unmount での stopBGM 撤廃 — P4
- fadeOutSE の同種問題 — P7
- scenario BGM レイヤーとの統合 — P9
- 新 API (crossfadeTo/cut/duck 等) の導入と各シーン移行
解消が見込まれる既存不具合
- INS-803 MainQuest 中断 → Home で BGM 停止
- ガチャ祈祷 → Home 速攻で BGM 停止
- キャラバン → Home 速攻で BGM 停止
検証方法
- 上記3ケースを手動再現で確認
- BGM が絡む主要シーン遷移を一通り回す(Title/Home/Caravan/Gacha/MainQuest/Battle/StageResult/ChapterResult/Scenario)
Comments (8)
INS-805 修正プラン
設計方針
- B案: 内部 state machine 化 + 外部 API (
playBGM/stopBGM/setBGMVolume) は互換維持 - トークン方式で transition を識別、preempt 時に走行中の fade timer をキャンセル
- volume と fade の責務分離(fade tick は
_bgmVolumeを都度参照) - crossfade step 数 (20) / duration (1000ms) は 現状維持
Step 1: 中核 state 追加(sound.ts SoundManager クラス内)
private _bgmTransitionToken = 0;
private _bgmFadeOutTimer: ReturnType<typeof setInterval> | null = null;
private _bgmFadeInTimer: ReturnType<typeof setInterval> | null = null;
private ヘルパー:
_clearBgmFadeTimers(): fadeOut/fadeIn timer を両方 clear & null 化
Step 2: playBGM 改修(sound.ts:908)
- 入口で
const myToken = ++this._bgmTransitionToken;+this._clearBgmFadeTimers(); - await
sound.play()後の race ガードをthis._bgmTransitionToken !== myTokenに置換 - fadeOut interval (
sound.ts:946) をthis._bgmFadeOutTimerに代入。tick 先頭で token 一致を確認、不一致なら自分で clear & return - fadeIn interval (
sound.ts:1017) をthis._bgmFadeInTimerに代入。同様に token チェック - 責務分離: fadeIn tick 内の最終音量
this._bgmVolumeを都度参照(const キャプチャしない)
Step 3: stopBGM 改修(sound.ts:1037)
- 入口で
this._bgmTransitionToken++;+this._clearBgmFadeTimers(); - その後の instance.stop() / state クリアは現状維持
Step 4: setBGMVolume 改修(sound.ts:1054)
- 既存挙動:
this._bgmVolume = volume; if (_bgmInstance) _bgmInstance.volume = volume;をそのまま維持 - fade timer は 触らない(fade は
_bgmVolumeを都度参照するので自動追従) - ダッキング即時性も維持(fade 中でないときは
_bgmInstance.volumeを即更新)
Step 5: 検証用 diagnostic log(実装中のみ)
[BGM-DBG] タグで:
playBGM入口(alias, myToken, currentToken前後, currentAlias, hasInstance)stopBGM入口(token 前後, currentAlias, hasInstance, stack 3行)- fadeOut/fadeIn timer 起動 / token 不一致による preempt / 自然終了
- 互換層 await race(token 不一致で早期 return)
Step 6: 手動回帰テスト
修正前/後で以下を実機確認:
今回 fix の対象3ケース:
- Home → MainQuest → InProgressView → 中断(速攻) → ChapterResult → Home(INS-803)
- Home → Gacha → 祈祷(速攻) → Home
- Home → Caravan(速攻) → Home
回帰確認: 4. Title → Home(TITLE → HOME crossfade) 5. Home → MainQuest → 戻る(同 alias 短絡が壊れていないか) 6. Home → Battle → ステージクリア → StageResult → Home(Battle の stopBGM 経路) 7. Home → Battle → 敗北 → Home 8. Battle のダッキング(CLEAR_SE 中に BGM が 30% に下がり、終了後戻る) 9. Battle ダッキング中にユーザーが option modal で音量変更 → ダッキング解除後に新しい音量が反映される 10. ホーム BGM 再生中に option modal で音量変更 → 即時反映 11. Scenario 再生中にホーム戻り → scenario BGM レイヤーへの影響なし 12. iOS で visibility 復帰 → BGM 復元(forceRestart 経路が壊れていないか)
Step 7: 仕上げ
- diagnostic log を全て削除(
[BGM-DBG]で grep して除去) - 編集ファイルのみ
bun lint実行(sound.ts) - 編集ファイルのみ
bun format実行 - PR description に修正前/後の挙動・テスト結果を記載
スコープ外( 別チケット化候補 )
- モーダル系の BGM 干渉(P3: push/pop API)
- シーン unmount での stopBGM 撤廃(P4)
fadeOutSEの同種 race(P7)- scenario BGM レイヤー統合(P9)
- crossfade duration の UX 調整(750ms 等)
B案-① で修正して 検証したところ以下問題が生じた。
問題点が大きく2点ありました。
[Log] [BGM-DBG] stopBGM – {newToken: 2, currentAlias: "sounds/bgm/charging.m4a", hasInstance: true} (index-CKrHzD2n.js, line 16) [Log] [BGM-DBG] playBGM – {alias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a", myToken: 3, prevToken: 2, …} (index-CKrHzD2n.js, line 16) {alias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a", myToken: 3, prevToken: 2, currentAlias: null, hasInstance: false, …}Object [Log] [BGM-DBG] fadeIn done – {alias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a"} (index-CKrHzD2n.js, line 16) {alias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a"}Object [Log] [BGM-DBG] playBGM – {alias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a", myToken: 4, prevToken: 3, …} (index-CKrHzD2n.js, line 16) {alias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a", myToken: 4, prevToken: 3, currentAlias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a", hasInstance: true, …}Objectalias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v1/object/public/sounds/sounds/e105a489-0ab7-4dfa-bb93-db494318d6c2/6c10b55c-59fd-4b95-ad9f…"currentAlias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v1/object/public/sounds/sounds/e105a489-0ab7-4dfa-bb93-db494318d6c2/6c10b55c-59fd-4b95-ad9f…"forceRestart: undefinedhasInstance: truemuteAll: falsemyToken: 4prevToken: 3skipFadeIn: undefinedObjectプロトタイプ
[Log] [BGM-DBG] playBGM – {alias: "sounds/bgm/battle_preparation.m4a", myToken: 5, prevToken: 4, …} (index-CKrHzD2n.js, line 16) {alias: "sounds/bgm/battle_preparation.m4a", myToken: 5, prevToken: 4, currentAlias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a", hasInstance: true, …}Object [Log] [BGM-DBG] fadeOut done – {oldAlias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a"} (index-CKrHzD2n.js, line 16) {oldAlias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a"}Object [Log] [BGM-DBG] fadeIn done – {alias: "sounds/bgm/battle_preparation.m4a"} (index-CKrHzD2n.js, line 16)
まず、ホーム画面で設定してあるBGMが正常に鳴っているのを確認したのち、 メインクエストへ画面遷移します。その際にBGMが消えます。(フェードアウトしたかも。) それがログの4行目の時点
そしてログ 5,6,7行目 は、チャレンジする章を選択して画面遷移したら出てくるログです。 そこで、、BGM無音状態から、意図した BGMが開始再生されるようになった際のログです。
(ここまで目立ったエラーログは見当たらないような気がします)
この ステージ選択画面で、BGMが鳴っているのを確認した状態で、右上 "中断" ボタンを押します。
[Log] [BGM-DBG] playBGM – {alias: "sounds/bgm/result_lose.m4a", myToken: 6, prevToken: 5, …} (index-CKrHzD2n.js, line 16) {alias: "sounds/bgm/result_lose.m4a", myToken: 6, prevToken: 5, currentAlias: "sounds/bgm/battle_preparation.m4a", hasInstance: true, …}Object [Log] [BGM-DBG] fadeOut done – {oldAlias: "sounds/bgm/battle_preparation.m4a"} (index-CKrHzD2n.js, line 16) [Log] [BGM-DBG] fadeIn done – {alias: "sounds/bgm/result_lose.m4a"} (index-CKrHzD2n.js, line 16) [Log] [BGM-DBG] stopBGM – {newToken: 7, currentAlias: "sounds/bgm/result_lose.m4a", hasInstance: true} (index-CKrHzD2n.js, line 16)
正常に、負けたときのBGMが意図した通りに再生されました。 "skip" - "冒険を続ける" を選択します。
[Log] [BGM-DBG] stopBGM – {newToken: 8, currentAlias: null, hasInstance: false} (index-CKrHzD2n.js, line 16) [Log] [BGM-DBG] playBGM – {alias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a", myToken: 9, prevToken: 8, …} (index-CKrHzD2n.js, line 16) {alias: "https://utsaqkhxqllqkazxzhow.supabase.co/storage/v…4318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a", myToken: 9, prevToken: 8, currentAlias: null, hasInstance: false, …}Object
ホーム画面に戻った際に、上記ログが出て、再び BGMが 停止しました。
中断後 ホーム画面にもどっても音声がならない点についてのみ以下エラーを確認
アサーション失敗: No sound matching alias 'https://utsaqkhxqllqkazxzhow.supabase.co/storage/v1/object/public/sounds/sounds/e105a489-0ab7-4dfa-bb93-db494318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a'.
Sound https://utsaqkhxqllqkazxzhow.supabase.co/storage/v1/object/public/sounds/sounds/e105a489-0ab7-4dfa-bb93-db494318d6c2/6c10b55c-59fd-4b95-ad9f-08a696c6cbbe.m4a not found or not yet loaded in assets
これらは、今回修正した sound.ts が問題の原因ではなく、preload.ts に既存の race が潜んでいて そこが根本要因か?
これは preload.ts 内の 既存の race に起因していて、私の sound.ts 変更とは独立した問題か?
根本原因
preload.ts:316-366 の unloadPreloadBundle の処理順序に問題があります:
export async function unloadPreloadBundle(bundle: string) {
// 1. 画像の解放(await あり)
if (imageCount > 0) {
await Assets.unloadBundle(bundle); // ← ここで待つ
// 画像 alias の削除
}
// 2. 音声の解放(同期、registeredSoundAliases.delete もここで実行)
if (soundCount > 0) {
sound.stop(alias); sound.remove(alias);
registeredSoundAliases.delete(alias);
}
}
そして usePreloader の cleanup は unloadPreloadBundle を await しない:
return () => {
unloadPreloadBundle(bundle).catch(...); // fire-and-forget
};
起きるシーケンス(Home → MainQuest)
| 時刻 | 旧シーン(Home)の unload | 新シーン(MainQuest)の preload | 状態 |
|---|---|---|---|
| t=0 | unloadPreloadBundle("home") 開始、await Assets.unloadBundle で停止中 | LUNA_BGM 鳴ってる、registeredSoundAliases に登録済み | |
| t=1 | (await 中) | preloadAssets("main-quest") 開始、LUNA_BGM について registeredSoundAliases.has(LUNA_BGM) → true で skip | MainQuest 側は LUNA_BGM を再 load しない |
| t=2 | await 完了、sound.remove(LUNA_BGM) 実行、registeredSoundAliases から削除 | preload 完了、playBGM(LUNA_BGM) | BGM 死亡 |
| t=3 | playBGM の短絡: _bgmAlias === LUNA_BGM && _bgmInstance → return | BGM 鳴らないまま |
→ これが 問題A(メインクエスト遷移時にBGM停止) の正体か?
問題B(Home戻り時)
同じ race が ChapterResult → Home でも発生:
- MainQuest unmount で
unloadPreloadBundle("main-quest")開始 - 同時に Home mount で
preloadAssets("home")開始 - Home が LUNA_BGM の preload をスキップ(まだ registered)
- 後で MainQuest の unload が完了、LUNA_BGM が remove される
- Home の playBGM(LUNA_BGM) →
sound.find(LUNA_BGM)?.isLoaded→ false → エラーログ + return → silence
なぜsound.ts の 修正で preload.ts の問題が顕在化したか(推測)
旧コードでは orphan fadeIn timer が走り続けて _bgmInstance.volume を更新し続けていたため、_bgmInstance の参照が常に「アクティブっぽく」見える状態を維持していた可能性があります。さらに偶然のタイミングで race が表面化しないこともありました。
新コードでは fade timer が確実に止まるため、race の振る舞いが安定的に「悪い側」に倒れるようになりました(=確実に再現する)。バグそのものは旧コードからあったが、症状の見え方が変わったと考えられます。
ということで、 sound.ts 側の論理を整理したため、ここから、preload.ts の問題も修正対応。
修正1: preload.ts の race 解消 を検討する
音声の解放を画像 await の 前 に移動する: