SwiftUIとAVFoundationでイコライザーつき音楽プレーヤーを作る

部屋の掃除をしていたらiPod touch 4th(背面がステンレスのやつです)を発掘して、昔はこれにアプリや音楽入れて遊んでたなぁ…としみじみしていました。
そんな中iOSで音楽プレイヤーの実装ってやったことなかったので、今回SwiftUI+AVFoundation+MediaPlayerを用いて作ってみました。

こんな感じ。
<img width="420" alt="s1" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/30756704-1df3-2519-9c0c-41cce45fe89f.jpeg">

ソースコードはこちら
**※シミュレータ上では動作しません。実機のご用意を！**
https://github.com/nekowen/ios-music-sample

## ミュージックライブラリへのアクセス許可を得る
iPhoneに入っている曲にアクセスするには、ユーザーに許可をいただく必要があります。
まずは、`Info.plist`に`NSAppleMusicUsageDescription`を追加して、利用用途文を記載します。

```xml
	<key>NSAppleMusicUsageDescription</key>
	<string>再生する音楽を探すために使いまーす</string>
```

アプリを起動した時、あるいはアクセスが必要なタイミングで
`MPMediaLibrary.requestAuthorization(_:)`を呼び出します。
https://developer.apple.com/documentation/mediaplayer/mpmedialibrary/1621276-requestauthorization

ユーザーが承認、あるいは拒否をするとhandlerに対して結果が返ってくるので、
これをみて処理を進めたりアラートを出すなりします。今回は承認されていれば楽曲一覧の取得を行います。

```swift
MPMediaLibrary.requestAuthorization { [weak self] status in
    switch status {
    case .authorized:
        self?.fetchSongs()
    case .notDetermined:
        self?.requestAuthorization()
    default:
        break
    }
}
```

## ミュージックライブラリにある楽曲リストを取得する
`MPMediaQuery`を用いて、ミュージックライブラリの取得クエリを呼び出します。
ここでは、楽曲単位でリストを取得します。これ以外にもアルバム単位、アーティスト単位、他色々な形で取得することができます。
https://developer.apple.com/documentation/mediaplayer/mpmediaquery

```swift
func fetchSongs() {
    guard let songItems = MPMediaQuery.songs().items else {
        return
    }
    ...
}
```

曲の情報は`MPMediaItem`クラスで管理されており、ここから曲のタイトル、アルバム名、アートワークなど情報を得ることができます。

今回必要な情報は以下の通り。

|プロパティ名|型|内容|
|:-----------------|:------------------|:------------------|
|persistentID|MPMediaEntityPersistentID(UInt64)|曲のID(内部的に必要)|
|assetURL|URL?|曲がある場所(内部的に必要)|
|title|String?|曲のタイトル|
|albumTitle|String?|曲のアルバム名|
|artwork|MPMediaItemArtwork?|アートワーク|

サンプルアプリでは、扱いやすくするためにこれらの情報を持つ`MusicItem`という構造体を作り、取得した曲リストを持たせています。

```swift
struct MusicItem: Identifiable, Equatable {
    let id: String
    let assetURL: URL?
    let title: String?
    let albumTitle: String?
    let artwork: MPMediaItemArtwork?
}

...

self.songs = songItems.map {
    MusicItem(
        id: String($0.persistentID),
        assetURL: $0.assetURL,
        title: $0.title,
        albumTitle: $0.albumTitle,
        artwork: $0.artwork
    )
}
```

## 曲を再生する
曲の情報が取得できたところで、次は実際に再生する処理を実装します。
ノーマル状態であれば`AVAudioPlayer`が使えたのですが、今回はイコライザを使うので以下のクラスを利用します。

### AVAudioPlayerNode
音声バッファ、あるいは音声ファイルの再生を行うノードです。
どのタイミング(何秒後など)で再生を開始するかを指定することができます。

### AVAudioUnitEQ
イコライザ処理をするノードです。継承元は`AVAudioUnitEffect`で、エフェクトの一種になります。
リアルタイムで処理されます。

### AVAudioEngine
音声の入出力から各ノードとの接続、音声信号の処理と色々やってくれるエンジンです。

ノードとあるように、上2つのノードとAVAudioEngineを繋ぎ込んで音を鳴らすことになります。
繋ぎ込みの順番はこうなります。

```
AVAudioPlayerNode -> AVAudioUnitEQ -> AVAudioEngine(mainMixerNode) -> スピーカー
```

実装に入ります。まず各ノードをAVAudioEngineにアタッチします。

