Linear ArchiveArchived issues viewer
← Back to list
INS-805

SoundManager: BGM transition の state machine 化(fade race 解消 / 互換 API 維持)

StatusDone
TeamInstansys
Assigneeasuki.uehata@instansys.co.jp
PriorityMedium
Created2026/05/22 07:50
Completed2026/05/29 07:28
Archived2026/06/06 03:57
Bug

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)

asuki.uehata@instansys.co.jp2026/05/22 08:23

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ケース:

  1. Home → MainQuest → InProgressView → 中断(速攻) → ChapterResult → Home(INS-803)
  2. Home → Gacha → 祈祷(速攻) → Home
  3. 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 等)

asuki.uehata@instansys.co.jp2026/05/25 09:16

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が 停止しました。

asuki.uehata@instansys.co.jp2026/05/25 09:17
asuki.uehata@instansys.co.jp2026/05/25 09:22

これらは、今回修正した sound.ts が問題の原因ではなく、preload.ts に既存の race が潜んでいて そこが根本要因か?

これは preload.ts 内の 既存の race に起因していて、私の sound.ts 変更とは独立した問題か?

根本原因

preload.ts:316-366unloadPreloadBundle の処理順序に問題があります:

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 は unloadPreloadBundleawait しない:

return () => {

unloadPreloadBundle(bundle).catch(...); // fire-and-forget

};

起きるシーケンス(Home → MainQuest)

時刻旧シーン(Home)の unload新シーン(MainQuest)の preload状態
t=0unloadPreloadBundle("home") 開始、await Assets.unloadBundle で停止中LUNA_BGM 鳴ってる、registeredSoundAliases に登録済み
t=1(await 中)preloadAssets("main-quest") 開始、LUNA_BGM について registeredSoundAliases.has(LUNA_BGM)true で skipMainQuest 側は LUNA_BGM を再 load しない
t=2await 完了、sound.remove(LUNA_BGM) 実行、registeredSoundAliases から削除preload 完了、playBGM(LUNA_BGM)BGM 死亡
t=3playBGM の短絡: _bgmAlias === LUNA_BGM && _bgmInstance → returnBGM 鳴らないまま

→ これが 問題A(メインクエスト遷移時にBGM停止) の正体か?

asuki.uehata@instansys.co.jp2026/05/25 09:22

問題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 の振る舞いが安定的に「悪い側」に倒れるようになりました(=確実に再現する)。バグそのものは旧コードからあったが、症状の見え方が変わったと考えられます。

asuki.uehata@instansys.co.jp2026/05/25 09:23

ということで、 sound.ts 側の論理を整理したため、ここから、preload.ts の問題も修正対応。

asuki.uehata@instansys.co.jp2026/05/25 09:23

修正1: preload.ts の race 解消 を検討する

asuki.uehata@instansys.co.jp2026/05/25 09:24

音声の解放を画像 await の に移動する: