AVAudioPlayerNode запланированные буферы и изменение аудио маршрута в iOS11

Я вижу различное поведение между iOS 9/10 и iOS 11 для буферов, которые в будущем запланированы на AVAudioPlayerNode, когда происходит изменение аудио маршрута (например, вы подключаете наушники). Кто-нибудь испытывал что-то подобное и как вы решили это? Обратите внимание, что я сообщил об этой проблеме на форуме поддержки Apple AVFoundation почти две недели назад, и получил абсолютно нулевой ответ.

Код, который демонстрирует эту проблему, показан ниже - сначала краткое объяснение: код представляет собой простой цикл, который периодически планирует запуск буфера для воспроизведения в будущем. Процесс запускается путем вызова метода runSequence, который планирует аудиобуфер для воспроизведения в будущем и устанавливает обратный вызов завершения для вложенного метода audioCompleteHandler. Обратный вызов завершения снова вызывает метод runSequence, который планирует другой буфер и поддерживает процесс навеки. Это тот случай, когда всегда есть запланированный буфер, кроме случаев, когда выполняется обработчик завершения. Метод trace в разных местах является внутренним методом печати только при отладке, поэтому его можно игнорировать.

В обработчике уведомлений об изменении аудио маршрута (handleAudioRouteChange), когда новое устройство становится доступным (case .newDeviceAvailable), код перезапускает движок и проигрыватель, повторно активирует аудио сеанс и вызывает runSequence, чтобы запустить цикл заново.

Все это прекрасно работает на iOS 9.3.5 (iPhone 5C) и iOS 10.3.3 (iPhone 6), но не работает на iOS 11.1.1 (iPad Air). Природа ошибки заключается в том, что AVAudioPlayerNode не воспроизводит аудио, а вместо этого сразу вызывает обработчик завершения. Это вызывает безудержную ситуацию. Если я удаляю строку, которая снова запускает цикл (как указано в коде), она отлично работает на iOS 11.1.1, но не работает на iOS 9.3.5 и iOS 10.3.3. Эта ошибка другая: звук просто останавливается, и в отладчике я вижу, что цикл не зацикливается.

Таким образом, возможное объяснение состоит в том, что в iOS 9.x и iOS 10.x будущие запланированные буферы не планируются, когда происходит изменение звукового маршрута, в то время как в iOS 11.x будущие запланированные буферы не являются незапланированными.

Это приводит к двум вопросам: 1. Кто-нибудь видел подобное поведение и каково было решение? 2. Может ли кто-нибудь указать мне документацию, которая описывает точное состояние движка, плеера (ов) и сеанса, когда происходит изменение аудио маршрута (или прерывание звука)?

private func runSequence() {

    // For test ony
    var timeBaseInfo = mach_timebase_info_data_t()
    mach_timebase_info(&timeBaseInfo)
    // End for test only

    let audioCompleteHandler = { [unowned self] in
        DispatchQueue.main.async {
            trace(level: .skim, items: "Player: \(self.player1.isPlaying), Engine: \(self.engine.isRunning)")
            self.player1.stop()
            switch self.runStatus {
            case .Run:
                self.runSequence()
            case .Restart:
                self.runStatus = .Run
                self.tickSeq.resetSequence()
                //self.updateRenderHostTime()
                self.runSequence()
            case .Halt:
                self.stopEngine()
                self.player1.stop()
                self.activateAudioSession(activate: false)
            }
        }
    }

    // Schedule buffer...
    if self.engine.isRunning {
        if let thisElem: (buffer: AVAudioPCMBuffer, duration: Int) = tickSeq.next() {
            self.player1.scheduleBuffer(thisElem.buffer, at: nil, options: [], completionHandler: audioCompleteHandler)
            self.player1.prepare(withFrameCount: thisElem.buffer.frameLength)
            self.player1.play(at: AVAudioTime(hostTime: self.startHostTime))
            self.startHostTime += AVAudioTime.hostTime(forSeconds: TimeInterval(Double(60.0 / Double(self.model.bpm.value)) * Double(thisElem.duration)))
            trace(level: .skim, items:
                "Samples: \(thisElem.buffer.frameLength)",
                "Time: \(mach_absolute_time() * (UInt64(timeBaseInfo.numer) / UInt64(timeBaseInfo.denom))) ",
                "Sample Time: \(player1.lastRenderTime!.hostTime)",
                "Play At: \(self.startHostTime) ",
                "Player: \(self.player1.isPlaying)",
                "Engine: \(self.engine.isRunning)")
        }
        else {
        }
    }
}


