Amazon Transcribe の「音声の文字起こし」を Livebook から実行する

## はじめに

Amazon Transcribe は AWS 上で使える AI による音声認識 = 音声の文字起こしサービスです

ブラウザからお手軽に試す場合、以下の記事を参考にしてください

https://qiita.com/RyoWakabayashi/items/396e2047d377e258950f

本記事では Elixir の Livebook から Transcribe を使い、音声を録音して認識するまでの一連処理を実装します

本記事での音声認識の流れ

- Livebook で自分の声を録音する
- 録音した音声ファイルを AWS 上の S3 バケットにアップロードする
- S3 上のファイルを対象として音声認識を実行する
- 音声認識結果をダウンロードする

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/aws/transcribe.livemd

## Livebook とは

Livebook は Elixir　のコードをブラウザから実行し、結果を表示してくれるツールです

https://livebook.dev/

Python における Jupyter のようなもので、 Elixir 入門や Elixir を使ったデータ分析、データの視覚化などに適しています

はじめ方は以下の記事を参考にしてください

https://qiita.com/RyoWakabayashi/items/dc82ad3ed866d58a8acf

## セットアップ

Livebook で新しいノートブックを開いたら、先頭のセットアップセルに以下のコードを入力し、実行してください

```elixir
Mix.install([
  {:aws, "~> 0.13"},
  {:hackney, "~> 1.20"},
  {:req, "~> 0.4"},
  {:kino, "~> 0.11"}
])
```

AWS サービスの操作用に AWS Elixir というモジュールをインストールしています

https://github.com/aws-beam/aws-elixir/tree/master

また、音声認識結果をダウンロードするため Req を使います

https://github.com/wojtekmach/req

## クライアントの準備

AWS の認証情報を入力するためのテキストボックスを準備します

```elixir
access_key_id_input = Kino.Input.password("ACCESS_KEY_ID")
secret_access_key_input = Kino.Input.password("SECRET_ACCESS_KEY")
region_input = Kino.Input.text("REGION")

[
  access_key_id_input,
  secret_access_key_input,
  region_input
]
|> Kino.Layout.grid(columns: 3)
```

セルを実行して表示されたテキストボックスにそれぞれ入力してください

![スクリーンショット 2023-12-10 15.13.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/ab5ed517-8074-1189-048f-d47fa4f8e42f.png)

AWS の API と通信するためのクライアントを用意します
この際、先ほどの認証情報を受け渡します

```elixir
client =
  AWS.Client.create(
    Kino.Input.read(access_key_id_input),
    Kino.Input.read(secret_access_key_input),
    Kino.Input.read(region_input)
  )
```

## バケット選択

音声ファイルを置いておく場所として S3 バケットを選択します

自分の AWS アカウントにある S3 バケットの一覧を確認してみましょう

```elixir
buckets =
  client
  |> AWS.S3.list_buckets()
  |> elem(1)
  |> Map.get("ListAllMyBucketsResult")
  |> Map.get("Buckets")
  |> Map.get("Bucket")

Kino.DataTable.new(buckets)
```

実行すると、 S3 バケットの取得結果をテーブルとして表示します

もし S3 バケットがない場合は作成しておきましょう

テキストボックスを用意します

```elixir
s3_bucket_input = Kino.Input.text("S3_BUCKET")
```

![スクリーンショット 2023-12-09 12.54.24.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/45915675-e47b-751a-f32d-29275046d823.png)

使いたい S3 バケットの名前を入力しておきます

## 音声ファイルアップロード

`Kino.Input.audio` で音声入力を用意します

このとき、 `format: :wav` を指定することで、 `.wav` 形式の音声ファイルとして録音結果を保存できます

```elixir
audio_input = Kino.Input.audio("Audio", format: :wav)
```

![スクリーンショット 2023-12-09 13.48.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/31a85078-3274-d702-1eb2-bd81e389db4f.png)

「Record」ボタンをクリックすると、録音が開始されます

初回だけブラウザからマイクの使用許可を求められるため、「許可する」をクリックしてください

![スクリーンショット 2023-12-09 13.51.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/fe78ea63-5bc8-3145-62ba-163013855282.png)

