wtorek, 31 maja 2016

[DSP2016] iOS prosto z poligonu odc.9 (próba audio w tle, z playlistą i wizualizacją FFT; CocoaPods; Swift + Objective-C/C)

Ten post będzie nieco inny od poprzednich. Do tej pory bywały dość szybkie piłki z od razu gotową implementacją. Tym razem będzie niemal na żywo o swoich zmaganiach z budową odtwarzacza audio w iOS połączonego z wizualizacją, co udało mi się dowiedzieć, co sprawdziłem, na jakie natrafiłem problemy… 

live

Co chcę mieć? To co wcześniej stworzyłem na Android w dość szybki i prosty sposób.  Chcę funkcjonalny odpowiednik systemowego odtwarzacza (odczytywanie z iPod Library, obsługa kolejki utworów, działanie w tle, sterowanie z lock screen) wzbogacony o możliwość analizy strumienia audio celem wizualizacji za pomocą FFT.

Na dzień dobry powiedzmy, jakie mamy zasadnicze możliwości odtwarzania audio w iOS. Są to:

  • systemowy odtwarzacz (używany przeze mnie w dotychczasowych źródłach, wysokopoziomowy full wypas)
  • wysokopoziomowe AVFoundation (AVAudioPlayer - dedykowany dla audio, ale bez kolejkowania utworów, AVPlayer - bardziej ogólny do audio i wideo, AVQueuePlayer - wersja AVPlayer z kolejkowaniem utworów)
  • Core Audio (mało przystępne niskopoziomowe API w C, dostęp do strumienia audio, możliwość analizy i przetwarzania, efektów itp.)
  • niskopoziomowe AVFoundation (AVAudioEngine, AVPlayerNode - opakowanie Core Audio w nowoczesną znacznie bardziej wygodną postać, nie zawsze satysfakcjonująca dokumentacja i przykłady)

W dokumentacji Apple warto rzucić okiem na Multimedia Programming Guide, AVFoundation Programming Guide czy Core Audio Overview. By robić wizualizację potrzebujemy dostępu do strumienia audio w trakcie jego odtwarzania, to jest możliwe albo za pomocą Core Audio albo za pomocą AVAudioEngine (pomijam tu proste wizualizacje w oparciu o siłę sygnału jak na stronie How To Make a Music Visualizer in iOS). A co Apple daje nam w zakresie transformaty FFT?  Mamy funkcje w C w ramach vDSP API w Acceleration Framework (są też oczywiście na świecie biblioteki, ale standardowe API może nam tutaj wystarczyć).

Eksperymenty zdecydowałem się przeprowadzać poza głównym projektem aplikacji iOS, na boku w osobnym “surowym” projekcie AudioPoligono (poligon doświadczalny). Po osiągnieciu stabilnego rozwiązania z założonymi funkcjonalnościami przeniosę go do aplikacji. 

Core Audio. Generalnie nie jest to bardzo przyjazne API, aczkolwiek po lekturze bardzo dobrej książki Learning Core Audio: A Hands-On Guide to Audio Programming for Mac and iOS przejaśni się co nieco w głowie i zobaczymy, że wilk nie jest taki straszny. W temacie przetwarzania FFT Apple oferuje całkiem niezły przykład aurioTouch, szkoda tylko że źródłem dźwięku jest w nim mikrofon, a nie plik audio. Jego nieoficjalną wersję w Swift znajdziemy pod adresem https://github.com/ooper-shlab/aurioTouch2.0-Swift.

Wilk jednak zawsze pozostanie wilkiem, nowoczesne AVAudioEngine było znacznie bardziej kuszące, więc praktyczną zabawę zacząłem od niego. Zachęciła mnie do tego prezentacja Building Modern Apps with AVAudioEngine. W efekcie powstała najpierw klasa CustomPlayer, a potem ją rozbudowałem do klasy CustomAudioPlayer, w której zmiksowałem obsługę kolejkowania wzorując się na samplu iOS Audio Player with Lock Screen Controls (gdzie playerem jest AVAudioPlayer). Metoda odtwarzająca wygląda tutaj tak:

func playItem(item: MPMediaItem) {
       
        guard let url = item.assetURL, let file = try? AVAudioFile(forReading: url) else {
            self.endPlayback()
            return
        }
               
        let audioEngine = AVAudioEngine()
        let audioPlayer = AVAudioPlayerNode()
       
        audioEngine.attachNode(audioPlayer)
        audioEngine.connect(audioPlayer, to: audioEngine.mainMixerNode, format: file.processingFormat)
       
        audioPlayer.scheduleFile(file, atTime: nil, completionHandler: {
            if self.nextItem == nil {
                self.endPlayback()
            }
            else {
                self.nextTrack()
            }
        })
        audioEngine.prepare()
       
        do {
            try audioEngine.start()
            audioPlayer.play()
           
            self.audioEngine = audioEngine
            self.audioPlayer = audioPlayer
           
            self.nowPlayingItem = item
           
            self.updateNowPlayingInfoForCurrentPlaybackItem()
           
            self.notifyOnTrackChanged()
           
        } catch {
            print("Error")
        }
    }

