mp3を文字起こししてslack通知できる環境を3分で用意する

概要
---
お手元のパソコンにAWS CLIとslackのwebhookがあれば、
3分程度でmp3の文字起こし環境が用意できるのでご紹介。

作った物
---

好きなmp3を用意して、S3にupload、
するとAmazon Transcribeで文字に起こした物がslackに飛んでくる。というもの。

![2_zu.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2240041/be6fbc1d-e44d-dfb0-7321-f6cad86a9999.png)

背景
---

私は絶望的に滑舌が悪く、最近社長に「聞き取りずらい」とよく言われます。
SoundOnlyで会話する機会が増えた昨今、致命的です。

なのでここ一ヶ月間「あ・え・い・う・え・お・あ・お」と、
鏡の前で発声練習を朝晩2回、歯磨きしながらやってます。

いつナレーションのオファーが来てもおかしくない状態に鍛え上げられ、
鏡がベチャベチャになった私は、社長から先日驚きのコメントをもらいました。
「聞き取りずらい」
そんなはずない、と判定を機械に委ねる事となったのです。

ということで、かなり前に日本語に対応したAmazon Transcribeを触ってみたました。
「私の滑舌は悪くない」そう誰かに言ってもらいたかったのです。

調べる
---

知見がない為、まずはAIサービスに詳しそうなりんなちゃんにきいてみます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2240041/a244cf38-d9f5-b567-de01-b67c3fea974a.png" width=50%>
会話になりませんでした。

