Amazon TranscribeにSwiftアプリから連携してみた

# Amazon Transcribeとは
音声ファイルから自動的に文字を起こしてくれるAmazonのサービスです。
https://aws.amazon.com/jp/transcribe/
類似のものに、Google CloudのSpeech-to-Textがあります。
https://cloud.google.com/speech-to-text?hl=ja

### 今回説明しないもの
・AWSの設定の詳細
・S3へのアップロード、ダウンロードの詳細
・動画や音声の詳細

# 準備
### AWS
・[Cognito](https://aws.amazon.com/jp/cognito/)
iOSアプリからAWSにアクセスするためのやつ。
・[S3](https://aws.amazon.com/jp/s3/)
Transcribeに使う音声ファイルやjobファイルを置くストレージ。
・[Transcribe](https://aws.amazon.com/jp/transcribe/)
本丸。Create Jobから特に困ることなく設定可能です。
### iOS
・[AWS SDK for iOS](https://github.com/aws-amplify/aws-sdk-ios)
**AWSS3**と**AWSTranscribe**だけあれば大丈夫です。(AWSCoreもついてくるので。)
# おおまかな流れ
iOSから音声(動画)ファイルをS3にアップロード 
-> iOSからTranscribeのjobを実行命令 
-> AWSのTranscribeがjobを実行し、先ほどアップロードしたS3のファイルを文字化 
-> Transcribeの結果取得
-> 変換結果のjsonファイルをS3からダウンロード
# 実装
### 初期設定
```swift:SceneDelegate.swift
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    // AWS Cognito & S3 & Transcribe registration
    let credentialProvider = AWSCognitoCredentialsProvider(regionType: .APNortheast1, identityPoolId: "ap-northeast-1:*******************")
    if let configuration = AWSServiceConfiguration(region:.APNortheast1, credentialsProvider:credentialProvider) {
        AWSS3TransferUtility.register(with:configuration!, forKey: "MY_S3")
        AWSTranscribe.register(with:configuration!, forKey: "MY_Transcribe")
    }
}

```
SceneDelegate(AppDelegate)のおなじみの起動ファンクションでAWS初期設定します。
Cognitoでregionと設定の際に発行されたIdentity Pool IDを設定します。
設定後、Transcribe、 AWSS3TransferUtilityへの登録も行っておきます。
### S3へ該当ファイルをアップロード
```swift
let bucketName = "mybucket"
let mp4URL = URL(fileURLWithPath: "your/video.mp4")
if let awss3 = AWSS3TransferUtility.s3TransferUtility(forKey: "MY_S3") {
    do {
        let videoData = try Data(contentsOf: mp4URL)
        let videoName = "s3video.mp4"
        awss3.uploadData(
            videoData,
            bucket: bucketName,
            key: videoName,
            contentType: "mp4",
            expression: nil, // 途中経過task nullable
            completionHandler: { task, error in
                if let error = error {
                    print("s3 upload error \(error)")
                } else {
                    print("success upload")
                }
    } catch let error {
        print("data convert error \(error)")
    }
}
```
今回はmp4ファイルで行います。Transcribeがフォローしているファイルタイプは、**flac, mp3, mp4, wav**の４タイプです。(2020/2/20現在)
録画なりして端末に入ってるmp4ファイルをData型で取得し、AWSS3TransferUtility.uploadDataでアップロードします。
ここでのbucketはCognitoで設定したものと同じでなくてはなりません。
expressionはアップロードの途中経過を取得できるtaskですが今回は省きます。
また、uploadData自体もTaskとして登録できるものもあるので使い分けてください。
### TranscribeのJobを実行
```swift
let awstrans = AWSTranscribe(forKey: "MY_Transcribe")
let jobName = "MY_JOB"
if let startRequest = AWSTranscribeStartTranscriptionJobRequest() {
    startRequest.languageCode = .jaJP // language code結構いっぱいある
    let media = AWSTranscribeMedia()
    media?.mediaFileUri = "https://s3-ap-northeast-1.amazonaws.com/\(bucketName)/\(videoName)"
    startRequest.media = media // 先ほどアップロードしたs3のファイルのurlを指定する
    startRequest.mediaFormat = .mp4 // flac, mp3, mp4, wav
    startRequest.mediaSampleRateHertz = 44100 // いらないかも
    startRequest.transcriptionJobName = jobName
    startRequest.outputBucketName = bucketName

    // Job実行
    awstrans.startTranscriptionJob(startRequest, completionHandler: {response, error in
        if let error = error {
            print("start job error \(error)")
        } else {
            print("success start job")
        }
    }
}
```
AWSTranscribeStartTranscriptionJobRequestでJobのステータスを設定します。
ここで勘違いしていたのが、startTranscriptionJobのcompletionがJob完了時に呼ばれるものだと思ってましたが、これはあくまでJobがスタートした時に呼ばれるものでした。
### TranscribeのJobが完了するまで待って取得
```swift
// timer使うので
DispatchQueue.main.async {
    self.timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true, block: { timer in
        if let getJobRequest = AWSTranscribeGetTranscriptionJobRequest() {
            getJobRequest.transcriptionJobName = projectId
            awstrans.getTranscriptionJob(getJobRequest, completionHandler: {response, error in
                if let error = error {
                    print("get job error \(error)")
                    self.timer.invalidate()
                }
                if let reason = response?.transcriptionJob?.failureReason {
                    print("job failed \(reason)")
                }
                if response?.transcriptionJob?.transcriptionJobStatus == .completed {
                    // 完了後、awsにアップロードされた、結果の記載されたjsonのuriが取得できる
                    print(response?.transcriptionJob?.transcript?.transcriptFileUri)
                    self.timer.invalidate()
                }
            })
        }
    })
}
```
探してみたところ、Jobの完了通知をしてくれるものは見当たらなかったので、取り急ぎTimerで完了するまでgetし続けるという原始的なことをしました。しかも、mp4ファイルだと3MBぐらいのサイズでも40秒とかかかったので、interval=10としました。
JobStatus=completeとなった段階で、transcriptionJobにいろいろな値がセットされて返却されるので、結果の記載されたjsonのURIを取得する。
余談ですが、ハンドラ内でTimerを実行する際は実行スレッドに注意。
### S3からTranscribeの結果jsonを取得
```swift
awss3.downloadData(
    fromBucket: bucketName,
    key: projectId + ".json",
    expression: nil,
    completionHandler:{task, location, data, error in
        if let error = error {
            print("s3 download error \(error)")
        } else {
            if let data = data {
                do {
                    let jsonDecoder = JSONDecoder()
                    let transcribeData = try jsonDecoder.decode(AmazonTranscribe.self, from: data)
                    print(transcribeData.results.transcripts)
                } catch let error {
                    print("decode error \(error)")
                }
            }
        }
    }
)
```
```swift:AmazonTranscribe.swift
struct AmazonTranscribe: Decodable {
    let jobName:String
    let accountId:String
    let results: AmazonTranscribeResults
    let status: String
}

struct AmazonTranscribeResults: Decodable {
    let transcripts: [AmazonTranscribeTranscripts]
    let items: [AmazonTranscribeItem]
}

struct AmazonTranscribeTranscripts: Decodable {
    let transcript: String // 全文
}

struct AmazonTranscribeItem: Decodable {
    let startTime: Double
    let endTime: Double
    let alternatives: [AmazonTranscribeAlternatives]
    let type: String
    
    private enum CodingKeys: String, CodingKey {
        case startTime = "start_time"
        case endTime = "end_time"
        case alternatives, type
    }
    
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        guard let startTimeDouble = Double(try values.decode(String.self, forKey: .startTime)) else {
            fatalError("The start time is not an Double")
        }
        guard let endTimeDouble = Double(try values.decode(String.self, forKey: .endTime)) else {
            fatalError("The end time is not an Double")
        }
        
        startTime = startTimeDouble
        endTime = endTimeDouble
        alternatives = try values.decode([AmazonTranscribeAlternatives].self, forKey: .alternatives)
        type = try values.decode(String.self, forKey: .type)
    }
}

struct AmazonTranscribeAlternatives: Decodable {
    let confidence: Double
    let content: String
    
    private enum CodingKeys: String, CodingKey {
        case confidence, content
    }
    
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        guard let confidenceDouble = Double(try values.decode(String.self, forKey: .confidence)) else {
            fatalError("The confidence is not an Double")
        }
        
        confidence = confidenceDouble
        content = try values.decode(String.self, forKey: .content)
    }
}
```
おまけでjsonをDecodeするのにつかったDecodableも載せておきます。返却値はこんな(AmazonTranscribe.swift)感じです。
各値の意味は深く調べてませんが、全文（なぜリスト？）と代替候補っぽいのはありました。
# 感想
正直なところ、日本語の文字起こし精度はさほど高くないように感じられました。
ファイルの形式等を調整したらもうちょい精度あがりそうですが、
試しにGoogleHomeとの、「ねぇGoogle、明日の天気は？」「明日の新宿は最高気温１５度、最低気温７度で晴れるでしょう」というやりとり動画を送信してみたら、
「めぐる 明日 の 天気 は 明日 の 新宿 は 最高 気温 十 五 度 再 激 音 など で 買える でしょ」
というアウトプットがきました。この場合の使える情報は、明日の新宿が最高気温１５度ってことだけでしょう。
英語はしゃべれないし、発音も悪いので試してません。