ツイキャスAPI v2を使った棒読みちゃんWebアプリを作った

#■はじめに
前にツイキャスAPI v2を使った認証を実装しましたが、それの続きです。今回はツイキャスのコメントを棒読みちゃんで読み上げるWebアプリを作りました。
なぜWebアプリなのかというと、それはツイキャスAPI v2がOAuthを使っているため、少なくとも認証にWebサイトを経由しないといけないからです。
別の方法として認証されたときのアクセストークンをユーザーに提示してそれをネイティブアプリに設定してもらう形でも実現できるとは思いますが、今回は素直にWebアプリにしました。
下記に公開しています。よろしければご覧ください。

TwcasChatListen http://starlightparade.usamimi.info/twcaschatlisten/

![20180620_TwcasChatListen.jpg](https://qiita-image-store.s3.amazonaws.com/0/252253/a2d2c683-0e55-3cd9-44cb-09f6d18fb086.jpeg)

#■1. モデル
ツイキャスAPI v2リクエスト処理メソッドをモデルにまとめています。

```models/twcas_api_v2.php
<?php

require_once 'vendor/autoload.php'; // TwitCastingOAuth
use Shucream0117\TwitCastingOAuth\ApiExecutor\AppExecutor;
use Shucream0117\TwitCastingOAuth\ApiExecutor\UserExecutor;
use Shucream0117\TwitCastingOAuth\Entities\AccessToken;
use Shucream0117\TwitCastingOAuth\GrantFlow\AuthCodeGrant;

/**
 * 定数
 */
const ClientId = 'クライアントID';
const ClientSecret = 'クライアントシークレット';
const OwnerId = 'オーナーID';
const CallbackUrl = 'http://starlightparade.usamimi.info/twcaschatlisten/callback.php';

/**
 * ツイキャスAPIV2
 */
class TwcasApiV2 {

    /**
     * 認証ページURLの取得
     */
    public function getConfirmPageUrl($csrfToken) {
        // AuthGrandの生成
        $grant = new AuthCodeGrant(ClientId, ClientSecret, CallbackUrl);

        // 認証ページURLの取得
        $url = $grant->getConfirmPageUrl($csrfToken);
        
        return $url;
    }
    
    /**
     * アクセストークンを取得する
     */
    public function getAccessToken($code) {
        // AuthGrandの生成
        $grant = new AuthCodeGrant(ClientId, ClientSecret, CallbackUrl);

        // アクセストークンのリクエスト
        $accessToken = $grant->requestAccessToken($code, new AppExecutor(ClientId, ClientSecret));
        
        $accessTokenStr = $accessToken->getAccessToken();
        
        return $accessTokenStr;
    }
    
    /**
     * アクセストークンを検証しユーザー情報を取得する
     */
    public function verifiyCredentials($accessTokenStr) {
        $accessToken = new AccessToken($accessTokenStr);
        $executor = new UserExecutor($accessToken);
        $userInfo = null;
        try {
            $response = $executor->get("verify_credentials");
            $json = $response->getBody()->getContents();
            $userInfo = TwcasApiV2::jsonDecode($json);
        }
        catch (Exception $exception) {
            // 不正なアクセストークン
            //echo $exception->getMessage() . '<br>';
            // {"error":{"code":1000,"message":"Invalid token"}} と表示される
        }
        return $userInfo;
    }
    
    /**
     * ユーザー情報を取得する
     */
    public function getUserInfo($accessTokenStr, $userId) {
        $accessToken = new AccessToken($accessTokenStr);
        $executor = new UserExecutor($accessToken);
        $userInfo = null;
        try {
            $response = $executor->get("users/{$userId}");
            $json = $response->getBody()->getContents();
            $userInfo = TwcasApiV2::jsonDecode($json);
        }
        catch (Exception $exception) {
            //echo $exception->getMessage() . '<br>';
        }
        return $userInfo;
    }
    
    /**
     * ユーザーが配信中の場合、ライブ情報を取得する
     */
    public function getCurrentLive($accessTokenStr, $userId) {
        $accessToken = new AccessToken($accessTokenStr);
        $executor = new UserExecutor($accessToken);
        $currentLive = null;
        try {
            $response = $executor->get("users/{$userId}/current_live");
            $json = $response->getBody()->getContents();
            $currentLive = TwcasApiV2::jsonDecode($json);
        }
        catch (Exception $exception) {
            //echo $exception->getMessage() . '<br>';
        }
        return $currentLive;
    }

    /**
     * コメントを作成日時の降順で取得する
     */
    public function getComments($accessTokenStr, $movieId, $offset, $limit, $sliceId) {
        $accessToken = new AccessToken($accessTokenStr);
        $executor = new UserExecutor($accessToken);
        $comments = null;
        try {
            $response = $executor->get("movies/{$movieId}/comments", [
                'offset' => $offset,
                'limit' => $limit,
                'slice_id' => $sliceId
            ]);
            $json = $response->getBody()->getContents();
            $comments = TwcasApiV2::jsonDecode($json);
        }
        catch (Exception $exception) {
            //echo $exception->getMessage() . '<br>';
        }
        return $comments;
    }

    
    /**
     * JSONをデコードする
     */
    public static function jsonDecode($json) {
        return json_decode($json, true);
    }
    
    /**
     * JSONをエンコードする
     */
    public static function jsonEncode($obj) {
        return json_encode($obj, JSON_UNESCAPED_UNICODE);
    }

}
```

#■2.コントローラー
認証リダイレクト、コールバックとインデックスページに対応するメソッド、およびコメント取得関連APIを実装しています。

```controllers/twcaschatlisten_controller.php
<?php

const CsrfTokenKey = 'csrfToken';
const AccessTokenKey = 'accessToken';

/**
 * TwcasChatListenコントローラー
 */
class TwcasChatListenController extends Controller {

    /**
     * プリプロセス
     */
    public function preProcess() {
        $this->view->setLayout('default_twcaschatlisten');

        $req = $this->request;
        $reqBase = $req->getBasePath();
        $this->view->reqBase = $reqBase;
        $reqBaseUrl = $req->getBaseUrl();
        $this->view->reqBaseUrl = $reqBaseUrl;
        $this->view->pageTitle = "";
    }

    /**
     * アプリ
     */
    public function index() {
        // モデル
        $TwcasApiV2 = $this->model('TwcasApiV2');

        $accessTokenStr = $this->session->get(AccessTokenKey);
        if (is_null($accessTokenStr)) {
            $confirmPageUrl = $this->view->reqBase . '/twcaschatlisten/confirm';
            $this->response->redirect($confirmPageUrl);
            return;
        }

        // アクセストークンの検証（ユーザー情報の取得）
        $userInfo = null;
        $userInfo = $TwcasApiV2->verifiyCredentials($accessTokenStr);
        if (is_null($userInfo)) {
            $confirmPageUrl = $this->view->reqBase . '/twcaschatlisten/confirm';
            $this->response->redirect($confirmPageUrl);
            return;
        }
        //var_dump($userInfo);
        
        // Js
        $this->view->addJs('bouyomichan_client.js');
        $this->view->addJs('twcaschatlisten_index.js');
    }
    
    /**
     * API
     */
    public function api() {
        $req = $this->request;
        
        // GETパラメータ
        $query = $req->getQuery();

        if (!isset($query['method'])) {
            return "";
        }
        $method = $query['method'];

        // モデル
        $TwcasApiV2 = $this->model('TwcasApiV2');

        // アクセストークン
        $accessTokenStr = $this->session->get(AccessTokenKey);
        if (is_null($accessTokenStr)) {
            return "";
        }
        
        // API実行
        $json = '';
        switch($method) {
            // ユーザー情報取得
            case 'getUserInfo':
                $userId = $query['user_id'];
                $userInfo = $TwcasApiV2->getUserInfo($accessTokenStr, $userId);
                $json = TwcasApiV2::jsonEncode($userInfo);
                break;

            // 現在のライブの情報取得
            case 'getCurrentLive':
                $userId = $query['user_id'];
                $currentLive = $TwcasApiV2->getCurrentLive($accessTokenStr, $userId);
                $json = TwcasApiV2::jsonEncode($currentLive);
                break;

            // コメント取得
            case 'getComments':
                $movieId = $query['movie_id'];
                $offset = isset($query['offset'])? $query['offset']: 0;
                $limit = isset($query['limit'])? $query['limit']: 10;
                $sliceId = isset($query['slice_id'])? $query['slice_id']: 0;
                $currentLive = $TwcasApiV2->getComments($accessTokenStr, $movieId, $offset, $limit, $sliceId);
                $json = TwcasApiV2::jsonEncode($currentLive);
                break;
        }
        
        $this->view->json = htmlspecialchars($json);
    }

    /**
     *  確認ページリダイレクト
     */
    public function confirm() {
        $this->view->pageTitle = "認証開始 - ";
        
        // モデル
        $TwcasApiV2 = $this->model('TwcasApiV2');

        // CSRFトークン
        $csrfToken = md5(uniqid(rand(),1));
        
        // 認証ページURLの取得
        $url = $TwcasApiV2->getConfirmPageUrl($csrfToken);

        // 以前のアクセストークンを削除
        $this->session->remove(AccessTokenKey);

        // セッションデータのセット
        $this->session->set(CsrfTokenKey, $csrfToken);

        // リダイレクト
        $this->response->redirect($url);
    }

    /*
     * コールバック
     */
    public function callback() {
        $req = $this->request;
        $this->view->pageTitle = "認証コールバック - ";
        
        // GETパラメータ
        $query = $req->getQuery();
        if (isset($query['result'])) {
            $result = $query['result'];
            if ($result == 'denied') {
                // 認証がキャンセルされた
                $this->_authFailed();
                return;
            }
        }
        
        // モデル
        $TwcasApiV2 = $this->model('TwcasApiV2');

        // セッションデータの取得
        $csrfTokenSaved = $this->session->get(CsrfTokenKey);
        if (is_null($csrfTokenSaved)) {
            //echo 'session has been expired<br>';
             $this->_authFailed();
             return;
        }
        // セッションデータの削除
        $this->session->remove(CsrfTokenKey);
        
        if (!isset($query['state'])) {
            //echo 'csrf token is invalid<br>';
            $this->_authFailed();
            return;
        }
        $state = $query['state'];
        if ($state != $csrfTokenSaved) {
            //echo 'csrf token is invalid<br>';
            $this->_authFailed();
            return;
        }
        $code = $query['code'];
        
        // アクセストークンの取得
        $accessTokenStr = $TwcasApiV2->getAccessToken($code);

        // セッションIDを再生成する
        session_regenerate_id(true);
        $this->session->set(AccessTokenKey, $accessTokenStr);
        
        // アプリページ(index)にリダイレクトする
        $this->response->redirect($this->view->reqBase . '/twcaschatlisten/');
        exit;

        /*
        // アクセストークンの表示
        $pageData = array(
            'confirmed' => 1,
            'accessToken' => htmlspecialchars($accessTokenStr),
        );
        
        $this->view->pageData = $pageData;
        */
    }
    
    /**
     * 認証失敗
     */
    private function _authFailed() {
        $pageData = array(
            'confirmed' => 0,
            'accessToken' => '',
        );
        $this->view->pageData = $pageData;
    }
}
```

#■3. ビュー
インデックスページとAPIページのビューです。

```views/layouts/default_twcaschatlisten.php
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Cache-Control" content="no-cache"> 
<script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-3.3.1.min.js"></script>
<!--<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>-->
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js" integrity="sha384-ZMP7rVo3mIykV+2+9J3UJ46jBk0WLaUAdn689aCwoqbBJiSnjAK/l8WvCWPIPm49" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/js/bootstrap.min.js" integrity="sha384-smHYKdLADwkXOn1EmN1qk/HfnUcbVRZyYmZ4qpPea6sjB/pTJ0euyQp0Mk8ck+5T" crossorigin="anonymous"></script>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css" integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB" crossorigin="anonymous">
<?php foreach ($javascripts as $js) { ?>
<script type="text/javascript" src="<?php echo $reqBase; ?>/js/<?php echo $js; ?>"></script>
<?php } ?>
<?php foreach ($stylesheets as $css) { ?>
<link rel="stylesheet" href="<?php echo $reqBase; ?>/css/<?php echo $css; ?>">
<?php } ?>
<link rel="shortcut icon" href="<?php echo $reqBase; ?>/images/favicon.ico">
<title><?php echo $pageTitle ?> TwcasChatListen</title>
</head>
<body>

<div class="content_header">
    <nav class="navbar navbar-light bg-light">
        <a class="navbar-brand" href="<?php echo $reqBase; ?>/twcaschatlisten/">
            <h1>
                <img width="80" src="<?php $reqBaseUrl ?>/images/TwicasChatListen_icon.png" class="d-inline-block align-top" alt="">TwcasChatListen
            </h1>
        </a>
        <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse" id="navbarNav">
            <ul class="navbar-nav">
                <li class="nav-item">
                    <h3>
                        <a class="nav-link" href="https://github.com/ryujimiya/Plugin_BymChnWebSocket" target="_blank">棒読みちゃんプラグインのインストール</a>
                    </h3>
                </li>
            </ul>
        </div>
    </nav>
    <p><b>棒読みちゃんにコメントを読ませるには、右上のボタンからプラグインをインストールしてください</b></p>
</div>

<div class="content_body">

<div class="container">

<div class="panel">
    <div class="panel-body">

<?php echo $inner_contents; ?>

    </div><!--/.panel-body -->
</div><!--/.panel -->

</div><!-- /.container -->

</div><!--/.content_body -->

<div class="content_footer">
    <div class="container">
        <h4>(C) 2018 ryujimiya</h4>
    </div>
</div><!--/.content_footer -->

</body>
</html>
```

インデックスページはコメント一覧が主なコンテンツです。

```views/templates/twcaschatlisten/index.php
<script>
    $(function() {
        $('#form1').submit(e => {
           onSubmit(e, '<?php $reqBase ?>')
        });
    });
</script>

<form method="get" id="form1" action="javascript:void(0)">
    <div class="form-group">
        <div class="row">
            <button type="submit" class="btn col-2"><img src="<?php $reqBase ?>/images/update.png"></button>
            <input type="text"  autocomplete="off" id="screen_id" name="channel_id" class="col-10">
        </div>
    </div>
</form>
<table id="table1" class="table table-bordered table-fixed">
  <thead class="thead-dark">
    <tr>
      <th scope="col" class="col-2">Icon</th>
      <th scope="col" class="col-4">Screen Name</th>
      <th scope="col" class="col-6">Comment</th>
    </tr>
  </thead>
  <tbody>
  </tbody>
</table>
```

テーブルが縦スクロールできるようにCSSをいじっています。（あまりうまくいってない気がする...)

