Chrome - SpeechRecognition API :「聞きっぱなし」実装のためのイベント周りの挙動まとめ

Nextremer Advent Calendar 2018 - Qiitaの8日目の記事です。 qiita.com

仕事柄 音声認識関係のDemoアプリを作ることが多く、自然とWebSpeechAPIに対する知見が溜まります。というわけで、今回は社内外で「たまに動かなくなるケースがある」という声を多く聞く「聞きっぱなし」を実装するにあたって必要なイベント発火周りの挙動についてお送りいたします。公式Docsにもあんまり書いていないことを、と思ったらこんな角度になってしまいました。よろしくお願いいたします。

注意 : Badノウハウてきな対処を含みます。

要旨

  • 画面を開いている間、ユーザの音声入力を受付し続ける機能、いわゆる「聞きっぱなし」を実装したい
  • WebSpeechAPIは一定時間無音状態が続くと終了してしまうため、適切にイベントをハンドリングして、リスタートしてやる必要がある
  • イベントの挙動が直感に反するのでその挙動とリスタート処理実装のポイントについてまとめる

ChromeのSpeech Recognition のイベント挙動

それぞれのシチュエーションにおけるイベント発火有無は下記のとおりです(僕調べ)。「騒音」のケースで onerror が発火しないのはどう考えても直感に反し、注意が必要であることがわかります。

  • 正常 : 音声が入力され、音声認識が成功した場合
  • 無音 : 一定期間、音声が入力されなかった場合
  • 騒音 : 音声は入力されたが、言語として認識されなかった場合(キーボードのタイプ音等々)

f:id:rocky_manobi:20181210033620p:plain
Chrome WebSpeechRecognitionのイベント周りの挙動

「聞きっぱなし」実装のポイント

先述の挙動を踏まえると「聞きっぱなし」を実装する上でのポイントは以下の2点といえます。

無音ケースへの対処

無音ケースに対処するため、onerrorイベントを利用します。onerrorイベントの引数にはエラー原因が文字列で渡されるので、それを評価して音声認識をリスタートするのが良いでしょう。

const recognizer = new webkitSpeechRecognizer();
recognizer.onerror = (e) => {
  if (e.error === 'no-speech') {
    // 無音状態で一定時間が経過した、ということなので再度音声認識をスタート
    recognizer.start();
  }
};

騒音ケースへの対処

残念ながら僕の調べた限りでは、騒音ケースに対処するためにはBadノウハウ的な対処が必要であり、完全な動作を保証することもできません。しかしそれでも「たまーに動かない」の「たまーに」の発生率を大幅に下げることは可能です。

騒音ケースに対処するためonspeechendイベントを利用します。 onaudioendonsoundendも発火しますが、無音、騒音ケースにおいて onerrorと排他的に発火するonspeechendをハンドリングする方が(比較的)シンプルで良いでしょう。

下準備

下準備として、SpeechRecognizerをWrapしたクラスを用意し、音声認識の成否の状態を保持するフラグを持たせます。(なお、recognizer.start()をWrapしたメソッドは Promise 形式にしています。)

// SpeechRecognizerをWrapしたクラス
// 音声認識が成功している/していない の状態を管理する変数を用意
//  -> onresult(成功)で フラグを立てる / onstart(開始)でフラグをおる
class AutoRetrySpeechRecognizer{
  constructor(){
    this.recognizer = new webkitSpeechRecognizer();
    this.recognized = false;
    this.recognizer.onstart = ()=>{
      this.recognized = false;
    };

  }

  start(){
    return new Promise((resolve, reject)=>{
      this.recognizer.onresult = (e)=>{
        this.recognized = true;
        resolve(/*結果*/);
      };
      this.recognizer.start();
    });
  }
}


// 実行してみる
const recognizer = new AutoRetrySpeechRecognizer();
recognizer.start().then((result)=>{
  console.log(result);
});

onstartonresultのイベントハンドリング処理を記述するところがバラバラなのが気になりますが、一旦おいておきましょう(というか、codeはイメージです)

onspeechendのハンドリング

onspeechendイベントをトリガーとした音声認識のリスタート処理を実装します。先ほど用意した音声認識成否のフラグを参照し、成功していない場合はリスタートします。また、setTimeoutを用いて一定時間(下記例では500msec) 待つことで、onresultよりも前にrestartがなされてしまうことを防ぎます。

  constructor(){
    this.recognizer.onspeechend = ()=>{
      setTimeout(()=>{
        if( this.recognized ){ return; }
        this.recognizer.start();
      },500);
    };
  }

おまけ : Already Started を無視する

すでに音声認識が走っている状態でrecognizer.start()を実行するとエラーが発生します。ほとんどの場合、start()を実行するからには音声認識が起動している状態を期待するはずなので、エラー内容が already started である場合は無視して問題ないと考えます。

try{
  this.recognizer.start();
}catch(e){
  /* already started の場合は無視 */
}

補足

recognizedなんてフラグを利用するならば、騒音でも無音でも発火するonaudioendに対してonsoundendでやったような処理を実装して、onerrorを見ずにすれば一箇所にかけるのに... という考え方もあると思いますが、今回実装したのはあくまでsetTimeoutを用いた実行時間に依存する綱渡りな処理であるため、やはり、公式に提供されて要るエラーケースでハンドリングできる「無音」のケースにおいては、公式のエラーをハンドリングする方が得策であると考えました。

おわりに

聞きっぱなし 実装する人(などという人が居るのだろうか。MA向けに何か作るとか、そういうネタアプリ作るときなんかにこの手の処理実装の需要はあるかもしれないなとは思っているのだけど、改めて考えるとやっぱり「聞きっぱなし」って現実的に周りの人の会話とかも拾ってワークしにくいからボタン式とかそういうのにしておいた方がよいのだよね...でも、例えば擬似的にVoiceTrigger的なものを実装したいとか、そういうときには必要になるんじゃないかな!)の参考になればと。