@objc func handleAudioRouteChange(_ notification: Notification) {

    trace(level: .skim, items: "Route change: Player: \(self.player1.isPlaying) Engine: \(self.engine.isRunning)")
    guard let userInfo = notification.userInfo,
        let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
        let reason = AVAudioSessionRouteChangeReason(rawValue:reasonValue) else { return }

    trace(level: .skim, items: audioSession.currentRoute, audioSession.mode)
    trace(level: .none, items: "Reason Value: \(String(describing: userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt)); Reason: \(String(describing: AVAudioSessionRouteChangeReason(rawValue:reasonValue)))")

    switch reason {
    case .newDeviceAvailable:
        trace(level: .skim, items: "In handleAudioRouteChange.newDeviceAvailable")
        for output in audioSession.currentRoute.outputs where output.portType == AVAudioSessionPortHeadphones {
            startEngine()
            player1.play()
            activateAudioSession(activate: true)
            //updateRenderHostTime()
            runSequence() // <<--- Problem: works for iOS9,10; fails on iOS11. Remove it and iOS9,10 fail, works on iOS11
        }
    case .oldDeviceUnavailable:
        trace(level: .skim, items: "In handleAudioRouteChange.oldDeviceUnavailable")
        if let previousRoute =
            userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription {
            for output in previousRoute.outputs where output.portType == AVAudioSessionPortHeadphones {
                player1.stop()
                stopEngine()
                tickSeq.resetSequence()
                DispatchQueue.main.async {
                    if let pp = self.playPause as UIButton? { pp.isSelected = false }
                }
           }
        }

1 ответ

Итак, проблема решена дальнейшим копанием / тестированием:

  • Существует разница в поведении между iOS 9/10 и iOS 11, когда AVAudioSession выдает уведомление об изменении маршрута. В обработчике уведомлений состояние движка не работает (engine.isRunning == false) около 90% времени для iOS 9/10, тогда как для iOS 11 состояние движка всегда работает (engine.isRunning == true)
  • В 10% случаев, когда iOS 9/10 указывает, что движок работает (engine.isRunning == true), это на самом деле не так. Двигатель НЕ работает независимо от того, что говорит engine.isRunning
  • Поскольку движок был остановлен в iOS 9/10, ранее подготовленное аудио было выпущено, и аудио не запустится только после перезапуска движка; Вы должны перепланировать файл или буфер в точке выборки, где двигатель был остановлен. К сожалению, вы не можете найти текущее время выборки, когда двигатель остановлен (игрок возвращает ноль), поэтому вы должны:

    • Запустить двигатель
    • Возьмите время выборки и накапливайте его (+=) в постоянном свойстве
    • Остановить игрока
    • Перепланируйте звук (и подготовьте его), начиная с только что взятого образца
    • Запустить плеер
  • Состояние движка в iOS 9/10 одинаково как для подключенного к корпусу наушников (.newDeviceAvailable), так и для удаленного кейса для наушников (.oldDeviceUnavailable), поэтому вам необходимо выполнить аналогичные действия и для удаленного кейса (накапливая образец необходимо время, чтобы вы могли перезапустить звук с того места, где он был остановлен, так как player.stop() сбросит время сэмплирования на 0)

  • Ничего из этого не требуется для iOS 11, но приведенный ниже код работает для iOS 9/10 и 11, поэтому, вероятно, лучше сделать это одинаково для всех версий

Приведенный ниже код работает на моих тестовых устройствах для iOS 9.3.5 (iPhone 5C), iOS 10.3.3 (iPhone 6) и iOS 11.1.1 (iPad Air) (но меня все еще беспокоит тот факт, что я не могу найти ранее комментарий о том, как правильно обрабатывать изменение маршрута, и, должно быть, сотни людей, которые сталкивались с этой проблемой. Обычно, когда я не могу найти какой-либо предварительный комментарий по теме, я предполагаю, что я делаю что-то неправильно или просто не понимаю... ну что ж...)

@objc func handleAudioRouteChange(_ notification: Notification) {

    guard let userInfo = notification.userInfo,
        let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
        let reason = AVAudioSessionRouteChangeReason(rawValue:reasonValue) else { return }

    switch reason {
    case .newDeviceAvailable:

        for output in audioSession.currentRoute.outputs where output.portType == AVAudioSessionPortHeadphones {
            headphonesConnected = true
        }

        startEngine()   // Do this regardless of whether engine.isRunning == true

        if let lrt = player.lastRenderTime, let st = player.playerTime(forNodeTime: lrt)?.sampleTime {
            playSampleOffset += st  // Accumulate so that multiple inserts/removals move the play point forward
            stopPlayer()
            scheduleSegment(file: playFile, at: nil, player: player, start: playSampleOffset, length: AVAudioFrameCount(playFile.length - playSampleOffset))
            startPlayer()
        }
        else {
            // Unknown problem with getting sampleTime; reset engine, player(s), restart as appropriate
        }

    case .oldDeviceUnavailable:
        if let previousRoute =
            userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription {
            for output in previousRoute.outputs where output.portType == AVAudioSessionPortHeadphones {
                headphonesConnected = false
            }
        }

        startEngine()   // Do this regardless of whether engine.isRunning == true

        if let lrt = player.lastRenderTime, let st = player.playerTime(forNodeTime: lrt)?.sampleTime  {
            playSampleOffset += st  // Accumulate...
            stopPlayer()
            scheduleSegment(file: playFile, at: nil, player: player, start: playSampleOffset, length: AVAudioFrameCount(playFile.length - playSampleOffset))
            startPlayer()   // Test only, in reality don't restart here; set play control to allow user to start audio
        }
        else {
            // Unknown problem with getting sampleTime; reset engine, player(s), restart as appropriate
        }

...

Другие вопросы по тегам