39
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

iOSAdvent Calendar 2019

Day 12

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

Last updated at Posted at 2019-12-11

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

こんな感じ。
s1

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

ミュージックライブラリへのアクセス許可を得る

iPhoneに入っている曲にアクセスするには、ユーザーに許可をいただく必要があります。
まずは、Info.plistNSAppleMusicUsageDescriptionを追加して、利用用途文を記載します。

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

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

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

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

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

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

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

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

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

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にアタッチします。

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を指定することですぐ再生されるようにできます。

    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メソッドを呼ぶと曲が再生されます。

    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)を指定します。

let eqNode = AVAudioUnitEQ(numberOfBands: 10)

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

filterType(AVAudioUnitEQFilterType)

音声に対してかけるフィルタを指定します。

フィルタ名 効果 表示
parametric 指定の周波数とバンド幅を元にブースト/カットする s1.png
lowPass 指定の周波数より高い音をカットする s2.png
highPass 指定の周波数より低い音をカットする s11.png
resonantLowPass バンド幅つきのLowPass s3.png
resonantHighPass バンド幅つきのHighPass s4.png
bandPass 指定の周波数のみを通し、他の周波数を減衰させる s5.png
bandStop 指定の周波数を減衰させ、他の周波数を通す。bandPassの逆 s6.png
lowShelf 指定の周波数より低い音をブースト/カットする s7.png
highShelf 指定の周波数より高い音をブースト/カットする s8.png
resonantLowShelf バンド幅つきのLowShelf s9.png
resonantHighShelf バンド幅つきのHighShelf s10.png

bandWidth

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

frequency

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

gain

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

bypass

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

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

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を、
DRMがかかっている曲かどうかはhasProtectedAssetを見ればわかるので、どちらもfalseの曲だけを取得すれば良さそうです。
addFilterPredicateを使って条件を指定して絞り込みましょう。

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を選択します。
スクリーンショット 2019-12-09 0.25.40.png

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

スクリーンショット 2019-12-09 0.28.40.png

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

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

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

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

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だとちょっと大変ですが、こういったシンプルなレイアウトのアプリを作るのには最適ですね。

付録

イコライザーとは

音響機器のイコライザー (Equalizer) とは、音声信号の周波数特性を変更する音響機器である。イコライザーを使って、音声信号の特定の周波数帯域 (倍音成分や高調波成分あるいはノイズ成分)を強調したり、逆に減少させる事ができ、全体的な音質の補正(平均化)や改善(音像の明確化など)、あるいは積極的な音作りに使用される。

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

スクリーンショット 2019-12-09 23.25.09.png

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

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

参考

Activating an Audio Session
AVAudioEngine | objective-audio
Performing Offline Audio Processing | Apple Developer Documentation

39
24
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
39
24

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?