録音中は音声入力が以下のような状態になります

![スクリーンショット 2023-12-09 13.49.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f082cb2c-c60a-63ff-32fb-3e5bb1e39acf.png)

喋る音声は何でもいいのですが、今回は日本国憲法第11条を読んでみました

https://www.shugiin.go.jp/internet/itdb_annai.nsf/html/statics/shiryo/dl-constitution.htm#3sho

> 国民は、すべての基本的人権の享有を妨げられない。この憲法が国民に保障する基本的人権は、侵すことのできない永久の権利として、現在及び将来の国民に与へられる。
>
> 〔自由及び権利の保持義務と公共福祉性〕

「Stop recording」をクリックすると、録音が終了します

Livebook で音声を録音した場合、その結果をファイルとして取得できます

一時ファイルとして保存したファイルのパスを取得します

```elixir
audio_file_path =
  audio_input
  |> Kino.Input.read()
  |> Map.get(:file_ref)
  |> Kino.Input.file_path()
```

実行結果
```elixir
"/tmp/livebook/sessions/4aggigzpnkov2utrwlskyao6ph5atqbowoy4njz7la3aw4mx/registered_files/v4qnt3zx2d6uobnzwlyp7wbwqpfi766n"
```

音声ファイルを S3 にアップロードします

```elixir
bucket_name = Kino.Input.read(s3_bucket_input)
s3_key = "audio_for_transcribe/input.wav"
body = File.read!(audio_file_path)
md5 = :crypto.hash(:md5, body) |> Base.encode64()

client
|> AWS.S3.put_object(
  bucket_name,
  s3_key,
  %{"Body" => body, "ContentMD5" => md5}
)
```

## 音声認識ジョブの実行

アップロード先の S3 バケット、キーを指定して音声認識を実行します

`start_transcription_job` で音声認識ジョブを開始しましょう

`"TranscriptionJobName"` は一意である必要があるため、もしもう一回実行する場合、名前を変えるか、先に実行したジョブを削除しておかなければいけません

`"LanguageCode" => "ja-JP"` により、日本語であることを指定しています

```elixir
transcription_job =
  client
  |> AWS.Transcribe.start_transcription_job(%{
    "TranscriptionJobName" => "sample-transcription-job",
    "Media" => %{
      "MediaFileUri" => "s3://#{bucket_name}/#{s3_key}"
    },
    "LanguageCode" => "ja-JP"
  })
  |> elem(1)
  |> Map.get("TranscriptionJob")
```

実行結果
```elixir
%{
  "CreationTime" => 1702090930.484,
  "LanguageCode" => "ja-JP",
  "Media" => %{"MediaFileUri" => "s3://<バケット名>/<S3キー>"},
  "StartTime" => 1702090930.51,
  "TranscriptionJobName" => "sample-transcription-job",
  "TranscriptionJobStatus" => "IN_PROGRESS"
}
```

この時点ではジョブを開始しただけで、まだ結果は出ていません

`"TranscriptionJobStatus" => "IN_PROGRESS"` となっている通り、実行中の状態です

`get_transcription_job` によって、指定した音声認識ジョブの状況を確認できます

```elixir
result =
  client
  |> AWS.Transcribe.get_transcription_job(%{
    "TranscriptionJobName" => transcription_job["TranscriptionJobName"]
  })
  |> elem(1)
  |> Map.get("TranscriptionJob")
```

何回か実行して以下のように `"TranscriptionJobStatus" => "COMPLETED"` となれば音声認識が完了しています

```elixir
%{
  "CompletionTime" => 1702090945.618,
  "CreationTime" => 1702090930.484,
  "LanguageCode" => "ja-JP",
  "Media" => %{"MediaFileUri" => "s3://<バケット名>/<S3キー>"},
  "MediaFormat" => "wav",
  "MediaSampleRateHertz" => 48000,
  "Settings" => %{"ChannelIdentification" => false, "ShowAlternatives" => false},
  "StartTime" => 1702090930.51,
  "Transcript" => %{
    "TranscriptFileUri" => "https://s3.ap-northeast-1.amazonaws.com/aws-transcribe-ap-northeast-1-prod/104378222407/sample-transcription-job/40028b98-1cba-4321-8d8c-5e8dc91fd0d8/asrOutput.json?X-Amz-Security-Token=..."
  },
  "TranscriptionJobName" => "sample-transcription-job",
  "TranscriptionJobStatus" => "COMPLETED"
}
```

