AUSamplerクラッシュとの戦い — オーディオ再初期化を整理する

| 開発記録 | ピアノ

タグ: #iOS #オーディオ #クラッシュ対応

Apple のオーディオエンジン AUSampler が想定外のタイミングでクラッシュする問題に遭遇しました。サウンドフォントのアンロードと再初期化回数の上限設定で乗り切った話です。

ある日突然のクラッシュ通報

Firebase Crashlytics を導入してから、AUSampler 周りで発生するクラッシュが可視化されるようになりました。報告の多くは「電話着信→アプリ復帰」「Bluetooth デバイス切替」「割り込み発生」のような、オーディオセッションが中断されるシチュエーションでした。

原因仮説: 再初期化が走りすぎている

アプリは中断(AVAudioSession.interruptionNotification)を受け取ると、オーディオエンジンを再初期化する処理を持っていました。ところが、特定の機種・特定の OS バージョンで、再初期化が連鎖的に走るケースが見つかりました。

さらに、旧サウンドフォントを解放しないまま新しいフォントを読み込み続けるとメモリが肥大化し、最終的に AUSampler がクラッシュするという挙動も確認しました。

対策 1: 旧サウンドフォントを明示的にアンロード

中断ハンドラ自体は Swift 側で次のように書いています。.began では何もせず、.ended でかつ shouldResume が真のときだけ再開する、というのが鉄則です。

@objc private func handleInterruption(_ note: Notification) {
  guard
    let info = note.userInfo,
    let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
    let type = AVAudioSession.InterruptionType(rawValue: raw)
  else { return }

  switch type {
  case .began:
    audioEngine.pause()
  case .ended:
    let optsRaw = info[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
    let options = AVAudioSession.InterruptionOptions(rawValue: optsRaw)
    guard options.contains(.shouldResume) else { return }
    try? AVAudioSession.sharedInstance().setActive(true)
    reinitializeSamplerIfAllowed()
  @unknown default:
    break
  }
}

再初期化のたびに、AUSampler.loadSoundBankInstrument() の前に明示的に旧フォントをアンロードする処理を入れました。これだけでメモリ使用量がぐっと下がりました。

対策 2: 再初期化回数に上限を設ける

Dart 側にも上限ロジックを置き、短時間に連続して走らないようガードしました。Stopwatch で経過時間を測り、一定時間内に閾値を超えたら以後の再初期化要求は黙って捨てます。

class AudioReinitGuard {
  static const _windowMs = 10000; // 10秒間
  static const _maxAttempts = 3;
  final _stopwatch = Stopwatch()..start();
  int _attempts = 0;

  bool tryReinit() {
    if (_stopwatch.elapsedMilliseconds > _windowMs) {
      _stopwatch.reset();
      _attempts = 0;
    }
    if (_attempts >= _maxAttempts) {
      _logSkipped();
      return false;
    }
    _attempts++;
    return true;
  }

  void _logSkipped() {
    FirebaseCrashlytics.instance.log('Skip reinit: too many attempts');
  }
}

短時間に何度も再初期化が走る場合、根本原因は別にあると考えるべきです。上限値を設けて、それを超えたら再初期化をスキップし、ログ収集側で根本原因を追跡できるようにしました。

対策 3: 中断イベント限定でトリガを整理

そもそも「いつ再初期化を走らせるか」が曖昧だったのも問題でした。コミット履歴を辿ると、当初はバックグラウンド遷移時にも再初期化していたのですが、これは過剰でした。

中断イベント(割り込み終了)に限定し、それ以外のタイミングは再初期化しない方針に整理しました。これで余計な再初期化が走らなくなり、AUSampler のクラッシュは大幅に減りました。

Crashlytics で見える化

クラッシュ報告そのものの数は、Crashlytics の導入前後で比較するとはっきり減少を確認できました。「直したつもり」が一番怖いので、可視化できる仕組みを早めに入れておくことの大切さを実感しました。

教訓

  • 解放と確保はペアで考える(特にネイティブリソース)
  • 「念のため」の再初期化は積み重なると害になる
  • Crashlytics は早めに入れる、リリース前から仕込む

ピアノアプリは累計 700 万ダウンロードを超えており、想定外のデバイス・想定外の OS で動いています。コードが「正しい」だけではダメで、「あらゆる中断状況で生き残る」コードでなければならない、というのを痛感した修正でした。