Core Hapticsを用いて触覚フィードバック機能つきのメトロノームアプリを作ってみる

Core Hapticsは、iOS 13から追加された振動と音声を統合的に制御するフレームワークです。Core Hapticsを用いることで振動機能のついたメトロノームを作成することができます。

## 1. はじめに
#### 1.1 本記事で作成するメトロノーム
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/961672/cc693e5c-734e-46ee-1f40-f9760f3b1c25.png" width="200px title="アプリのスクリーンショット">
本記事では以下の機能がついた簡単なメトロノームを作成します。なお、UIの配置にはSwiftUIを用います。

* BPMを表示する機能
* ボタンによりBPMを1ずつ増やす機能
* 設定したBPMのテンポで音を繰り返し鳴らす機能
* 音に合わせてiPhone本体を振動させる機能


#### 1.2 本記事のソースコード
本記事のソースコードは、[https://github.com/Ossamoon/sample-haptics](https://github.com/Ossamoon/sample-haptics) に掲載してあります。


#### 1.3 Core Hapticsについて
この記事では、Core Hapticsフレームワークの基本的な使い方は省略します。基本的な使い方を学びたい方は、Qiitaの記事『[Core Haptics - カスタムハプティックパターンの作成と再生](https://qiita.com/shu223/items/7e1578d7515cb44d3980#fn1)』および、[Appleの公式ドキュメント](https://developer.apple.com/documentation/corehaptics)を参考にしてください。


#### 1.4 動作環境
* Xcode 12.5
* Swift 5.4


## 2. アプリの作成
#### 2.1 プロジェクトの作成
Xcodeで新規プロジェクトを作成します。SwiftUIが使えるようInterfaceにはSwiftUIを指定し、Life CycleにはSwiftUI Appを指定します。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/961672/56c82543-a9d6-bde9-42d2-4393c1e2f044.png" width="500px title="スクリーンショット">

プロジェクトを作成するとContentView.swiftファイルが自動生成されます。このファイル内のContentView構造体は**2.4 Viewの作成**にて書き直します。


#### 2.2 音源の取り込み
メトロノームの音として利用するための外部音源をXcodeに取り込みます。今回はフリーの効果音を配布されている[効果音ラボ](https://soundeffect-lab.info/)さんの音源を利用させていただきます。

[ボタン・システム音のページ](https://soundeffect-lab.info/sound/button/)に移動し、**"決定、ボタン押下2"**の音源をダウンロードしてください。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/961672/63cead5b-e4e4-073b-39ad-e6fe72274222.png" width="500px title="効果音ラボ(https://soundeffect-lab.info/)">

ダウンロードした音源はXcode上にドラッグアンドドロップすることでXcodeに取り込むことができます。場所はどこでもいいですが、ContentView.swiftと同じ階層が分かりやすいでしょう。後で使いやすいように、音源のファイル名を**sound.mp3**に変更しておきましょう。


#### 2.3 Controllerの作成
振動機能を制御するコントローラーを記述していきます。ContentView.swiftと同じ階層に新規ファイルを作成し、ファイル名を**HapticController.swift**としてください。

HapticController.swiftを開き、以下のコードを記述します。

```swift:HapticController.swift
import Foundation
import CoreHaptics
import AVFoundation

class HapticController {
    // メトロノームのパラメーター
    var bpm: Double = 120.0
    
    // AudioSession
    private var audioSession: AVAudioSession
    
    // 音声データに関わるパラメータ
    private let audioResorceNames = "sound"
    private var audioURL: URL?
    private var audioResorceID: CHHapticAudioResourceID?
    
    // HapticEngine
    private var engine: CHHapticEngine!
    
    // 端末がCore Hapticsに対応しているか
    private var supportsHaptics: Bool = false
    
    // HapticPatternPlayer
    private var player: CHHapticAdvancedPatternPlayer?
    
    // HapticEventのパラメーター
    private let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.4)
    private let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0)
    private var hapticDuration: TimeInterval = TimeInterval(0.08)
    
    // AudioEventのパラメーター
    private let audioVolume = CHHapticEventParameter(parameterID: .audioVolume, value: 1.0)
    private var audioDuration: TimeInterval {
        TimeInterval(60.0 / bpm)
    }
    
    init(){
        // AudioSessionの設定
        audioSession = AVAudioSession.sharedInstance()
        do {
            try audioSession.setCategory(.playback)
            try audioSession.setActive(true)
        } catch {
            print("Failed to set and activate audio session category.")
        }
        
        //　端末がCore Hapticsに対応しているかを調べる
        let hapticCapability = CHHapticEngine.capabilitiesForHardware()
        supportsHaptics = hapticCapability.supportsHaptics
        
        // 外部音源の取り込み
        if let path = Bundle.main.path(forResource: audioResorceNames, ofType: "mp3") {
            audioURL = URL(fileURLWithPath: path)
        } else {
            print("Error: Failed to find audioURL")
        }
        
        createAndStartHapticEngine()    //この関数は下で定義
    }
    
    // Engineの作成と開始
    private func createAndStartHapticEngine() {
        // 端末の対応を確認
        guard supportsHaptics else {
            print("This device does not support CoreHaptics")
            return
        }
        
        // AudioSessionを渡してEngineを作成
        do {
            engine = try CHHapticEngine(audioSession: audioSession)
        } catch let error {
            fatalError("Engine Creation Error: \(error)")
        }
        
        // Engineをスタート
        do {
            try engine.start()
        } catch let error {
            print("Engin Start Error: \(error)")
        }
    }
    
    // メトロノームを再生
    func play() {
        // 端末の対応を確認
        guard supportsHaptics else { return }
        
        do {
            // Engineをスタート
            try engine.start()
            
            // HapticPatternを作成
            let pattern = try createPattern()   //この関数は下で定義
            
            // Playerを作成(Advacedの方を利用していることに注意)
            player = try engine.makeAdvancedPlayer(with: pattern)
            player!.loopEnabled = true
            
            // 再生
            try player!.start(atTime: CHHapticTimeImmediate)
            
        } catch let error {
            print("Haptic Playback Error: \(error)")
        }
    }
    
    // メトロノームを停止
    func stop(){
        // 端末の対応を確認
        guard supportsHaptics else { return }
        
        // 停止
        engine.stop()
    }
    
    // HapticPatternの作成
    private func createPattern() throws -> CHHapticPattern {
        do {
            var eventList: [CHHapticEvent] = []
            
            // AudioResorceIDを取得
            audioResorceID = try self.engine.registerAudioResource(audioURL!)
            
            // eventListにHapticEventを加えていく
            eventList.append(CHHapticEvent(audioResourceID: audioResorceID!, parameters: [audioVolume], relativeTime: 0, duration: self.audioDuration))
            eventList.append(CHHapticEvent(eventType: .hapticTransient, parameters: [sharpness, intensity], relativeTime: 0))
            eventList.append(CHHapticEvent(eventType: .hapticContinuous, parameters: [sharpness, intensity], relativeTime: 0, duration: self.hapticDuration))
            
            // HapticPatternを生成し返す
            let pattern = try CHHapticPattern(events: eventList, parameters: [])
            return pattern
            
        } catch let error {
            throw error
        }
    }
}
```

いくつかのポイントを詳しく説明します。

-  外部音源を利用するにはEngineにAudioSessionを渡す

Core Hapticsで外部音源を利用するには、

1. AudioSessionのインスタンスを取得し、アクティブにする
1. `CHHapticEngine(audioSession: audioSession)`のように、Engineのインスタンスを取得する際にオーディオセッションを渡す
1. 外部音源のパスを取得し、AudioResorceIDを取得する
1. AudioResorceIDを渡したHapticEventを生成し、それをHapticPatternに組み込む

といった手順が必要になります。EngineはAudioSessionを渡さなくてもインスタンスが取得できるのですが、その場合は外部音源が再生できなかったり、音量が端末本体の音量と連動しなかったりします。

-  メトロノームアプリにTimerは使えない

一定の時間感覚で繰り返し処理を行う場合、一般的にはTimerが使用されます。しかしながら、Timerの時間間隔は厳密でなく、メトロノームアプリに採用してしまうとテンポの揺らぎを感じてしまいます。（少なくとも筆者は違和感を感じました）

そこで、Core Hapticsに内蔵されたループ機能を用います。ループの長さはHapticPatternやHapticEventの長さに準拠するので、それらを調節することによってループの長さを調節できます。

-  ループ機能を使うにはmakeAdvancedPlayerメソッドでPlayer作成する

HapticPatternPlayerを作るためのメソッドは`makePlayer(with:)`と`makeAdvancedPlayer(with:)`の2種類が用意されていますが、ループ機能を用いる時はAdvancedの方が必須となります。Advancedは、再生速度を変えたいときなどにも用いられます。


#### 2.4 Viewの作成
ContentView.swiftファイル内のContentView構造体を以下のように書き直します。

```swift:ContentView.swift
import SwiftUI

struct ContentView: View {
    // UI表示に必要なパラメーター
    @State private var bpm: Int = 60
    @State private var isPlaying: Bool = false
    
    // コントローラーのインスタンス
    private var hapticController = HapticController()
    
    var body: some View {
        VStack {
            Text("BPM")
                .font(.system(size: 20))
            
            // BPMを表示
            Text(String(bpm))
                .font(.system(size: 68))
            
            // "+"ボタン: タップするとBPMを1増やす
            Button(action: {
                    self.bpm += 1
            }, label: {
                Image(systemName: "plus.circle")
                    .resizable()
                    .scaledToFit()
                    .frame(width: 60.0, height: 60.0)
            })
            .disabled(isPlaying)
            
            // 再生・停止ボタン
            if isPlaying == false {
                Button(action: {
                    hapticController.bpm = Double(self.bpm)
                    hapticController.play()
                    isPlaying = true
                }) {
                    Text("Start")
                        .font(.system(size: 60))
                }
            } else {
                Button(action: {
                    hapticController.stop()
                    isPlaying = false
                }) {
                    Text("Stop")
                        .font(.system(size: 60))
                }
            }
        }
    }
}
```

プレビューでUIの見た目を確認すると**1.1 本記事で作成するメトロノーム**で紹介したスクリーンショットのような見た目になっていると思います。


#### 2.5 実機で確認
Core HapticsをSimulatorでテストすることはできません。よって、想像通りのものができているか確認するためには、実機テストが必要不可欠になります。

お手元にiPhone 8以降の端末があれば、ぜひBuildして実機テストを行ってみてください。指定のBPMで音が鳴ると同時に端末が振動していれば完成です。


## 3. より深く知りたい方へ
筆者が作成したメトロノームアプリがApp Storeに公開されています。ソースコードも[筆者のGitHub](https://github.com/Ossamoon/HapticMetronome-iOS)にて公開しているので、まずはApp Storeでダウンロードして使い心地を試した後、ソースコードをじっくり読んでみることをお勧めします。

<a href="https://apps.apple.com/jp/app/haptic-metronome/id1565909516"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/961672/1d889a77-fe53-901d-c5a7-517dd6e9f6f2.png" width="150px title="アプリのスクリーンショット"></a>
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/961672/98fddc75-2077-3121-29c4-a346a8bda57a.png" width="500px title="アプリのスクリーンショット">