LoginSignup
7

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-12-01

kintone と Amazon Polly の連携

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

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

kintoneと相性が良さそうなサービスが幾つかリリースされましたが、今回はまず「Amazon Polly」と呼ばれる「文章から音声を作り出す」サービスとの連携をやってみました。Amazon Pollyに関する詳細はこちらのブログ等をご覧頂くのが良いと思います。
polly18.png

kintone と Amazon Polly の連携

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

Untitled (4).png

Amazon Pollyとkintone連携の設定

kintoneアプリ - 「Speak by Polly」(読み上げアプリ)

次のようなフィールドのkintoneアプリを準備します。

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

スクリーンショット 2016-12-01 2.17.38.png

AWS Lambda

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

Lambda関数コーディング前準備

  • 今回、kintoneへのREST APIアクセス用にrequestsモジュールを利用しますので、事前にインストールしておいてください。
  • また、作業フォルダを今回次のように決めて作業を進めていきます。
$ 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フォルダに保存します。

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フォルダにboto3requestsをインストールし、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

今回のLambda関数は先程のパッケージングしたものを使うので、「Blank Function」を選択して、設定を進めます。
polly2.png

ここはそのまま「Next」をクリックして先に進みます。
polly3.png

ここでメインの設定画面ですが、次のように設定します。設定が一通り出来たら、「Next」で先に進みましょう。
polly4.png

「Next」をクリックして進んでくると、設定のレビュー画面が出てきますので、「Create function」をクリックして設定を終えます。
polly5.png

Amazon API Gateway

kintoneのレコードIDを流し込みつつ、先に設定したLambda関数を起動するAPI Gatewayの設定を行います。APIセットを新しく「Create API」から作成していきます。
polly6.png

今回はAPI名を「kintone-polly」として、「Create API」をクリックしてAPIを作成していきます。
polly7.png

「Resources」の「Actions」から「Create Method」をクリックしてリクエストメソッドを作成します。リソース・パスは今回1つなので、「/」の直下にそのまま作成します。
polly8.png

今回のリクエストは、GETメソッドとすることとして、「GET」を選択します。
polly9.png

そうすると、「GET」メソッドの詳細設定に進んでいきます。次のように進めますが、先ほど作成したLambda関数に紐つけをここでやっているということです。選択・入力が終わったら「Save」をクリックします。
polly10.png

これで「OK」をクリックすれば、紐つけの完了です。
polly11.png

まだまだ続きます。本来はこの4パート丁寧に設定していく必要がありますが、今回は「Integration Request」だけを設定して成功ケースだけ通るようにします。
polly12.png

ここでは、APIリクエストのボディやクエリをどのようにLambda(のevent)に渡すかを設定します。次のように進めましょう。
polly13.png

「Save」を押したら、「Actions」から「Deploy API」をクリックして、いよいよAPIの設定は終盤です。
poly14.png

デプロイに際して、最後にステージを選択できますが、今回は既存選択肢がないので「New Stage」で「prod」と入力して、「Deploy」をクリックしてください。
polly15.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に置き換えてください。

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

ここで、 51-current-default.csskintone Plug-in SDKのページから取得できます。このスタイルシートを使うことで、kintoneにあったスタイルを当てることができます。

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

「Speak by Polly」(読み上げアプリ)を試してみる

レコード新規登録画面で、「Text」フィールドに音声ファイル化したい文字列(文章)を入力して、レコードを保存します。
スクリーンショット 2016-12-01 6.53.39.png

保存成功後イベントで、MP3音声ファイルを作成して、添付しています。そして、リロードがかかると次のように添付されている様子が確認でき、「Speak」というボタンが現れます。
スクリーンショット 2016-12-01 6.54.00.png

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

所感

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

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

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7