【Speech Framework】音声認識してテキストを入力する

# 概要
AppleのSpeechフレークワークサンプルコードを再現します。
iOS10から追加された`Speech`フレームワークを使って音声認識。
「認識された音声をテキスト化して画面に表示する」という単純なアプリが完成します。
ほとんどApple提供のサンプルコードのままですが、日本語対応させたりコードの記述が違ったりする箇所があります。

> Swiftを基礎から学ぶには
自著、工学社より発売中の「[まるごと分かるSwiftプログラミング](https://amzn.asia/d/7YPWJwl)」をお勧めします。変数、関数、フロー制御構文、データ構造はもちろん、構造体からクロージャ、エクステンション、プロトコル、クロージャまでを基礎からわかりやすく解説しています。
> また、Swiftプログラミングを基礎から動画で学びたい方には、Udemyコース「[今日からはじめるプログラミング](https://www.google.com/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&cad=rja&uact=8&ved=2ahUKEwii1qTuj_2BAxV4hlYBHbj5BqEQFnoECBYQAQ&url=https%3A%2F%2Fwww.udemy.com%2Fcourse%2Fstart-programming-today%2F&usg=AOvVaw2vs4Sccq-bpfA4ZD2rCyNm&opi=89978449)」をお勧めします。

# 動作環境及び開発環境
Swift3
Xcode8
macOS10.12 Sierra
iOS10（iPad, iPhone）

# 手順

## プロジェクト作成
プロジェクト名は「SpeakWriter」とする。
テンプレートは「iOS > Single View Application」を選択。

## プロパティリスト
`Info.plist`に許諾説明を追加する。
Privacy - NSSpeechRecognitionUsageDescription: このアプリは音声認識機能を使用します。
Privacy - Microphone Usage Description: このアプリはマイクを使用します。

## Storyboard
まずは、ユーザインターフェースをつくる

### 認識テキストを表示するビュー
`UITextView`を配置（認識された音声をテキスト表示: textView）
背景・テキストカラー・フォントサイズを変更
インスペクタエリアから`isUserInteraction`プロパティを無効にする

### 録音開始ボタン
`UIButton`を`UITextView`の真下に配置
背景・テキストカラー・フォントサイズを変更

### Constraintsを付加する
この設定値および設定方法は適当にしてある。
UITextView（Top, Leading, Trailing Space = 0, Bottom to next view = 10）
UIButton（Width = 200, Height = 64, Bottom to = 10, Horizontal center）

### Interface Builderと接続
UITextView <=> @IBOutlet: textView
UIButton <=> @IBOutlet: recordButton, @IBAction: recordButtonTapped

## ViewController.swiftにコーディング

### 録音開始ボタンを無効にする
recordButtonはアプリ起動時は無効で、ユーザから録音許可を得た後に有効化する。

```swift:viewDidLoad()メソッド
recordButton.isEnabled = false
```
### Speechフレームワークを組み込む
音声認識するための機能を盛り込んだフレームワーク

```swift:viewControllerクラス
import Speech
```

### SFSpeechRecognizerインスタンスを生成
ローカルを指定しない場合は、端末の地域設定になる。
メンバプロパティで生成・初期化しておく。
明示的アンラップしておく。

```swift:viewControllerクラス
private let speechRecognizer = SFSpeechRecognizer(locale: Locale(localeIdentifier: "ja-JP"))!
```

### ユーザから使用許可を得る
コールバックをメインスレッドで実行している

```swift: viewWillAppear(_:)メソッド
SFSpeechRecognizer.requestAuthorization { (status) in
    OperationQueue.main().addOperation {
                switch status {
                case .authorized:   // 許可OK
                    self.recordButton.isEnabled = true
                    self.recordButton.backgroundColor = UIColor.blue
                case .denied:       // 拒否
                    self.recordButton.isEnabled = false
                    self.recordButton.setTitle("録音許可なし", for: .disabled)
                case .restricted:   // 限定
                    self.recordButton.isEnabled = false
                    self.recordButton.setTitle("このデバイスでは無効", for: .disabled)
                case .notDetermined:// 不明
                    self.recordButton.isEnabled = false
                    self.recordButton.setTitle("録音機能が無効", for: .disabled)
                }
    }
}
```

### 一旦、ビルド
アプリ起動直後、ユーザ許可を求められることを確認する

### 音声認識のタスクを宣言する
メンバプロパティでタスクのオブジェクトを宣言
初期値は設定しないからオプショナル

```swift: ViewControllerクラス
.
.
.
private var recognitionTask: SFSpeechRecognitionTask?
```


### 録音スタート処理を実装する
タスクにリクエストを登録すると、その結果に音声認識された文字列が返ってくる
startRecording()throwsメソッドを実装
エラーハンドリングするためthrowsキーワードをつける（実際はしない）

```swift:ViewControllerクラス
private func startRecording() throws {
//ここに録音する処理を記述
}
```


#### 既存のタスクが存在するなら、全てキャンセル

```swift: startRecording()メソッド
    if let recognitionTask = recognitionTask {
    // 既存タスクがあればキャンセルしてリセット
        recognitionTask.cancel()
        self.recognitionTask = nil
    }
```

#### セッションを準備する

```swift: startRecording()メソッド
    ...
    ...

    let audioSession = AVAudioSession.sharedInstance()
    try audioSession.setCategory(AVAudioSessionCategoryRecord)
    try audioSession.setMode(AVAudioSessionModeMeasurement)
    try audioSession.setActive(true, with: .notifyOthersOnDeactivation)
```

#### 認識リクエストのインスタンスを宣言
クラスのメンバプロパティで宣言する
初期値は設定しないからオプショナル

```swift: ViewControllerクラス
private var recognitionRequest: SFSpeechAudioBufferRecognitionRequest?
```

#### 認識開始の前に認識リクエストを初期化

```swift: startRecording()メソッド
    ...
    ...

    recognitionRequest = SFSpeechAudioBufferRecognitionRequest()
    guard let recognitionRequest = recognitionRequest else { fatalError("リクエスト生成エラー") }
````

#### shouldReportPartialResultsプロパティ
録音完了前に途中の結果を報告してくれる（デフォルトはfalse）
trueにすると、完了時に結果をさかのぼって修正してくれる

```swift: startRecording()メソッド
    ...
    ...

    recognitionRequest.shouldReportPartialResults = true
```

#### 端末のマイクを使う準備

`AVAudioEngine`クラス（iOS8~）を使う
メンバプロパティで生成・初期化する

```swift: ViewControllerクラス
...
...

private let audioEngine = AVAudioEngine()
```

#### inputNode
`audioEngine`インスタンスの`inputNode`プロパティを取得する

```swift: startRecording()メソッド
    ...
    ...

    guard let inputNode = audioEngine.inputNode else { fatalError("InputNodeエラー") }
```

#### リクエストを登録してタスクを実行

認識タスクのインスタンスの初期化メソッド内のハンドラで、結果を処理する
生成されるタスクのインスタンスは、`SFSpeechRecognitionTask`型オブジェクト
ハンドラ内のresultには`SFSpeechRecognitionResult`型オブジェクト
`resultのbestTranscription`プロパティには、最も精度が高かった認識結果のStringオブジェクトが入っている
`shouldReportPartialResults`プロパティがtrueなら、`bestTranscription`のオブジェクトが変化するのかもしれない

```swift: startRecording()メソッド
    ...
    ...

    recognitionTask = speechRecognizer.recognitionTask(with: recognitionRequest) { (result, error) in
    var isFinal = false

    if let result = result {
        self.textView.text = result.bestTranscription.formattedString
        isFinal = result.isFinal
    }

    if error != nil || isFinal {
        self.audioEngine.stop()
        inputNode.removeTap(onBus: 0)

        self.recognitionRequest = nil
        self.recognitionTask = nil

        self.recordButton.isEnabled = true
        self.recordButton.setTitle("Start Recording", for: [])
        self.recordButton.backgroundColor = UIColor.blue

    }
}
```

マイクからの録音フォーマット

```swift: startRecording()メソッド
    ...
    ...

    let recordingFormat = inputNode.outputFormat(forBus: 0)


inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { (buffer: AVAudioPCMBuffer, when: AVAudioTime) in
    self.recognitionRequest?.append(buffer)
}
```

オーディオエンジンで録音を開始して、テキスト表示を変更する

```swift: startRecording()メソッド
    ...
    ...    
    
    audioEngine.prepare()   // オーディオエンジン準備
    try audioEngine.start() // オーディオエンジン開始
        
    textView.text = “(認識中…そのまま話し続けてください)”
```


### デリゲート処理

ViewControllerクラスにデリゲートを採用する
speechRecognizerの状態変化を受け取れるようになる

```swift: viewControllerクラス
Class ViewController: UIViewController, SFSpeechRecognitionTaskDelegate {

   ...
   ...

```

自身のクラスをデリゲート先を設定する

```ruby:viewWillAppear(_;)メソッド
    override func viewWillAppear(_ animated: Bool) {
        speechRecognizer.delegate = self    // デリゲート先になる
        ...
        ...
```

#### デリゲートメソッド実装

音声認識機能の状態が変化するタイミングで呼ばれる
録音ボタンの有効と無効を切り替える

```swift: viewDidLoad()メソッド
    public func speechRecognizer(_ speechRecognizer: SFSpeechRecognizer, availabilityDidChange available: Bool) {
        if available {
        // 利用可能になったら、録音ボタンを有効にする
            recordButton.isEnabled = true
            recordButton.setTitle("Start Recording", for: [])
            recordButton.backgroundColor = UIColor.blue
        } else {
        // 利用できないなら、録音ボタンは無効にする
            recordButton.isEnabled = false
            recordButton.setTitle("現在、使用不可", for: .disabled)
        }
    }
```

#### IBActionメソッドを実装

recordButtonがタップされたときの処理
audioEngineが動作中なら、音声の認識中ということ
録音開始

```swift: ViewControllerクラス
    @IBAction func recordButtonTapped() {
        if audioEngine.isRunning {
        // 音声エンジン動作中なら停止
            audioEngine.stop()
            recognitionRequest?.endAudio()
            recordButton.isEnabled = false
            recordButton.setTitle("Stopping", for: .disabled)
            recordButton.backgroundColor = UIColor.lightGray
            return
        }
        // 録音を開始する
        try! startRecording()
        recordButton.setTitle("認識を完了する", for: [])
        recordButton.backgroundColor = UIColor.red
    }
```

# 考察

面白い。すごく面白い。
初心者にはいろいろ、ややこしいことは多かったが作業量の割に楽しめるものができた。
テキストビューのリセットボタンつけたり、を外部ライブラリの翻訳機能と連携させたりしたら、使えるアプリも作れるかもしれない。