[Swift] シンプルなオーディオプレイヤーを作ってみよう

意外とこういう記事が少なかったので書きましたー
2023.01.18

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

CX事業本部の中安です。まいどです。

今回は「オーディオプレイヤーを作ってみよう」という趣旨でブログを書きたいと思います。

オーディオプレイヤーのUIは、リッチに作ろうと思えばいくらでもリッチなものを組めるとは思いますが、 あくまでも今回は再生ボタンとシークバー、そして再生時間の表示というシンプルなものを目指します。

最後にコピペで動くソースコードも用意しております。

基本準備

1.ユーザインターフェイス

今回は Sample.storyboardというストーリーボードを用意しました。 SwiftUIに慣れている方はご自身で読み替えてください。

左から playPauseButton seekBar currentTimeLabel と並んでいます。

2. ビューコントローラ

今回は SampleViewController というビューコントローラを1つ用意しました。 ベースの実装は下記のような感じです。

import UIKit

class SampleViewController: UIViewController {
    
    @IBOutlet private weak var playPauseButton: UIButton!
    @IBOutlet private weak var currentTimeLabel: UILabel!
    @IBOutlet private weak var seekBar: UISlider!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // あとで実装
    }
    
    @IBAction private func didTapPlayPauseButton() {
        // あとで実装
    }
}

3.音声(音楽)ファイル

今回は sample.mp3という音声ファイルを1つ用意して、アプリにバンドルします。

基本的なUIの制御

最初に、それぞれのUIを制御するメソッドを準備しておきます。

再生/一時停止ボタン

一般的なアプリ(たとえばiTunesなど)のほとんどがそのようなUIを採用していると思いますので「再生するボタン」と「一時停止するボタン」は同じボタンとして扱います。

playPauseButtonの見た目を切り替えるための実装として、 下記のようなメソッドを用意しました。

/// 再生/一時停止ボタンの見た目を「再生」にする
private func updateButtonImageToPlay() {
    playPauseButton.setImage(
        UIImage(systemName: "play.fill"),
        for: .normal
    )
}

/// 再生/一時停止ボタンの見た目を「一時停止」にする
private func updateButtonImageToPause() {
    playPauseButton.setImage(
        UIImage(systemName: "pause.fill"),
        for: .normal
    )
}

システム画像が用意されているので、そちらで十分かと思います。

シークバー

音声の再生位置を指定するためのシークバーは、今回はUISliderで賄います。

private func setupSeekBar() {
    seekBar.value = 0
}

ストーリーボード上で指定してもいいですが、シークバーの初期値は0の位置に設定しておきます。

setupSeekBar()は、画面生成時に呼ばれる前提のメソッドです。 現時点ではシンプルな実装ですが、後ほど色々追加実装していきます。

再生時間表示ラベル

currentTimeLabelに再生時間を表示していきます。 一般的なオーディオプレイヤーのように mm:ssという表示にします。

private func stringOf(time: TimeInterval) -> String {
    let formatter = DateComponentsFormatter()
    formatter.unitsStyle = .positional
    formatter.zeroFormattingBehavior = .pad
    formatter.allowedUnits = time >= 60 * 60 ? [.hour, .minute, .second] : [.minute, .second]
    return formatter.string(from: time)!
}

TimeInterval型を文字列表現に変換するためにDateComponentsFormatterを使用します。 DateComponentsFormatterについては下記のブログも参考になるでしょう。

【Swift】秒から何時間何分何秒に変換する

ハイライトした5行目ですが、1時間以上の音声ファイルの場合に hh:mm:ss形式になるように一工夫を入れています。 この辺りは好みかと思うので、ご自身で調整してください。

private func updateCurrentTimeLabel(_ current: TimeInterval, duration: TimeInterval) {
    currentTimeLabel.text = "\(stringOf(time: current)) / \(stringOf(time: duration))"
}

初期処理

ここまでのUI処理を使用して、viewDidLoadに画面生成時の初期処理を実装します。

