Twilioでリアルタイムの文字起こし

# 作るもの
[Twilio](https://twilio.kddi-web.com/) Client(JavaScript)を使って通話をsoft-realtimeで文字起こしします。マニュアルには書いていないやり方なので、動かなくなったらごめんなさい。通話の片方は必ずブラウザでの通話が必要ですが（文字起こしして画面に表示させるから）、もう片方はなんでも構いません。

# 用意するもの
- Twilioアカウント
- ngrok
- node.js

# レシピ
まずはサーバを作ります。機能はざっくりこんな感じ：
- Twilioのトークンを発行
- 着信時に[Agent Conference](https://www.twilio.com/blog/2017/06/introducing-agent-conference-for-contact-centers.html)用のConferenceを作成
- 作成したConferenceにオペレータを招待
- 作成したConferenceに文字起こしを実行するSupervisorを招待
- オペレータをConferenceに参加させる
- SupervisorをConferenceに参加させる
- 参加したSupervisorをGatherで文字起こし開始させる
- 文字起こしの結果を受け取ってwebsocket(socket.io)でブラウザに通知

うん、シンプルですね！

# サーバを作る

```bash
$ cd PROJECT_DIR
$ yarn init -y
$ yarn add express socket.io twilio dotenv
```

さあ、index.jsをサクッと作っちゃいましょう。あえてどノーマルのJSで。

```javascript
require('dotenv').config();

var express = require('express'),
    app = express(),
    twilio = require('twilio'),
    port = process.env.PORT || 8888,
    VoiceResponse = twilio.twiml.VoiceResponse,
    ClientCapability = twilio.jwt.ClientCapability,
    server = require('http').createServer(app),
    io = require('socket.io').listen(server),
    client = new twilio(process.env.AccountSid, process.env.AuthToken),
    AGENT = process.env.AGENT,//FIXME
    SUPERVISOR = process.env.SUPERVISOR;//FIXME

app.use(express.static(__dirname + '/public'));
app.use(express.json());
app.use(express.urlencoded({extended: false}));

/*
  CapabilityTokenを発行
*/
app.get('/token/:identity', function(req, res) {
  // FIXME 実運用ではちゃんと認証しようね！
  io.in(req.params.identity).emit('message', {message: 'socket.io is ready'});
  var capability = new ClientCapability({
    accountSid: process.env.AccountSid,
    authToken: process.env.AuthToken
  });
  capability.addScope(
    new ClientCapability.IncomingClientScope(req.params.identity)
  );
  res.send(capability.toJwt());
});

/*
  受話したらConferenceを開始
*/
app.post('/start', function(req, res) {
  var conference = Math.random().toString(36).substr(2),
      twiml = new VoiceResponse();
  client.calls.create({
    url: req.protocol + '://' + req.get('host') + '/join/' + conference,
    to: 'client:' + AGENT,
    from: process.env.CallerId
  }).then(function(){
    twiml.dial()
    .conference(conference, {
      endCoferenceOnExit: true
    });
    res.type('text.xml');
    res.send(twiml.toString());
  }).catch(function(){
    sorry(twiml, res, 'カンファレンスを開始できませんでした。ごめんね');
  });
});

/*
  オペレータがカンファレンスに参加、スーパイバイザーを召喚
*/
app.post('/join/:conference', function(req, res) {
  var twiml = new VoiceResponse();
  client.calls.create({
    url: req.protocol + '://' + req.get('host') + '/join_supervisor/' + req.params.conference + '/' + req.body.CallSid,
    to: 'client:' + SUPERVISOR,
    from: process.env.CallerId
  }).then(function(){
    twiml.dial()
      .conference(req.params.conference, {
        statusCallback: req.protocol + '://' + req.get('host') + '/start_gather/' + call.sid + '/' + req.params.conference,
        statusCallbackEvent: 'join'
      });
    res.type('text/xml');
    res.send(twiml.toString());
  }).catch(function(){
    sorry(twiml, res, 'カンファレンスを開始できませんでした。ごめんね');
  });
});

/*
  召喚されたスーパーバイザーが参加
*/
app.post('/join_supervisor/:conference/:call_sid', function(req, res) {
  var twiml = new VoiceResponse();
  twiml.dial()
    .conference(req.params.conference, {
      whisper: req.params.call_sid
    });
  res.type('text/xml');
  res.send(twiml.toString());
});

/*
  参加したスーパーバイザーを転送して文字起こしの準備開始
*/
app.post('/start_gather/:call_sid/:conference', function(req, res) {
  if(req.body.StatusCallbackEvent == 'participant-join' && req.params.call_sid == req.body.CallSid) {
    client.calls(req.params.call_sid)
      .update({
        url: req.protocol + '://' + req.get('host') + '/gather/' + req.params.conference,
        method: 'POST',
      });
  }
  res.send('ok')
});

/*
  スーパーバイザーが文字起こしを開始
*/
app.post('/gather/:conference', function(req, res) {
  var twiml = new VoiceResponse();
  twiml.gather({
    input: 'speech',
    partialResultCallback: req.protocol + '://' + req.get('host') + '/partial/' + req.params.conference,
    language: 'ja-JP'
  });
  res.type('text/xml');
  res.send(twiml.toString());
});

/*
  文字起こしの結果を受け取る
*/
app.post('/partial/:conference', function(req, res) {
  io.in(AGENT).emit('message', {message: req.body.UnstableSpeechResult});
  res.send('ok');
});

app.post('/status', function(req, res) {
  var twiml = new VoiceResponse();
  twiml.hangup();
  res.type('text/xml');
  res.send(twiml.toString());
});

app.post('/fallback', function(req, res) {
  var twiml = new VoiceResponse();
  twiml.hangup();
  res.type('text/xml');
  res.send(twiml.toString());
});

function sorry(twiml, res, message){
  twiml.say(message, {language: 'ja-JP'}).hangup();
  res.type('text.xml');
  res.send(twiml.toString());
}

server.listen(port, function() {
  console.log('Server is running');
});

io.on('connection', function(socket){
  // FIXME 今回はデモなのでチャンネル名は決め打ち
  socket.join(AGENT);
});
```

うん、思ったより長いね。スーパイバイザーとオペレーターはIDを決め打ちにしています。そのまま実運用するとあまりいことはないので直しましょう。

それから`.env`ファイルを同じ階層に用意して、Twilioのアカウント情報を入力します。

```javascript
AccountSid=あなたのAccount SID
AuthToken=あなたのAuth Token
CallerId=あなたのTwilio電話番号
PORT=18888
AGENT=smith
SUPERVISOR=trinity
```

# クライアントを作る

クライアント側は２つの役割を実装します。1つは普通のオペレータとして通話を受ける係。もう１つは通話を受信して文字起こしの結果を受け取り処理する係です。それぞれ`client.js`と`transcript.js`が担当します。共通部分になるTwilioとの接続は`twilio.js`にやってもらいます。

```bash
public/
├── client.js
├── index.html
├── transcript.html
├── transcript.js
└── twilio.js
```

`client.js`は特に凝ったことはしません。

```javascript
(function(){
  var AGENT = 'smith';
  var socket = io();

  socket.on('message', function(e) {
    console.log(e);
    $('#message').text(e.message);
  });

  $('#disconnect').click(function(){
    Twilio.Device.disconnectAll();
    document.getElementById('transcript').contentWindow.disconnect();
  });
  init(AGENT);
})();
```

Twilioを初期化して、Socket.ioのメッセージを待って画面に表示し、iframeのfunctionを呼んでいます。この`client.js`を呼んでいるのが`index.html`です。

```html
<!DOCTYPE HTML>
<html>
  <head>
    <script type="text/javascript" src="//media.twiliocdn.com/sdk/js/client/v1.4/twilio.min.js"></script>
    <script src="/socket.io/socket.io.js"></script>
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/css/bootstrap.min.css">
    <script src="//ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.3/umd/popper.min.js"></script>
    <script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta.2/js/bootstrap.min.js"></script>

  </head>
<body>
  <iframe src="/transcript.html" id="transcript" width="0" height="0"></iframe>
  <button id="disconnect" class="btn btn-primary">disconnect</button>
  <div id="message"></div>
  <script src="/twilio.js"></script>
  <script src="/client.js"></script>
</body>
</html>
```

このiframeがキモで、要するに１台のクライアントでオペレータとスーパーバイザの両方を兼ねているわけです。iframeの中身の`transcript.html`はこんな感じ。

```html
<!DOCTYPE HTML>
<html>
  <head>
    <script type="text/javascript" src="//media.twiliocdn.com/sdk/js/client/v1.4/twilio.min.js"></script>
    <script src="//ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
    <script tyle="text/javascript" src="/twilio.js"></script>
    <script tyle="text/javascript" src="/transcript.js"></script>
  </head>
<body>
</body>
</html>
```

`transcript.js`はもっと空っぽで

```javascript
(function(){
  var SUPERVISOR = 'trinity';
  init(SUPERVISOR);
})();

function disconnect(){
  Twilio.Device.disconnectAll();
}
```

最後に共通部分の`twilio.js`ですが、かなり端折っています。

```javascript
var initialized = false;
function setUp(token) {
  if (initialized === false) {
    Twilio.Device.ready(function(){
      // FIXME
    });
    Twilio.Device.error(function(){
      // FIXME
    });
    Twilio.Device.cancel(function(){
      // FIXME
    });
    Twilio.Device.incoming(function(conn){
      // FIXME 問答無用で受話する
      conn.accept();
    });
    initialized = true;
  }
  Twilio.Device.setup(token);
}

function init(identity) {
  console.log('initializing ' + identity);
  $.ajax({
    url: '/token/' + identity,
    method: 'GET',
    success: function(token) {
      setUp(token);
    } // FIXME エラー処理していない
  });
}
```

これで準備完了です。

```bash
ngrok http 18888
```

ngrokを使って手元のマシンをTwilioからアクセス可能にしたら、Twilioの管理画面でTwimlアプリを作成し、CallbackのURLをngrokから配給されるhttpsのURL（後ろは`/start`）にします。作成したTwimlアプリを電話番号と紐付けたら電話してみてください。

動かしてみると、まあ多分初回はブラウザの許可を求めるダイアログが出てうまくいかないけど、何回か試したら（真面目に作ってないからすいません）動きます。

<img style="border: solid 1px;" width="295" alt="christmas.png" src="https://qiita-image-store.s3.amazonaws.com/0/1782/f62d7770-5aaf-c151-025f-c16add201831.png">

こんな感じ。

で、もう少し詳しく説明すると、うまくいけば文字起こしの途中結果が`/partial/カンファレンス名`に、`/gather/カンファレンス名`にある程度の長さの結果が配信されます。しばらく繰り返しているうちに時々切れたりしますから、再接続を実装するといいでしょう。

ただ、これやってみるとわかるんですが、通話料がかなり上がります。普通の受話に加えてConferenceと聞き取りの分で通話時間にもよりますがおそらくクライアント側は倍以上になるでしょう。早くTwilioから音声データをサーバ側でストリーミング受信できるようになるといいですね（Clientでやる手もありますが）。