Amazon Pollyを使って、kintone読み上げアプリを作ってみた

# kintone と Amazon Polly の連携
今週は、AWSディベロッパーの祭典**「AWS re:Invent 2016」**が開催されています。今年はkintoneもブース出展がありますが、現地時間の11/30と12/1はそれぞれKeynoteが行われ、AWSの新サービスが色々と発表される非常にエキサイティングなイベントです。

今朝のKeynoteでも幾つかの新サービスの発表がありました。ということで、「kintone Advent Calendar 2016」の初日は、そんなホットなAWSの新サービスの連携をお届けしたいと思います。

kintoneと相性が良さそうなサービスが幾つかリリースされましたが、今回はまず「[Amazon Polly](https://aws.amazon.com/polly/)」と呼ばれる**「文章から音声を作り出す」**サービスとの連携をやってみました。Amazon Pollyに関する詳細はこちらの[ブログ](https://aws.amazon.com/jp/blogs/news/polly-text-to-speech-in-47-voices-and-24-languages/)等をご覧頂くのが良いと思います。
![polly18.png](https://qiita-image-store.s3.amazonaws.com/0/43383/69c6b6f9-361c-a729-2022-1716b80fa4c7.png)

# kintone と Amazon Polly の連携
様々なユースケースが想定されていますが、今回はシンプルに**MP3ファイルを生成するkintoneアプリ**をAmazon Pollyを使って作成したいと思います。構成は次のようになります。

![Untitled (4).png](https://qiita-image-store.s3.amazonaws.com/0/43383/0231f65b-97bd-9654-8314-63a4402787f1.png)

## Amazon Pollyとkintone連携の設定
### kintoneアプリ - 「Speak by Polly」（読み上げアプリ）
次のようなフィールドのkintoneアプリを準備します。

| 項目名・フィールドコード | 内容 | フィールドタイプ |
|:-:|:-:|:-:|
| Text  | 音声に変換したい文字列（文章）を入力するためのフィールドです。  | 文字列1行  |
| Attachment  | 文字列から変換された音声（MP3）ファイルを保存するためのフィールドです。  | 添付ファイル  |

![スクリーンショット 2016-12-01 2.17.38.png](https://qiita-image-store.s3.amazonaws.com/0/43383/992a4a6c-bee4-af73-345e-2166c14abf25.png)

### AWS Lambda
こちらが今回のメインになりますが、大まかには次のような内容です。
1. Amazon API Gateway経由で受け取ったkintoneのレコードIDから音声に変換したい文字列（Text）を取得する
2. 取得したレコードIDから音声に変換したい文字列（Text）を取得する
3. 取得した文字列をAmazon PollyのAPIにかけて、音声（MP３）ファイルを生成する
4. 生成したMP3ファイルをkintoneにアップロードする
これらの動きをAPI Gateway経由で実行できるようにします。

#### Lambda関数コーディング前準備
- 今回、kintoneへのREST APIアクセス用に[requestsモジュール](http://docs.python-requests.org/en/master/)を利用しますので、事前にインストールしておいてください。
- また、作業フォルダを今回次のように決めて作業を進めていきます。

```
$ mkdir workspace
$ cd workspace
```

#### Lambda関数のPythonコード
```{domain}```、```{app. id}```、```{api token}```、```{access key}```、```{secret key}``` は適宜書き換えてください。今回はLambdaからAmazon PollyへのアクセスについてはAmazon Pollyが利用可能なアクセスキー、シークレットキーを持つユーザーの認証情報を使うことにします（本当はIAMポリシーやLambda環境変数でしっかり対応すべきところですが、手抜きです）。

こちらを```lambda_function.py```というファイル名で```workspace```フォルダに保存します。

```python:lambda_function.py

from __future__ import print_function

import json
from boto3 import Session
from botocore.exceptions import BotoCoreError, ClientError
from contextlib import closing
import os
import sys
import subprocess
from tempfile import gettempdir
import requests, urllib2

# parameters of kintone
KINTONE_DOMAIN = "{domain}"
BASE_URL = "https://"+ KINTONE_DOMAIN
APP_ID = "{app. id}"
API_TOKEN = "{api token}"

# parameters of AWS
ACCESS_KEY = '{access key}'
SECRET_KEY = '{secret key}'
REGION = 'us-east-1'

# file name attaced to kintone
MP3_FILE_NAME = 'speech.mp3'

print('Loading function')

# get a kintone record
def getRecord(id):
    query = 'app='+APP_ID+'&id='+id
    url = BASE_URL + "/k/v1/record.json?" + query
    headers = {"X-Cybozu-API-Token": API_TOKEN}
    # http request with requests
    res = requests.get(url, headers=headers, data={})
    print(res.text, res.status_code)
    return {"res":json.loads(res.text), "code":res.status_code}

# upload the MP3 file to kintone
def uploadMP3File(file):
    fileUrl = BASE_URL + "/k/v1/file.json"
    fileHeaders = {"X-Cybozu-API-Token": API_TOKEN}
    files = {'file': (MP3_FILE_NAME, open(file, 'rb'), 'audio/mp3', {'Expires': '0'})}
    res = requests.post(fileUrl, headers=fileHeaders, files=files)
    print(res.text, res.status_code)
    fileKey = json.loads(res.text)['fileKey']
    return fileKey

# update the kintone record by a fileKey
def updateRecordForFile(recordId, fileKey):
    fileKeys = [{"fileKey":fileKey}]
    record = {'Attachment':{'value':fileKeys}}
    request = {'app':APP_ID,'id': recordId,'record':record}
    requestJson = json.dumps(request)
    url = BASE_URL + "/k/v1/record.json"
    headers = {"X-Cybozu-API-Token": API_TOKEN, "Content-Type" : "application/json"}
    res = requests.put(url, headers=headers, data=requestJson)
    print(res.text, res.status_code)
    return {"res":json.loads(res.text), "code":res.status_code}

# build the MP3 file from text with Amazon Polly API
def buildMP3File(text):
    # Create a client using the credentials and region defined in the [adminuser]
    # section of the AWS credentials file (~/.aws/credentials).
    #session = Session(profile_name="adminuser")
    session = Session(aws_access_key_id=ACCESS_KEY,
                      aws_secret_access_key=SECRET_KEY,
                      region_name=REGION)
    polly = session.client("polly")

    try:
        # Request speech synthesis
        response = polly.synthesize_speech(Text=text, OutputFormat="mp3",
                                            VoiceId="Joey")
    except (BotoCoreError, ClientError) as error:
        # The service returned an error, exit gracefully
        print(error)
        sys.exit(-1)

    # Access the audio stream from the response
    if "AudioStream" in response:
        # Note: Closing the stream is important as the service throttles on the
        # number of parallel connections. Here we are using contextlib.closing to
        # ensure the close method of the stream object will be called automatically
        # at the end of the with statement's scope.
        with closing(response["AudioStream"]) as stream:
            output = os.path.join(gettempdir(), MP3_FILE_NAME)
            try:
                # Open a file for writing the output as a binary stream
                with open(output, "wb") as file:
                    file.write(stream.read())
            except IOError as error:
                # Could not write to file, exit gracefully
                print(error)
                sys.exit(-1)

    else:
        # The response didn't contain audio data, exit gracefully
        print("Could not stream audio")
        sys.exit(-1)
    return output

# Lambda function
def lambda_handler(event, context):
    recordId = event['params']['querystring']['id'] # obtain the record id of kintone via Amazon API Gateway
    obj = getRecord(recordId)
    text = obj['res']['record']['Text']['value'] # obtain the text to convert
    if(len(text) is 0):
        text = 'Text field is empty.'
    mp3 = buildMP3File(text) # convert text to MP3-voice
    fileKey = uploadMP3File(mp3) # upload the MP3 file to kintone and obtain the fileKey
    updateRecordForFile(recordId, fileKey) # update the kintone record with attachment of the MP3 file
    return 'Process is completed.'
```

ファイルを保存したら、```workspace```フォルダに```boto3```と```requests```をインストールし、zipコマンドを使って```workspace```内のすべてのファイルを圧縮します。圧縮後のファイル名は今回```upload.zip```としておきます。

```
$ pip install boto3 -t .
$ pip install requests -t .
$ zip -r upload.zip *
```

#### Lambda関数のコンソール設定（コード以外）
「Lambda > Functions」から「Create a Lambda function」をクリックして設定開始です。AWSのリージョンは**「バージニア（us-east-1）」**で進めていきます。
![polly1.png](https://qiita-image-store.s3.amazonaws.com/0/43383/826b0690-8c92-ac05-715c-22515ce3e9e7.png)

今回のLambda関数は先程のパッケージングしたものを使うので、「Blank Function」を選択して、設定を進めます。
![polly2.png](https://qiita-image-store.s3.amazonaws.com/0/43383/8dcf8ffd-4df3-4966-a07b-e98165ee8732.png)

ここはそのまま「Next」をクリックして先に進みます。
![polly3.png](https://qiita-image-store.s3.amazonaws.com/0/43383/913e32dc-92a4-b5e0-f567-11fd1ac99218.png)

ここでメインの設定画面ですが、次のように設定します。設定が一通り出来たら、「Next」で先に進みましょう。
![polly4.png](https://qiita-image-store.s3.amazonaws.com/0/43383/9a070405-bb5d-f32b-64e1-563bc3e215e1.png)

「Next」をクリックして進んでくると、設定のレビュー画面が出てきますので、「Create function」をクリックして設定を終えます。
![polly5.png](https://qiita-image-store.s3.amazonaws.com/0/43383/7634c064-2121-3819-d4f3-83284e10b766.png)

### Amazon API Gateway
kintoneのレコードIDを流し込みつつ、先に設定したLambda関数を起動するAPI Gatewayの設定を行います。APIセットを新しく「Create API」から作成していきます。
![polly6.png](https://qiita-image-store.s3.amazonaws.com/0/43383/70369a14-f497-872f-3866-f8bee93b2d05.png)

今回はAPI名を「kintone-polly」として、「Create API」をクリックしてAPIを作成していきます。
![polly7.png](https://qiita-image-store.s3.amazonaws.com/0/43383/a6a87fd3-6743-8a95-eb6d-983845c00d94.png)

「Resources」の「Actions」から「Create Method」をクリックしてリクエストメソッドを作成します。リソース・パスは今回１つなので、「/」の直下にそのまま作成します。
![polly8.png](https://qiita-image-store.s3.amazonaws.com/0/43383/d516189f-2fe0-8ea6-1ba0-a6bb8b89deed.png)

今回のリクエストは、GETメソッドとすることとして、「GET」を選択します。
![polly9.png](https://qiita-image-store.s3.amazonaws.com/0/43383/cdd377c9-83fc-5370-5079-d9630f4ae13c.png)

そうすると、「GET」メソッドの詳細設定に進んでいきます。次のように進めますが、先ほど作成したLambda関数に紐つけをここでやっているということです。選択・入力が終わったら「Save」をクリックします。
![polly10.png](https://qiita-image-store.s3.amazonaws.com/0/43383/e506df16-5850-57f5-082d-fb4fe2a14e61.png)

これで「OK」をクリックすれば、紐つけの完了です。
![polly11.png](https://qiita-image-store.s3.amazonaws.com/0/43383/bd06755e-2c27-ca3e-6ad6-19542d65a079.png)

まだまだ続きます。本来はこの4パート丁寧に設定していく必要がありますが、今回は「Integration Request」だけを設定して成功ケースだけ通るようにします。
![polly12.png](https://qiita-image-store.s3.amazonaws.com/0/43383/e54ed9f4-f918-90a4-8bb3-40155b7a7e0c.png)

ここでは、APIリクエストのボディやクエリをどのようにLambda（のevent）に渡すかを設定します。次のように進めましょう。
![polly13.png](https://qiita-image-store.s3.amazonaws.com/0/43383/89d0b170-7a5a-aa5d-c638-9e2c9ef49a7c.png)

「Save」を押したら、「Actions」から「Deploy API」をクリックして、いよいよAPIの設定は終盤です。
![poly14.png](https://qiita-image-store.s3.amazonaws.com/0/43383/e830870a-e011-0ecf-2bdf-7bb6e8481759.png)

デプロイに際して、最後にステージを選択できますが、今回は既存選択肢がないので「New Stage」で「prod」と入力して、「Deploy」をクリックしてください。
![polly15.png](https://qiita-image-store.s3.amazonaws.com/0/43383/67b4f651-29a3-b40d-293c-9000790713f5.png)

これでAPI　Gatewayの設定も完了ですので、ここまで設定したAPIをkintoneからコールできるようにすればkintoneアプリ完成です。

### kintone JavaScriptカスタマイズ
最後の設定はkintoneアプリへのJavaScriptカスタマイズです。大きく2つの機能を入れます。
- API GatewayのURLにGETメソッドでリクエストし、文字列から変換されたMP3音声ファイルをkintoneレコードに添付する（保存成功後イベント）
- 添付のMP3音声ファイルを再生する（レコード詳細画面表示イベント）

これらの機能を有するJavaScriptは次のようになります。なお、```{api gw url}```は、API Gatewayで設定したリクエストURLに置き換えてください。

```js:polly.js
jQuery.noConflict();
(function($) {
  'use strict';

  // API to convert text to speech, and to upload the converted MP3 file to kintone with Amazon Polly
  var POLLY_URL = '{api gw url}';

  // show spinner
  var showSpinner = function() {
    // initialization
    if ($('.kintone-spinner').length == 0) {
      // create elements for spinner and background
      var spin_div = $('<div id ="kintone-spin" class="kintone-spinner"></div>');
      var spin_bg_div = $('<div id ="kintone-spin-bg" class="kintone-spinner"></div>');

      // append spinner element to "body"
      $(document.body).append(spin_div, spin_bg_div);

      // style for spinner
      $(spin_div).css({
        'position': 'fixed',
        'top': '50%',
        'left': '50%',
        'z-index': '510',
        'background-color': '#fff',
        'padding': '26px',
        '-moz-border-radius': '4px',
        '-webkit-border-radius': '4px',
        'border-radius': '4px'
      });
      $(spin_bg_div).css({
        'position': 'absolute',
        'top': '0px',
        'z-index': '500',
        'width': '150%',
        'height': '150%',
        'background-color': '#000',
        'opacity': '0.5',
        'filter': 'alpha(opacity=50)',
        '-ms-filter': "alpha(opacity=50)"
      });

      // options for spinner
      var opts = {
        'color': '#000'
      };

      // invoke spinner
      new Spinner(opts).spin(document.getElementById('kintone-spin'));
    }

    // start(show) spinner
    $('.kintone-spinner').show();
  };

  // stop(hide) spinner
  var hideSpinner = function() {
    // hide spinner element
    $('.kintone-spinner').hide();
  };

  // download file from kintone
  var downloadFile = function(fileKey, callback, errback) {
    var url = kintone.api.url('/k/v1/file', false) + '?fileKey=' + fileKey;
    var xhr = new XMLHttpRequest();
    xhr.open('GET', url);
    xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
    xhr.responseType = 'blob';
    xhr.onload = function() {
      if (xhr.status === 200) {
        // success
        var blob = new Blob([xhr.response]);
        return callback(blob);
      } else {
        // error
        var err = JSON.parse(xhr.responseText);
        if (errback) {
          return errback(err);
        }
      }
    };
    xhr.onerror = function(err) {
      // error
      if (errback) {
        return errback(err);
      }
    };
    xhr.send();
  };

  // convert Blob to Base64
  var convertBlobToBase64 = function(blob, callback, errback) {
    var reader = new window.FileReader();
    reader.readAsDataURL(blob);
    reader.onload = function() {
      // success
      var base64data = reader.result;
      return callback(base64data.split(',', 2)[1]);
    };
    reader.onerror = function(err) {
      // error
      if (errback) {
        return errback(err);
      }
    };
  };

  // submit success events
  kintone.events.on([
    'app.record.create.submit.success',
    'app.record.edit.submit.success',
  ], function(event) {
    // show spinner
    showSpinner();
    // call API with Amazon Polly
    var id = event.recordId;
    var query = 'id=' + id;
    var url = POLLY_URL + '?' + query;
    return kintone.proxy(url, 'GET', {}, {}).then(function(r){
      // hide spinner
      hideSpinner();
      return event;
    }).catch(function(e){
      // hide spinner
      hideSpinner();
      event.error = 'Error occurred.'
      return event;
    });
  });

  // after showing record details
  kintone.events.on(['app.record.detail.show'], function(event) {
    // show spinner
    showSpinner();
    var record = event.record;
    new kintone.Promise(function(resolve, reject) {
      // download the MP3 file
      var file = record['Attachment'].value[0];
      var fileKey = file.fileKey;
      downloadFile(fileKey, function(r) {
        return resolve(r);
      }, function(e) {
        return reject(e);
      });
    }).then(function(blob) {
      // convert the MP3 blob file to Base64 style
      return new kintone.Promise(function(resolve, reject) {
        convertBlobToBase64(blob, function(r) {
          return resolve(r);
        }, function(e) {
          return reject(e);
        });
      });
    }).then(function(base64data) {
      // hide spinner
      hideSpinner();
      // append the Base64-encoded MP3 file to "audio" element
      var audioUrl = 'data:audio/mp3;base64,' + base64data;
      $(document.body).append(
        $('<audio>').prop({
          'id': "voice",
          'preload': "auto"
        }).append(
          $('<source>').prop({
            'src': audioUrl,
            'type': "audio/mp3"
          })
        )
      );

      // append the button that starts to play the MP3 file
      var elAttachment = kintone.app.record.getFieldElement('Attachment');
      $(elAttachment).append(
        $('<button>').prop({
          id: 'speak'
        }).addClass('kintoneplugin-button-dialog-ok').text('Speak')
      );

      // attach click event to the "#speak" button for playing
      $('#speak').click(function() {
        var voice = $('#voice');
        voice[0].currentTime = 0;
        voice[0].play();
      });
      return;
    }).catch(function(e) {
      hideSpinner();
      console.log(e);
    });
  });
})(jQuery);
```

#### kintone JavaScriptカスタマイズの設定
先の ```polly.js``` を作成したら、kintoneへ設定していきます。

「アプリの設定を変更」から「設定」 - 「カスタマイズ」 - 「JavaScript/CSSでカスタマイズ」を選択します。そして、次のように設定します。

![polly17.png](https://qiita-image-store.s3.amazonaws.com/0/43383/16211f63-e55e-277e-4ec8-cf10983aab4c.png)


- PC用のJavaScriptファイル
    - https://js.kintone.com/jquery/3.0.0/jquery.min.js
    - https://js.kintone.com/spinjs/2.3.2/spin.min.js
    - polly.js
- PC用のCSSファイル
    - 51-current-default.css

ここで、 ```51-current-default.css``` は [kintone Plug-in SDK](https://github.com/kintone/plugin-sdk)のページから取得できます。このスタイルシートを使うことで、kintoneにあったスタイルを当てることができます。

カスタマイズファイルが出来たら、「保存」を押して、更に「アプリの更新」をクリックすると、これで全行程終了です！

# 「Speak by Polly」（読み上げアプリ）を試してみる

レコード新規登録画面で、「Text」フィールドに音声ファイル化したい文字列（文章）を入力して、レコードを保存します。
![スクリーンショット 2016-12-01 6.53.39.png](https://qiita-image-store.s3.amazonaws.com/0/43383/fdfc37f7-d0e4-7c5d-677b-952cd12091b1.png)

保存成功後イベントで、MP3音声ファイルを作成して、添付しています。そして、リロードがかかると次のように添付されている様子が確認でき、「Speak」というボタンが現れます。
![スクリーンショット 2016-12-01 6.54.00.png](https://qiita-image-store.s3.amazonaws.com/0/43383/2bc72a4a-244e-562f-4bc5-a0e1382fbfe3.png)

これを押すと、Amazon Pollyによって文字列から変換された音声を実際に**読み上げ**てくれます。

# 所感
記事自体のボリューム感はなかなかで、私は発表後SDKのドキュメントはまだアップデートされてなかったり、Lambdaのboto3には新サービス対応版が入ってなかったりと少しハマりましたが、**実際設定してみると意外と時間はかからない**と思います。

外部のクラウドの力を借りることでkintoneの可能性が広がることをまた実感できました。

機械学習・AIをゼロから学ぶには敷居が高すぎるけど、やってみたいという方にもオススメできるかなぁと思います。是非お試し頂ければと思います。