部屋の掃除をしていたらiPod touch 4th(背面がステンレスのやつです)を発掘して、昔はこれにアプリや音楽入れて遊んでたなぁ…としみじみしていました。
そんな中iOSで音楽プレイヤーの実装ってやったことなかったので、今回SwiftUI+AVFoundation+MediaPlayerを用いて作ってみました。
ソースコードはこちら
※シミュレータ上では動作しません。実機のご用意を!
https://github.com/nekowen/ios-music-sample
ミュージックライブラリへのアクセス許可を得る
iPhoneに入っている曲にアクセスするには、ユーザーに許可をいただく必要があります。
まずは、Info.plist
にNSAppleMusicUsageDescription
を追加して、利用用途文を記載します。
<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)
音声に対してかけるフィルタを指定します。
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
を選択します。
選択すると、項目が追加されるので、Audio, AirPlay, and Picture in Picture
にチェックを入れます。
次に、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の範囲でスライダーを調整することで、自分好みの音を作ることができます。
32〜125hzは(超)低音域と呼ばれ、ベースやバスドラムの重低音がここに該当します。
500〜2khzは中音域となり、ここをブーストするとボーカルがクリアに聞こえるようになります。
4〜16khzは(超)高音域です。ここをブーストするとシャリシャリした音になります。
ちなみに僕は重低音が好きなので32〜125hzをクリッピング限界までブーストしてよく聞きます。
参考
Activating an Audio Session
AVAudioEngine | objective-audio
Performing Offline Audio Processing | Apple Developer Documentation