[電話無人対応] Amazon Connectで通話中に発話した内容を、Amazon Transcribeで文字起こしし復唱してみた

2024.03.29

はじめに

Amazon Connectでの発話内容をAmazon Transcribeで文字起こしし、音声出力するフローを構築しましたので、手順をまとめました。コンタクトセンターの無人対応を想定しています。

文字起こし内容を音声出力するまでの流れは次の通りです。

  1. コンタクトフロー内で「メディアストリーミングの開始」ブロックを使って、Amazon Kinesis Video Streams(以降、KVS)への音声のストリーミングを開始します。
  2. 発話します。
  3. 発話後、「顧客の入力を保存する」ブロックで、顧客が特定の番号を押すと、ストリーミングが終了します。
  4. 「AWS Lambda関数を呼び出す」ブロックを使い、以下の処理を行います。
    1. LambdaでKVSからメディアデータを取得します。
    2. メディアデータから音声データを抽出し、WAV形式に変換し、S3バケットに音声ファイルを保存します。
    3. Amazon TranscribeでS3に保存した音声ファイルに対して、文字起こしジョブを実行します。
  5. 「AWS Lambda関数を呼び出す」ブロックを使い、以下の処理を行います。
    1. Transcribeの文字起こしジョブが完了しているか確認し、完了していれば、文字起こし内容をコンタクトフローに返します。
    2. Lambdaがタイムアウト(8秒)になるまでに、ジョブが完了しなければ、このLambdaをループさせます。
  6. 「プロンプトの再生」で文字起こし内容を音声出力します。

今回は住所を発話し、文字起こし内容を音声出力します。

コンタクトフロー

コンタクトフローは以下の通りです。