Na samym początku muzyka mi się nie odtwarzała. Okazało się, że system zwalniał obiekt odtwarzacza zanim rozpoczął granie, musiałem związać go z klasą (np.ViewController).

Jakimś cudem wpadłem na pomysł, by przekazać assetURL z MPMediaItem do AVAudioFile, co pozwoliło odtwarzać mi muzykę z iPod Library przez AVAudioEngine. Nie natrafiłem nigdzie na taki przykład, nawet było gdzieś o to pytanie na forum Apple’a i stackoverflow pozostawione bez odpowiedzi.

Zachęcony pierwszymi sukcesemi zrobiłem sobie audio w tle. Jak to się robi? Pomocna okazało się Q&A z dokumentacji (Playing media while in the background using AV Foundation on iOS) oraz kilka sampli (np. Playing audio in the background czy kapitalny iOS Audio Player with Lock Screen Controls). W capabilities projektu zaznaczamy pracę w tle i wybieramy audio. Dodatkowo w kodzie musimy ustawić sesję audio, bez tego to nie zadziała:

self.audioSession = AVAudioSession.sharedInstance()

try! self.audioSession.setCategory(AVAudioSessionCategoryPlayback)
try! self.audioSession.setActive(true)

W kodzie nie musimy nic robić, to używane przez nas API powoduje, że system podtrzymuje rozpoczętą aktywność odtwarzania po opuszczeniu foreground przez app-kę, a nawet po wyłączeniu ekranu telefonu. Kolejny sukces.

W dalszej kolejności zamierzałem skorzystać z tap-a na węźle końcowym, by pobierać co jakiś czas strumień audio trafiający do głośników, a później obrabiać go vDSP. Pomijając już niezbyt dobrą dokumentację (miałem kilka wpisów, jakiś slajd z prezentacji), o zaprzestaniu tej drogi zadecydowały pewne problemy z przejmowaniem odtwarzania audio, gdy inna aplikacja już coś odtwarzała. Taka obsługa wznawiania sesji jak w przypadku AVPlayer nie była możliwa, ale to w sumie detal. Najbardziej rozwalił mnie current time odtwarzacza i problem z prawidłową obsługą panelu odtwarzającego na lock-screen. AVAudioEngine nie ma takiego pola jak current time, które ma AVPlayer. Co prawda jest sposób, by sobie je obliczyć, jednak te wartości użyte do odświeżania informacji o aktualnie odtwarzanym utworze na lock-screen powodowały, że nie mogłem wznawiać odtwarzania po spauzowaniu, a czas leciał nawet po spauzowaniu. Co prawda znalazłem sposób, by go zatrzymać, ale powodowało to powrót do początku utworu.

Postanowiłem pójść w biblioteki. EZAudio wydawało się bardzo obiecujące. Nie dość, że mamy banalnie prostą obsługę odtwarzania, to jeszcze mamy też dość prosty przykład z analizą FFT:

5621705a-2971-11e5-88ed-9a865e422ade

Jak skorzystać z tej biblioteki?  Najprościej użyć CocoaPods, takiego odpowiednika nugeta. Polecam filmik, by zacząć go szybko używać.

Kolejnym problemem jest fakt, że EZAudio nie jest napisane w Swift, tylko w Objective-C. Ale problem nie okazał się duży. Apple bardzo elegancko to rozwiązał. Polecam szybką lekturę Using Swift with Cocoa and Objective-C (Swift 2.2). W jednym projekcie możemy mieć pliki zarówno w Swift, jak i w Objective-C. Mogą się wywoływać wzajemnie. Bajka. Tylko tworzymy plik pomostowy plik nagłówkowy .h z importem odpowiedniego nagłówka .h z Objective-C. XCode sam go zaproponuje stworzyć, jak zaczniemy tworzyć dowolny plik Objective-C w projekcie Swift (plik Objective-C możemy potem wyrzucić). W ten sposób stworzyłem plik AudioPoligono-Bridging-Header.h , którego treść zmodyfikowałem do postaci:

#import <EZAudio/EZAudio.h>

W źródłach EZAudio 1.4 był problem i zgodnie z wątkiem na github poprawiłem tam import w dwóch plikach .h. Zobaczyłem BTW że jest już wersja 1.5 i może ją wypróbuję następnym razem (1.4 zainstalowałem kierując się dokumentacją na głównej stronie github).  Dla porównania przykładowy projekt będący taką integracją, ale sprzed 11 miesięcy możemy znaleźć na https://github.com/syedhali/EZAudio-Swift.