## 音声認識結果の取得

音声認識結果をダウンロードしましょう

```elixir
transcript_file_uri =
  result
  |> Map.get("Transcript")
  |> Map.get("TranscriptFileUri")
```

実行結果
```elixir
"https://s3.ap-northeast-1.amazonaws.com/aws-transcribe-ap-northeast-1-prod/104378222407/sample-transcription-job/40028b98-1cba-4321-8d8c-5e8dc91fd0d8/asrOutput.json?X-Amz-Security-Token=..."
```

音声認識結果は JSON 形式で、 AWS 管理下の S3 上に保存され、セキュリティトークン付きでダウンロードできるようになっています

:::note info
音声認識ジョブの開始時、自分の AWS アカウント上の S3 バケットを出力先に指定することも可能です
:::

Req を使って JSON ファイルの内容を取得します

```elixir
transcript_json =
  transcript_file_uri
  |> Req.get!()
  |> Map.get(:body)
```

実行結果
```elixir
%{
  "accountId" => "104378222407",
  "jobName" => "sample-transcription-job",
  "results" => %{
    "items" => [
      %{
        "alternatives" => [%{"confidence" => "0.999", "content" => "国民"}],
        "end_time" => "1.909",
        "start_time" => "1.159",
        "type" => "pronunciation"
      },
      ...
      ],
    "transcripts" => [
      %{
        "transcript" => "国民は全ての基本的人権の共有を妨げられない。この憲法が国民に保障する基本的人権は侵すことのできない永久の権利として、現在および将来の国民に与えられる自由及び権利の保持義務と公共福祉性"
      }
    ]
  },
  "status" => "COMPLETED"
}
```

音声認識結果からテキスト全文を抜き出します

```elixir
transcript_json["results"]["transcripts"]
|> Enum.at(0)
|> Map.get("transcript")
```

実行結果
```elixir
"国民は全ての基本的人権の共有を妨げられない。この憲法が国民に保障する基本的人権は侵すことのできない永久の権利として、現在および将来の国民に与えられる自由及び権利の保持義務と公共福祉性"
```

単語単位の認識結果をテーブル表示してみましょう

```elixir
transcript_json["results"]["items"]
|> Enum.map(fn item ->
  top = Enum.at(item["alternatives"], 0)

  item
  |> Map.put("content", top["content"])
  |> Map.put("confidence", top["confidence"])
  |> Map.put("start_time", Map.get(item, "start_time", nil))
  |> Map.put("end_time", Map.get(item, "end_time", nil))
  |> Map.drop(["alternatives"])
end)
|> Kino.DataTable.new()
```

実行結果
![スクリーンショット 2023-12-09 13.03.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/74691095-ad3c-f8be-fb9e-0a3bc20ae6b6.png)

「享有」が「共有」になっているのはご愛嬌

## 音声認識ジョブの削除

最後に、不要になったものを削除しておきましょう

```elixir
client
|> AWS.Transcribe.delete_transcription_job(%{
  "TranscriptionJobName" => transcription_job["TranscriptionJobName"]
})
```

アップロードした音声ファイルも消しておきます

```elixir
client
|> AWS.S3.delete_object(bucket_name, s3_key, %{})
```

## まとめ

Livebook で録音した音声を Amazon Transcribe で文字起こしできました

Livebook で実装できたということは、同じように Phoenix LiveView で作った画面から音声を録音し、 Amazon Transcribe で文字起こしすることも可能です

https://www.phoenixframework.org/

もちろん、 Amazon Transcribe を使わずに Bumblebee 等で音声認識することも可能ですが、音声認識の頻度、掛けられるコスト、システム構成などに応じて使い分けましょう

https://github.com/elixir-nx/bumblebee

https://qiita.com/RyoWakabayashi/items/4db05611945f3bba6e30