```css/twcaschatlisten.css

@charset "utf-8";
body {
    font-size: 12pt;
    margin: 0;
    padding: 0;
}

button {
   color: #FFF;
   background-color: #000;
}

button > img {
    width: 40px;
}

.table-fixed table {
    height: 400px;
}

.table-fixed thead {
    width: 100%;
}

.table-fixed tbody {
    height: 350px;
    overflow-y: scroll;
    overflow-x: hidden;
    width: 100%;
}

.table-fixed tbody {
    display: block;
    box-sizing: border-box;
}

.table-fixed tbody td, .table-fixed thead > tr > th {
    float: left;
    border-bottom-width: 0;
    box-sizing: border-box;
}
```

API用ページは使用しているレンタルサーバーによっては、JSONで応答できない場合があるので、HTMLページとして出力します。

```views/templates/twcaschatlisten/api.php
<span id="json"><?php echo $json ?></span><br>
```

#■4. コメントの取得
コメント取得は一定時間ごとに行います。

```js/twcaschatlisten_index.js
/**
 * ツイキャスチャットリッスン
 */
let twcasChatListen = null;

/**
 * フォームが送信された
 */
const onSubmit = (e, reqBase) => {
    let screenId = $('#screen_id').val();
    console.log('screenId', screenId);

    // 前の配信の情報のクリア
    if (twcasChatListen != null) {
        twcasChatListen.stop();
        twcasChatListen = null;
    }
    let $tableBody = $('#table1 > tbody');
    $tableBody.html("");

    if (screenId === "") {
        return;
    }
    twcasChatListen = new TwcasChatListen(screenId, onCommentsReceived, reqBase);
    twcasChatListen.start();
    return false;
}

/**
 * コメントを受信した
 */
const onCommentsReceived = list => {
    
    list.map((item, index) => {
        let message = item.message;
        let user = item.from_user;
        let screenId = user.screen_id;
        let screenName = user.name;
        let iconImgSrc = user.image;
        let isBouyomiOn = item.isBouyomiOn;
        let trTag =
`    <tr>
      <td class="col-2"><img src="${iconImgSrc}"></td>
      <td class="col-4"><b>${screenName}</b>&nbsp;@${screenId}</td>
      <td class="col-6">${message}</td>
    </tr>`;

        let $tableBody = $('#table1 > tbody');
        let $trArray = $('#table1 > tbody > tr');
        let rowCnt = $trArray.length;
        //console.log("rowCnt=", rowCnt);
        if (rowCnt === 0) {
            $tableBody.html(trTag);
        }
        else {
            $trArray.eq(rowCnt - 1).after(trTag);
        }
        
        if (isBouyomiOn) {
            let bouyomiChanClient = new BouyomiChanClient();
            bouyomiChanClient.talk(message);
        }
    });
    
    // テーブルボディスクロール
    tableBodyScroll();
}

/***
 * テーブルボディスクロール
 */
const tableBodyScroll = () => {
    let $tableBody = $('#table1 > tbody');
    let pos = $tableBody[0].scrollHeight;
    $tableBody.scrollTop(pos);
}


////////////////////////////////////////////////////////////////////////////////////
/**
 * ツイキャスチャットリッスンクラス
 */
/**
 * コンストラクタ
 */
const TwcasChatListen = function(screenId, onCommentsReceived, reqBase) {
    this.screenId = screenId;
    this.onCommentsReceived = onCommentsReceived;
    this.reqBase = reqBase;
    this.liveTimerId = 0;
    this.commentsTimerId = 0;
    this.currentLive = null;
    this.lastCommentId = "0";
}

/**
 * 開始する
 */
TwcasChatListen.prototype.start = async function() {
    await this.reqLiveAsync();
    await this.reqCommentsAsync();
    this.livveTimerId = setInterval(this.liveTimerProc.bind(this), 1000 * 30);
    this.commentsTimerId = setInterval(this.commentsTimerProc.bind(this), 1000 * 3);
}

/**
 * 終了する
 */
TwcasChatListen.prototype.stop = function() {
    clearInterval(this.movieTimerId);
    clearInterval(this.commentsTimerId);
}

/**
 * ライブタイマーイベント
 */
TwcasChatListen.prototype.liveTimerProc = async function(e) {
    await this.reqLiveAsync();
}

/**
 * コメントタイマーイベント
 */
TwcasChatListen.prototype.commentsTimerProc = async function(e) {
    await this.reqCommentsAsync();
}

/**
 * JSONページの解析(<span id="json">JSON</span>)
 */
TwcasChatListen.prototype.getJsonFromResponse = function(data) {
    // HTMLパース処理
    let doc = document.implementation.createDocument('http://www.w3.org/1999/xhtml', 'html', null);
    let body = document.createElementNS('http://www.w3.org/1999/xhtml', 'body');
    body.innerHTML = data;
    doc.documentElement.appendChild(body);

    // id="json"にJSONが格納されている
    let jsonSpan = doc.getElementById('json');
    let jsonText = jsonSpan.textContent;
    let json = $.parseJSON(jsonText);

    return json;
}

/**
 * 配信情報をリクエスト
 */
TwcasChatListen.prototype.reqLiveAsync = function() {
    const f = (resolve, reject) => {
        if (this.screenId === "") {
            reject();
            return;
        }
        let url = this.reqBase + '/twcaschatlisten/api?method=getCurrentLive&user_id=' + this.screenId;
        $.ajax({
            url: url,
            type: 'GET',
            dataType: 'html',
            cache: false
        })
        .done(data => {
            this.currentLive = this.getJsonFromResponse(data);
            //console.log(this.currentLive);
            resolve();
        })
        .fail(data => {
            console.log("[ERROR]reqLiveAsync ajax failed");
            resolve();
        });
    }
    
    const p = new Promise(function(resolve, reject) {
        f(resolve, reject);
    });
    return p;
}


/**
 * コメントをリクエスト
 */
TwcasChatListen.prototype.reqCommentsAsync = function() {
    const f = (resolve, reject) => {
        if (this.currentLive === null) {
            reject();
            return;
        }
        //console.log("f lastCommentId", this.lastCommentId);
        let movieId = this.currentLive.movie.id;
        let url = this.reqBase + '/twcaschatlisten/api?method=getComments&movie_id=' + movieId
                      + '&offset=0&limit=50' + '&slice_id=' + this.lastCommentId;
        $.ajax({
            url: url,
            type: 'GET',
            dataType: 'html',
            cache: false
        })
        .done(data => {
            let comments = this.getJsonFromResponse(data);
            let list =  this.getCommentListAsc(comments);
            if (list.length === 0) {
                // 単純に更新がなかっただけなのでlastCommentIdはそのままにする
            }
            else {
                if (this.lastCommentId == 0) {
                    for (let i = 0; i < list.length; i++) {
                        list[i].isBouyomiOn = false;
                    }
                }
                let lastComment = list[list.length - 1];
                this.lastCommentId = lastComment.id;
                //console.log("changed lastCommentId", this.lastCommentId);
                //console.log("list", list);
                if (typeof onCommentsReceived == "function") {
                    onCommentsReceived(list);
                }
            }

            resolve();
        })
        .fail(data => {
            console.log("[ERROR]reqCommentsAsync ajax failed");
            resolve();
        });
    }

    let p  = new Promise((resolve, reject) => {
        f(resolve, reject);
    });
    return p;
}

/**
 * コメントを昇順に並べ替える
 */
TwcasChatListen.prototype.getCommentListAsc = function(comments) {
    let allCount = comments.all_count;
    let listDesc = comments.comments;
    let listAsc = [];
    // 昇順に並べ替えて追加する
    for (let i = 0; i < allCount; i++) {
        let item = listDesc[allCount - 1 - i];
        if (typeof item === "undefined") { // なぜかundefinedのコメントデータがある
            continue;
        }
        item.isBouyomiOn = true;
        listAsc.push(item);
        //console.log(item);
    }
    //console.log(listAsc);
    return listAsc;
}
```