```swift
class MusicPlayManager: ObservableObject {
    private lazy var playerNode = AVAudioPlayerNode()
    private lazy var eqNode = AVAudioUnitEQ()
    private lazy var engine = AVAudioEngine()
    
    init() {
        self.engine.attach(self.playerNode)
        self.engine.attach(self.eqNode)
    }

    ...
}
```

再生する直前、あるいは準備するタイミングで`connect`メソッドを呼びノードを繋ぎます。
同時に`AVAudioPlayerNode`に対してスケジューリングを行います。atにnilを指定することですぐ再生されるようにできます。

```swift
    func prepare(_ item: MusicItem) throws {
        guard let path = item.assetURL else {
            return
        }
        let audioFile = try AVAudioFile(forReading: path)
        
        //  Connect AVAudioPlayerNode -> AVAudioUnitEQ
        self.engine.connect(self.playerNode, to: self.eqNode, format: audioFile.processingFormat)
        //  Connect AVAudioUnitEQ -> AVAudioEngine(mainMixerNode)
        self.engine.connect(self.eqNode, to: self.engine.mainMixerNode, format: audioFile.processingFormat)

        self.playerNode.scheduleFile(audioFile, at: nil, completionHandler: nil)
        ...
    }
```

あとは再生するタイミングでエンジンを開始し、`play`メソッドを呼ぶと曲が再生されます。

```swift  
    func play() throws {
        try AVAudioSession.sharedInstance().setActive(true, options: [])
        try self.engine.start()
        self.playerNode.play()
        ...
    }
```

## AVAudioUnitEQにパラメータをセットする
上記のコードだとイコライザを通す準備はできているものの、何も設定をしていないため再生しても音に変化がありません。
パラメータを設定するには、`AVAudioUnitEQ`の初期化時にバンド数を指定して初期化する必要があります。
今回は10バンド(32,64,128,256,500,1000,2000,4000,8000,16000)を指定します。

```swift
let eqNode = AVAudioUnitEQ(numberOfBands: 10)
```

初期化をすると、bandsにバンド数分の`AVAudioUnitEQFilterParameters`が追加されます。
以下のパラメータが設定できます。

#### filterType(AVAudioUnitEQFilterType)
音声に対してかけるフィルタを指定します。


|フィルタ名|効果|表示|
|:-----------------|:------------------|:------------------:|
|parametric|指定の周波数とバンド幅を元にブースト/カットする|<img width="530" alt="s1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/0e22b7dc-0d69-b9da-c49e-897133391e95.png">|
|lowPass|指定の周波数より高い音をカットする|<img width="530" alt="s2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/b8c6d31d-e30b-b615-e1c7-f794394d677e.png">|
|highPass|指定の周波数より低い音をカットする|<img width="530" alt="s11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/d3fbeada-f68e-bbcc-8b62-1c1850070700.png">|
|resonantLowPass|バンド幅つきのLowPass|<img width="530" alt="s3.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/8eab6a58-2bd3-9896-dc8e-4c9307ca1d20.png">|
|resonantHighPass|バンド幅つきのHighPass|<img width="530" alt="s4.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/eefe59ca-ab3b-4772-d6ad-f3f65fd04600.png">|
|bandPass|指定の周波数のみを通し、他の周波数を減衰させる|<img width="530" alt="s5.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/7c387ea1-c676-6978-56c2-1434c441605e.png">|
|bandStop|指定の周波数を減衰させ、他の周波数を通す。bandPassの逆|<img width="530" alt="s6.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/f6785aec-e4fd-1ef5-a6a2-ce616e161da3.png">|
|lowShelf|指定の周波数より低い音をブースト/カットする|<img width="530" alt="s7.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/fcf84244-a069-c5a5-1e38-8804b315f061.png">|
|highShelf|指定の周波数より高い音をブースト/カットする|<img width="530" alt="s8.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/9fa0fb45-7332-3c06-a0c2-3067f62df6a4.png">|
|resonantLowShelf|バンド幅つきのLowShelf|<img width="530" alt="s9.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/0fe4ee3a-e369-bafd-0887-a2854e85287f.png">|
|resonantHighShelf|バンド幅つきのHighShelf|<img width="530" alt="s10.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/540b884e-c0e9-dc68-0901-2f864c307add.png">|

#### bandWidth
バンド幅(オクターブ)を指定します。ここは基準値の1で良いと思います
`0.05〜5.0`の間で指定ができます。

#### frequency
周波数を指定します。
`20〜(サンプリング周波数/2)hz`の間で指定ができます。

#### gain
音をどれくらい増幅させるかをdb単位で指定します。
`-96〜24db`の間で指定ができます。

