ARKitとVUIの組み合わせを実装

スマートフォンARのUI/UXで、Voice User Interface (VUI) に言及される文脈が増えています。

参考:
-  [スマホARでユーザはどう情報を操作すべきか？｜タッチ操作からの脱却とVUIの可能性] (https://note.mu/ar_ojisan/n/n0322d9bbf8ef)
- [なぜ、声が拡張現実インターフェイスへと進化を遂げるのか？ あるいは、音声UIがなぜ主流になるのか？](https://medium.com/@iguchijp/%E3%81%AA%E3%81%9C-%E5%A3%B0%E3%81%8C%E6%8B%A1%E5%BC%B5%E7%8F%BE%E5%AE%9F%E3%82%A4%E3%83%B3%E3%82%BF%E3%83%BC%E3%83%95%E3%82%A7%E3%82%A4%E3%82%B9%E3%81%B8%E3%81%A8%E9%80%B2%E5%8C%96%E3%82%92%E9%81%82%E3%81%92%E3%82%8B%E3%81%AE%E3%81%8B-c2d191421a07)
- [音声インターフェイスについて思う7つのこと](https://jp.techcrunch.com/2018/12/09/2018-12-06-7-things-to-think-about-voice/)

スマートフォンの**「小さい面に表示された遅延した映像+AR表示」**の世界観から脱却した、より自然な操作系が出てくるのは当然の流れのように思えます。

今回、2018年においてiOSアプリで ARKit + VUI の実装を説明していきます。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">先日のYahoo! HACK DAY で同僚とこんな物を作りました。喋った単語が3Dオブジェクトに。SwiftでのiOSネイティブアプリ。ARKit、SceneKit、Google Speech API、Natural Language API, Poly API <a href="https://t.co/8VPrG3BGRi">pic.twitter.com/8VPrG3BGRi</a></p>&mdash; satoshi0212 (@shmdevelop) <a href="https://twitter.com/shmdevelop/status/940278040642195457?ref_src=twsrc%5Etfw">2017年12月11日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

参考: Google Speech APIとARKitとPolyと組み合わせた例(2017年)

## VUIについて

VUIの概要についてはこちらの資料が詳しいです。 [Voice UI Designer Meetup Tokyo 「 VUIデザインの勘所」](https://www.slideshare.net/yukio.andoh/voice-ui-designer-meetup-tokyo-vui)

Leszek Zawadzki 氏の提唱する会話の6分類は要チェック。

<img width="630" alt="スクリーンショット 2018-12-24 18.55.50.png" src="https://qiita-image-store.s3.amazonaws.com/0/63205/506b85c6-b389-76c4-ab3f-b256b9614aee.png">
(引用: Voice UI Designer Meetup Tokyo 「 VUIデザインの勘所」)

資料内で言及のある Leszek Zawadzki 氏のエントリ。
[Conversational UI Principles — Complete Process of Designing a Website Chatbot](https://medium.com/swlh/conversational-ui-principles-complete-process-of-designing-a-website-chatbot-d0c2a5fee376)

今回はこの概念にすべて対応した実装まではせず `Core` が理想の状態で渡される想定です。

追記
[Super Developers Reveal Tips to Create Great Clova Skills and Three Things to Know About VUI Future](https://www.slideshare.net/linecorp/super-developers-reveal-tips-to-create-great-clova-skills-and-three-things-to-know-about-vui-future-124220523)

## 音声認識について

iOS10からApple公式の`SFSpeechRecognizer`が使用可能ですが、制限もあります。
(要最新情報確認)

[[iOS 10] SFSpeechRecognizerで音声認識を試してみた](https://dev.classmethod.jp/smartphone/iphone/try-ios10-speech-recognizer/)

> - 端末ごとの音声認識回数には制限がある
- アプリごとにも認識回数に制限がある
- バッテリーとネットワークに関してハイコスト
- 一回のディクテーション時間のMaxは1分

2016年のエントリと少し古いですが、その他のiOSでの音声認識機能実装はこちらでまとめてあります。 参考までに。[iOSアプリでの音声認識機能実装方法まとめ](https://qiita.com/satoshi0212/items/af4928b808b4fbba8091)

今回は商用利用可能かつ精度が高い Googleの `Cloud Speech API` を利用してみます。
iOSアプリで使うならば `SFSpeechRecognizer` との2択だと思います。

### 登録

[Google Cloud プラットフォーム](https://console.cloud.google.com/apis/dashboard?hl=ja)

細かい登録手順はここでは省略します。
`Cloud Speech API` を有効化してください。
iOSアプリからの呼び出しに際し `API Key` が必要です。

### 実装

音声ファイルを生成し送信する方法とストリームで送受信する方法があります。今回は処理がシンプルな前者の方法のみ説明します。ストリーム送受信を含んだサンプルを置いてあるので参考にしてみてください。

[GoogleSpeechRecognizerSample](https://github.com/satoshi0212/GoogleSpeechRecognizerSample)
(注意: info.plistにマイク使用許可を足さないと起動時に落ちるかも！)

#### 音声ファイルを保存

主な部分を抜粋します。

```swift
    private var audioRecorder: AVAudioRecorder?

    private var soundFilePath: String {
        let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask , true)
        guard let path = paths.first else { return "" }
        return path.appending("/sound.caf")
    }

    func record() {
        audioRecorder?.record(forDuration: 15)
    }
```

#### 音声ファイルを送信し結果取得

```swift

    func soundFileToText() {
        let service = "https://speech.googleapis.com/v1/speech:recognize?key=\(API_KEY)"
        let data = try! Data(contentsOf: URL(fileURLWithPath: soundFilePath))
        let config: [String: Any] = [
            "encoding": "LINEAR16",
            "sampleRateHertz": "16000",
            "languageCode": "ja-JP",
            "maxAlternatives": 1]
        
        let audioRequest = ["content": data.base64EncodedString()]
        let requestDictionary = ["config": config, "audio": audioRequest]
        let requestData = try! JSONSerialization.data(withJSONObject: requestDictionary, options: [])
        let request = NSMutableURLRequest(url: URL(string: service)!)
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = requestData
        request.httpMethod = "POST"
        let task = URLSession.shared.dataTask(with: request as URLRequest, completionHandler: { (data, resp, err) in
            if let data = data, let json = try! JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
                let results = (json["results"] as? [[String: Any]])
                if let first = results?.first, let alternatives = first["alternatives"] as? [[String: Any]] {
                    if let alternativesFirst = alternatives.first, let str = alternativesFirst["transcript"] as? String {
                        DispatchQueue.main.async {
                            AudioRESTController.sharedInstance.delegate.doneAnalyze([str])
                        }
                    }
                }
            }
        })
        task.resume()
    }
```

APIのレスポンスから認識された文字列を取得することができました。


## ARKitと音声認識を組み合わせる

では `ARKit` と音声認識を合体させてみましょう。
今回は、色の異なる3つの球体を音声で操作してみます。

### オブジェクト操作コマンド

処理対象指定、命令、パラメータを含む発言をひとかたまりとして処理します。

`「青いターゲットを回転」`
`「緑のターゲットを拡大」`
`「赤いターゲットを下に移動」`

今回は3つの発言をそれぞれ別個に行いました。

音声認識結果から要素を取得します。
このあたりは非常に簡易的な実装をしています。

```swift
enum VUICommand {
    case noMatch
    case objectRotate
    case objectMove
    case objectScaleUp
    case objectScaleDown
    case sendToSlack
}

struct MoveParameter {
    var isUp: Bool

    var moveAmount: CGFloat {
        return isUp ? 0.08 : -0.08
    }
}
```

```swift
    private func findCommand(str: String) -> VUICommand {
        if str.contains("回転") {
            return .objectRotate
        } else if str.contains("移動") {
            return .objectMove
        } else if str.contains("拡大") {
            return .objectScaleUp
        } else if str.contains("縮小") {
            return .objectScaleDown
        } else if str.lowercased().contains("slack") {
            return .sendToSlack
        }
        return .noMatch
    }

    private func findTarget(str: String) -> SCNNode? {
        if str.contains("赤") {
            return sphereNodes[0]
        } else if str.contains("緑") {
            return sphereNodes[1]
        } else if str.contains("青") {
            return sphereNodes[2]
        }
        return nil
    }

    private func findMoveParameter(str: String) -> MoveParameter {
        if str.contains("下") {
            return MoveParameter(isUp: false)
        }
        return MoveParameter(isUp: true)
    }
```

取得した要素をもとに表示処理を実行します。

```swift

    private func processCommand(str: String) {
        let command = findCommand(str: str)
        let target = findTarget(str: str)

        switch command {
        case .objectRotate:
            target?.runAction(SCNAction.rotateBy(x: 0, y: .pi * 2, z: 0, duration: 3))
        case .objectMove:
            let parameter = findMoveParameter(str: str)
            target?.runAction(SCNAction.moveBy(x: 0, y: parameter.moveAmount, z: 0, duration: 0.3))
        case .objectScaleUp:
            target?.runAction(SCNAction.scale(by: 2.0, duration: 0.3))
        case .objectScaleDown:
            target?.runAction(SCNAction.scale(by: 0.5, duration: 0.3))
        case .sendToSlack:
            sendToSlack(str)
        case .noMatch:
            break
        }
    }
```

### Slack送信コマンド

Slackに送信してみます。

`「Slackにテスト送信」`

音声操作により、オブジェクト操作とは全く別の処理を続けて実行できることがわかると思います。

```swift
    private func sendToSlack(_ sourceStr: String) {
        let service = "https://hooks.slack.com/services/[web hook 対象 URL]"
        let requestDictionary: [String: Any] = [
            "channel": "#bots",
            "username": "webhookbot",
            "text": "ARKit VUI Demo: \(sourceStr)"]
        let requestData = try! JSONSerialization.data(withJSONObject: requestDictionary, options: [])
        let request = NSMutableURLRequest(url: URL(string: service)!)
        request.addValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = requestData
        request.httpMethod = "POST"
        let task = URLSession.shared.dataTask(with: request as URLRequest, completionHandler: { (data, resp, err) in
            if let data = data {
                print(data)
            }
        })
        task.resume()
    }
```

### 実行

![demo003s.gif](https://qiita-image-store.s3.amazonaws.com/0/63205/4c290455-9411-7705-3847-9cc9ece84b8f.gif)

無事実行されました！

今回は任意に配置したオブジェクトを音声コマンドで操作しました。
画面にタップして対象を選択するのではなく、音声でターゲットと処理内容を指定し実行。

この時点で「画面の奥の方にある小さいオブジェクトを狙ってタップできない」ことや「処理を一覧から選んで行わなければならない」などいくつかの課題を解決している状態です。
現実からオブジェクト抽出し実行するようにすればより本質的な価値が出るでしょう。

今回のサンプルはこちらにアップしています。良かったら参考にしてみてください。
[ARKitVUISample](https://github.com/satoshi0212/ARKitVUISample)