override func viewDidLoad() {
    super.viewDidLoad()
    updateButtonImageToPlay()
    updateCurrentTimeLabel(0, duration: 0)
    setupSeekBar()
}

ここまでで実行するとこのような感じです。

オーディオプレイヤー

ここからは実際に音声を再生したり、停止したりする処理を書いていきます。

音声を再生するために使用するのは、AVAudioPlayerクラスです。 こちらはAVFoundationに用意されているクラスなので、インポートをする必要があります。

import UIKit
import AVFoundation

ビューコントローラにAVAudioPlayerのプロパティを追記します。

private var audioPlayer: AVAudioPlayer?

プレイヤーのセットアップ

音声ファイルのパス文字列を引数に取り、オーディオプレイヤーのインスタンスをプロパティにセットするメソッドを作成します。

private func setupAudioPlayer(path: String) {
    guard let player = try? AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) else {
        // エラー表示
        return
    }
    player.delegate = self
    self.audioPlayer = player
}

AVAudioPlayerのデリゲートに自身のビューコントローラを指定したので、 下記のようにAVAudioPlayerDelegateを実装する必要があります。

extension SampleViewController: AVAudioPlayerDelegate {
    
    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        // あとで実装
    }

    func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
        // あとで実装
    }
}

プレイヤーの再生と一時停止

オーディオプレイヤーの再生と一時停止の実装は実にシンプルです。 その名の通りのメソッドが用意されているので呼び出すだけです。

ここではオーディオプレイヤーの操作に加えて、先ほどのボタン画像の制御も行うメソッドをビューコントローラに用意します。

private func playAudioPlayer() {
    audioPlayer?.play()
    updateButtonImageToPause()
}

private func pauseAudioPlayer() {
    audioPlayer?.pause()
    updateButtonImageToPlay()
}

「音声再生されていれば一時停止ボタンを、音声再生されていなければ再生ボタンを」というように現在の再生ステータスとは逆になることに注意してください。

実際に再生してみよう

それでは、用意していたsample.mp3を再生してみます。

画面生成時(viewDidLoad)にオーディオプレイヤーをセットアップします。

override func viewDidLoad() {
    super.viewDidLoad()
    (中略)
    setupAudioPlayer(path: Bundle.main.path(
        forResource: "sample", ofType: "mp3")!
    )
}

didTapPlayPauseButton()の中を実装して、再生ボタン押下で音声が再生されるようにします。

@IBAction private func didTapPlayPauseButton() {
    if audioPlayer!.isPlaying {
        pauseAudioPlayer()
    } else {
        playAudioPlayer()
    }
}

これで、再生ボタンを押すとplayAudioPlayer()で音声を実際に再生するようになります。 再生中は一時停止ボタンが表示され、押下すると音声は一時停止します。

画像だけなので申し訳ないのですが、上図は再生している状態を表しています。

ただし、この時点では再生するか止めるかしか制御ができず、シークバーも再生時間ラベルも何も動きません。

次はそのあたりを作っていきます。

音声再生の進捗状況を取得

実はAVAudioPlayerは、再生時の進捗イベントは送信してくれません。 そこで、別のクラスにその処理を委ねることになります。

それがCoreAnimationで用意されているCADisplayLinkです。

参考:公式ドキュメント CADisplayLink

ちなみに、Timerを使う手段も考えられますが、こちらの記事によるとあまり良くない手段のようです。

ビューコントローラに CADisplayLinkのプロパティを追加します。

private var displayLink: CADisplayLink?

DisplayLinkの有効/無効

DisplayLinkのインスタンスを生成して実際に走らせるメソッドと、 インスタンスを無効にして破棄するメソッドを用意します。

これで、displayLinkが有効である限り一定期間でdidUpdatePlaybackStatus()が断続的に呼ばれるようになります。

private func validateDisplayLink() {
    displayLink = CADisplayLink(
        target: self,
        selector: #selector(didUpdatePlaybackStatus)
    )
    displayLink!.add(
        to: .main,
        forMode: .common
    )
}

