Web Speech API + Movable Type で、音声認識による記事投稿アプリを作る

この記事は、[Movable Type Advent Calendar 2018](https://adventar.org/calendars/2900) の 20日目の記事です。

ブログ記事を書きたい、ウェブサイトを更新したい。
しかし、キーボードで入力するのは面倒くさい。

という悩みをお持ちの方は多いと思います(当てずっぽう)。

そこで、音声認識APIを利用し、音声を文字に変換して、Movable Type で運用されているサイトの更新を行うウェブアプリを作ってみました。

## やること

Web Speech API を利用して、Movable Type へ音声入力による記事を投稿するウェブアプリを作る
実装は JavaScript で行う。

## 仕様

- ウェブアプリから、Movable Type に認証を行う
- 認証が通ったら、投稿画面が表示される。
- Web Speech APIを利用した音声認識を使い、音声を自動的にテキスト変換する。
- テキスト変換した原稿を、Movable Type へ投稿する
- サイトに新しい記事が登録され、更新される

今回のアプリは、Google Chromeのみ対応とする(理由は後述)


## Web Speech API とは

[W3C Community Final Specification Agreement（FSA）](https://www.w3.org/community/about/agreements/final/)の下、[Speech API Community](https://www.w3.org/community/speech-api/)により策定されている、JavaScript のAPI。

音声認識と、音声合成の2つの機能を持つ。

Speech API Community が公開している仕様文章はこちら。
https://w3c.github.io/speech-api

MDNのリファレンスはこちら。
https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API

[MDNのリファレンス](https://developer.mozilla.org/ja/docs/Web/API/Web_Speech_API#%E3%83%96%E3%83%A9%E3%82%A6%E3%82%B6%E3%83%BC%E4%BA%92%E6%8F%9B%E6%80%A7%E3%83%86%E3%83%BC%E3%83%96%E3%83%AB)によると、FireFoxはまだ音声認識を実装していないようなので、WebSpeech APIを試す場合、実質的にGoogle Chrome 一択となるようです。

## Web Speech APIを利用した音声認識と制限

今回はWeb Speech API の音声認識機能を使用します。

W3Cが公開する仕様では、音声認識には [SpeechRecognition](https://w3c.github.io/speech-api/#speechreco-section)経由で音声認識機能を利用する、と書かれており、SpeechRecognitionのオブジェクトを生成して各種機能にアクセスする方法が書かれています。

以下は、[W3Cが公開しているサンプルコードの一部](https://w3c.github.io/speech-api/#examples-recognition) です。

```JavaScript
<textarea id="textarea" rows=10 cols=80></textarea>
<button id="button" onclick="toggleStartStop()"></button>

<script type="text/javascript">
  var recognizing;
  var recognition = new SpeechRecognition();
  recognition.continuous = true;
  reset();
  recognition.onend = reset;

  recognition.onresult = function (event) {
    for (var i = event.resultIndex; i < event.results.length; ++i) {
      if (event.results[i].isFinal) {
        textarea.value += event.results[i][0].transcript;
      }
    }
  }

  function reset() {
    recognizing = false;
    button.innerHTML = "Click to Speak";
  }

  function toggleStartStop() {
    if (recognizing) {
      recognition.stop();
      reset();
    } else {
      recognition.start();
      recognizing = true;
      button.innerHTML = "Click to Stop";
    }
  }
</script>
```

このサンプルでは、「Click to Speak」をクリックすることで音声認識を行い、テキストエリアに文章を連続で認識しています。

2018年12月現在、このサンプルを Google Chrome (ver 70.0.3538.102 ) で実行すると

```
Uncaught ReferenceError: SpeechRecognition is not defined
```
というエラーが発生して音声認識ができません。

[MDNを参照すると、Google Chromeでは ベンダープレフィックスをつけて「webkitSpeechRecognition」と記述しないと動かない](https://developer.mozilla.org/ja/docs/Web/API/SpeechRecognition/SpeechRecognition) とのことでした。

そこで、[Google が公開する Web Speech APIのデモコード](https://www.google.com/intl/ja/chrome/demos/speech.html) を見つつ、W3Cのサンプルに若干修正を加えました。



以下のコードは、Google Chrome で動作するサンプルコードとなります。


```JavaScript
  <script type="text/javascript">
    var recognizing;
    var recognition = new webkitSpeechRecognition();
    recognition.continuous = true;
    reset();
    recognition.onend = reset;
    recognition.lang = 'ja';
    recognition.onresult = function (event) {
      for (var i = event.resultIndex; i < event.results.length; ++i) {
        if (event.results[i].isFinal) {
          textarea.value += event.results[i][0].transcript;
        }
      }
    }

    function reset() {
      recognizing = false;
      button.innerHTML = "Click to Speak";
    }

    function toggleStartStop() {
      if (recognizing) {
        recognition.stop();
        reset();
      } else {
        recognition.start();
        recognizing = true;
        button.innerHTML = "Click to Stop";
      }
    }
  </script>

```

W3Cのコードからの変更点は以下のとおりです。

- コントローラーを「SpeechRecogntion」から「webkitSpeechRecognition」に変更
- 認識言語を日本語に設定　(recognition.lang = 'ja')


下記が実際に動作するサンプルです。Google Chrome のみで動作確認をしています。

https://nick-smallworld.github.io/WebSpeechAPIPractice/sample-1.html


## Movable Type の認証・投稿と組み合わせる

WebSpeech APIの使い方を一通りさらったところで、Movable Type への投稿アプリを作ります。

[以前に公開した拙文](https://qiita.com/TakeshiNickOsanai/items/f4b1335d87a2f5525964) のコードと組み合わせ、MTの認証・投稿ロジックと音声認識APIを組み合わせます。

MTの投稿アプリに、音声認識を組み合わせたコードサンプルがこちらです。

```javascript
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta content="IE=edge" http-equiv="X-UA-Compatible">
  <meta content="width=device-width, initial-scale=1" name="viewport">
  <title>autheticationを使った投稿サンプル</title>
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.1.1/jquery.min.js">
  </script>
  <link href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
  <div class="container">
    <div class="form-group col-sm-8" id="content">
      <h2>サインイン</h2>
      <div class="form-group">
        <input class="form-control" id="username" placeholder="ユーザー名" type="text">
      </div>
      <div class="form-group">
        <input class="form-control" id="password" placeholder="パスワード" type="password">
      </div>
      <div class="form-group">
        <button id="signin">サインイン</button>
      </div>
    </div>
  </div>
  <script>


  $(function() {

  // MT投稿アプリの初期設定

  const clientId = 'WebSpeechAPI_post_sample';
  const currentSession = 'mt_session_' + clientId;
  const currentToken = 'mt_token_' + clientId;
  const nowTime = Math.floor(new Date().getTime() / 1000);

  // MTのData APIのURI、およびサイトIDを記述

  const apiUrl = '<MTCGIPath><MTDataAPIScript>';
  const siteId = '<MTBlogID>';

  // 音声認識に使う WebSpeechAPI の初期設定

  let recognizing;
  const recognition = new webkitSpeechRecognition();
  recognition.continuous = true;
  recognition.onend = reset;
  recognition.lang = 'ja';

  // 認証チェック 
  // 認証が済んでいる場合、関数haveSignedInを実行して投稿画面を表示

  if (sessionStorage.getItem(currentSession)) {
    haveSignedIn();
  }

  // 認証用関数 signInを実行
  // ユーザー名、パスワードが正しければ認証を実行

  $('#signin').on('click', function() {
    signIn();
  });

  // 音声認識用関数 toggleStartStop を実行
  // 本文欄に認識した音声を文字情報として表示

  $('#speech').on('click', function() {
    toggleStartStop();
  });

  // 投稿するときに関数 postEntryを実行

  $('#submit').on('click', function() {
    const promise = Promise.resolve(checkToken());
    promise.then(function() {
      PostEntry();
    });
  });

  // サインアウトをクリックしたときに関数 revokeSessionを実行

  $('#signout').on('click', function() {
    revokeSession();
  });

  // WebSpeechAPI を利用した音声認識を実行して、本文入力欄に反映する
    
    recognition.onresult = function (event) {
      for (let i = event.resultIndex; i < event.results.length; ++i) {
        if (event.results[i].isFinal) {
          body.value += event.results[i][0].transcript;
        }
      }
    }


  // 認証済みだった場合、投稿画面を表示

  function haveSignedIn() {
    const displayData = `
        <h3>投稿する</h3>
        <div class="col-sm-8">
        <div class="form-group">
          <input id="title" name="title" placeholder="タイトル" type="text"  class="form-control">
        </div>

        <div class="form-group">
          <button id="speech"></button>
        </div>

        <div class="form-group">
          <textarea id="body" maxlength="140" placeholder="本文" rows="7"  class="form-control"></textarea>
        </div>

        <div class="form-group">
          <button id="submit" name="submit" type="button">投稿する</button>
        </div>
        <div class="form-group">
          <button id="signout" name="signout" type="button">サインアウト</button>
        </div>
        </div>
        `
    $("#content").empty();
    $("#content").append(displayData);
    reset();
  }

  // MTの認証機能を通じてサインインを行う

  function signIn() {
    const username = $('#username').val();
    const password = $('#password').val();
    $.ajax({
      url: apiUrl + '/v4/authentication',
      type: 'POST',
      dataType: 'json',
      data: {
        'username': username,
        'password': password,
        'clientId': clientId,
      }
    }).done(function(data) {
      sessionStorage.setItem(currentSession, data.sessionId);
      let TokenData = {
        'accessToken': data.accessToken,
        'expiresIn': data.expiresIn,
        'gotTokenTime': nowTime,
      }
      sessionStorage.setItem(currentToken, JSON.stringify(TokenData));
      alert("サインインしました。");
      location.reload();
    }).fail(function(data) {
      alert("サインイン時にエラーが発生しました。再度サインインをお試しください。");
    });
  }

  // 音声認識機能を初期化   

    function reset() {
      recognizing = false;
      $("#speech").html( "クリックして音声認識" );
    }
  
  
  // 音声認識の開始・ストップをコントロール

    function toggleStartStop() {
      if (recognizing) {
        recognition.stop();
        reset();
      } else {
        recognition.start();
        recognizing = true;
        $("#speech").html( "認識中 クリックでストップ" );
      }
    }

  // MTへデータを投稿

  function PostEntry() {
    const entry = {};
    entry.title = $('#title').val();
    entry.body = $('#body').val();
    $.ajax({
      url: apiUrl + '/v4/sites/' + siteId + '/entries',
      type: 'POST',
      dataType: 'json',
      headers: {
        'X-MT-Authorization': 'MTAuth accessToken=' + JSON.parse(sessionStorage.getItem(currentToken)).accessToken,
      },
      data: {
        'entry': JSON.stringify(entry)
      },
    }).done(function() {
      alert("データを投稿しました。");
    }).fail(function(data) {
      alert("投稿時にエラーが発生しました。");
    });
  }

  // 認証情報を破棄してサインアウトする

  function revokeSession() {
    $.ajax({
      url: apiUrl + '/v4/token',
      type: "DELETE",
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'X-MT-Authorization': 'MTAuth accessToken=' + JSON.parse(sessionStorage.getItem(currentToken)).accessToken,
      }
    }).done(function() {
      $.ajax({
        url: apiUrl + "/v4/authentication",
        type: "DELETE",
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'X-MT-Authorization': 'MTAuth sessionId=' + sessionStorage.getItem(currentSession),
        }
      })
    }).done(function() {
      sessionStorage.removeItem(currentSession);
      sessionStorage.removeItem(currentToken);
      alert("サインアウトしました。");
      location.reload();
    }).fail(function(data) {
      alert("サインアウト中にエラーが発生しました。もう一度サインインし直してください。");
      sessionStorage.removeItem(currentSession);
      sessionStorage.removeItem(currentToken);
      location.reload();
    });
  }

  // トークンチェック

  function checkToken() {
    const expiresIn = JSON.parse(sessionStorage.getItem(currentToken)).expiresIn;
    const gotTokenTime = JSON.parse(sessionStorage.getItem(currentToken)).gotTokenTime;
    if (nowTime >= expiresIn + gotTokenTime) {
      refreshToken();
    }
  }

  // トークンリフレッシュ

  function refreshToken() {
    $.ajax({
      url: apiUrl + '/v4/token',
      type: "DELETE",
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'X-MT-Authorization': 'MTAuth accessToken=' + JSON.parse(sessionStorage.getItem(currentToken)).accessToken,
      }
    }).done(function() {
      $.ajax({
        url: apiUrl + '/v4/token',
        type: "POST",
        dataType: "json",
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'X-MT-Authorization': 'MTAuth sessionId=' + sessionStorage.getItem(currentSession),
        }
      }).done(function(data) {
        let TokenData = {
          'accessToken': data.accessToken,
          'expiresIn': data.expiresIn,
          'gotTokenTime': nowTime,
        }
        sessionStorage.setItem(currentToken, JSON.stringify(TokenData));
      }).fail(function(data) {
        alert("アクセストークンのリフレッシュ中にエラーが発生しました。サインし直してください。");
        sessionStorage.removeItem(currentSession);
        sessionStorage.removeItem(currentToken);
        location.reload();

      });
    });
  }
  });


  </script>
</body>
</html>

```

サンプルコートでは、 Data API のバージョンとしてv4を選択しました。
MT6で動かしたいときには、Data API のバージョンをv3と指定すれば動くと思います。


## 備考

WebSpeechAPIはまだ最終仕様が決定していないため、数年後にはコントローラーの仕様などが変わっている可能性があります。

都度、W3CやMDNなどのリファレンスをご参照ください。