電話着信後にピアノが鳴らなくなる問題をネイティブ実装で解決

| 開発記録 | ピアノ

タグ: #iOS #ネイティブ #AVAudioSession

「電話に出た後にアプリに戻ると音が鳴らなくなる」。ピアノアプリのレビューに定期的に届く報告でした。プラグインの設定では直らず、最終的に iOS ネイティブで中断通知を購読して、割り込み終了後に setActive(true) を呼び直すところで決着します。Method Channel で Dart 側につないだ記録です。

レビューに繰り返し現れる「音が出ない」報告

「電話に出た後にアプリに戻ると音が鳴らなくなる」「Bluetoothを切り替えると無音になる」。ピアノアプリのレビューに、似たような報告が定期的に届いていました。

再現は意外と簡単でした。演奏中に電話を受けて切る。それだけで、確かに音が鳴らなくなります。自分の端末であっさり再現したときは、ちょっと申し訳なくなりました。

Flutter プラグインの限界

当初はオーディオ系プラグインの設定で何とかしようとしました。AVAudioSession の category を .playback にする。.mixWithOthers を外す。ひととおり試しましたが、根本解決には至りません。

プラグインが提供する API では、AVAudioSession.interruptionNotification.ended 通知を受け取ってからの「適切な再開処理」が抜けていたのです。

ネイティブ実装で AVAudioSession を直接扱う

Swift 側のコアは一点に集約されます。中断通知を購読し、.endedshouldResume を見て setActive(true) を呼び直す。それだけです。下記はその最小実装。

final class AudioInterruptionListener {
  private let channel: FlutterMethodChannel

  init(channel: FlutterMethodChannel) {
    self.channel = channel
    NotificationCenter.default.addObserver(
      self,
      selector: #selector(onInterruption(_:)),
      name: AVAudioSession.interruptionNotification,
      object: AVAudioSession.sharedInstance()
    )
  }

  @objc private func onInterruption(_ note: Notification) {
    guard
      let info = note.userInfo,
      let raw = info[AVAudioSessionInterruptionTypeKey] as? UInt,
      let type = AVAudioSession.InterruptionType(rawValue: raw)
    else { return }
    if type == .ended {
      try? AVAudioSession.sharedInstance().setActive(true)
      channel.invokeMethod("interruptionEnded", arguments: nil)
    } else {
      channel.invokeMethod("interruptionBegan", arguments: nil)
    }
  }
}

最終的に、iOS 側にネイティブコード(Swift)を書き、AVAudioSession.interruptionNotification をリッスンする実装を入れました。

割り込み終了時の処理として:

  1. AVAudioSession.sharedInstance().setActive(true) を呼び直す
  2. オーディオエンジンを再起動
  3. Flutter 側にイベントを通知して、UI 状態を整合させる

の 3 段階を実装しました。

Method Channel で Dart 側と連携

MethodChannel('piano/audio_interrupt') を作り、ネイティブからの通知を Dart 側で受け取れるようにしました。Dart 側ではこれを購読し、Provider 経由で UI に伝えています。

Dart 側のラッパーはシンプルです。Method Channel のハンドラを setMethodCallHandler で登録するだけ。

class AudioInterruptBridge {
  static const _channel = MethodChannel('piano/audio_interrupt');
  final _controller = StreamController<AudioInterruptEvent>.broadcast();

  Stream<AudioInterruptEvent> get events => _controller.stream;

  void start() {
    _channel.setMethodCallHandler((call) async {
      switch (call.method) {
        case 'interruptionBegan':
          _controller.add(AudioInterruptEvent.began);
        case 'interruptionEnded':
          _controller.add(AudioInterruptEvent.ended);
      }
    });
  }
}

修正後のリリースノート

この修正はv1.7 のリリースに含めました。リリースノートには控えめに「電話着信後の音声停止問題を修正」と書きましたが、内部的にはけっこう大きな変更でした。

教訓

  • iOS のオーディオ周りは、結局ネイティブで触らないと解決しない局面がある
  • プラグインの守備範囲を見極める。超えるところは自前で書く
  • 割り込み(interruption)は「終わったあと」をちゃんと面倒見ないと、音は戻らない

プラットフォームの API に深く踏み込むと、Flutter とネイティブの境界をどう設計するかが効いてきます。Method Channel は重宝します。ただ頻繁に使うと依存が増えるので、最小限に絞るのがコツです。