private func invalidateDisplayLink() {
    displayLink?.invalidate()
    displayLink = nil
}

@objc private func didUpdatePlaybackStatus() {
    // あとで実装
}

再生/一時停止時にDisplayLinkを有効/無効にする

先ほど作った再生と一時停止の処理に、DisplayLinkの有効/無効を追記します。 ハイライトした部分がそれになります。

private func playAudioPlayer() {
    audioPlayer?.play()
    updateButtonImageToPause()
    validateDisplayLink()
}

private func pauseAudioPlayer() {
    audioPlayer?.pause()
    updateButtonImageToPlay()
    invalidateDisplayLink()
}

シークバーを更新するメソッド

音声再生が進捗するごとにシークバーの位置が変わるようにしていきたいので、 シークバーを更新するメソッドを作成します。

再生時間のラベルを更新するメソッドは最初に作ってあったので、それと呼び方を合わせるようにしています。

private func updateSeekBarToCurrent(_ current: TimeInterval, duration: TimeInterval) {
    seekBar.value = Float(current / duration)
}

このように、シークバーの値は全体の長さからのパーセンテージを表すことになります。

再生時間の進捗を受け取った時

DisplayLinkにより再生中は断続的にdidUpdatePlaybackStatus()が呼び出されます。 その都度、シークバーと再生時間ラベルを更新していきます。

@objc private func didUpdatePlaybackStatus() {
    updateSeekBarToCurrent(
        audioPlayer!.currentTime,
        duration: audioPlayer!.duration
    )
    updateCurrentTimeLabel(
        audioPlayer!.currentTime,
        duration: audioPlayer!.duration
    )
}

AVAudioPlayerには現在の再生時間 (currentTime)と、音声ファイルの長さ (duration)が秒数で取得できます。 それぞれの更新用メソッドには、それらの値を渡して進捗状況を表示してもらいます。

これで、音声が再生されるとシークバーと再生時間ラベルが更新されて、オーディオプレイヤーっぽさが出てきました。

シークバーで再生時間を移動

シークバーなのですから、ツマミを動かすことによって再生位置を変えられるようにしたいものです。 ここからはシークバー自体の挙動を作っていきましょう。

最初の方に作った setupSeekBar()メソッドに追加する形でソースコードを書いていきます。 ハイライトした部分が追加分です。

private func setupSeekBar() {
    seekBar.value = 0

    seekBar.isContinuous = false

    seekBar.addTarget(
        self,
        action: #selector(didStartDragSeekBar),
        for: .touchDown
    )
    seekBar.addTarget(
        self,
        action: #selector(didEndDragSeekBar),
        for: .valueChanged
    )
}

UISliderisContinuousプロパティは、ツマミを動かした時に継続的にイベントを発火させるかどうかのブール値です。 移動中のイベントは特に必要がないのでfalseに設定しておきます。

また、シークバー(UISlider)にアクションを設定していきます。 こちらはストーリーボード上でも設定可能なのですが、明示的にaddTargetすることで分かりやすくなると思い、ソースコード上に落とし込んでいます。 この辺もお好みで使い分けてください。

追加したアクションをトリガーするメソッドも下記のように追加しておきます。

@objc private func didStartDragSeekBar() {
    // あとで実装
}

@objc private func didEndDragSeekBar() {
    // あとで実装
}

ドラッグ開始時

touchDownをドラッグ開始イベントとして、そのイベントリスナを実装していきます。

@objc private func didStartDragSeekBar() {
    displayLink?.isPaused = true
}

ドラッグ中はDisplayLinkの動作を一時停止しておくことで、シークバーと再生時間ラベルが更新されるのをストップさせています。

ドラッグ終了時

反対にドラッグが終わる時にはdisplayLinkの動作を再開させます。

そしてシークバーのツマミが止まった場所まで、AVAudioPlayerの再生位置を動かし、ラベルの表示も更新させていくというわけです。