フローのコード (クリックすると展開します)
{
  "Version": "2019-10-30",
  "StartAction": "b1f1195a-a765-4c06-b7a7-42f22a982881",
  "Metadata": {
    "entryPointPosition": {
      "x": 244.8,
      "y": 724
    },
    "ActionMetadata": {
      "458ebe83-7b8f-4942-9d39-c257e5769afc": {
        "position": {
          "x": 553.6,
          "y": 734.4
        },
        "children": [
          "35a362b9-badc-4119-aafd-462212737706"
        ],
        "overrideConsoleVoice": true,
        "fragments": {
          "SetContactData": "35a362b9-badc-4119-aafd-462212737706"
        },
        "overrideLanguageAttribute": true
      },
      "35a362b9-badc-4119-aafd-462212737706": {
        "position": {
          "x": 553.6,
          "y": 734.4
        },
        "dynamicParams": []
      },
      "fc6d8a7b-df5b-4450-adae-3fbf4f145597": {
        "position": {
          "x": 450.4,
          "y": 1161.6
        }
      },
      "b5252f38-4f50-4347-b4d2-c8b91ba17c92": {
        "position": {
          "x": 660.8,
          "y": 1160
        },
        "parameters": {
          "LambdaFunctionARN": {
            "displayName": "transcribe-job-check"
          },
          "LambdaInvocationAttributes": {
            "job_id": {
              "useDynamic": true
            }
          }
        },
        "dynamicMetadata": {
          "job_id": true
        }
      },
      "130f08ed-fcf9-4ee2-9164-3703543b8ef9": {
        "position": {
          "x": 874.4,
          "y": 1158.4
        }
      },
      "d93b5784-b4ab-432e-b0b2-4647f14af4b5": {
        "position": {
          "x": 1090.4,
          "y": 1152
        },
        "parameters": {
          "PromptId": {
            "displayName": "Beep.wav"
          }
        },
        "promptName": "Beep.wav"
      },
      "45412eb5-2a87-4541-9752-5389d70e5da4": {
        "position": {
          "x": 1301.6,
          "y": 1154.4
        },
        "conditionMetadata": [],
        "countryCodePrefix": "+1"
      },
      "b1f1195a-a765-4c06-b7a7-42f22a982881": {
        "position": {
          "x": 340,
          "y": 730.4
        }
      },
      "f5294a69-3768-44c4-8e12-8c5aa7e44cd7": {
        "position": {
          "x": 1517.6,
          "y": 1156
        },
        "conditions": [],
        "conditionMetadata": [
          {
            "id": "2f026fcd-1b40-42d9-98ad-7eda427a32cb",
            "operator": {
              "name": "Equals",
              "value": "Equals",
              "shortDisplay": "="
            },
            "value": "1"
          }
        ]
      },
      "6cff7a1d-ecb3-4f12-ae04-364fb1660821": {
        "position": {
          "x": 772.8,
          "y": 712.8
        }
      },
      "2c7202e9-2972-4a06-987d-1ef9159c3782": {
        "position": {
          "x": 990.4,
          "y": 716
        },
        "parameters": {
          "PromptId": {
            "displayName": "Beep.wav"
          }
        },
        "promptName": "Beep.wav"
      },
      "0e04c014-854c-405c-ab7b-d362cdd099d5": {
        "position": {
          "x": 237.6,
          "y": 948
        },
        "toCustomer": false,
        "fromCustomer": true
      },
      "b071b271-f506-40ac-87a6-9a4439dd47fb": {
        "position": {
          "x": 236,
          "y": 1160.8
        },
        "parameters": {
          "LambdaFunctionARN": {
            "displayName": "transcribe-job-start"
          }
        },
        "dynamicMetadata": {}
      },
      "85ac5a2f-2147-405c-a7dc-36be1be0347d": {
        "position": {
          "x": 452,
          "y": 940.8
        },
        "conditionMetadata": [],
        "countryCodePrefix": "+1"
      },
      "bfe3ee13-0e92-418c-af65-4380393334b7": {
        "position": {
          "x": 668.8,
          "y": 932.8
        }
      },
      "4430e46f-e9f7-4a02-9a7b-9720bca4de60": {
        "position": {
          "x": 706.4,
          "y": 1382.4
        }
      },
      "f7256b8b-9650-4358-9213-a8ca4d990d4c": {
        "position": {
          "x": 1733.6,
          "y": 1159.2
        },
        "parameters": {
          "Attributes": {
            "address": {
              "useDynamic": true
            }
          }
        },
        "dynamicParams": [
          "address"
        ]
      },
      "dca43812-d2df-4e26-b7a1-8a2f596a2993": {
        "position": {
          "x": 1720.8,
          "y": 1402.4
        }
      },
      "6e456060-8469-49e4-a2f4-118386cd472c": {
        "position": {
          "x": 1965.6,
          "y": 1451.2
        }
      }
    },
    "Annotations": [],
    "name": "transcribe-kvs-test",
    "description": "",
    "type": "contactFlow",
    "status": "published",
    "hash": {}
  },
  "Actions": [
    {
      "Parameters": {
        "TextToSpeechEngine": "Neural",
        "TextToSpeechStyle": "None",
        "TextToSpeechVoice": "Kazuha"
      },
      "Identifier": "458ebe83-7b8f-4942-9d39-c257e5769afc",
      "Type": "UpdateContactTextToSpeechVoice",
      "Transitions": {
        "NextAction": "35a362b9-badc-4119-aafd-462212737706"
      }
    },
    {
      "Parameters": {
        "LanguageCode": "ja-JP"
      },
      "Identifier": "35a362b9-badc-4119-aafd-462212737706",
      "Type": "UpdateContactData",
      "Transitions": {
        "NextAction": "6cff7a1d-ecb3-4f12-ae04-364fb1660821",
        "Errors": [
          {
            "NextAction": "6cff7a1d-ecb3-4f12-ae04-364fb1660821",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": "少々お待ちください。"
      },
      "Identifier": "fc6d8a7b-df5b-4450-adae-3fbf4f145597",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "b5252f38-4f50-4347-b4d2-c8b91ba17c92",
        "Errors": [
          {
            "NextAction": "b5252f38-4f50-4347-b4d2-c8b91ba17c92",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "LambdaFunctionARN": "arn:aws:lambda:ap-northeast-1:111111111111:function:transcribe-job-check",
        "InvocationTimeLimitSeconds": "8",
        "LambdaInvocationAttributes": {
          "job_id": "$.External.job_id"
        },
        "ResponseValidation": {
          "ResponseType": "STRING_MAP"
        }
      },
      "Identifier": "b5252f38-4f50-4347-b4d2-c8b91ba17c92",
      "Type": "InvokeLambdaFunction",
      "Transitions": {
        "NextAction": "130f08ed-fcf9-4ee2-9164-3703543b8ef9",
        "Errors": [
          {
            "NextAction": "4430e46f-e9f7-4a02-9a7b-9720bca4de60",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": "ご住所は、$.External.transcription_text、ですね。\n正しい場合、「1」を、修正する場合は「2」を押してください。"
      },
      "Identifier": "130f08ed-fcf9-4ee2-9164-3703543b8ef9",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "d93b5784-b4ab-432e-b0b2-4647f14af4b5",
        "Errors": [
          {
            "NextAction": "d93b5784-b4ab-432e-b0b2-4647f14af4b5",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "PromptId": "arn:aws:connect:ap-northeast-1:111111111111:instance/3ff2093d-af96-43fd-b038-3c07cdd7609c/prompt/54bb3277-0484-45eb-bdc7-2e0b1af31b5c"
      },
      "Identifier": "d93b5784-b4ab-432e-b0b2-4647f14af4b5",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "45412eb5-2a87-4541-9752-5389d70e5da4",
        "Errors": [
          {
            "NextAction": "45412eb5-2a87-4541-9752-5389d70e5da4",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "StoreInput": "True",
        "InputTimeLimitSeconds": "20",
        "Text": ",",
        "DTMFConfiguration": {
          "DisableCancelKey": "False"
        },
        "InputValidation": {
          "CustomValidation": {
            "MaximumLength": "1"
          }
        }
      },
      "Identifier": "45412eb5-2a87-4541-9752-5389d70e5da4",
      "Type": "GetParticipantInput",
      "Transitions": {
        "NextAction": "f5294a69-3768-44c4-8e12-8c5aa7e44cd7",
        "Errors": [
          {
            "NextAction": "f5294a69-3768-44c4-8e12-8c5aa7e44cd7",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "FlowLoggingBehavior": "Enabled"
      },
      "Identifier": "b1f1195a-a765-4c06-b7a7-42f22a982881",
      "Type": "UpdateFlowLoggingBehavior",
      "Transitions": {
        "NextAction": "458ebe83-7b8f-4942-9d39-c257e5769afc"
      }
    },
    {
      "Parameters": {
        "ComparisonValue": "$.StoredCustomerInput"
      },
      "Identifier": "f5294a69-3768-44c4-8e12-8c5aa7e44cd7",
      "Type": "Compare",
      "Transitions": {
        "NextAction": "6cff7a1d-ecb3-4f12-ae04-364fb1660821",
        "Conditions": [
          {
            "NextAction": "f7256b8b-9650-4358-9213-a8ca4d990d4c",
            "Condition": {
              "Operator": "Equals",
              "Operands": [
                "1"
              ]
            }
          }
        ],
        "Errors": [
          {
            "NextAction": "6cff7a1d-ecb3-4f12-ae04-364fb1660821",
            "ErrorType": "NoMatchingCondition"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": "住所を音声でお伝えください。お伝え後、シャープ、を入力ください。"
      },
      "Identifier": "6cff7a1d-ecb3-4f12-ae04-364fb1660821",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "2c7202e9-2972-4a06-987d-1ef9159c3782",
        "Errors": [
          {
            "NextAction": "2c7202e9-2972-4a06-987d-1ef9159c3782",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "PromptId": "arn:aws:connect:ap-northeast-1:111111111111:instance/3ff2093d-af96-43fd-b038-3c07cdd7609c/prompt/54bb3277-0484-45eb-bdc7-2e0b1af31b5c"
      },
      "Identifier": "2c7202e9-2972-4a06-987d-1ef9159c3782",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "0e04c014-854c-405c-ab7b-d362cdd099d5",
        "Errors": [
          {
            "NextAction": "0e04c014-854c-405c-ab7b-d362cdd099d5",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "MediaStreamingState": "Enabled",
        "MediaStreamType": "Audio",
        "Participants": [
          {
            "ParticipantType": "Customer",
            "MediaDirections": [
              "From"
            ]
          }
        ]
      },
      "Identifier": "0e04c014-854c-405c-ab7b-d362cdd099d5",
      "Type": "UpdateContactMediaStreamingBehavior",
      "Transitions": {
        "NextAction": "85ac5a2f-2147-405c-a7dc-36be1be0347d",
        "Errors": [
          {
            "NextAction": "85ac5a2f-2147-405c-a7dc-36be1be0347d",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "LambdaFunctionARN": "arn:aws:lambda:ap-northeast-1:111111111111:function:transcribe-job-start",
        "InvocationTimeLimitSeconds": "8",
        "ResponseValidation": {
          "ResponseType": "STRING_MAP"
        }
      },
      "Identifier": "b071b271-f506-40ac-87a6-9a4439dd47fb",
      "Type": "InvokeLambdaFunction",
      "Transitions": {
        "NextAction": "fc6d8a7b-df5b-4450-adae-3fbf4f145597",
        "Errors": [
          {
            "NextAction": "fc6d8a7b-df5b-4450-adae-3fbf4f145597",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "StoreInput": "True",
        "InputTimeLimitSeconds": "300",
        "Text": "、",
        "DTMFConfiguration": {
          "DisableCancelKey": "False"
        },
        "InputValidation": {
          "CustomValidation": {
            "MaximumLength": "1"
          }
        }
      },
      "Identifier": "85ac5a2f-2147-405c-a7dc-36be1be0347d",
      "Type": "GetParticipantInput",
      "Transitions": {
        "NextAction": "bfe3ee13-0e92-418c-af65-4380393334b7",
        "Errors": [
          {
            "NextAction": "bfe3ee13-0e92-418c-af65-4380393334b7",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "MediaStreamingState": "Disabled",
        "Participants": [
          {
            "ParticipantType": "Customer",
            "MediaDirections": [
              "To",
              "From"
            ]
          }
        ],
        "MediaStreamType": "Audio"
      },
      "Identifier": "bfe3ee13-0e92-418c-af65-4380393334b7",
      "Type": "UpdateContactMediaStreamingBehavior",
      "Transitions": {
        "NextAction": "b071b271-f506-40ac-87a6-9a4439dd47fb",
        "Errors": [
          {
            "NextAction": "b071b271-f506-40ac-87a6-9a4439dd47fb",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "LoopCount": "2"
      },
      "Identifier": "4430e46f-e9f7-4a02-9a7b-9720bca4de60",
      "Type": "Loop",
      "Transitions": {
        "NextAction": "dca43812-d2df-4e26-b7a1-8a2f596a2993",
        "Conditions": [
          {
            "NextAction": "fc6d8a7b-df5b-4450-adae-3fbf4f145597",
            "Condition": {
              "Operator": "Equals",
              "Operands": [
                "ContinueLooping"
              ]
            }
          },
          {
            "NextAction": "dca43812-d2df-4e26-b7a1-8a2f596a2993",
            "Condition": {
              "Operator": "Equals",
              "Operands": [
                "DoneLooping"
              ]
            }
          }
        ]
      }
    },
    {
      "Parameters": {
        "Attributes": {
          "address": "$.External.transcription_text"
        },
        "TargetContact": "Current"
      },
      "Identifier": "f7256b8b-9650-4358-9213-a8ca4d990d4c",
      "Type": "UpdateContactAttributes",
      "Transitions": {
        "NextAction": "6e456060-8469-49e4-a2f4-118386cd472c",
        "Errors": [
          {
            "NextAction": "dca43812-d2df-4e26-b7a1-8a2f596a2993",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {
        "Text": "エラーとなりました。電話をきります。"
      },
      "Identifier": "dca43812-d2df-4e26-b7a1-8a2f596a2993",
      "Type": "MessageParticipant",
      "Transitions": {
        "NextAction": "6e456060-8469-49e4-a2f4-118386cd472c",
        "Errors": [
          {
            "NextAction": "6e456060-8469-49e4-a2f4-118386cd472c",
            "ErrorType": "NoMatchingError"
          }
        ]
      }
    },
    {
      "Parameters": {},
      "Identifier": "6e456060-8469-49e4-a2f4-118386cd472c",
      "Type": "DisconnectParticipant",
      "Transitions": {}
    }
  ]
}

ポイントは、Transcribeのジョブ実行するLambdaとジョブのステータスチェックするLambdaを分けている点です。

Amazon Transcribeの文字起こし方法は、バッチ式とストリーミング式の2つがありますが、今回利用するバッチ式の場合、私が確認したところ、1秒程度の音声ファイルでも文字起こしジョブが完了するまでに最低7秒かかりました。

コンタクトフローからLambdaを呼ぶ際のタイムアウトは最大8秒ですので、LambdaでKVSから音声ファイルを作成し、文字起こしジョブを完了するまで待つとタイムアウトします。

そのため、Transcribeのジョブ実行するLambdaとジョブのステータスチェックするLambdaを分けています。

1つ目のLambdaでジョブを実行時、ジョブIDを取得できますので、コンタクトフローに返します。

2つ目のLambdaでジョブIDを元にステータスを確認し、ジョブが完了していれば、文字起こし内容を取得します。Lambdaがタイムアウトするまでに、ジョブが完了していなければ、ループします。

Transcribe

今回は、文字起こしの精度を向上させるTranscribeの機能であるカスタム語彙を利用し、文字起こしします。

カスタム語彙は、コンソールで作成できます。

以下の通り、「フレーズ」に発音、「DisPlayAs」に変換したいテキストを記載します。他のオプションは、空白で構いません。

ボキャブラリー名は、nameとしました。

Lambdaを作成

1つ目のLambda

ユーザーの発話内容を「メディアストリーミングの開始」ブロックを使って、KVSへの音声のストリーミング後、1つ目のLambdaで以下の処理を行います。再掲です。

  1. LambdaでKVSからメディアデータを取得します。
  2. メディアデータから音声データを抽出し、WAV形式に変換し、S3バケットに音声ファイルを保存します。
  3. Amazon TranscribeでS3に保存した音声ファイルに対して、文字起こしジョブを実行します。

Lambdaは、以下の通り設定します。

  • Lambda名:transcribe-job-start
  • ランタイム:Python 3.12
  • メモリ:1024MB
    • メモリがデフォルトの128MBだと、実行時間が6秒だったため、1024MBにあげています。実行時間が1秒に短縮できます。
  • タイムアウト:10秒
  • IAMロール:以下のポリシーを追加
    • AmazonKinesisVideoStreamsReadOnlyAccess
    • AmazonS3FullAccess
    • AmazonTranscribeFullAccess
  • Lambdaレイヤー
    • ebmlite

以下がLambdaのコードです。コードにおいて、上記の1と2の処理は、以下の記事で詳細に解説していますので、ご参考ください。

import boto3, io, struct,json
from ebmlite import loadSchema
from enum import Enum
from datetime import datetime, timedelta
from botocore.config import Config

JST_OFFSET = timedelta(hours=9)

class Mkv(Enum):
    SEGMENT = 0x18538067
    CLUSTER = 0x1F43B675
    SIMPLEBLOCK = 0xA3

class Ebml(Enum):
    EBML = 0x1A45DFA3

class KVSParser:
    def __init__(self, media_content):
        self.__stream = media_content["Payload"]
        self.__schema = loadSchema("matroska.xml")
        self.__buffer = bytearray()

    @property
    def fragments(self):
        return [fragment for chunk in self.__stream if (fragment := self.__parse(chunk))]

    def __parse(self, chunk):
        self.__buffer.extend(chunk)
        header_elements = [e for e in self.__schema.loads(self.__buffer) if e.id == Ebml.EBML.value]
        if header_elements:
            fragment_dom = self.__schema.loads(self.__buffer[:header_elements[0].offset])
            self.__buffer = self.__buffer[header_elements[0].offset:]
            return fragment_dom

def get_simple_blocks(media_content):
    parser = KVSParser(media_content)
    return [
        b.value for document in parser.fragments
        for b in next(filter(lambda c: c.id == Mkv.CLUSTER.value, next(filter(lambda s: s.id == Mkv.SEGMENT.value, document))))
        if b.id == Mkv.SIMPLEBLOCK.value
    ]

def create_audio_sample(simple_blocks, margin=4):
    total_length = sum(len(block) - margin for block in simple_blocks)
    combined_samples = bytearray(total_length)
    position = 0
    for block in simple_blocks:
        temp = block[margin:]
        combined_samples[position:position+len(temp)] = temp
        position += len(temp)
    return combined_samples

def convert_bytearray_to_wav(samples):
    length = len(samples)
    channel = 1
    bit_par_sample = 16
    format_code = 1
    sample_rate = 8000
    header_size = 44
    wav = bytearray(header_size + length)

    wav[0:4] = b"RIFF"
    wav[4:8] = struct.pack("<I", 36 + length)
    wav[8:12] = b"WAVE"
    wav[12:16] = b"fmt "
    wav[16:20] = struct.pack("<I", 16)
    wav[20:22] = struct.pack("<H", format_code)
    wav[22:24] = struct.pack("<H", channel)
    wav[24:28] = struct.pack("<I", sample_rate)
    wav[28:32] = struct.pack("<I", sample_rate * channel * bit_par_sample // 8)
    wav[32:34] = struct.pack("<H", channel * bit_par_sample // 8)
    wav[34:36] = struct.pack("<H", bit_par_sample)
    wav[36:40] = b"data"
    wav[40:44] = struct.pack("<I", length)
    wav[44:] = samples
    return wav

def create_archive_media_client(ep):
    region_name = "ap-northeast-1"
    return boto3.client("kinesis-video-archived-media", endpoint_url=ep, config=Config(region_name=region_name))

def upload_audio_to_s3(bucket_name, audio_data, filename):
    s3_client = boto3.client("s3")
    s3_client.upload_fileobj(io.BytesIO(audio_data), bucket_name, filename, ExtraArgs={"ContentType": "audio/wav"})

def get_media_data(arn, start_timestamp, end_timestamp):
    kvs_client = boto3.client("kinesisvideo")
    list_frags_ep = kvs_client.get_data_endpoint(StreamARN=arn, APIName="LIST_FRAGMENTS")["DataEndpoint"]
    list_frags_client = create_archive_media_client(list_frags_ep)

    fragment_list = list_frags_client.list_fragments(
        StreamARN=arn,
        FragmentSelector={
            "FragmentSelectorType": "PRODUCER_TIMESTAMP",
            "TimestampRange": {"StartTimestamp": start_timestamp, "EndTimestamp": end_timestamp}
        }
    )

    sorted_fragments = sorted(fragment_list["Fragments"], key=lambda fragment: fragment["ProducerTimestamp"])
    fragment_number_array = [fragment["FragmentNumber"] for fragment in sorted_fragments]

    get_media_ep = kvs_client.get_data_endpoint(StreamARN=arn, APIName="GET_MEDIA_FOR_FRAGMENT_LIST")["DataEndpoint"]
    get_media_client = create_archive_media_client(get_media_ep)

    media = get_media_client.get_media_for_fragment_list(StreamARN=arn, Fragments=fragment_number_array)
    return media

def transcribe_audio(bucket_name, filename, job_name, vocabulary_name):
    transcribe_client = boto3.client("transcribe")
    transcribe_client.start_transcription_job(
        TranscriptionJobName=job_name,
        Media={'MediaFileUri': f"s3://{bucket_name}/{filename}"},
        MediaFormat='wav',
        LanguageCode='ja-JP',
        Settings={'VocabularyName': vocabulary_name}
    )
    return job_name

def convert_ms_to_datetime(timestamp_ms_str, add_seconds=1):
    timestamp_seconds = float(timestamp_ms_str) / 1000 + add_seconds
    return datetime.utcfromtimestamp(timestamp_seconds)

def lambda_handler(event, context):
    print('Received event:' + json.dumps(event, ensure_ascii=False))
    media_streams = event["Details"]["ContactData"]["MediaStreams"]["Customer"]["Audio"]
    stream_arn = media_streams["StreamARN"]

    start_timestamp = convert_ms_to_datetime(media_streams["StartTimestamp"])
    end_timestamp = convert_ms_to_datetime(media_streams["StopTimestamp"])

    combined_samples = create_audio_sample(
        get_simple_blocks(get_media_data(stream_arn, start_timestamp, end_timestamp)))

    wav_audio = convert_bytearray_to_wav(combined_samples)

    bucket_name = "バケット名を指定"
    jst_time = datetime.utcnow() + JST_OFFSET
    filename = f"output_{jst_time.strftime('%Y%m%d_%H%M%S')}.wav"

    upload_audio_to_s3(bucket_name, wav_audio, filename)

    transcribe_job_name = f"Transcription_{jst_time.strftime('%Y%m%d_%H%M%S')}"

    vocabulary_name = "name"

    job_id = transcribe_audio(bucket_name, filename, transcribe_job_name, vocabulary_name)

    return {
        "job_id": job_id
    }

bucket_namevocabulary_nameは、各自変えて下さい。

MKVファイルの解析にはebmliteライブラリを使用します。このライブラリを使用する場合は、先にZIP化してLambda レイヤーにアップロードしておきます。

$ python3 -m pip install -t ./python ebmlite
$ zip -r ebmlite-3.3.1.zip ./python

2つ目のLambda

1つ目のLambdaでジョブ実行後、2つ目のLambdaでジョブIDを元に、ステータスを確認します。ジョブが完了していれば、文字起こし内容を取得します。Lambdaがタイムアウトするまでに、ジョブが完了していなければ、ループします。

  • Lambda名:transcribe-job-check
  • ランタイム:Python 3.12
  • タイムアウト:10秒
  • IAMロール:以下のポリシーを追加
    • AmazonTranscribeReadOnlyAccess
  • Lambdaレイヤー
    • requests

以下がLambdaのコードです。

import boto3,time,requests,json

transcribe_client = boto3.client('transcribe')

def check_job_status(job_name):
    return transcribe_client.get_transcription_job(TranscriptionJobName=job_name)['TranscriptionJob']['TranscriptionJobStatus']

def get_transcript_text(transcript_file_uri):
    response = requests.get(transcript_file_uri)
    return response.json()['results']['transcripts'][0]['transcript']

def wait_for_job(job_name):
    while (status := check_job_status(job_name)) not in ['COMPLETED', 'FAILED']:
        time.sleep(1)
    return status

def lambda_handler(event, context):
    print('Received event:' + json.dumps(event, ensure_ascii=False))

    job_name = event['Details']['Parameters']['job_id']
    status = wait_for_job(job_name)

    if status == 'COMPLETED':
        job = transcribe_client.get_transcription_job(TranscriptionJobName=job_name)
        transcript_file_uri = job['TranscriptionJob']['Transcript']['TranscriptFileUri']
        transcription_text = get_transcript_text(transcript_file_uri)
        print('Received transcription_text:' + json.dumps(transcription_text, ensure_ascii=False))

        return {
            "transcription_text": transcription_text
        }
    return {}

requestsモジュールは、公開されているKeith's Layers (Klayers)を利用しました。

試してみる

電話をかけて、住所を発話してみます。

住所は、クラスメソッドの本社です。

  • 発話内容:東京都港区西新橋1-1-1 日比谷フォートタワー 26階

文字起こし内容は、以下の通りでした。

  • 文字起こし内容:東京都港区西新橋一の一の一、日比谷ポートタワー 二十六回

発話内容と文字起こし内容を比較すると、一部の単語で誤りはあるものの、全体的には高い精度で文字起こしができていると言えます。

「日比谷フォートタワー」が「日比谷ポートタワー」、と認識されているため、建物名の認識には多少の誤りがあります。しかし、住所の主要な部分である「東京都港区西新橋1-1-1」は正確に認識されています。

気になる点は、発話後、文字起こし内容を音声出力されるまでに、14,15秒かかりました。

14~15秒程度かかっていましたが、把握できているその内訳は以下の通りです。

  • 1つ目のLambda実行時間: 1秒
  • Transcribeジョブ実行時間: 9秒
  • 2つ目のLambda実行時間(ジョブステータス完了待ちを除く):1秒

割合として多いのが、文字起こしのジョブで、9秒かかっていました。Transcribeサービス由来ですので、避けられないですね。残り時間は、フロー内のブロックの遷移にかかっていると思われます。

Transcribeのコンソール画面からジョブ実行時間を確認できます。ちなみに、カスタム語彙を利用する場合、利用しない場合に比べ、ジョブ実行時間が1,2秒多いことも確認できました。

最後に

今回は、無人対応を想定したAmazon Connectでの発話内容をAmazon Transcribeで文字起こしする構築手順をまとめました。

処理時間が気になるものの精度はよい印象でした。

以前、コールセンター向けAIチャットボットを構築する際、Amazon Lexの利用すべきケースと利用すべきでないケースの記事を執筆しましたが、Amazon Transcribeを利用するケースもありますので、本記事を参考にしていただければ幸いです。