この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。
1 はじめに
AIソリューション部の平内(SIN)です。
Amazon Connect(以下、Connect)では、昨年末、顧客との通話をリアルタイムでストリームに流す機能が追加されました。
Amazon Connect にリアルタイム顧客音声ストリーム機能を追加
今回は、この機能を利用して、サーバレスで留守番電話を作ってみました。営業時間外に着信した場合、留守電話となってメッセージを受け取り、翌営業日に、担当者が確認するイメージです。
2 構成
構成は以下のとおりです。
- Connectのコンタクトフローは、営業時間外には、 留守番電話のメッセージを流して、通話をKinesis Video Streamsに保存します。
- 通話終了時に、Streamsに保存したデータの諸元(発信者番号、ストリーム名、フラグメント番号など)をLambdaを利用してS3に保存します。
- S3の諸元データが保存されると、その情報を元に、Kinesis Video Streamsから当該データ(RAWデータ)を取得し、WAVファイルに変換し、S3に保存します。
- 管理者は、ブラウザから保存されたWAVファイルを再生します。
3 問い合わせフロー
営業時間外の問い合わせフローは、次のようになっています。
問い合わせフローでは、メディアストリーミングの開始ブロックを配置すると、そこからKinesis Video Streamsへのデータ送信が始まり、メディアストリーミングの終了ブロックで、送信は止まります。 今回は、この間に、13秒のブレイクを置いたプロンプト再生を置くことで、メッセージを受け取れるよにしました。
また、メディアストリーミングの開始ブロックでは、下図のように、顧客からのみにチェックを入れて下さい。
ストリーム機能を利用するには、インスタンスのデータストレージ内にあるライブメディアストリーミングを有効化する必要がありますが、詳しくは、下記のブログをご参照下さい。
[アップデート]Amazon Connectにリアルタイム顧客音声ストリーム機能が追加されました
4 諸元の保存
問い合わせフローからLambdaを呼び出すと、リクエストには、発信者番号やストリームの情報が含まれます。この内、ストリームへの保存完了後に、kinesis Video Streamsからデータを取得する際に必要なのは、StartFragmentNumberとStreamARNに含まれているストリーム名です。
{
"Details": {
"ContactData": {
・・・略・・・
"CustomerEndpoint": {
"Address": "+819068739557", <= 発信者番号
・・・略・・・
},
・・・略・・・
"MediaStreams": {
"Customer": {
"Audio": {
"StartFragmentNumber": "xxxxx", <= 開始フラグメント番号
"StartTimestamp": "1555032992938", <=開始タイムスタンプ
"StopFragmentNumber": "xxxxx", <=終了フラグメント番号
"StopTimestamp": "1555033001125", <=終了タイムスタンプ
"StreamARN": "arn:aws:kinesisvideo:ap-northeast-1:xxxx:stream/voicemail-connect-xxxxx/xxx" <= StreamARN
}
}
},
・・・略・・・
いくつかの付加情報を含めて、諸元をS3に保存しているコードは、以下です。
const AWS = require("aws-sdk");
const region = 'ap-northeast-1';
const bucketName = 'voice-mail-stream-information';
exports.handler = async (event) => {
console.log(JSON.stringify(event));
// リクエストから必要データを取得する
let streamInformation = {};
const contactData = event.Details.ContactData;
streamInformation.contactId = contactData.ContactId; // コンタクトID
streamInformation.customerEndpoint = contactData.CustomerEndpoint.Address; // 発信者番号
streamInformation.systemEndpoint = contactData.SystemEndpoint.Address; // 着信番号
const audio = contactData.MediaStreams.Customer.Audio;
streamInformation.startFragmentNumber = audio.StartFragmentNumber; // 録音データの開始フラグメント番号
streamInformation.startTimestamp = audio.StartTimestamp; // 録音データの開始時間
streamInformation.stopFragmentNumber = audio.StopFragmentNumber;// 録音データの終了フラグメント番号
streamInformation.stopTimestamp = audio.StopTimestamp;// 録音データの終了時間
streamInformation.streamARN = audio.StreamARN; // ストリームのARN
// S3に日付文字列をキーといして保存する
const s3 = new AWS.S3({region:region});
const key = createKeyName();
const body = JSON.stringify(streamInformation);
const params = {
Bucket: bucketName,
Key: key,
Body: body
};
await s3.putObject(params).promise();
return {};
};
function createKeyName() {
const date = new Date();
const year = date.getFullYear();
const mon = (date.getMonth() + 1);
const day = date.getDate();
const hour = date.getHours();
const min = date.getMinutes();
const sec = date.getSeconds();
const space = (n) => {
return ('0' + (n)).slice(-2)
}
let result = year + '_';
result += space(mon) + '_';
result += space(day) + '_';
result += space(hour) + '_';
result += space(min) + '_';
result += space(sec);
return result;
}
5 データ取得とWAVへの変換
Kinesis Video Streamsからデータを取得しWAVファイルに変換してS3に保存しているメインコードは、以下のとおりです。
const AWS = require("aws-sdk");
const region = 'ap-northeast-1';
const bucketName = 'voice-mail-wav-file';
exports.handler = async (event) => {
const s3 = new S3(AWS, region);
for (let record of event.Records) {
const key = record.s3.object.key;
// S3から録音データに関する情報を取得
const data = await s3.get(record.s3.bucket.name, key);
const info = JSON.parse(data.Body);
const streamName = info.streamARN.split('stream/')[1].split('/')[0];
const fragmentNumber = info.startFragmentNumber;
// Kinesis Video Streamsから当該RAWデータの取得
const raw = await getMedia(streamName, fragmentNumber);
// RAWデータからWAVファイルを作成
const wav = Converter.createWav(raw, 8000);
// WAVファイルをS3に保存する
let tagging = ''; // 付加情報をタグに追加する
tagging += "customerEndpoint=" + info.customerEndpoint + '&';
tagging += "systemEndpoint=" + info.systemEndpoint + '&';
tagging += "startTimestamp=" + info.startTimestamp + '&';
tagging += "stopTimestamp=" + info.stopTimestamp;
await s3.put(bucketName, key + '.wav', Buffer.from(wav.buffer), tagging)
}
return {};
};
class S3 {
constructor(AWS, region){
this._s3 = new AWS.S3({region:region});
}
async get(bucketName, key){
const params = {
Bucket: bucketName,
Key: key
};
return await this._s3.getObject(params).promise();
}
async put(bucketName, key, body, tagging) {
const params = {
Bucket: bucketName,
Key: key,
Body: body,
Tagging: tagging
};
return await this._s3.putObject(params).promise();
}
}
Kinesis Video Streamsからデータを取得するには、getMedia()を使用します。なお、getMedia()は、予めgetDataEndpoint()で取得したエンドポインで初期化が必要です。
getMedia()は、ストリーム名とフラグメント番号を指定することで、当該データが取得できます。
取得したデータは、Matroska(MKV)のコンテナとなっていますので、ebmlにより、チャンクごとに分解して、コンテナ内のデータのみを取得しています。
const ebml = require('ebml');
async function getMedia(streamName, fragmentNumber) {
// Endpointの取得
const kinesisvideo = new AWS.KinesisVideo({region: region});
var params = {
APIName: "GET_MEDIA",
StreamName: streamName
};
const end = await kinesisvideo.getDataEndpoint(params).promise();
// RAWデータの取得
const kinesisvideomedia = new AWS.KinesisVideoMedia({endpoint: end.DataEndpoint, region:region});
var params = {
StartSelector: {
StartSelectorType: "FRAGMENT_NUMBER",
AfterFragmentNumber:fragmentNumber,
},
StreamName: streamName
};
const data = await kinesisvideomedia.getMedia(params).promise();
const decoder = new ebml.Decoder();
let chunks = [];
decoder.on('data', chunk => {
if(chunk[1].name == 'SimpleBlock'){
chunks.push(chunk[1].data);
}
});
decoder.write(data["Payload"]);
// chunksの結合
const margin = 4; // 各chunkの先頭4バイトを破棄する
var sumLength = 0;
chunks.forEach( chunk => {
sumLength += chunk.byteLength - margin;
})
var sample = new Uint8Array(sumLength);
var pos = 0;
chunks.forEach(chunk => {
let tmp = new Uint8Array(chunk.byteLength - margin);
for(var e = 0; e < chunk.byteLength - margin; e++){
tmp[e] = chunk[e + margin];
}
sample.set(tmp, pos);
pos += chunk.byteLength - margin;
})
return sample.buffer;
}
実は、Connectで保存された、MKVコンテナの音声データは、Single 16bit PCM 8000Hz のRAWデータであり 、バイトオーダーは、リトルインディアンとなっています。
このままでは、再生することが出来ないので、ヘッダを追加してWAVファイルに変換しています。
class Converter {
// WAVファイルの生成
static createWav(samples, sampleRate) {
const len = samples.byteLength;
const view = new DataView(new ArrayBuffer(44 + len));
this._writeString(view, 0, 'RIFF');
view.setUint32(4, 32 + len, true);
this._writeString(view, 8, 'WAVE');
this._writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true); // リニアPCM
view.setUint16(22, 1, true); // モノラル
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
this._writeString(view, 36, 'data');
view.setUint32(40, len, true);
let offset = 44;
const srcView = new DataView(samples);
for (var i = 0; i < len; i+=4, offset+=4) {
view.setInt32(offset, srcView.getUint32(i));
}
return view;
}
static _writeString(view, offset, string) {
for (var i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
}
6 録音データの一覧
管理者が、録音データを確認するためのページでは、CognitoのPoolIdでS3へのパーミッションを付与した、スクリプトになっています。なお、表示用のスクリプトを配置するS3にはCORSの設定が必要です。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Voice Mail Viewer</title>
<script src="https://sdk.amazonaws.com/js/aws-sdk-2.283.1.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<link rel="stylesheet" type="text/css" href="style.css">
</head>
<body>
<h1 id="time">留守番電話</h1>
<audio id="sound" preload="auto">
</audio>
<table id="contents"/ border=1>
<script>
AWS.config.region = 'ap-northeast-1';
AWS.config.credentials = new AWS.CognitoIdentityCredentials({
IdentityPoolId: 'ap-northeast-1:xxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
});
const bucketName = 'voice-mail-wav-file';
const s3 = new AWS.S3();
async function refresh(){
const list = await s3.listObjects({Bucket: bucketName}).promise();
const keys = list.Contents.map( v => v.Key );
let items = await Promise.all(keys.map(async (key)=>{
// S3上のオブジェクトのタグ情報を取得する
const tagging = await s3.getObjectTagging({Bucket: bucketName, Key: key }).promise();
const customerEndpoint = tagging.TagSet.find((v => { return v.Key == "customerEndpoint"}))["Value"];
const startTimestamp = Number(tagging.TagSet.find((v => { return v.Key == "startTimestamp"}))["Value"]);
const item = {
customerEndpoint: endpoint,
startTimestamp: dateString(new Date(startTimestamp)), // yyyy/mm/dd hh:mm
key: key
};
return item;
}));
// 録音時間でソートする
items = items.sort((a, b) => b.startTimestamp - a.startTimestamp);
const table = $("#contents");
const tr = table.append($("<tr></tr>"))
tr.append("<th>着信時間</th>")
tr.append("<th>発信番号</th>")
tr.append("<th></th>")
// 一覧の表示
items.forEach( item => {
const customerEndpoint = $("<td></td>").text(item.customerEndpoint);
const startTimestamp = $("<td></td>").text(item.startTimestamp);
const listenButton = $("<td><img src=listen.png></td>").click(()=> {listen(listenButton,item.key)});
const tr = table.append($("<tr></tr>"))
tr.append(startTimestamp)
tr.append(customerEndpoint)
tr.append(listenButton)
});
}
refresh();
// スピーカーのアイコンをクリックした際にWAVファイルを取得する
async function listen(element, key) {
var params = {
Bucket: bucketName,
Key: key
};
const data = await s3.getObject(params).promise();
var blob = new Blob([data.Body], {type : 'audio/wav'});
element.html('<audio src=' + URL.createObjectURL(blob) + ' controls></audio>');
}
function dateString(date) {
const year = date.getFullYear();
const mon = (date.getMonth() + 1);
const day = date.getDate();
const hour = date.getHours();
const min = date.getMinutes();
const space = (n) => {
return ('0' + (n)).slice(-2)
}
let result = year + '/';
result += space(mon) + '/';
result += space(day) + ' ';
result += space(hour) + ':';
result += space(min);
return result;
}
</script>
</body>
</html>
7 Cognito
最後に、CoginitoのPoolIdへ付与したパーミションです。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetBucketTagging",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::voice-mail-wav-file"
]
},
{
"Effect": "Allow",
"Action": [
"s3:GetObjectTagging",
"s3:*Object"
],
"Resource": [
"arn:aws:s3:::voice-mail-wav-file/*"
]
}
]
}
8 最後に
今回は、Connectの通話をストリームに流す機能を利用して、留守番電話を作成してみました。
Kinesis Video Streamsに保存されたRAWデータを、再生可能なファイルにデコードするのに、ちょっと手間取りましたが、全部、Lambdaだけで実装できたので、サーバレスな仕組みとして、簡単に再利用可能なのでは無いでしょうか。
アイコンは、下記のものを利用させて頂きました。
弊社ではAmazon Connectのキャンペーンを行なっております。
3月に引き続き、4月も「無料Amazon Connectハンズオンセミナー」を開催致します。導入を検討されておられる方は、是非、お申し込み下さい。
また音声を中心とした各種ソリューションの開発支援も行なっております。