@objc private func didEndDragSeekBar() {
    displayLink?.isPaused = false
    
    let soughtTime = audioPlayer!.duration * TimeInterval(seekBar.value)
    audioPlayer!.currentTime = soughtTime
    
    updateCurrentTimeLabel(
        audioPlayer!.currentTime,
        duration: audioPlayer!.duration
    )
}

ここまでで、再生中にシークバーを動かすと再生位置が変更されて、好きな位置から再生することができるようになったはずです。

ツマミ以外でもシークバーを動かせるようにする

前項までの実装で好きな位置に再生位置を動かせるようになりましたが、 ユーザはシークバー(UISlider)のツマミをわざわざ押してドラッグする必要があります。

使いやすさを追求すると、シークバーのツマミ以外をタップしたときは、ツマミがその位置まで追従してきてもらいたいものです。

なので、ツマミ以外で操作できるように下記のような処理を追記していきましょう。(ハイライトした場所が追記部分です)

private func setupSeekBar() {
    (中略)

    let tapGestureRecognizer = UITapGestureRecognizer(
        target: self,
        action: #selector(didTapSeekBar(_:))
    )
    seekBar.addGestureRecognizer(tapGestureRecognizer)
}

@objc private func didTapSeekBar(_ gesture: UIGestureRecognizer) {
    displayLink?.isPaused = false
    let location = gesture.location(in: seekBar)
    let value = location.x / seekBar.frame.width
    seekBar.setValue(Float(value), animated: true)
    didEndDragSeekBar()
}

タップジェスチャレコグナイザを使用し、その位置までツマミを移動させることによって、シークバーはツマミ部分以外でも操作できるようになります。

didEndDragSeekBar()メソッドを最後に呼び出すことでドラッグ時と同じ処理が呼び出されるようになります。

再生終了時の処理

音声ファイルがすべて再生し終わると、AVAudioPlayerDelegateaudioPlayerDidFinishPlayingが呼ばれます。

オーディオプレイヤーの仕様によりますが、 再生が終わったら先頭に戻って停止状態になるのが自然かと思うので、そのように実装しています。

func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
    player.currentTime = 0
    updateButtonImageToPlay()
    updateCurrentTimeLabel(0, duration: player.duration)
    updateSeekBarToCurrent(0, duration: player.duration)
    invalidateDisplayLink()
}

エラー発生時にはaudioPlayerDecodeErrorDidOccurが呼ばれますが、ここではエラー処理は割愛します。 アラートダイアログを表示するなど、各々で何かしら実装してください。

全体のソースコード

ここまででシンプルなオーディオプレイヤーが完成しました。

この項では全体のソースコードを掲載しておきます。 UIと音声ファイルを用意して、こちらをコピペしてもらえば、おそらく動くと思いますので、 実際に動かしてみてください。

import UIKit
import AVFoundation

class SampleViewController: UIViewController {
    
    @IBOutlet private weak var playPauseButton: UIButton!
    @IBOutlet private weak var currentTimeLabel: UILabel!
    @IBOutlet private weak var seekBar: UISlider!
    
    private var audioPlayer: AVAudioPlayer?
    private var displayLink: CADisplayLink?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        updateButtonImageToPlay()
        updateCurrentTimeLabel(0, duration: 0)
        setupSeekBar()
        