#### bypass
特定のバンドに対して有効/無効を指定できます。
bypassするとそのバンドはフラットな状態になります。
**デフォルトはtrueとなっているので、パラメータを指定する際は忘れずにfalseを指定してあげる必要があります**


以上のパラメータを元に実装します。

```swift
class MusicPlayManager: ObservableObject {
    ...

    struct EQParameter {
        let type: AVAudioUnitEQFilterType
        let bandWidth: Float?
        let frequency: Float
        let gain: Float
    }

    //  10-Bands Parametric EQ
    private var eqParameters: [EQParameter] = [
        EQParameter(type: .parametric, bandWidth: 1.0, frequency: 32.0, gain: 3.0),
        EQParameter(type: .parametric, bandWidth: 1.0, frequency: 64.0, gain: 3.0),
        EQParameter(type: .parametric, bandWidth: 1.0, frequency: 128.0, gain: 3.0),
        EQParameter(type: .parametric, bandWidth: 1.0, frequency: 256.0, gain: 2.0),
        EQParameter(type: .parametric, bandWidth: 1.0, frequency: 500.0, gain: 0.0),
        EQParameter(type: .parametric, bandWidth: 1.0, frequency: 1000.0, gain: -6.0),
        EQParameter(type: .parametric, bandWidth: 1.0, frequency: 2000.0, gain: -6.0),
        EQParameter(type: .parametric, bandWidth: 1.0, frequency: 4000.0, gain: -6.0),
        EQParameter(type: .parametric, bandWidth: 1.0, frequency: 8000.0, gain: -6.0),
        EQParameter(type: .parametric, bandWidth: 1.0, frequency: 16000.0, gain: -6.0)
    ]
    
    init() {
        ...

        self.eqNode = AVAudioUnitEQ(numberOfBands: self.eqParameters.count)
        self.eqNode.bands.enumerated().forEach { index, param in
            param.filterType = self.eqParameters[index].type
            param.bypass = false
            if let bandWidth = self.eqParameters[index].bandWidth {
                param.bandwidth = bandWidth
            }
            param.frequency = self.eqParameters[index].frequency
            param.gain = self.eqParameters[index].gain
        }
        
        ...
    }
```

再生すると籠もった音でドンドコすると思います。

## 気になった・ハマったところ
実装していて気になったところをまとめました。

### 取得した一部の曲のassetURLがnilで帰ってくる
楽曲リストを取得した後、一部の曲のassetURLがnilになっており、再生できない問題がありました。

調べてみると、楽曲リストには**Apple Musicでオフライン再生のためにダウンロードした曲も含まれていて、曲にDRMがかかっており直接再生することはできない**ようです。がっくし。
また**iCloudに上がっている曲も同様にリストに含まれているものの、ローカルに曲が存在しないため再生ができません。**
再生できなければ意味がないので、リストに含めないようにしましょう。

