【iOS】エフェクト付き音楽プレーヤーアプリの実装

# はじめに
最近「音音(ネオン)」というiOSの音楽プレーヤーアプリをリリースしました。

こちらのように曲のテンポの速さやキーの高さを変更できたり、
<iframe width="500" height="500" src="https://www.youtube.com/embed/zP1p-4SfeQA" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

こちらのように曲の区間を設定し、繰り返し聴くことができます。
<iframe width="500" height="500" src="https://www.youtube.com/embed/19uZWAio5mM" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

レイアウトはSwiftUIで実装し、バックエンドはありません。(FirebaseのAnalyticsとCrashlyticsは入れていますが)

楽曲再生には[AVFAudioフレームワーク](https://developer.apple.com/documentation/avfaudio)のクラスを使っています。

レイアウトはゴリゴリ作っていけばいいのですが、プレーヤー部分の実装には結構苦労しました。

曲のエフェクトや再生区間の機能を除いた、普通の音楽プレーヤーを実装するだけでも大変でした。

この記事では音楽プレーヤーの一般的な機能の実装と、音音独自の機能をどうやって実装したかを紹介します。

**※プレーヤーの[自作ライブラリ](https://github.com/tsuruken0802/MusicPlayer)と[サンプルコード](https://github.com/tsuruken0802/MusicPlayer/tree/main/example)も公開しています。**

**※ 音音のダウンロードは[こちら](https://apps.apple.com/app/id1621934170)です。**

# 一般的な音楽プレーヤーの機能一覧
最低限音楽プレーヤーとしてあるべき機能は下記の通りだと思います。

* 再生と一時停止
* 次/前の曲に移動する
* 再生秒数を変更する
* バックグラウンド再生
* バックグラウンドで曲の操作をする

それに加え、できたら下記も対応したいところです。
* 電話が来たら曲を停止する
* イヤホンが外れたら曲を一時停止する

どのように実装したか一つずつ見ていきます。

# 再生と一時停止

まずiOSで楽曲を再生するにはプレーヤークラスを選択する必要があります。

iOSでは下記のプレーヤークラスが用意されています。

* [AVAudioEngine](https://developer.apple.com/documentation/avfaudio/avaudioengine)
* [AVAudioPlayer](https://developer.apple.com/documentation/avfaudio/avaudioplayer)
* [MPMusicPlayerController](https://developer.apple.com/documentation/mediaplayer/mpmusicplayercontroller)

この中で楽曲のエフェクトを設定できるのは`AVAudioEngine`のみですので、こちらを選択します。

AVAudioPlayerとMPMusicPlayerControllerはAPIが直感的で使いやすいので、簡易的なプレーヤーを作成する場合は適していると思います。

凝ったことやりたい場合はAVAudioEngine一択になるかなと思います。

## AVAudioEngineの基礎知識

AVAudioEngineでは下記の図のように**オーディオノード**と呼ばれるオブジェクトを接続することで音を出力します。(図は公式ドキュメント引用)

![スクリーンショット 2022-05-24 22.22.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263659/5be07d6d-8b3e-9cd4-9040-43fb5833daa2.png)

図では、[PlayerNode](https://developer.apple.com/documentation/avfaudio/avaudioplayernode)、[MixerNode](https://developer.apple.com/documentation/avfaudio/avaudiomixernode)、[OutputNode](https://developer.apple.com/documentation/avfaudio/avaudiooutputnode)を接続しています。

PlayerNodeでは、Source fileの楽曲を再生します。
次に再生された音源をMixerNodeで単一の音にします。(複数の音源を一つの音源にできるが今回は一つ)
最後にOutputNodeに出力することで、端末(スピーカーやイヤホン)に出力しているという流れになります。

上記はシンプルに楽曲を音源通りに再生するだけの接続例となりますが、オーディオノードの組み合わせによって楽曲の音源をカスタマイズすることができます。

例えば音音では、[AVAudioUnitTimePitch](https://developer.apple.com/documentation/avfaudio/avaudiounittimepitch)というオーディオノードを使って、曲のテンポの速さとキーの高さを変更できるようにしています。
下記のコードはオーディオノードを接続し、曲の再生準備をするまでのコードになっています。

``` MusicPlayer.swift
// AudioEngineとオーディオノードの初期化
private let audioEngine: AVAudioEngine = .init()
private let playerNode: AVAudioPlayerNode = .init()
private let pitchControl: AVAudioUnitTimePitch = .init()

init() {
   // 接続するオーディオノードをAudioEngineにアタッチする
   audioEngine.attach(playerNode)
   audioEngine.attach(pitchControl)
}

func setScheduleFile() -> Bool {
    // 楽曲のURLを取得する
    // currentItem.itemはMPMediaItemクラス
    guard let assetURL = currentItem?.item.assetURL else { return false }
    do {
        // Source fileを取得する
        audioFile = try AVAudioFile(forReading: assetURL)
        // PlayerNodeからAVAudioUnitTimePitchへ接続する
        audioEngine.connect(playerNode, to: pitchControl, format: nil)
        // AVAudioUnitTimePitchからAudioEngineのoutput先であるmainMixerNodeへ接続する
        audioEngine.connect(pitchControl, to: audioEngine.mainMixerNode, format: nil)
        // 再生準備
        playerNode.scheduleFile(audioFile!, at: nil)
        return true
    }
    catch let e {
        print(e.localizedDescription)
        return false
    }
}
```

再生準備の手順としてはこちらの通りです。
1. オーディオノードをAudioEngineにアタッチする
2. Source fileを取得する
3. オーディオノードの接続をする
4. scheduleFileメソッドで再生準備をする


## 再生処理
再生準備ができれば再生自体は簡単です。
下記の実装で楽曲を再生できます。

``` MusicPlayer.swift
/// Play current item
func play() {
    do {
        // 再生処理
        try audioEngine.start()
        playerNode.play()
    }
    catch let e {
        print(e.localizedDescription)
    }
}
```

AVAudioEngineとの`start`メソッド、AVAudioPlayerNodeの`play`メソッドどちらも呼ぶ必要があります。

## 一時停止
一時停止も簡単です。

``` MusicPlayer.swift
/// Pause playback
func pause() {
    audioEngine.pause()
    playerNode.pause()
}
```

## 停止
一時停止ではなく、完全に停止させる場合は下記のように実装します。

``` MusicPlayer.swift
/// stop playback
func stop() {
    audioEngine.stop()
    playerNode.stop()
}
```

ここまでできたら楽曲の再生と一時停止は完成です。

# 次/前の曲に移動する
AVAudioEngineには曲のキューを保持する機能がありません。

なのでアプリケーションコードとして再生リストを保持しておく必要があります。

音音では再生リストのデータと再生中のindexを保持し、今再生中の[MPMediaItem](https://developer.apple.com/documentation/mediaplayer/mpmediaitem)を参照できるようにしています。

``` MPSongItem.swift
// 曲のリストを表すクラス
public struct MPSongItemList {
    // 再生リスト
    public let items: [MPSongItem]
    // 再生中の曲のインデックス
    public var currentIndex: Int
    
    // 現在再生している曲を取得する
    public var currentItem: MPSongItem? {
        if !items.indices.contains(currentIndex) {
            return nil
        }
        return items[currentIndex]
    }
    
    public init(items: [MPSongItem], currentIndex: Int = 0) {
        self.items = items
        self.currentIndex = currentIndex
    }
    
    public init() {
        self.items = []
        self.currentIndex = 0
    }
}
```

曲のデータである`MPSongItem`はこのような定義になっています。

``` MPSongItem.swift
// 曲を表すクラス
public class MPSongItem {
    public let item: MPMediaItem
    public var effect: MPSongItemEffect?
    public var trimming: MPSongItemTrimming?
    public var division: MPDivision?
    public var duration: TimeInterval { item.playbackDuration }
    public var id: MPMediaEntityPersistentID { item.persistentID }
    public var title: String? { item.title }
    public var artist: String? { item.artist }
    public var artwork: MPMediaItemArtwork? { item.artwork }
    
    public func image(size: CGFloat) -> UIImage? {
        return item.artwork?.image(at: CGSize(width: size, height: size))
    }
    
    public init(item: MPMediaItem,
                effect: MPSongItemEffect? = nil,
                trimming: MPSongItemTrimming? = nil,
                divisions: MPDivision? = nil) {
        self.item = item
        self.effect = effect
        self.trimming = trimming
        self.division = divisions
    }
}
```
[MPMediaItem](https://developer.apple.com/documentation/mediaplayer/mpmediaitem)が実際の曲のデータになります。
曲ごとにエフェクトや再生区間を設定したいため、`item: MPMediaItem`の他に、それらを表す`MPSongItemEffect`や`MPDivision`などのデータも定義しています。

次の曲 or 前の曲に移動する場合は、`MPSongItemList`のindexを変更し、前述した`play`メソッドを呼ぶだけです。
次の曲に移動する`next`メソッドは下記の通りです。(肝となる部分だけ載せています)

``` MusicPlayer.swift
/// MPSongItemListのindex
private var currentIndex: Int {
    get { itemList.currentIndex }
    set { itemList.currentIndex = newValue }
}

func next() {
    // indexを次の曲にする(前の曲なら-1になる)
    var nextIndex = currentIndex + 1
    // 再生中のindexを更新する
    currentIndex = nextIndex
    // indexの曲の再生準備をする
    setScheduleFile()
    // indexのエフェクトを設定する
    setCurrentEffect(effect: currentItem?.effect,
                     trimming: currentItem?.trimming,
                     division: currentItem?.division)
    // 再生する
    play()
}
```

# 再生秒数を変更する
一般的な音楽プレーヤーでは再生中の曲が今何秒なのかをシークバーで表示しています。
そのためかなり高頻度で今何秒かを更新し続ける必要があります。

音音ではシンプルにTimerでポーリング状態を作り、後述する現在の再生時間取得メソッドで取得し、画面に反映させるといった実装になっています。

AVAudioPlayerの場合は[playメソッド](https://developer.apple.com/documentation/avfaudio/avaudioplayer/1389324-play)に再生したい秒数のTimeIntervalの値を設定してあげればその秒数まで移動することができるでしょう。
しかしAVAudioPlayerNodeではそうはいかず、曲の秒数をFloatやDoubleで管理することはありません。

**AVAudioPlayerNodeではSampleTimeを使用して秒数を管理します。**

## SampleTimeとは？
デジタル音声にはサンプルレートというものがあります。簡単に言えば動画のフレームレート(FPS)の音声版と言ったところです。

フレームレートは1秒間に何枚の画像(フレーム)を表示するかを表しますが、サンプルレートでは**音をアナログ信号からデジタル信号に変換する時の、一秒間に何回サンプリングしているかを表します。**
一般的なサンプルレートは`44,100Hz`と言われていますが、これは１秒間に44,100回サンプリングし、アナログ信号からデジタル信号に変換しているということになります。

SampleTimeとは**その時点で何回サンプリングしたか**を表す数値です。
例えばサンプルレートが44,100Hzで、2秒経過している状態だったらSampleTimeは、
`44,100 × 2 = 88,200`
となります。

AVAudioPlayerNodeではこのSampleTimeを使って秒数を計算します。

## SampleTimeには二つの時間軸がある
ではSampleTimeを使って秒数を取得したいところなのですが、実はSampleTimeには**二つの時間軸があります。**
それが`node time`と`player time`です。(二つとも名前が決まっているわけではないのですが、便宜上僕が勝手につけています)

`node time`は端末システム時間(mach_absolute_time)上のSampleTimeです。
mach_absolute_timeを調べると日本語訳で「ティック単位で単調に増加するクロックの現在の値」などど[公式ドキュメント](https://developer.apple.com/documentation/kernel/1462446-mach_absolute_time)に記載されていましたが、何のこっちゃ。
まあとにかく`node time`は特に意識することはないです。
`node time`はAVAudioPlayerNodeの`lastRenderTime`プロパティから参照することができますが、こちらの値をそのまま使うことはほとんどありません。(少なくとも音楽プレーヤーを作る上では)

ほとんどのケースでは`player time`に変換して使うことになるでしょう。
`player time`では**楽曲を再生してからのSampleTime**が取得できるからです。
つまり、**その曲が今何回サンプリングしたかがわかります**

## 現在の秒数を保持する
SampleTimeとは何を踏まえた上で、現在の秒数を取得します。

``` MusicPlayer.swift
func updateCurrentTime() {
    // 最後にサンプリングしたデータを取得する ①
    guard let nodeTime = playerNode.lastRenderTime else { return }
    // playerNodeの時間軸に変換する ②
    guard let playerTime = playerNode.playerTime(forNodeTime: nodeTime) else { return }
    // サンプルレートとサンプルタイム取得する ③
    let sampleRate = playerTime.sampleRate
    let sampleTime = playerTime.sampleTime
    // 秒数を取得し保持する ④
    let time = Double(sampleTime) / sampleRate
    currentTime = time
}
```

①では`AVAudioNodeのlastRenderTime`から、最後にサンプリングした情報を取得しています。(node time)

②では`playerNode.playerTime`メソッドを`player time`の時間軸に変換しています。

③でSampleTimeとサンプルレートを取得しています。

④で現在の秒数を取得して保持しています。
サンプルレートは**1秒あたりのサンプリング数**なので、SampleTimeをサンプルレートで割ってあげれば求められます。

```
秒数 = SampleTime / サンプルレート
```

そして、計算した秒数を`currentTime`プロパティに設定します。
`currentTime`はPublishedで定義しており、それをSwiftUIのViewから参照することで現在の再生時間を表示しています。

``` MusicPlayer.swift
/// current playback time (seconds)
@Published public var currentTime: Float = 0
```

## 再生する秒数の変更の実装
では肝心の再生する秒数の変更の実装です。(セーフティーコードは除外し、肝となる実装のみ載せています)

``` MusicPlayer.swift
func setSeek() {
    // 変更する秒数
    let time = TimeInterval(currentTime)
    // サンプルレートを取得する
    let sampleRate = audioFile.processingFormat.sampleRate
    // 変更する秒数のSampleTimeを取得する
    let startSampleTime = AVAudioFramePosition(sampleRate * time)
    
    // 変更した後の曲の残り時間とそのSampleTimeを取得する(曲の秒数-変更する秒数)
    let length = duration - time
    let remainSampleTime = AVAudioFrameCount(length * Double(sampleRate))

    // 変更した秒数をキャッシュしておく
    cachedSeekBarSeconds = Float(time)
    
    // 変更した秒数から曲を再生し直すため、AudioEngineとPlayerNodeを停止する
    stop()
    
    // 曲の再生秒数の変更メソッド
    playerNode.scheduleSegment(audioFile, startingFrame: startSampleTime, frameCount: remainSampleTime, at: nil)
    
    // 停止状態なので再生する
    play()
}
```

やっていることは下記の通りです。
* 再生したい秒数のSampleTimeを取得する
* 曲の残りのSampleTimeも取得する
* `scheduleSegment`メソッドで秒数を反映させる
* 再生し直す

ただし一点注意が必要で実は、**`playerNode.scheduleSegment`メソッドで秒数を変更した後に`lastRenderTime`でサンプリング情報を取得しても、変更した後のサンプリング情報が取得できません。**

なぜかというと上記の`stop`メソッド内で`AVAudioEngine.stop`メソッドを呼んでいてその結果、**サンプリング情報もリセットされてしまうからです。**

あくまで`lastRenderTime`は**曲が再生されてからどれぐらいサンプリングしたか**しか覚えてくれないので、stopしたらリセットされ、再度参照してもサンプリング数0になってしまいます。

なので例えば30秒の位置にシークバーを合わせてもリセットされるので、**実際に聴いている曲は30秒後でも、UI的には0秒の位置にある**という状態になってしまいます。

それを避けるために**どの位置まで秒数を変更したかをキャッシュしておく**`cachedSeekBarSeconds`プロパティを用意し、`currentTime`の更新処理の中でその値を加算してあげることで帳尻を合わせています。(不細工なやり方なのでもっと良い解決法あったら教えていただきたいです)

`cachedSeekBarSeconds`を反映させた、現在時間更新のメソッド(updateCurrentTime)は下記のようになります。

``` MusicPlayer.swift
/// update current playback time
func updateCurrentTime() {
    guard let nodeTime = playerNode.lastRenderTime else { return }
    guard let playerTime = playerNode.playerTime(forNodeTime: nodeTime) else { return }

    let sampleRate = playerTime.sampleRate
    let sampleTime = playerTime.sampleTime

    let time = Double(sampleTime) / sampleRate
    let newCurrentTime = Float(time) + cachedSeekBarSeconds // New!
    currentTime = newCurrentTime
}
```

# バックグラウンド再生対応

## Xcodeでの設定

バックグラウンドで曲を再生するには、[公式ドキュメント](https://developer.apple.com/documentation/avfoundation/media_playback_and_selection/creating_a_basic_video_player_ios_and_tvos/enabling_background_audio)に記載されている通り、Xcodeの`Background Modes`の`Audio, AirPlay, and Picture in Picture`にチェックを入れます。

![set_background.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263659/f9b4ebda-8d9a-7004-7391-942fbe3d1214.png)

## AVAudioSessionのカテゴリ設定
[AVAudioSession](https://developer.apple.com/documentation/avfaudio/avaudiosession)の設定を行います。
AVAudioSessionは、アプリでオーディオをどのように扱うかを設定するためのクラスです。
AVAudioSessionを使えばバックグラウンドでの曲の再生の挙動を設定することができます。

AppDelegateで、AVAudioSessionのCategory設定をします。
``` AppDelegate.swift
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
    let session = AVAudioSession.sharedInstance()
    do {
        try session.setCategory(.playback, mode: .default)
    }
    catch let e {
        print(e.localizedDescription)
    }
    return true
}
```

これだけでバックグラウンド再生の実装は完了です！

# バックグラウンドで曲の操作をする

## 曲の操作を定義する

下記のスクショのようにバックグラウンドで曲を操作するための実装をします。

[MPRemoteCommandCenter](https://developer.apple.com/documentation/mediaplayer/mpremotecommandcenter)を使用すれば実現できます。

![IMG_2220 2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263659/8eca3792-5944-14c6-6bb8-99dab91b9190.png)

操作できるアクションは下記の通りです。

* 再生ボタン押下
* 一時停止ボタン押下
* 前の曲ボタン押下
* 次の曲ボタン押下
* 秒数バックボタン押下
* 秒数進むボタン押下
* シークバーでの秒数変更

これらのアクションをMPRemoteCommandCenterに追加します。

``` MusicPlayer.swift
func initRemoteCommand() {
    // 再生ボタン
    let commandCenter = MPRemoteCommandCenter.shared()
    commandCenter.playCommand.removeTarget(self)
    commandCenter.playCommand.isEnabled = true
    commandCenter.playCommand.addTarget { [unowned self] event in
        play()
        return .success
    }
    // 一時停止ボタン
    commandCenter.pauseCommand.removeTarget(self)
    commandCenter.pauseCommand.isEnabled = true
    commandCenter.pauseCommand.addTarget { [unowned self] event in
        pause()
        return .success
    }
    // 前の曲ボタン
    commandCenter.previousTrackCommand.removeTarget(self)
    commandCenter.previousTrackCommand.isEnabled = !leftRemoteCommand.isSkipType
    commandCenter.previousTrackCommand.addTarget { [unowned self] event in
        back(backEnableSong: self.backgroundMode)
        return .success
    }
    // 次の曲ボタン
    commandCenter.nextTrackCommand.removeTarget(self)
    commandCenter.nextTrackCommand.isEnabled = !rightRemoteCommand.isSkipType
    commandCenter.nextTrackCommand.addTarget { [unowned self] event in
        next(forwardEnableSong: self.backgroundMode)
        return .success
    }
    // 秒数進むボタン
    commandCenter.skipForwardCommand.removeTarget(self)
    commandCenter.skipForwardCommand.isEnabled = rightRemoteCommand.isSkipType
    commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(integerLiteral: remoteSkipSeconds)]
    commandCenter.skipForwardCommand.addTarget { [unowned self] event in
        setSeek(addingSeconds: Float(remoteSkipSeconds))
        return .success
    }
    // 秒数バックボタン
    commandCenter.skipBackwardCommand.removeTarget(self)
    commandCenter.skipBackwardCommand.isEnabled = leftRemoteCommand.isSkipType
    commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(integerLiteral: remoteSkipSeconds)]
    commandCenter.skipBackwardCommand.addTarget { [unowned self] event in
        setSeek(addingSeconds: Float(-remoteSkipSeconds))
        return .success
    }
    // シークバーでの秒数変更
    commandCenter.changePlaybackPositionCommand.removeTarget(self)
    commandCenter.changePlaybackPositionCommand.isEnabled = true
    commandCenter.changePlaybackPositionCommand.addTarget { [unowned self] event in
        guard let positionCommandEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
        currentTime = Float(positionCommandEvent.positionTime)
        setSeek()
        return .success
    }
}
```

それぞれのアクションを検知した後に実行する処理は、フォアグラウンド時と一緒です。

ちなみに「前の曲」ボタンと「秒数バック」ボタンは同じ左側に位置するボタンです。
どちらかのコマンドの`isEnabled`をtrueにしたら、残った方は表示されません。(次の曲ボタンと秒数進むボタンも同様)


## メタデータの表示

バックグラウンドで設定できる曲の主なメタデータは下記の通りです。

* タイトル
* サムネ
* 現在の再生時間
* 曲の速さ
* 曲の総再生時間

``` MusicPlayer.swift
func setNowPlayingInfo() {
    let center = MPNowPlayingInfoCenter.default()
    var nowPlayingInfo = center.nowPlayingInfo ?? [String : Any]()
    
    // タイトル
    nowPlayingInfo[MPMediaItemPropertyTitle] = currentItem?.title
    // サムネ
    let size = CGSize(width: 50, height: 50)
    if let image = currentItem?.artwork?.image(at: size) {
        nowPlayingInfo[MPMediaItemPropertyArtwork] =
        MPMediaItemArtwork(boundsSize: image.size) { _ in
            return image
        }
    }
    else {
        nowPlayingInfo[MPMediaItemPropertyArtwork] = nil
    }
    // 現在の再生時間
    nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
    // 曲の速さ
    if isPlaying {
        nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = rate
    }
    else {
        nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 0.0
    }
    // 曲の総再生時間
    nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
    // メタデータを設定する
    center.nowPlayingInfo = nowPlayingInfo
}
```

地味にハマったのが、曲の速さを表す`MPNowPlayingInfoPropertyPlaybackRate`です。
再生中は曲の速さにあった値を設定すれば良いのですが、**曲が停止中の場合は0を設定しないとシークが移動し続けてしまいます。**
まあわかってしまえばなんてことないのですが、停止したんだからOS側でよしなにやってくれよと思いました。笑

# 電話が来たら曲を停止する
音楽プレーヤーとして必須の機能となるのが、[他のオーディオセッションによる中断](https://developer.apple.com/documentation/avfaudio/avaudiosession/responding_to_audio_session_interruptions)への対応です。

例えば音楽を聴いているときに、電話がかかってくることもあります。
当然電話中には音楽が停止状態であることが望ましいです。
なので他のオーディオによる中断を検知して、適切に処理する必要があります。

こちらはオーディオによる中断を検知する通知を追加しています。

``` MusicPlayer.swift
NotificationCenter.default.addObserver(self,
                                       selector: #selector(onInterruption(_:)),
                                       name: AVAudioSession.interruptionNotification,
                                       object: AVAudioSession.sharedInstance())
```

中断の状態には、`開始`と`終了`があります。
音音では`開始`されたら曲を停止し、`終了`したら曲を再開するようにしています。

``` MusicPlayer.swift
@objc func onInterruption(_ notification: Notification) {
    guard let userInfo = notification.userInfo,
          let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
          let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
              return
          }
    switch type {
    case .began:
        if isPlaying {
            pause()
        }
        break
    case .ended:
        if !isPlaying {
            play()
        }
        break
    @unknown default:
        break
    }
}
```

# イヤホンが外れたら曲を一時停止する
またもや音楽プレーヤーとして必須となるのが、[オーディオセッションルートの変更](https://developer.apple.com/documentation/avfaudio/avaudiosession/responding_to_audio_session_route_changes)への対応です。
一番よくあるのは**イヤホンが外れたら音楽を停止し、接続したら再生する**というケースです。

イヤホン外した時にiPhoneから音楽が流れっぱなしになってしまったら恥ずかしいですよね。
それに対応します。

まずはオーディオセッションルートの変更の通知を追加します。

``` MusicPlayer.swift
NotificationCenter.default.addObserver(self,
                                       selector: #selector(onAudioSessionRouteChanged(_:)),
                                       name: AVAudioSession.routeChangeNotification,
                                       object: nil)
```

そして検知した後の実装です。
オーディオセッションルートが変更された理由は下記の二つが取得できます。

* `newDeviceAvailable`: デバイスに接続した時(イヤホンに接続した)
* `oldDeviceUnavailable`: デバイスの接続が外れてしまった(イヤホンの接続が外れた)

音音では下記の実装で、イヤホンに接続した時は再生し、接続が解除されたときは一時停止しています。

``` MusicPlayer.swift
@objc func onAudioSessionRouteChanged(_ notification: Notification) {
    guard let userInfo = notification.userInfo,
          let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt,
          let reason = AVAudioSession.RouteChangeReason(rawValue:reasonValue) else {
              return
          }
    
    switch reason {
    case .newDeviceAvailable:
        if !isPlaying {
            play()
        }
    case .oldDeviceUnavailable:
        if isPlaying {
            pause()
        }
    default:
        break
    }
}
```

一般的な音楽プレーヤーとしての実装はここまでです！

# 音音独自の機能
音音には曲のキーとテンポを変更できる機能があります。

その実装について書きたいと思います。

## 曲のキーの高さを変える
記事の冒頭でお話しした通り、音音では曲のキーの高さを変更できます。
カラオケでも「#」を押したら半音あがり、「♭」を押したら半音下がりますね。それと同じことをやります。
![スクリーンショット 2022-05-31 8.29.49 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263659/dfda5a64-f360-e863-a280-e05289433360.png)

キーの高さを変更するには`AVAudioUnitTimePitch`の`pitch`プロパティを変更します。


半音の定義は下記の通りです。([公式ドキュメント参照](https://developer.apple.com/documentation/avfaudio/avaudiounittimepitch/1387188-pitch))
```
1 musical semitone(半音) = 100cents
```

`pitch`の値はこの`cents`単位で設定します。

音音では100cents単位でインクリメントorデクリメントしています。

注意点として、pitchの値の範囲は`-2400cents`から`2400cents`です。
これはデバイスの限界値ですので、範囲内で変更するようにしましょう。

インクリメントする処理は下記の通りです。

``` MusicPlayer.swift
    func incrementPitch() {
        pitch = incrementedPitch()
    }
    func incrementedPitch(startPitch: Float? = nil) -> Float {
        let startPitch = startPitch ?? pitch
        // 最大値チェック
        if startPitch >= pitchOptions.maxValue {
            return startPitch
        }
        return startPitch + pitchOptions.unit
    }
```

`pitch`と`pitchOptions`の定義は下記の通りです。
`pitchOptions`は最小値、最大値、インクリメント(もしくはデクリメント)する単位、デフォルトの値などを定義しています。

``` MusicPlayer.swift
    /// Pitch
    @Published public var pitch: Float = MPConstants.defaultPitchValue
    @Published public var pitchOptions: MusicEffectRangeOption = .init(minValue: MPConstants.defaultPitchMinValue,
                                                                       maxValue: MPConstants.defaultPitchMaxValue,
                                                                       unit: MPConstants.defaultPitchUnit,
                                                                       defaultValue: MPConstants.defaultPitchValue)
```

そして`pitch`をsinkして値が変更されたら`pitchControl`の`pitch`を変更して、キーの高さを変更しています。

``` MusicPlayer.swift
    $pitch.sink { [weak self] value in
        guard let self = self else { return }
        // enablePitchValueメソッドでデバイスの限界値を超えない値にしている
        self.pitchControl.pitch = MusicPlayerService.enablePitchValue(value: value)
    }
    .store(in: &cancellables)
```

## テンポの速さを変える
音音ではテンポの速さも変更できます。

![tempo (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263659/4a4d1380-6e15-1699-3ca7-2fdc9f52158c.png)

テンポの速さを変更するには`AVAudioUnitTimePitch`の`rate`プロパティを変更します。
注意点としてテンポもキーも変更する場合は、**テンポの速さを変えるNodeとキーの高さを変えるNodeをAVAudioUnitTimePitchで統一することです。**

最初テンポの速さを変えるNodeには`AVAudioUnitVarispeed`を使って、下記の構成にしていました。
```
PlayerNode -> AVAudioUnitVarispeed -> AVAudioUnitTimePitch -> outputNode
```
しかし`AVAudioUnitVarispeed`の`rate`プロパティを使ってテンポの速さを変えたら、AVAudioUnitTimePitchのpitchを変更していないのに、キーの高さも変わってしまうという不具合が発生して、この構成だとどうしてもそれが解決できませんでした。

そこで`AVAudioUnitTimePitch`クラスを確認したら、AVAudioUnitTimePitchにも`rate`プロパティがあることに気づきそちらを使ってテンポの速さを変えたらその不具合は解決されました。

最終的にはAVAudioUnitVarispeedを除いた下記の構成にしました。
```
PlayerNode -> AVAudioUnitTimePitch -> outputNode
```

[公式ドキュメント](https://developer.apple.com/documentation/avfaudio/avaudiounittimepitch/1389380-rate)にも記載されていますが、`AVAudioUnitTimePitch`の`rate`プロパティの範囲は`1/32から32.0`です。
こちらもデバイスの限界値なので範囲内で指定するようにしましょう。

「+」ボタンを押したときのインクリメントする処理は下記の通りです。
``` MusicPlayer.swift
    /// increment playback rate
    func incrementRate() {
        rate = incrementedRate()
    }
    func incrementedRate(startRate: Float? = nil) -> Float {
        let startRate = startRate ?? rate
        if startRate >= rateOptions.maxValue {
            return startRate
        }
        return startRate + rateOptions.unit
    }
```

`rate`と`rateOptions`の定義は下記の通りです。
`rateOptions`はpitchOption同様に、最小値、最大値、インクリメント(もしくはデクリメント)する単位、デフォルトの値などを定義しています。

``` MusicPlayer.swift
    /// rate
    @Published public var rate: Float = MPConstants.defaultRateValue
    @Published public var rateOptions: MusicEffectRangeOption = .init(minValue: MPConstants.defaultRateMinValue,
                                                                      maxValue: MPConstants.defaultRateMaxValue,
                                                                      unit: MPConstants.defaultRateUnit,
                                                                      defaultValue: MPConstants.defaultRateValue)
```

そして`rate`をsinkして値が変更されたら`pitchControl`の`rate`を変更して、テンポの速さを変更しています。

``` MusicPlayer.swift
    $rate.sink { [weak self] value in
        guard let self = self else { return }
        // enableRateValueメソッドでデバイスの限界値を超えない値にしている
        self.pitchControl.rate = MusicPlayerService.enableRateValue(value: value)
    }
    .store(in: &cancellables)
```

# 終わりに
音楽プレーヤーアプリをリリースしてみて思ったのは、やはりiOSのコアフレームワークを扱うのはそれなりに大変だなと思いました。
特にAVFAudioフレームワークはドキュメントがそこまで充実していないので理解するのにかなり時間がかかりました。

冒頭で紹介したプレーヤーの[サンプルコード](https://github.com/tsuruken0802/MusicPlayer/tree/main/example)では音楽プレーヤーとしての基本機能や、音音のキー&テンポ変更機能も実装されています。
この記事で挙げた内容は全て実装されていますので、参考にしてください！

この記事が音楽プレーヤーアプリを作りたい方の助けになれば幸いです。