LoginSignup
20
13

More than 1 year has passed since last update.

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

Posted at

はじめに

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

こちらのように曲のテンポの速さやキーの高さを変更できたり、

こちらのように曲の区間を設定し、繰り返し聴くことができます。

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

楽曲再生にはAVFAudioフレームワークのクラスを使っています。

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

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

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

※プレーヤーの自作ライブラリサンプルコードも公開しています。

※ 音音のダウンロードはこちらです。

一般的な音楽プレーヤーの機能一覧

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

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

それに加え、できたら下記も対応したいところです。

  • 電話が来たら曲を停止する
  • イヤホンが外れたら曲を一時停止する

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

再生と一時停止

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

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

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

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

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

AVAudioEngineの基礎知識

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

スクリーンショット 2022-05-24 22.22.27.png

図では、PlayerNodeMixerNodeOutputNodeを接続しています。

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

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

例えば音音では、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を参照できるようにしています。

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が実際の曲のデータになります。
曲ごとにエフェクトや再生区間を設定したいため、item: MPMediaItemの他に、それらを表すMPSongItemEffectMPDivisionなどのデータも定義しています。

次の曲 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メソッドに再生したい秒数の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 timeplayer timeです。(二つとも名前が決まっているわけではないのですが、便宜上僕が勝手につけています)

node timeは端末システム時間(mach_absolute_time)上のSampleTimeです。
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での設定

バックグラウンドで曲を再生するには、公式ドキュメントに記載されている通り、XcodeのBackground ModesAudio, AirPlay, and Picture in Pictureにチェックを入れます。

set_background.png

AVAudioSessionのカテゴリ設定

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を使用すれば実現できます。

IMG_2220 2.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側でよしなにやってくれよと思いました。笑

電話が来たら曲を停止する

音楽プレーヤーとして必須の機能となるのが、他のオーディオセッションによる中断への対応です。

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

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

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
    }
}

イヤホンが外れたら曲を一時停止する

またもや音楽プレーヤーとして必須となるのが、オーディオセッションルートの変更への対応です。
一番よくあるのはイヤホンが外れたら音楽を停止し、接続したら再生するというケースです。

イヤホン外した時に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

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

半音の定義は下記の通りです。(公式ドキュメント参照)

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
    }

pitchpitchOptionsの定義は下記の通りです。
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して値が変更されたらpitchControlpitchを変更して、キーの高さを変更しています。

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

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

最初テンポの速さを変えるNodeにはAVAudioUnitVarispeedを使って、下記の構成にしていました。

PlayerNode -> AVAudioUnitVarispeed -> AVAudioUnitTimePitch -> outputNode

しかしAVAudioUnitVarispeedrateプロパティを使ってテンポの速さを変えたら、AVAudioUnitTimePitchのpitchを変更していないのに、キーの高さも変わってしまうという不具合が発生して、この構成だとどうしてもそれが解決できませんでした。

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

最終的にはAVAudioUnitVarispeedを除いた下記の構成にしました。

PlayerNode -> AVAudioUnitTimePitch -> outputNode

公式ドキュメントにも記載されていますが、AVAudioUnitTimePitchrateプロパティの範囲は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
    }

raterateOptionsの定義は下記の通りです。
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して値が変更されたらpitchControlrateを変更して、テンポの速さを変更しています。

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フレームワークはドキュメントがそこまで充実していないので理解するのにかなり時間がかかりました。

冒頭で紹介したプレーヤーのサンプルコードでは音楽プレーヤーとしての基本機能や、音音のキー&テンポ変更機能も実装されています。
この記事で挙げた内容は全て実装されていますので、参考にしてください!

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

20
13
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
20
13