AWS Transcribe x S3 with Golangでインタビュー内容を文字起こししてみた
皆さんこんにちは!i-Vinciの元自衛官エンジニアの藤田です。
最近モンハンにはまっています。
さて今回は、AWSのTranscribeを使用してインタビュー内容が記録された音声ファイルからテキストファイルを生成してみたいと思います。
会社サイトリニューアル
実は私、現在(2020年12月時点)i-Vinciのコーポレートサイトリニューアルのお手伝いをしております。
担当している作業は、i-Vinciメンバーにインタビューをして、その文字起こしをして採用ページ用のコンテンツを作成することなのですが、これがなかなか骨が折れそうだなと感じています。
現在3名ほどの方をそれぞれ30分ほどインタビューをしました。まだあと10名近くの方にインタビューをする予定なのですが、単純に計算すると30分 x 12名 = 6時間となります。
インタビューの様子を録音させていただいているので、インタビュー中は特に手を動かさずに楽しくおしゃべりをして「へぇ!!」とか「ほぉほぉ」などとリアクションを取って楽しまさせていただいているのですが、6時間分の音声を聞き直して文字に起こすのは考えただけでしんどいです。音声を聞きながらすべてを一発で文字に起こすタイピングもかなりの集中力を必要としそうです。聞き直しなども必要となるでしょう。
長男だったら我慢できるかもしれないけど、私は三男なので我慢とか無理です!!
しかも文字起こしをしただけでは成果物とはならず、文章を整えて内容をまとめて校正をする、などの作業が後に控えています。
出来ることならば、文字起こしだけでも作業を楽にしたいなぁ、自動化したいなぁと考えました。
今回私がやりたいことは、「音声データとしての言語」を「テキストデータとしての文字列」に処理することです。賢明なる読者諸氏はもうお気づきでしょう。そう、自然言語処理としての機械学習の出番です。
しかしながら自然言語処理を自前で実装することは、もはや現実的な選択肢ではありません。必要となる知識、実装する手間が到底個人で賄えるものではありません。出来合いのサービスを利用するべきでしょう。
今回私はAWS Transcribeを選択しました。
Amazon Transcribeとは
AWS Transcribeの概要はこちらに記載してあります。
音声データからテキストへの変換はもちろん、独自の言語モデル(方言とかもいけるということですね)の使用や特定の単語に対するフィルタリング、臨床医療で使用する言語などの専門用語にも対応しているようです。
ユースケースとして挙げられていますが、Amazon Comprehendを利用してテキスト内の言葉の関係性を抽出したり、Elasticsearchを利用してアーカイブ化するなど、ユーザーのニーズに応じて柔軟に使用できるようです。
実際にやってみます??
おあつらえ向きのチュートリアルが準備してありました。こちらのチュートリアル通りに行えば、恐らく10分と立たずに文字起こしは完了しそうです。
よかった、よかった。
...
...
...
いや、本当にそうだろうか?(疑問)
AWSのマネージドサービスの本当の魅力は、充実したそのAPIにプログラムで触れてこそ分かるのではないだろうか?いや、そうに違いない。(確信)
というわけで、いい感じにTranscribeを利用するプログラムを作ってみましょう。
実際にやってみましょう!!
作成したコードがこちら↓
main.go
package main
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/aws/aws-sdk-go/service/s3/s3manager"
"github.com/aws/aws-sdk-go/service/transcribeservice"
"github.com/fujimaru-lab/transcribe_go/internal/constant"
)
func main() {
// sessionの作成
sess, err := session.NewSession(&aws.Config{
Region: aws.String(constant.Region),
})
if err != nil {
log.Fatal("failed to get session:", err)
}
// s3Svc := s3.New(sess)
// 未アップロードの音声ファイルをリストアップ
var inptFilePaths []string
err = filepath.Walk(filepath.Join(constant.RootDir, constant.InputDir),
func(path string, info os.FileInfo, err error) error {
if err != nil {
log.Println("Failed to access the filepath ->", path, ":", err)
return err
} else if info.IsDir() || info.Name() == ".gitkeep" {
return nil
} else {
inptFilePaths = append(inptFilePaths, path)
return nil
}
})
if err != nil {
log.Fatal("failed to list input files")
}
if len(inptFilePaths) == 0 {
log.Println("No input file exists")
return
}
// 音声ファイルアップロード
uploader := s3manager.NewUploader(sess)
downloader := s3manager.NewDownloader(sess)
trscrbSvc := transcribeservice.New(sess)
for _, path := range inptFilePaths {
filename := filepath.Base(path)
log.Printf("start to upload file: %s", filename)
f, _ := os.Open(path)
defer f.Close()
upldInput := &s3manager.UploadInput{
Bucket: aws.String(constant.BucketName),
Key: aws.String("input/" + filename),
Body: f,
}
upldOutput, nil := uploader.Upload(upldInput)
if err != nil {
log.Println("failed to upload file:", filename)
}
// Transcribe job実行
now := time.Now()
datetimeSuffix := fmt.Sprintf("%d%d_%d%d%d", now.Month(), now.Day(), now.Hour(), now.Minute(), now.Second())
strtTscrptJobInpt := transcribeservice.StartTranscriptionJobInput{
LanguageCode: aws.String(transcribeservice.LanguageCodeJaJp),
Media: &transcribeservice.Media{MediaFileUri: &upldOutput.Location},
OutputBucketName: aws.String(constant.BucketName),
OutputKey: aws.String("output/" + filename + ".json"),
TranscriptionJobName: aws.String(fmt.Sprintf("%s%s", constant.TrnscrptJobName, datetimeSuffix)),
}
log.Printf("start to transcribe. param: %v\n", strtTscrptJobInpt)
strtTscrptJobOtpt, err := trscrbSvc.StartTranscriptionJob(&strtTscrptJobInpt)
if err != nil {
log.Fatal("failed to start transcript job:", err)
}
getTscrptJobInpt := transcribeservice.GetTranscriptionJobInput{
TranscriptionJobName: aws.String(*strtTscrptJobOtpt.TranscriptionJob.TranscriptionJobName),
}
// job実行結果取得
isNotFin := true
for isNotFin {
job, _ := trscrbSvc.GetTranscriptionJob(&getTscrptJobInpt)
jobStatus := *job.TranscriptionJob.TranscriptionJobStatus
if transcribeservice.TranscriptionJobStatusCompleted == jobStatus {
log.Printf("job is %s", jobStatus)
// job実行結果書き出し
tmp := filepath.Join(constant.RootDir, constant.OutputDir, filename)
outputPath := tmp + ".json"
f, err := os.Create(outputPath)
if err != nil {
log.Printf("failed to create download file")
}
defer f.Close()
key := "output/" + filename + ".json"
_, err = downloader.Download(f, &s3.GetObjectInput{
Bucket: aws.String(constant.BucketName),
Key: aws.String(key),
})
if err != nil {
log.Printf("failed to download Key:%s, err:%+v\n", key, err)
log.Printf("CHECK OUT S3: %s\n", *job.TranscriptionJob.Transcript.TranscriptFileUri)
} else {
log.Printf("succeed at download file:%s\n", filepath.Base(outputPath))
}
isNotFin = false
}
if transcribeservice.TranscriptionJobStatusInProgress == jobStatus {
log.Printf("job is %s......", jobStatus)
isNotFin = true
}
if transcribeservice.TranscriptionJobStatusFailed == jobStatus {
log.Printf("job is %s. file:%s", jobStatus, filename)
isNotFin = false
}
// ちょっとインターバル
time.Sleep(5 * time.Second)
}
}
}
解説
今回のこのコードでは大きく分けて3つのことを行っています。
- S3のバケットへ音声ファイルのアップロード
- アップロードした音声ファイルに対してAWS Transcribeサービスによるテキストへの変換ジョブを実行
- ジョブの完了を一定間隔で監視。完了を確認出来たならば、S3のバケットから変換されたテキストファイルのダウンロード
AWS SDK for Go APIの特徴として、利用するために以下のようなお作法があります。
- APIを利用するためには必要なクレデンシャル情報をもとに「セッション」を作成する必要がある。
クレデンシャル情報の取り扱いについてはいくつかの方法が存在しますが、今回はホームディレクトリ配下の.awsディレクトリにcredentialsファイルを作成しました。
手動で作成してもいいのですが、AWS CLIを使用して、configureコマンドから設定したほうが誤りが少ないと思います。 - 一つのOperationに対してInputオブジェクトとOutputオブジェクトが対になって存在する
例えば「S3にバケットを作成する」や「TranscribeのJobを開始する」などの一つ一つのOperationに対してInとOutのインターフェイスが決まっています。
結果
実行時のログがこちら↓
実行時ログ
$ ./cmd/transcribe
2020/12/12 18:14:41 start to upload file: 001-sibutomo.mp3
2020/12/12 18:14:42 start to transcribe. param: {
LanguageCode: "ja-JP",
Media: {
MediaFileUri: "https://yoshi777-transcribe.s3.ap-northeast-1.amazonaws.com/input/001-sibutomo.mp3"
},
OutputBucketName: "yoshi777-transcribe",
OutputKey: "output/001-sibutomo.mp3.json",
TranscriptionJobName: "myTrnscrptJob1212_181442"
}
2020/12/12 18:14:42 job is IN_PROGRESS......
2020/12/12 18:14:47 job is IN_PROGRESS......
2020/12/12 18:14:52 job is IN_PROGRESS......
2020/12/12 18:14:58 job is IN_PROGRESS......
2020/12/12 18:15:03 job is COMPLETED
2020/12/12 18:15:03 succeed at download file:001-sibutomo.mp3.json
2020/12/12 18:15:08 start to upload file: g_03.mp3
2020/12/12 18:15:08 start to transcribe. param: {
LanguageCode: "ja-JP",
Media: {
MediaFileUri: "https://yoshi777-transcribe.s3.ap-northeast-1.amazonaws.com/input/g_03.mp3"
},
OutputBucketName: "yoshi777-transcribe",
OutputKey: "output/g_03.mp3.json",
TranscriptionJobName: "myTrnscrptJob1212_18158"
}
2020/12/12 18:15:08 job is IN_PROGRESS......
2020/12/12 18:15:13 job is IN_PROGRESS......
2020/12/12 18:15:18 job is IN_PROGRESS......
2020/12/12 18:15:24 job is IN_PROGRESS......
2020/12/12 18:15:29 job is COMPLETED
2020/12/12 18:15:29 succeed at download file:g_03.mp3.json
2020/12/12 18:15:34 start to upload file: g_05.mp3
2020/12/12 18:15:34 start to transcribe. param: {
LanguageCode: "ja-JP",
Media: {
MediaFileUri: "https://yoshi777-transcribe.s3.ap-northeast-1.amazonaws.com/input/g_05.mp3"
},
OutputBucketName: "yoshi777-transcribe",
OutputKey: "output/g_05.mp3.json",
TranscriptionJobName: "myTrnscrptJob1212_181534"
}
2020/12/12 18:15:34 job is IN_PROGRESS......
2020/12/12 18:15:39 job is IN_PROGRESS......
2020/12/12 18:15:45 job is IN_PROGRESS......
2020/12/12 18:15:50 job is IN_PROGRESS......
2020/12/12 18:15:55 job is COMPLETED
2020/12/12 18:15:55 succeed at download file:g_05.mp3.json
音声→テキスト変換結果の確認
変換の精度をざっくりと見てみましょう。
実行結果json1
{
"jobName": "myTrnscrptJob1212_181442",
"accountId": "157201564717",
"results": {
"transcripts": [
{
"transcript": "無 添加 の シャボン 玉 石けん なら もう 安心 天然 の 放出 成分 が 含ま れる ため 肌 に 潤い を 与え 健やか に 保ち ます お 肌 の こと で お 悩み の 方 は ぜひ 一 度 無 添加 シャボン 玉 石けん を お 試し ください お 求め は ゼロ 一 二 ゼロ ゼロ ゼロ 午後 急行 まで"
}
],
"items": [
{
"start_time": "1.04",
"end_time": "1.26",
"alternatives": [
{
"confidence": "1.0",
"content": "無"
}
],
"type": "pronunciation"
},
{
"start_time": "1.26",
"end_time": "1.58",
"alternatives": [
{
"confidence": "1.0",
"content": "添加"
}
],
"type": "pronunciation"
},
{
"start_time": "1.58",
"end_time": "1.73",
"alternatives": [
{
"confidence": "1.0",
"content": "の"
}
],
"type": "pronunciation"
},
{
"start_time": "1.73",
"end_time": "2.04",
"alternatives": [
{
"confidence": "0.9997",
"content": "シャボン"
}
],
"type": "pronunciation"
},
{
"start_time": "2.04",
"end_time": "2.24",
"alternatives": [
{
"confidence": "0.9996",
"content": "玉"
}
],
"type": "pronunciation"
},
{
"start_time": "2.24",
"end_time": "2.56",
"alternatives": [
{
"confidence": "0.9992",
"content": "石けん"
}
],
"type": "pronunciation"
},
{
"start_time": "2.56",
"end_time": "2.84",
"alternatives": [
{
"confidence": "0.9989",
"content": "なら"
}
],
"type": "pronunciation"
},
{
"start_time": "2.96",
"end_time": "3.38",
"alternatives": [
{
"confidence": "0.9997",
"content": "もう"
}
],
"type": "pronunciation"
},
{
"start_time": "3.38",
"end_time": "3.95",
"alternatives": [
{
"confidence": "0.9985",
"content": "安心"
}
],
"type": "pronunciation"
},
{
"start_time": "4.94",
"end_time": "5.41",
"alternatives": [
{
"confidence": "1.0",
"content": "天然"
}
],
"type": "pronunciation"
},
{
"start_time": "5.41",
"end_time": "5.52",
"alternatives": [
{
"confidence": "1.0",
"content": "の"
}
],
"type": "pronunciation"
},
{
"start_time": "5.52",
"end_time": "5.82",
"alternatives": [
{
"confidence": "0.8841",
"content": "放出"
}
],
"type": "pronunciation"
},
{
"start_time": "5.82",
"end_time": "6.17",
"alternatives": [
{
"confidence": "1.0",
"content": "成分"
}
],
"type": "pronunciation"
},
{
"start_time": "6.17",
"end_time": "6.27",
"alternatives": [
{
"confidence": "1.0",
"content": "が"
}
],
"type": "pronunciation"
},
{
"start_time": "6.27",
"end_time": "6.59",
"alternatives": [
{
"confidence": "0.9989",
"content": "含ま"
}
],
"type": "pronunciation"
},
{
"start_time": "6.59",
"end_time": "6.79",
"alternatives": [
{
"confidence": "1.0",
"content": "れる"
}
],
"type": "pronunciation"
},
{
"start_time": "6.79",
"end_time": "7.09",
"alternatives": [
{
"confidence": "1.0",
"content": "ため"
}
],
"type": "pronunciation"
},
{
"start_time": "7.3",
"end_time": "7.54",
"alternatives": [
{
"confidence": "1.0",
"content": "肌"
}
],
"type": "pronunciation"
},
{
"start_time": "7.54",
"end_time": "7.75",
"alternatives": [
{
"confidence": "1.0",
"content": "に"
}
],
"type": "pronunciation"
},
{
"start_time": "7.75",
"end_time": "8.22",
"alternatives": [
{
"confidence": "1.0",
"content": "潤い"
}
],
"type": "pronunciation"
},
{
"start_time": "8.22",
"end_time": "8.31",
"alternatives": [
{
"confidence": "1.0",
"content": "を"
}
],
"type": "pronunciation"
},
{
"start_time": "8.31",
"end_time": "8.7",
"alternatives": [
{
"confidence": "1.0",
"content": "与え"
}
],
"type": "pronunciation"
},
{
"start_time": "8.98",
"end_time": "9.5",
"alternatives": [
{
"confidence": "1.0",
"content": "健やか"
}
],
"type": "pronunciation"
},
{
"start_time": "9.5",
"end_time": "9.68",
"alternatives": [
{
"confidence": "1.0",
"content": "に"
}
],
"type": "pronunciation"
},
{
"start_time": "9.68",
"end_time": "10.02",
"alternatives": [
{
"confidence": "1.0",
"content": "保ち"
}
],
"type": "pronunciation"
},
{
"start_time": "10.02",
"end_time": "10.35",
"alternatives": [
{
"confidence": "1.0",
"content": "ます"
}
],
"type": "pronunciation"
},
{
"start_time": "11.34",
"end_time": "11.49",
"alternatives": [
{
"confidence": "1.0",
"content": "お"
}
],
"type": "pronunciation"
},
{
"start_time": "11.49",
"end_time": "11.76",
"alternatives": [
{
"confidence": "0.9994",
"content": "肌"
}
],
"type": "pronunciation"
},
{
"start_time": "11.76",
"end_time": "11.83",
"alternatives": [
{
"confidence": "1.0",
"content": "の"
}
],
"type": "pronunciation"
},
{
"start_time": "11.83",
"end_time": "12.02",
"alternatives": [
{
"confidence": "0.8793",
"content": "こと"
}
],
"type": "pronunciation"
},
{
"start_time": "12.02",
"end_time": "12.13",
"alternatives": [
{
"confidence": "1.0",
"content": "で"
}
],
"type": "pronunciation"
},
{
"start_time": "12.13",
"end_time": "12.22",
"alternatives": [
{
"confidence": "1.0",
"content": "お"
}
],
"type": "pronunciation"
},
{
"start_time": "12.22",
"end_time": "12.54",
"alternatives": [
{
"confidence": "1.0",
"content": "悩み"
}
],
"type": "pronunciation"
},
{
"start_time": "12.54",
"end_time": "12.63",
"alternatives": [
{
"confidence": "1.0",
"content": "の"
}
],
"type": "pronunciation"
},
{
"start_time": "12.63",
"end_time": "12.86",
"alternatives": [
{
"confidence": "1.0",
"content": "方"
}
],
"type": "pronunciation"
},
{
"start_time": "12.86",
"end_time": "13.04",
"alternatives": [
{
"confidence": "1.0",
"content": "は"
}
],
"type": "pronunciation"
},
{
"start_time": "13.24",
"end_time": "13.67",
"alternatives": [
{
"confidence": "0.6134",
"content": "ぜひ"
}
],
"type": "pronunciation"
},
{
"start_time": "13.67",
"end_time": "13.82",
"alternatives": [
{
"confidence": "1.0",
"content": "一"
}
],
"type": "pronunciation"
},
{
"start_time": "13.82",
"end_time": "13.96",
"alternatives": [
{
"confidence": "1.0",
"content": "度"
}
],
"type": "pronunciation"
},
{
"start_time": "14.3",
"end_time": "14.46",
"alternatives": [
{
"confidence": "0.9995",
"content": "無"
}
],
"type": "pronunciation"
},
{
"start_time": "14.46",
"end_time": "14.81",
"alternatives": [
{
"confidence": "1.0",
"content": "添加"
}
],
"type": "pronunciation"
},
{
"start_time": "14.81",
"end_time": "15.14",
"alternatives": [
{
"confidence": "1.0",
"content": "シャボン"
}
],
"type": "pronunciation"
},
{
"start_time": "15.14",
"end_time": "15.34",
"alternatives": [
{
"confidence": "0.9972",
"content": "玉"
}
],
"type": "pronunciation"
},
{
"start_time": "15.34",
"end_time": "15.75",
"alternatives": [
{
"confidence": "0.9974",
"content": "石けん"
}
],
"type": "pronunciation"
},
{
"start_time": "15.75",
"end_time": "15.93",
"alternatives": [
{
"confidence": "1.0",
"content": "を"
}
],
"type": "pronunciation"
},
{
"start_time": "15.94",
"end_time": "16.06",
"alternatives": [
{
"confidence": "1.0",
"content": "お"
}
],
"type": "pronunciation"
},
{
"start_time": "16.06",
"end_time": "16.36",
"alternatives": [
{
"confidence": "1.0",
"content": "試し"
}
],
"type": "pronunciation"
},
{
"start_time": "16.36",
"end_time": "16.85",
"alternatives": [
{
"confidence": "0.9336",
"content": "ください"
}
],
"type": "pronunciation"
},
{
"start_time": "18.05",
"end_time": "18.15",
"alternatives": [
{
"confidence": "1.0",
"content": "お"
}
],
"type": "pronunciation"
},
{
"start_time": "18.15",
"end_time": "18.51",
"alternatives": [
{
"confidence": "1.0",
"content": "求め"
}
],
"type": "pronunciation"
},
{
"start_time": "18.51",
"end_time": "18.68",
"alternatives": [
{
"confidence": "1.0",
"content": "は"
}
],
"type": "pronunciation"
},
{
"start_time": "18.93",
"end_time": "19.31",
"alternatives": [
{
"confidence": "1.0",
"content": "ゼロ"
}
],
"type": "pronunciation"
},
{
"start_time": "19.31",
"end_time": "19.47",
"alternatives": [
{
"confidence": "0.9886",
"content": "一"
}
],
"type": "pronunciation"
},
{
"start_time": "19.47",
"end_time": "19.73",
"alternatives": [
{
"confidence": "0.8895",
"content": "二"
}
],
"type": "pronunciation"
},
{
"start_time": "19.73",
"end_time": "20.03",
"alternatives": [
{
"confidence": "1.0",
"content": "ゼロ"
}
],
"type": "pronunciation"
},
{
"start_time": "20.36",
"end_time": "20.64",
"alternatives": [
{
"confidence": "1.0",
"content": "ゼロ"
}
],
"type": "pronunciation"
},
{
"start_time": "20.64",
"end_time": "20.92",
"alternatives": [
{
"confidence": "1.0",
"content": "ゼロ"
}
],
"type": "pronunciation"
},
{
"start_time": "20.92",
"end_time": "21.46",
"alternatives": [
{
"confidence": "0.382",
"content": "午後"
}
],
"type": "pronunciation"
},
{
"start_time": "21.69",
"end_time": "22.21",
"alternatives": [
{
"confidence": "0.9982",
"content": "急行"
}
],
"type": "pronunciation"
},
{
"start_time": "22.21",
"end_time": "22.51",
"alternatives": [
{
"confidence": "1.0",
"content": "まで"
}
],
"type": "pronunciation"
}
]
},
"status": "COMPLETED"
}
その他の実行結果については、必要な結果であるtranscript要素のみ記載します。
実行結果json2
"イスタンブール は 世界 で 唯一 アジア 大陸 と ヨーロッパ 大陸 に 跨る 町 で この 二つ の 大陸 を 分け て、 いる の が ボスポラス 海峡 です アジア と ヨーロッパ の 間 を 進ん で いく 壮大 な 体験 が できる ボスポラス 海峡 クルーズ を 堪能 し て いただく 予定 です"
実行結果json3
"サージング カット 切削 伊波 気さく 性 の 悪い 視点 です し た コバル インコ ネル 道 と いっ た 探索 材 加工 湯 として ユーザー 様 に ご 支持 を 頂き 制度 を 重視 する 自動車 部品 医療 機器 部品 衛生 関連 部品 航空機 部品 若年 部品 など の 金属 加工 湯 として 実績 が ござい ます"
いい感じですね。実行結果1,2では非常に精度が高く変換されました。実行結果3ではいくつか誤変換がありますが、まぁまずまずではないでしょうか?
AWS Transcribeの特徴として、一つ一つの結果に対してタイムスタンプがついています。そのため、変換結果と音声の時系列での突合せが簡単に行えます。
また、結果の一つ一つに対して変換の確度(confidence)が0.0~1.0で判定されています。「精度」とは違うので、恐らくではありますが、「変換に使用した言語モデル上では......」という前提は存在すると思います。
まとめ
今回、AWS Transcribeを利用した自然言語処理(深層学習)により、身近な課題をちょっとコードを書くだけで解決することが出来ました。
自然言語処理や深層学習は非常に敷居が高いイメージがありますが、AWSが提供しているサービスを利用することで、こんなにも簡単に自分のアプリケーションに導入することが出来ます。
読者の皆様が持っているアイディアもAWSを利用することで実現できる可能性がぐっと上がるかもしれません。
さて、私はインタビューの文字起こしが予定より早く終わりそうなので、空いた時間でモンハンをしまっす!
今回作成したコードはこちらのリポジトリにまとめています。