#■5. 棒読みちゃんへ送信
前回作成した棒読みちゃんプラグインにコメントを送信します。
棒読みちゃんプラグイン：
https://github.com/ryujimiya/Plugin_BymChnWebSocket

```js/bouyomichan_client.js
/**
 * 接続先
 */
const HOST = 'localhost';
//const PORT = 50001;  // 棒読みちゃん直接
const PORT = 50002;  // WebSocketサーバー経由

/**
 * コンストラクタ
 */
const BouyomiChanClient = function() {
}

/**
 * 読み上げる
 */
BouyomiChanClient.prototype.talk = function(cmntStr) {
    this.cmntStr = cmntStr;
    let _socket = new WebSocket('ws://' + HOST + ':' + PORT + '/ws/');
    this.socket = _socket;
    this.socket.binaryType = 'arraybuffer';
    this.socket.onopen = this.socket_onopen.bind(this);
    this.socket.onerror = this.socket_onerror.bind(this);
    this.socket.onclose = this.socket_onclose.bind(this);
    this.socket.onmessage = this.socket_onmessage.bind(this);
}

/**
 * WebSocketが接続した
 */
BouyomiChanClient.prototype.socket_onopen = function(e) {
    //console.log("socket_onopen");
    
    // 棒読みちゃんデータを生成
    let data = this.makeBouyomiChanDataToSend(this.cmntStr);
    //console.log(data);
    // 送信
    this.socket.send(data.buffer);
}


/**
 * WebSocketが接続に失敗した
 */
BouyomiChanClient.prototype.socket_onerror = function(e) {
    console.log("socket_onerror");

    this.socket.close();
}

/**
 * WebSocketがクローズした
 */
BouyomiChanClient.prototype.socket_onclose = function(e) {
    //console.log("socket_onclose");
}

/**
 * WebSocketがデータを受信した
 */
BouyomiChanClient.prototype.socket_onmessage = function(e) {
    console.log("socket_onmessage");
    console.log(e.data);

    // データ受信したら切断する
    this.socket.close();
}


/**
 * 棒読みちゃんへ送信するデータの生成
 */
BouyomiChanClient.prototype.makeBouyomiChanDataToSend = function(cmntStr) {
    let command = 0x0001; //[0-1]  (16Bit) コマンド          （ 0:メッセージ読み上げ）
    let speed = -1; //[2-3]  (16Bit) 速度              （-1:棒読みちゃん画面上の設定）
    let tone = -1; //[4-5]  (16Bit) 音程              （-1:棒読みちゃん画面上の設定）
    let volume = -1; //[6-7]  (16Bit) 音量              （-1:棒読みちゃん画面上の設定）
    let voice = 0; //[8-9]  (16Bit) 声質              （ 0:棒読みちゃん画面上の設定、1:女性1、2:女性2、3:男性1、4:男性2、5:中性、6:ロボット、7:機械1、8:機械2、10001～:SAPI5）
    let code = 0; //[10]   ( 8Bit) 文字列の文字コード（ 0:UTF-8, 1:Unicode, 2:Shift-JIS）
    let len = 0; //[11-14](32Bit) 文字列の長さ

    let cmntByteArray = stringToUtf8ByteArray(cmntStr);
    
    len = cmntByteArray.length;
    let bytesLen = 2 + 2 + 2 + 2 + 2 + 1 + 4 + cmntByteArray.length;
    let data = new Uint8Array(bytesLen);
    let pos = 0;
    data[pos++] = command & 0xFF;
    data[pos++] = (command >> 8) & 0xFF;
    data[pos++] = speed & 0xFF;
    data[pos++] = (speed >> 8) & 0xFF;
    data[pos++] = tone & 0xFF;
    data[pos++] = (tone >> 8) & 0xFF;
    data[pos++] = volume & 0xFF;
    data[pos++] = (volume >> 8) & 0xFF;
    data[pos++] = voice & 0xFF;
    data[pos++] = (voice >> 8) & 0xFF;
    data[pos++] = code & 0xFF;
    data[pos++] = len & 0xFF;
    data[pos++] = (len >> 8) & 0xFF;
    data[pos++] = (len >> 16) & 0xFF;
    data[pos++] = (len >> 24) & 0xFF;
    for (let i = 0; i < cmntByteArray.length; i++) {
        data[pos++] = cmntByteArray[i];
    }
    return data;
}

///////////////////////////////////////////////////////////////////////////////////////
// Util
/**
 * string --> UTF8 byteArray変換
 */
function stringToUtf8ByteArray(str) {
    let out = [], p = 0;
    for (var i = 0; i < str.length; i++) {
        let c = str.charCodeAt(i);
        if (c < 128) {
            out[p++] = c;
        }
        else if (c < 2048) {
            out[p++] = (c >> 6) | 192;
            out[p++] = (c & 63) | 128;
        }
        else if (
            ((c & 0xFC00) == 0xD800) && (i + 1) < str.length &&
            ((str.charCodeAt(i + 1) & 0xFC00) == 0xDC00)) {

            // Surrogate Pair
            c = 0x10000 + ((c & 0x03FF) << 10) + (str.charCodeAt(++i) & 0x03FF);
            out[p++] = (c >> 18) | 240;
            out[p++] = ((c >> 12) & 63) | 128;
            out[p++] = ((c >> 6) & 63) | 128;
            out[p++] = (c & 63) | 128;
        }
        else {
            out[p++] = (c >> 12) | 224;
            out[p++] = ((c >> 6) & 63) | 128;
            out[p++] = (c & 63) | 128;
        }
    }
    return out;
}
```

#■まとめ
ツイキャスAPI v2を使った棒読みちゃんWebアプリを作りました。