Stworzyłem kod odtwarzający audio (aha, aby używać obiektów z biblioteki dodałem jeszcze u góry pliku:  import EZAudio):

func playItem(item: MPMediaItem) {       
      
       let file = EZAudioFile(URL: item.assetURL)
      
       let player = EZAudioPlayer(delegate: self)
      
       player.playAudioFile(file)
      
       self.audioPlayer = player
      
       self.sampleRate = Float(file.clientFormat.mSampleRate)
       self.fft = EZAudioFFTRolling(windowSize: FFTWindowSize, sampleRate: self.sampleRate, delegate: self)
      
       self.nowPlayingItem = item
      
       self.updateNowPlayingInfoForCurrentPlaybackItem()
      
       self.notifyOnTrackChanged()
   }

Okazało się jednak, że oryginalne EZAudio (przynajmniej to 1.4) rzuca błedem przy przekazaniu do EZAudioFile (czy EZAudioPlayer) url-a z iPod Library. Lektura wątku na github spowodowała, że wgrałem zapronowaną przez kogoś poprawkę warunku i odtwarzanie ruszyło. Hurra!

Odkryłem jednak, że EZAudio zawiesza odtwarzanie, jak wyłączy się ekran telefonu. Jak się okazało na kolejnym wątku w github, to znany problem. Tymczasowo można rozwiązać go poprzez ustawienie rodzaju sesji na odtwarzanie i nagrywanie, zamiast tylko na odtwarzanie. Dało to oczekiwany efekt. Zauważyłem jednak, że czasami odtwarzanie na malutką chwilkę może spowodować lekką chrypkę podczas przechodzenia aplikacji do background. Nie zawsze, ale może zdarzyć się. Dochodzi do tego fakt, że dotychczasowa implementacja kolejkowania utworów, o ile dobrze działała w tle w poprzednim przypadku z AVFoundation, o tyle w EZAudio następuje co prawda przejście na następnego kawałka, ale po kilku sekundach odtwarzacz milknie. Plusem natomiast jest to, że EZAudio ma current time tak jak AVAudioPlayer, co daje nam poprawne sterowanie odtwarzaniem z poziomu lock-screen. Mogę pauzować, wznawiać, przechodzić między utworami bez żadnych problemów, czas dobrze się pokazuje i zatrzymuje kiedy potrzeba.

Pewne problemy z EZAudio może byłbym w stanie jakoś przeboleć (i szukać nieco później dla nich rozwiazania nawet analizując kod EZAudio), by cieszyć się wizualizacją FFT. Tutaj sukces jest jednak tylko częściowy. Co prawda implementując metodę protokołu EZAudioPlayerDelegate:

public func audioPlayer(audioPlayer: EZAudioPlayer!, playedAudio buffer: UnsafeMutablePointer<UnsafeMutablePointer<Float>>, withBufferSize bufferSize: UInt32, withNumberOfChannels  numberOfChannels: UInt32, inAudioFile audioFile: EZAudioFile!) {
       
        self.fft.computeFFTWithBuffer(buffer[0], withBufferSize: bufferSize)
    }

i protokołu EZAudioFFTDelegate:

public func fft(fft: EZAudioFFT!, updatedWithFFTData fftData: UnsafeMutablePointer<Float>, bufferSize: vDSP_Length) { … }

udało mi się podczas odtwarzania dostawać regularnie wyliczane tablice float-ów, ale czasami przy starcie odtwarzania czy tuż po jego zakończeniu miałem EXC_BAD_ACCESS na UnsafeMutablePointer<Float>, który jest odpowiednikiem tablicy z Objective-C w Swift. To powinno dać się poprawić. Sporej poprawki z pewnością wymaga jednak logika przetwarzajaca dane otrzymywane w ostatniej metodzie. Próba adaptacji kodu z Androida, gdzie otrzymywałem bajty zamiast floatów po uwzględnieniu tego faktu nie daje w pełni satysfakcjonujących wyników. Na kanale basu mam w miarę sensowne wartości od 0 do 1, o tyle w kanale środkowym to Nan, a tony wysokie zawsze oscylują przy zerze. Może winna jest klasa EZAudioFFTRolling używana w wykresach i może zamiast niej bezpośrednio korzystać z EZAudioFFT czy systemowego vDSP?

A może znajdzie są coś lepszego i bardziej niezawodnego od EZAudio? W każdym razie ta tematyka w iOS nie jest tak prosta jak w przypadku Androida, gdzie mamy bardzo dobry przykład od Google i gotowe wsparcie dla FFT przy odtwarzaniu na poziomie systemowego API.

Myślę, że to zagadnienie będę dalej kontynuować, już poza Daj Się Poznać 2016, bo jesteśmy na finishu tej zacnej inicjatywy. To oczywiście niejedyna rzecz, jaką zajmiemy się w okresie post-DSP –Winking smile

Brak komentarzy: