はじめに
最近「音音(ネオン)」というiOSの音楽プレーヤーアプリをリリースしました。
こちらのように曲のテンポの速さやキーの高さを変更できたり、
こちらのように曲の区間を設定し、繰り返し聴くことができます。
レイアウトはSwiftUIで実装し、バックエンドはありません。(FirebaseのAnalyticsとCrashlyticsは入れていますが)
楽曲再生にはAVFAudioフレームワークのクラスを使っています。
レイアウトはゴリゴリ作っていけばいいのですが、プレーヤー部分の実装には結構苦労しました。
曲のエフェクトや再生区間の機能を除いた、普通の音楽プレーヤーを実装するだけでも大変でした。
この記事では音楽プレーヤーの一般的な機能の実装と、音音独自の機能をどうやって実装したかを紹介します。
※プレーヤーの自作ライブラリとサンプルコードも公開しています。
※ 音音のダウンロードはこちらです。
一般的な音楽プレーヤーの機能一覧
最低限音楽プレーヤーとしてあるべき機能は下記の通りだと思います。
- 再生と一時停止
- 次/前の曲に移動する
- 再生秒数を変更する
- バックグラウンド再生
- バックグラウンドで曲の操作をする
それに加え、できたら下記も対応したいところです。
- 電話が来たら曲を停止する
- イヤホンが外れたら曲を一時停止する
どのように実装したか一つずつ見ていきます。
再生と一時停止
まずiOSで楽曲を再生するにはプレーヤークラスを選択する必要があります。
iOSでは下記のプレーヤークラスが用意されています。
この中で楽曲のエフェクトを設定できるのはAVAudioEngine
のみですので、こちらを選択します。
AVAudioPlayerとMPMusicPlayerControllerはAPIが直感的で使いやすいので、簡易的なプレーヤーを作成する場合は適していると思います。
凝ったことやりたい場合はAVAudioEngine一択になるかなと思います。
AVAudioEngineの基礎知識
AVAudioEngineでは下記の図のようにオーディオノードと呼ばれるオブジェクトを接続することで音を出力します。(図は公式ドキュメント引用)
図では、PlayerNode、MixerNode、OutputNodeを接続しています。
PlayerNodeでは、Source fileの楽曲を再生します。
次に再生された音源をMixerNodeで単一の音にします。(複数の音源を一つの音源にできるが今回は一つ)
最後にOutputNodeに出力することで、端末(スピーカーやイヤホン)に出力しているという流れになります。
上記はシンプルに楽曲を音源通りに再生するだけの接続例となりますが、オーディオノードの組み合わせによって楽曲の音源をカスタマイズすることができます。
例えば音音では、AVAudioUnitTimePitchというオーディオノードを使って、曲のテンポの速さとキーの高さを変更できるようにしています。
下記のコードはオーディオノードを接続し、曲の再生準備をするまでのコードになっています。
// 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
}
}
再生準備の手順としてはこちらの通りです。
- オーディオノードをAudioEngineにアタッチする
- Source fileを取得する
- オーディオノードの接続をする
- scheduleFileメソッドで再生準備をする
再生処理
再生準備ができれば再生自体は簡単です。
下記の実装で楽曲を再生できます。
/// Play current item
func play() {
do {
// 再生処理
try audioEngine.start()
playerNode.play()
}
catch let e {
print(e.localizedDescription)
}
}
AVAudioEngineとのstart
メソッド、AVAudioPlayerNodeのplay
メソッドどちらも呼ぶ必要があります。
一時停止
一時停止も簡単です。
/// Pause playback
func pause() {
audioEngine.pause()
playerNode.pause()
}
停止
一時停止ではなく、完全に停止させる場合は下記のように実装します。
/// stop playback
func stop() {
audioEngine.stop()
playerNode.stop()
}
ここまでできたら楽曲の再生と一時停止は完成です。
次/前の曲に移動する
AVAudioEngineには曲のキューを保持する機能がありません。
なのでアプリケーションコードとして再生リストを保持しておく必要があります。
音音では再生リストのデータと再生中のindexを保持し、今再生中のMPMediaItemを参照できるようにしています。
// 曲のリストを表すクラス
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
はこのような定義になっています。
// 曲を表すクラス
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が実際の曲のデータになります。
曲ごとにエフェクトや再生区間を設定したいため、item: MPMediaItem
の他に、それらを表すMPSongItemEffect
やMPDivision
などのデータも定義しています。
次の曲 or 前の曲に移動する場合は、MPSongItemList
のindexを変更し、前述したplay
メソッドを呼ぶだけです。
次の曲に移動するnext
メソッドは下記の通りです。(肝となる部分だけ載せています)
/// 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メソッドに再生したい秒数のTimeIntervalの値を設定してあげればその秒数まで移動することができるでしょう。
しかしAVAudioPlayerNodeではそうはいかず、曲の秒数をFloatやDoubleで管理することはありません。
AVAudioPlayerNodeではSampleTimeを使用して秒数を管理します。
SampleTimeとは?
デジタル音声にはサンプルレートというものがあります。簡単に言えば動画のフレームレート(FPS)の音声版と言ったところです。
フレームレートは1秒間に何枚の画像(フレーム)を表示するかを表しますが、サンプルレートでは音をアナログ信号からデジタル信号に変換する時の、一秒間に何回サンプリングしているかを表します。
一般的なサンプルレートは44,100Hz
と言われていますが、これは1秒間に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を調べると日本語訳で「ティック単位で単調に増加するクロックの現在の値」などど公式ドキュメントに記載されていましたが、何のこっちゃ。
まあとにかくnode time
は特に意識することはないです。
node time
はAVAudioPlayerNodeのlastRenderTime
プロパティから参照することができますが、こちらの値をそのまま使うことはほとんどありません。(少なくとも音楽プレーヤーを作る上では)
ほとんどのケースではplayer time
に変換して使うことになるでしょう。
player time
では楽曲を再生してからのSampleTimeが取得できるからです。
つまり、その曲が今何回サンプリングしたかがわかります
現在の秒数を保持する
SampleTimeとは何を踏まえた上で、現在の秒数を取得します。
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から参照することで現在の再生時間を表示しています。
/// current playback time (seconds)
@Published public var currentTime: Float = 0
再生する秒数の変更の実装
では肝心の再生する秒数の変更の実装です。(セーフティーコードは除外し、肝となる実装のみ載せています)
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)は下記のようになります。
/// 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での設定
バックグラウンドで曲を再生するには、公式ドキュメントに記載されている通り、XcodeのBackground Modes
のAudio, AirPlay, and Picture in Picture
にチェックを入れます。
AVAudioSessionのカテゴリ設定
AVAudioSessionの設定を行います。
AVAudioSessionは、アプリでオーディオをどのように扱うかを設定するためのクラスです。
AVAudioSessionを使えばバックグラウンドでの曲の再生の挙動を設定することができます。
AppDelegateで、AVAudioSessionのCategory設定をします。
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を使用すれば実現できます。
操作できるアクションは下記の通りです。
- 再生ボタン押下
- 一時停止ボタン押下
- 前の曲ボタン押下
- 次の曲ボタン押下
- 秒数バックボタン押下
- 秒数進むボタン押下
- シークバーでの秒数変更
これらのアクションをMPRemoteCommandCenterに追加します。
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にしたら、残った方は表示されません。(次の曲ボタンと秒数進むボタンも同様)
メタデータの表示
バックグラウンドで設定できる曲の主なメタデータは下記の通りです。
- タイトル
- サムネ
- 現在の再生時間
- 曲の速さ
- 曲の総再生時間
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側でよしなにやってくれよと思いました。笑
電話が来たら曲を停止する
音楽プレーヤーとして必須の機能となるのが、他のオーディオセッションによる中断への対応です。
例えば音楽を聴いているときに、電話がかかってくることもあります。
当然電話中には音楽が停止状態であることが望ましいです。
なので他のオーディオによる中断を検知して、適切に処理する必要があります。
こちらはオーディオによる中断を検知する通知を追加しています。
NotificationCenter.default.addObserver(self,
selector: #selector(onInterruption(_:)),
name: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance())
中断の状態には、開始
と終了
があります。
音音では開始
されたら曲を停止し、終了
したら曲を再開するようにしています。
@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
}
}
イヤホンが外れたら曲を一時停止する
またもや音楽プレーヤーとして必須となるのが、オーディオセッションルートの変更への対応です。
一番よくあるのはイヤホンが外れたら音楽を停止し、接続したら再生するというケースです。
イヤホン外した時にiPhoneから音楽が流れっぱなしになってしまったら恥ずかしいですよね。
それに対応します。
まずはオーディオセッションルートの変更の通知を追加します。
NotificationCenter.default.addObserver(self,
selector: #selector(onAudioSessionRouteChanged(_:)),
name: AVAudioSession.routeChangeNotification,
object: nil)
そして検知した後の実装です。
オーディオセッションルートが変更された理由は下記の二つが取得できます。
-
newDeviceAvailable
: デバイスに接続した時(イヤホンに接続した) -
oldDeviceUnavailable
: デバイスの接続が外れてしまった(イヤホンの接続が外れた)
音音では下記の実装で、イヤホンに接続した時は再生し、接続が解除されたときは一時停止しています。
@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
}
}
一般的な音楽プレーヤーとしての実装はここまでです!
音音独自の機能
音音には曲のキーとテンポを変更できる機能があります。
その実装について書きたいと思います。
曲のキーの高さを変える
記事の冒頭でお話しした通り、音音では曲のキーの高さを変更できます。
カラオケでも「#」を押したら半音あがり、「♭」を押したら半音下がりますね。それと同じことをやります。
キーの高さを変更するにはAVAudioUnitTimePitch
のpitch
プロパティを変更します。
半音の定義は下記の通りです。(公式ドキュメント参照)
1 musical semitone(半音) = 100cents
pitch
の値はこのcents
単位で設定します。
音音では100cents単位でインクリメントorデクリメントしています。
注意点として、pitchの値の範囲は-2400cents
から2400cents
です。
これはデバイスの限界値ですので、範囲内で変更するようにしましょう。
インクリメントする処理は下記の通りです。
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
は最小値、最大値、インクリメント(もしくはデクリメント)する単位、デフォルトの値などを定義しています。
/// 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
を変更して、キーの高さを変更しています。
$pitch.sink { [weak self] value in
guard let self = self else { return }
// enablePitchValueメソッドでデバイスの限界値を超えない値にしている
self.pitchControl.pitch = MusicPlayerService.enablePitchValue(value: value)
}
.store(in: &cancellables)
テンポの速さを変える
音音ではテンポの速さも変更できます。
テンポの速さを変更するにはAVAudioUnitTimePitch
のrate
プロパティを変更します。
注意点としてテンポもキーも変更する場合は、テンポの速さを変えるNodeとキーの高さを変えるNodeをAVAudioUnitTimePitchで統一することです。
最初テンポの速さを変えるNodeにはAVAudioUnitVarispeed
を使って、下記の構成にしていました。
PlayerNode -> AVAudioUnitVarispeed -> AVAudioUnitTimePitch -> outputNode
しかしAVAudioUnitVarispeed
のrate
プロパティを使ってテンポの速さを変えたら、AVAudioUnitTimePitchのpitchを変更していないのに、キーの高さも変わってしまうという不具合が発生して、この構成だとどうしてもそれが解決できませんでした。
そこでAVAudioUnitTimePitch
クラスを確認したら、AVAudioUnitTimePitchにもrate
プロパティがあることに気づきそちらを使ってテンポの速さを変えたらその不具合は解決されました。
最終的にはAVAudioUnitVarispeedを除いた下記の構成にしました。
PlayerNode -> AVAudioUnitTimePitch -> outputNode
公式ドキュメントにも記載されていますが、AVAudioUnitTimePitch
のrate
プロパティの範囲は1/32から32.0
です。
こちらもデバイスの限界値なので範囲内で指定するようにしましょう。
「+」ボタンを押したときのインクリメントする処理は下記の通りです。
/// 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同様に、最小値、最大値、インクリメント(もしくはデクリメント)する単位、デフォルトの値などを定義しています。
/// 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
を変更して、テンポの速さを変更しています。
$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フレームワークはドキュメントがそこまで充実していないので理解するのにかなり時間がかかりました。
冒頭で紹介したプレーヤーのサンプルコードでは音楽プレーヤーとしての基本機能や、音音のキー&テンポ変更機能も実装されています。
この記事で挙げた内容は全て実装されていますので、参考にしてください!
この記事が音楽プレーヤーアプリを作りたい方の助けになれば幸いです。