        setupAudioPlayer(path: Bundle.main.path(
            forResource: "sample", ofType: "mp3")!
        )
    }
    
    @IBAction private func didTapPlayPauseButton() {
        if audioPlayer!.isPlaying {
            pauseAudioPlayer()
        } else {
            playAudioPlayer()
        }
    }
    
    private func updateButtonImageToPlay() {
        playPauseButton.setImage(
            UIImage(systemName: "play.fill"),
            for: .normal
        )
    }

    private func updateButtonImageToPause() {
        playPauseButton.setImage(
            UIImage(systemName: "pause.fill"),
            for: .normal
        )
    }
    
    private func setupSeekBar() {
        seekBar.value = 0
        
        seekBar.isContinuous = false

        seekBar.addTarget(
            self,
            action: #selector(didStartDragSeekBar),
            for: .touchDown
        )
        seekBar.addTarget(
            self,
            action: #selector(didEndDragSeekBar),
            for: .valueChanged
        )
        
        let tapGestureRecognizer = UITapGestureRecognizer(
            target: self,
            action: #selector(didTapSeekBar(_:))
        )
        seekBar.addGestureRecognizer(tapGestureRecognizer)
    }
    
    @objc private func didStartDragSeekBar() {
        displayLink?.isPaused = true
    }

    @objc private func didEndDragSeekBar() {
        displayLink?.isPaused = false
        
        let soughtTime = audioPlayer!.duration * TimeInterval(seekBar.value)
        audioPlayer!.currentTime = soughtTime
        
        updateCurrentTimeLabel(
            audioPlayer!.currentTime,
            duration: audioPlayer!.duration
        )
    }
    
    @objc private func didTapSeekBar(_ gesture: UIGestureRecognizer) {
        displayLink?.isPaused = false
        let location = gesture.location(in: seekBar)
        let value = location.x / seekBar.frame.width
        seekBar.setValue(Float(value), animated: true)
        didEndDragSeekBar()
    }
    
    private func stringOf(time: TimeInterval) -> String {
        let formatter = DateComponentsFormatter()
        formatter.unitsStyle = .positional
        formatter.zeroFormattingBehavior = .pad
        formatter.allowedUnits = time >= 60 * 60 ? [.hour, .minute, .second] : [.minute, .second]
        return formatter.string(from: time)!
    }

    private func updateCurrentTimeLabel(_ current: TimeInterval, duration: TimeInterval) {
        currentTimeLabel.text = "\(stringOf(time: current)) / \(stringOf(time: duration))"
    }
    
    private func updateSeekBarToCurrent(_ current: TimeInterval, duration: TimeInterval) {
        seekBar.value = Float(current / duration)
    }
    
    private func setupAudioPlayer(path: String) {
        guard let player = try? AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) else {
            // エラー表示
            return
        }
        player.delegate = self
        self.audioPlayer = player
    }
    
    private func playAudioPlayer() {
        audioPlayer?.play()
        updateButtonImageToPause()
        validateDisplayLink()
    }

    private func pauseAudioPlayer() {
        audioPlayer?.pause()
        updateButtonImageToPlay()
        invalidateDisplayLink()
    }
    
    private func validateDisplayLink() {
        displayLink = CADisplayLink(
            target: self,
            selector: #selector(didUpdatePlaybackStatus)
        )
        displayLink!.add(
            to: .main,
            forMode: .common
        )
    }

    private func invalidateDisplayLink() {
        displayLink?.invalidate()
        displayLink = nil
    }

    @objc private func didUpdatePlaybackStatus() {
        updateSeekBarToCurrent(
            audioPlayer!.currentTime,
            duration: audioPlayer!.duration
        )
        updateCurrentTimeLabel(
            audioPlayer!.currentTime,
            duration: audioPlayer!.duration
        )
    }
}

extension SampleViewController: AVAudioPlayerDelegate {
    
    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        player.currentTime = 0
         updateButtonImageToPlay()
         updateCurrentTimeLabel(0, duration: player.duration)
         updateSeekBarToCurrent(0, duration: player.duration)
         invalidateDisplayLink()
    }

    func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
        // エラー表示
    }
}

おわりに

今回ご紹介したものは、とりあえずシンプルなオーディオプレイヤーを作って動かしたいという意図で書かせていただきました。 そんな理由もあって異常系の処理はほとんど入っていません。

本来であれば、音声ファイルがまだセットされていない状態もあると思うので、 UIの制御はもう少し細かくなるとは思います。

それゆえにaudioPlayernilだったときのNullポインタ対策などを入れる必要があると思いますが、 その辺りは入れていなので注意してください。

誰かの何かの参考になれば幸いです。

それではまたー。