[チュートリアル](https://aws.amazon.com/jp/getting-started/hands-on/create-audio-transcript-transcribe/)を見ると実にシンプルで、S3バケットにファイルを配置し、Transcription jobs (文字起こしジョブ) を作成すればいいだけのようです。

1ヶ月あたり60分の利用は無料なので、これで収まる範囲で触ってみたいと思います。

S3のcreateObjectをトリガーにしたLambda経由でjobを生成、結果を取得・整形してslackに飛ばす事にします。
という事で利用するリソース、用意するものは以下です

- S3バケット（input用と結果配置用の2つ）
- Lambda（Transcription jobsの作成と結果を生成してslack通知する）
- slackのwebhook

準備
---

CLIで完結でき、かつ終わったら一括でリソースが消せるのでCloudFormationのテンプレートを用意します。
用意したのは以下。

```yaml:template.yaml 
AWSTemplateFormatVersion: 2010-09-09
Description: Transcribe Test

Parameters:
  SlackWebHookUrl:
    Type: String
  OutputBucketName:
    Type: String
  InputBucketName:
    Type: String

Resources:
  CreateTranscribeLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonS3FullAccess
        - arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
        - arn:aws:iam::aws:policy/AmazonTranscribeFullAccess
      RoleName: create-transcribe-job-role

  CreateTranscribeLambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt CreateTranscribeLambda.Arn
      Principal: s3.amazonaws.com
      SourceArn: !Sub "arn:aws:s3:::${InputBucketName}"

  CreateTranscribeLambda:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: create-transcribe-job
      Runtime: python3.7
      Handler: index.lambda_handler
      Role: !GetAtt CreateTranscribeLambdaRole.Arn
      MemorySize: 128
      Timeout: 600
      Environment:
        Variables:
          SLACK_WEB_HOOK_URL:
            Ref: SlackWebHookUrl
          OUT_PUT_BUCKET_NAME:
            Ref: OutputBucketName
      Code:
        ZipFile: |
          import os
          import json
          import boto3
          import datetime
          import json
          import urllib

          s3 = boto3.client('s3')
          transcribe = boto3.client('transcribe')
          output_bucket_name = os.environ['OUT_PUT_BUCKET_NAME']
          web_hook_url = os.environ['SLACK_WEB_HOOK_URL']

          def lambda_handler(event, context):
            bucket = event['Records'][0]['s3']['bucket']['name']
            key = urllib.parse.unquote_plus(event['Records'][0]['s3']['object']['key'], encoding='utf-8')
            try:
              print('=== FunctionStart ' + 'bucket: ' + bucket + ' key:' + key)

              jobName = datetime.datetime.now().strftime('%Y%m%d%H%M%S') + '_testJob'
              fileUrl = 'https://s3.ap-northeast-1.amazonaws.com/' + bucket + '/' + key
              transcribe.start_transcription_job(
                TranscriptionJobName = jobName,
                LanguageCode='ja-JP',
                Media={
                  'MediaFileUri': fileUrl
                },
                OutputBucketName=output_bucket_name
              )
              print('=== StartTranscriptionJob ===')

              while True:
                result = transcribe.get_transcription_job(TranscriptionJobName=jobName)
                status = result['TranscriptionJob']['TranscriptionJobStatus']
                if status == 'COMPLETED':
                  break
              print('=== TranscriptionJob COMPLETED ===')

              output_file_name = result['TranscriptionJob']['TranscriptionJobName'] + '.json'
              response = s3.get_object(Bucket=output_bucket_name, Key=output_file_name)
              body = response['Body'].read()

              transcripts = json.loads(body.decode('utf-8'))
              transtext = ''
              for transcript in transcripts['results']['transcripts']:
                transtext += transcript['transcript']

              post_data = {
                "text": "【文字起こし】" + "\n"+ transtext,
              }
              request = urllib.request.Request(
                web_hook_url,
                data=json.dumps(post_data).encode('utf-8'),
                method="POST"
              )
              with urllib.request.urlopen(request) as response:
                response_body = response.read().decode('utf-8')

              print('=== PostedToSlack ===')
              print(response_body)
            except Exception as e:
              print(e)
              raise e

  InputS3Bucket:
    Type: AWS::S3::Bucket
    DependsOn: CreateTranscribeLambdaPermission
    DeletionPolicy: Delete
    Properties:
      BucketName:
        Ref: InputBucketName
      NotificationConfiguration:
        LambdaConfigurations:
          - Event: s3:ObjectCreated:*
            Filter:
              S3Key:
                Rules:
                  - Name: suffix
                    Value: mp3
            Function: !GetAtt CreateTranscribeLambda.Arn

  OutputS3Bucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Delete
    Properties:
      BucketName:
        Ref: OutputBucketName
```

デザイナーでみるとこんな感じ
![3_de.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2240041/b469b5d8-4df6-c088-4b99-7f07df243932.png)

次に、テンプレートと同階層にパラメータファイルを用意します。

```json:parameter.json
[
  {
    "ParameterKey": "SlackWebHookUrl",
    "ParameterValue": "{$slack webhook url}"
  },
  {
    "ParameterKey": "OutputBucketName",
    "ParameterValue": "{$お好きなinput用バケット名}"
  },
  {
    "ParameterKey": "InputBucketName",
    "ParameterValue": "{$お好きなoutput用バケット名}"
  }
]
```

2つのファイルを作り終わったら、以下のような形になってると思います。

```
作業ディレクトリ
  ├─ template.yaml
  └─ parameter.json
```

リソース解説
--- 

- `AWS::IAM::Role`
    - Lambda用のロールです。S3とログ用のCloudwatch、TranscribeのFullAccessを付与してます
- `AWS::Lambda::Permission`
    - Lambda用のリソースベースポリシーです。画面から設計図を元にLambdaを作る時は意識しないのでテンプレートに起こす時忘れがちです。トリガーとなるinput用のS3バケットを指定します。ここ、GetAttでinput用バケットを指すと相互依存でエラーになるので、リソースネームはパラメータからバケット名をとってきて文字列生成
- `AWS::Lambda::Function`
    - Lambda本体です。Codeは直書きしたかったのでZipFileを指定してます。テンプレートごとgit管理できるのはいいですが見にくいですね。機密情報を外に出すのも少し手間です。今回はCFパラメータ→Lambda環境変数の経路で値を渡す事で、テンプレートの外に情報を出してます
    - やってる事は以下
        - eventからputしたobjectを取得
        - ↑を元にtranscription_job生成
        - jobが終わるのを待つ
        - 終わったら成果物を取得
        - 文字列生成
        - webhookへPOST
- `AWS::S3::Bucket`
    - inputとoutputの2つ。 input用バケットのObjectCreatedでLambdaが発火しますが、形式はmp3に限定してます（特に縛る必要はないのですが）
    - グローバルでユニークというバケット名争奪戦を勝ち抜く必要があるのでパラメータに出してます

いざ、スタック作成
---

テンプレートとパラメータを配置したディレクトリに移動し、createStackします。

```bash
aws cloudformation create-stack --stack-name {$お好きなスタック名} --template-body file://template.yaml --parameters file://parameter.json --capabilities CAPABILITY_NAMED_IAM
```

1,2分で出来上がると思います。

```bash
aws cloudformation describe-stacks --stack-name {$お好きなスタック名} | jq .Stacks[].StackStatus
> CREATE_COMPLETE
```

Transcribeの実力はいかに
---

さぁ準備は終わりました。
まずはTranscribeが信頼に足る人物であるかを評価する必要があります。
サンプル音声で動作を試してみましょう。

[こちら](http://pro-video.jp/voice/announce/)のサイトの`C-29`を採用します。
![4_sample_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2240041/145a5a4e-22f6-87ce-4313-6345681969be.png)
なんともクリーンな、お手本の用な「もう少し、頑張れ」です。
これを文字化できないようであればTranscribeに価値はないでしょう。

s3cpでinput用バケットに配置します。

```bash
aws s3 cp /path/to/もう少し頑張れ s3://{$input用バケット名}
```

たったこれだけ。簡単でしょ。
mp3は激軽ですが、30秒くらいかかるようです。

メッセージが届きました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2240041/7f81a552-de5e-ff41-c206-49180bb68a5e.png" width=50%>
怒られてる気になった所で、もう少し長いファイルにしてみます。

今度は[こちら](https://soundeffect-lab.info/sound/voice/info-lady1.html)のサイトの音声を投げてみます。
![6_sample_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2240041/fe4e98fe-2ddb-dd02-84e1-b81f15ef002e.png)
結果がコレ
![7_test_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2240041/9576f9d2-9f09-4c8a-e7fd-64940c4c69a4.png)
パーフェクトです。素晴らしい。

さぁ、すべての準備は整いました。

Let's 自分の声を文字起こし
---

こっそり隠し撮りしたオフィスでの雑談mp3を通してみます。
結果がこちら
![8_test_3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2240041/8956da52-00de-a220-4885-a36b0f6f8c88.png)
まともにしゃべれてるのか不安になりました。
「雑談してもらってもいいですか？」だけは聞きとってもらえたようです。
恥ずかしいので元データは載せません。

後片付け
---

気分が落ち込んだ所で後片付けします。
deleteStackすればいいだけですが、バケットはファイルが存在すると消えてくれないので中身を消します。
s3 rm にはご注意を。

```bash
aws s3 rm s3://{$input用バケット} --recursive
aws s3 rm s3://{$output用バケット} --recursive
aws cloudformation delete-stack --stack-name {$作ったスタック名}
```

要改善点
---

- 私の滑舌
- Lamdaのコード
    - `start_transcription_job`した後`get_transcription_job`で結果を取得し続けてます。大容量のファイルを配置するとjobが終わるまで結構かかる為、Lambdaのタイムアウトを大きくしなくてはなりません。ここは `start_transcription_job`する用のFunctionと、outputバケットをトリガーとした結果の取得用Functionに分けるべきだったなと思います。

最後に
---

自分の声の文字起こしに使った音声はmacのQuicktimePlayerでPCから割と離れた所から撮った音声で、入力として全然適切でないデータです。いくつか音声試しましたがちゃんとした物ならTranscribeはちゃんと返してくれますので、決して制度悪く有りません、ご承知おきください。
Webミーティングで音声取得する機会多いと思いますので、こんなのあったら割と便利ですよね。

終わりになりますが、弊社は今共に働くエンジニアを募集してます。
私の滑舌を聞いてみたい方は是非ご連絡ください。

参考文献
---

https://qiita.com/Gerbera/items/f4a8ee300d8cf54ce78e

https://qiita.com/AInosukey/items/01c2908cce9bd4eca4ac

https://aws.amazon.com/jp/premiumsupport/knowledge-center/cloudformation-s3-notification-lambda/

https://memo.morelents.com/kaisou-henkan/