iCloudの曲かどうかは[isCloudItem](https://developer.apple.com/documentation/mediaplayer/mpmediaitem/1621697-isclouditem)を、
DRMがかかっている曲かどうかは[hasProtectedAsset](https://developer.apple.com/documentation/mediaplayer/mpmediaitem/1621678-hasprotectedasset)を見ればわかるので、どちらも`false`の曲だけを取得すれば良さそうです。
`addFilterPredicate`を使って条件を指定して絞り込みましょう。

```swift
let songQuery = MPMediaQuery.songs()
let icloudItemPredicate = MPMediaPropertyPredicate(value: false, forProperty: MPMediaItemPropertyIsCloudItem)
let protectedAssetPredicate = MPMediaPropertyPredicate(value: false, forProperty: MPMediaItemPropertyHasProtectedAsset)

songQuery.addFilterPredicate(icloudItemPredicate)
songQuery.addFilterPredicate(protectedAssetPredicate)

guard let songItems = songQuery.items else {
    return
}

...
```


### バックグラウンドに遷移すると曲が止まる
何も考えずに実装していたため、アプリがバックグラウンドになったタイミングで再生中の曲が一時停止される素晴らしい仕様となっていました。これは直しましょう。

まず、Xcodeの`TARGET`から`Signing & Capabilities`を選択します。
左上にある＋をクリックし、`Background Mode`を選択します。
<img width="727" alt="スクリーンショット 2019-12-09 0.25.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/17d1e8ff-2262-c448-16c2-2456b1fba460.png">

選択すると、項目が追加されるので、`Audio, AirPlay, and Picture in Picture`にチェックを入れます。

<img width="783" alt="スクリーンショット 2019-12-09 0.28.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/b97f0b9b-cd2b-5ead-05c0-9529b7ebb197.png">

次に、OSに対してアプリが音声をどのように使用するのか伝えます。
いくつかカテゴリとして定義されているのですが、音楽再生アプリなので`.playback`を指定しています。

```swift
try AVAudioSession.sharedInstance().setCategory(.playback)
```

最後に、音楽を再生するタイミングでセッションのアクティベートを行います。
これにより、**OSに「今から音声セッションを使うぞ〜」という宣言ができ、OS側は既に再生している音楽があれば止めるといった処理をしてくれます(カテゴリによります)。**

```swift
try AVAudioSession.sharedInstance().setActive(true, options: [])
```

これでバックグラウンド対応ができました。

### イヤホンやAirPodsの接続が切れても音楽再生は止まらない
まず僕の認識が甘かったのですが、音声再生系APIはイヤホンが抜かれると勝手に再生が止まるものだと思ってました(冷静に考えてそんなワケないのですが。。。)
音楽を聴いていた時にAirPodsとの接続が切れたのですが、**iPhoneのスピーカーに切り替わり、そのまま音楽が流れ続けてしまいました。**
もしも電車の中で使っていたら…と考えると恐ろしいですね。これも対応しましょう。

Appleのドキュメントによると、`AVAudioSession.routeChangeNotification`をハンドリングすることでイヤホンやAirPodsの接続を検知出来るようです。
これを使って音楽を再生したり止めたりしましょう。
https://developer.apple.com/documentation/avfoundation/avaudiosession/responding_to_audio_session_route_changes

```swift
self.routeChangeNotificationObserver = NotificationCenter.default.addObserver(forName: AVAudioSession.routeChangeNotification, object: nil, queue: nil) { [weak self] notification in
    guard let userInfo = notification.userInfo,
        let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
        let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else {
        return
    }
    
    switch reason {
    case .newDeviceAvailable:
        // 新しい音声デバイスにつながった
        self?.play()
    case .oldDeviceUnavailable:
        // 音声デバイスが使えなくなった
        self?.pause()
    default: break
    }
}

//  必要に応じて解除する
/*
if let routeChangeNotificationObserver = self.routeChangeNotificationObserver {
    NotificationCenter.default.removeObserver(routeChangeNotificationObserver)
}
*/
```

## まとめ
iOSでイコライザーを扱うのはめんどくさそうだな…と思っていたのですが、意外に、というよりかなりあっさり作れてなかなかいい感じです。
また今回軽く作り込むためにSwiftUIを使ったのですが、リスト画面なんかは何もしなくても勝手にダークモード対応されててビビりました。複雑なUIだとちょっと大変ですが、こういったシンプルなレイアウトのアプリを作るのには最適ですね。

## 付録
### イコライザーとは
https://ja.wikipedia.org/wiki/%E3%82%A4%E3%82%B3%E3%83%A9%E3%82%A4%E3%82%B6%E3%83%BC_(%E9%9F%B3%E9%9F%BF%E6%A9%9F%E5%99%A8)
>音響機器のイコライザー (Equalizer) とは、音声信号の周波数特性を変更する音響機器である。イコライザーを使って、音声信号の特定の周波数帯域 （倍音成分や高調波成分あるいはノイズ成分）を強調したり、逆に減少させる事ができ、全体的な音質の補正（平均化）や改善（音像の明確化など）、あるいは積極的な音作りに使用される。

iTunesやWALKMANで設定できるアレです。
32hz〜16khzの範囲でスライダーを調整することで、自分好みの音を作ることができます。

<img width="552" alt="スクリーンショット 2019-12-09 23.25.09.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186419/dd3eb7c1-22cb-5ed7-0416-efa0bf4c00e5.png">

32〜125hzは(超)低音域と呼ばれ、**ベースやバスドラムの重低音がここに該当します。**
500〜2khzは中音域となり、ここをブーストすると**ボーカルがクリアに聞こえるようになります。**
4〜16khzは(超)高音域です。ここをブーストすると**シャリシャリした音になります。**

ちなみに僕は重低音が好きなので32〜125hzをクリッピング限界までブーストしてよく聞きます。

## 参考
[Activating an Audio Session](https://developer.apple.com/library/archive/documentation/Audio/Conceptual/AudioSessionProgrammingGuide/ConfiguringanAudioSession/ConfiguringanAudioSession.html)
[AVAudioEngine | objective-audio](https://objective-audio.jp/2014/09/28/avaudioengine/)
[Performing Offline Audio Processing | Apple Developer Documentation](https://developer.apple.com/documentation/avfoundation/audio_track_engineering/performing_offline_audio_processing)