JavaScript
twilio
TwilioDay 25

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

作るもの

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

用意するもの

  • Twilioアカウント
  • ngrok
  • node.js

レシピ

まずはサーバを作ります。機能はざっくりこんな感じ:
- Twilioのトークンを発行
- 着信時にAgent Conference用のConferenceを作成
- 作成したConferenceにオペレータを招待
- 作成したConferenceに文字起こしを実行するSupervisorを招待
- オペレータをConferenceに参加させる
- SupervisorをConferenceに参加させる
- 参加したSupervisorをGatherで文字起こし開始させる
- 文字起こしの結果を受け取ってwebsocket(socket.io)でブラウザに通知

うん、シンプルですね!

サーバを作る

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

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

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のアカウント情報を入力します。

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

クライアントを作る

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

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

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

(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です。

<!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がキモで、要するに1台のクライアントでオペレータとスーパーバイザの両方を兼ねているわけです。iframeの中身のtranscript.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はもっと空っぽで

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

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

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

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 エラー処理していない
  });
}

これで準備完了です。

ngrok http 18888

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

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

christmas.png

こんな感じ。

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

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