Angularで音声認識Serviceをつくった

AngularでSpeechRecognitionを使うためのライブラリを実装しました。

Chromeで実装されているくらいでほとんど実装しているブラウザがないので、ほとんど公開する意味はないですが個人的なリベンジマッチの意味合いが強いです。

経緯

なぜか動かなかったComponent

1年程前、こんな感じのこんな感じのコードを書いたのですが、start()してから、 consoleに結果は出てきているのですが、stop()押したりするまで テンプレートの{{message}}が反映されませんでした。

import { Component } from '@angular/core';

@Component({
  selector: 'demo-main',
  template: `
  <p>MainCompoent.message: {{message}}</p>
  <button (click)="start()">start</button>
  <button (click)="stop()">end</button>
  <p>lang: ja</p>
  <p>grammars: none</p>
  `,
  styleUrls: ['./main.component.css'],
})
export class MainComponent {

  public message = '';

  private speechRecognition: SpeechRecognition = new webkitSpeechRecognition();

  constructor(
  ) {

    this.speechRecognition.onstart = (e) => {
      console.log('onstart');
    };
    this.speechRecognition.onresult = (e) => {
      this.message = e.results[0].item(0).transcript;
    };
  }

  // 音声認識開始
  start() {
    this.speechRecognition.start();
  }

  // 音声認識終了
  stop() {
    this.speechRecognition.stop();
  }
}

Angularに変更検知できるように

当時はよくわからず放置していたのですが、Angularの変更検知のタイミングを伝える必要があるとわかったのでServiceとして実装しました。

@Injectable()
export class SpeechRecognitionService {

  private internal: SpeechRecognition = new webkitSpeechRecognition();

  private audiostartHandler: (ev: Event) => any;

  constractor(
    // ApplicationRefをDIする
    private ref: ApplicationRef,
  ) {

    this.internal.onaudiostart = (e: Event) => {
      // SpeechRecognition のonaudiostartを
      // ServiceのaudiostartHandlerを同期する。
      this.audiostartHandler(e);
      // Angularに変更を伝える
      this.ref.tick();
    };

  }

  // onaudiostartのSetter
  set onaudiostart(handler: (ev: Event) => any) {
    this.audiostartHandler = handler;
  }

  // イベント毎に実装
  //   onaudiostart
  //   onsoundstart
  //   onspeechstart
  //   onspeechend
  //   onsoundend
  //   onaudioend
  //   onresult
  //   onnomatch
  //   onerror
  //   onstart
  //   onend

}

API

2種類のService

まずはじめに上記と同じような実装を行いました。

interface SpeechRecognitionService {
  // プロパティのgetter/setter
  grammars: SpeechGrammarList;
  lang: string;
  continuous: boolean;
  interimResults: boolean;
  maxAlternatives: number;
  serviceURI: string;

  // Eventハンドラーのsetter
  onaudiostart: (ev: Event) => any;
  onsoundstart: (ev: Event) => any;
  onspeechstart: (ev: Event) => any;
  onspeechend: (ev: Event) => any;
  onsoundend: (ev: Event) => any;
  onaudioend: (ev: Event) => any;
  onresult: (ev: SpeechRecognitionEvent) => any;
  onnomatch: (ev: SpeechRecognitionEvent) => any;
  onerror: (ev: SpeechRecognitionError) => any;
  onstart: (ev: Event) => any;
  onend: (ev: Event) => any;

  // メソッド
  start(): void;
  stop(): void;
  abort(): void;
}

interface RxSpeechRecognitionService {

  // rxjs/operators/filterで指定したtypeのイベントを取得できるように
  static on(type: string);

  // rxjs/operators/mapでonresultのイベントが発生したときに
  // SpeechRecognitionResultListを流す。
  static resultList;

  // プロパティのgetter/setter
  grammars: SpeechGrammarList;
  lang: string;
  continuous: boolean;
  interimResults: boolean;
  maxAlternatives: number;
  serviceURI: string;

  // startからstop、abortを抽象化したAPI
  listen(): Observable<Event | SpeechRecognitionEvent>;
}

現状のMDNを参考に同じAPIを持つSpeechRecognitionServiceとイベントを扱い易くしたRxSpeechRecognitionServiceです。

RxSpeechRecognitionServiceを利用するとすると、こんな書き方ができます。

export class RxComponent {

  message = '';

  constructor(
    private service: RxSpeechRecognitionService,
  ) { }
  listen() {
    this.service
      .listen()
      .pipe(RxSpeechRecognitionService.resultList)
      .subscribe((list: SpeechRecognitionResultList) => {
        this.message = list.item(0).item(0).transcript;
      });
  }

}

.listen()はstartからendまでのイベントをObservableにして返します。

このObservableを.unsubscribe()するとabortされます。

DIできるように

Angularで使っているからにはDIできるようにしたいと思い、 こんな感じでできるようにしました。

Component

import { Component } from '@angular/core';

import {
  // Service
  SpeechRecognitionService,

  // Optional Params
  SpeechRecognitionLang,
  SpeechRecognitionMaxAlternatives,
  SpeechRecognitionGrammars,
} from '@kamiazya/speech-recognition';

import {
  ColorGrammar,
} from './sub.component.grammar';

@Component({
  selector: 'demo-sub',
  templateUrl: './sub.component.html',
  styleUrls: ['./sub.component.css'],
  providers: [
    // こんな感じで依存解決できます。
    {
      // langを解決
      provide: SpeechRecognitionLang,
      useValue: 'en-US',
    },
    {
      // maxAlternativesを解決
      provide: SpeechRecognitionMaxAlternatives,
      useValue: 3,
    },
    {
      // grammarsを解決
      provide: SpeechRecognitionGrammars,
      useValue: ColorGrammar,
    },
    SpeechRecognitionService,
  ],
})
export class SubComponent {
  // 実装は省略
}

Module

デフォルト値設定のインターフェース

Moduleで共通のConfigを作れるようにしました。

SpeechRecognitionConfigのインターフェースは、MDNを参考に同じように作りました。


export interface SpeechRecognitionConfig {
  grammars?: SpeechGrammarList;
  lang?: string;
  continuous?: boolean;
  interimResults?: boolean;
  maxAlternatives?: number;
  serviceURI?: string;
  onaudiostart?: (ev: Event) => any;
  onsoundstart?: (ev: Event) => any;
  onspeechstart?: (ev: Event) => any;
  onspeechend?: (ev: Event) => any;
  onsoundend?: (ev: Event) => any;
  onaudioend?: (ev: Event) => any;
  onresult?: (ev: SpeechRecognitionEvent) => any;
  onnomatch?: (ev: SpeechRecognitionEvent) => any;
  onerror?: (ev: SpeechRecognitionError) => any;
  onstart?: (ev: Event) => any;
  onend?: (ev: Event) => any;
}


@NgModule({
  providers: [
    SPEECH_RECOGNITION_DEFAULT,
  ],
})
export class SpeechRecognitionModule {

  static buildProvidersFromConfig(config: SpeechRecognitionConfig): Provider[];

  static forRoot(config: SpeechRecognitionConfig): ModuleWithProviders;

  static withConfig(config: SpeechRecognitionConfig): ModuleWithProviders;
}

Module全体でDIするときの例

@NgModule({
  declarations: [
    // app container.
    DemoComponent,

    // MainComponentでは、DemoModuleで
    // SpeechRecognitionModule::withConfigを使用して生成された
    // SpeechRecognitionServiceの使用方法を使用します。
    MainComponent,
  ],
  imports: [
    BrowserModule,
    RouterModule,

    // デフォルト設定を作成
    SpeechRecognitionModule.withConfig({
      lang: 'ja',

      interimResults: true,
      maxAlternatives: 10,

      // sample handlers.
      onaudiostart:  (ev: Event)                  => console.log('onaudiostart',  ev),
      onsoundstart:  (ev: Event)                  => console.log('onsoundstart',  ev),
      onspeechstart: (ev: Event)                  => console.log('onspeechstart', ev),
      onspeechend:   (ev: Event)                  => console.log('onspeechend',   ev),
      onsoundend:    (ev: Event)                  => console.log('onsoundend',    ev),
      onaudioend:    (ev: Event)                  => console.log('onaudioend',    ev),
      onresult:      (ev: SpeechRecognitionEvent) => console.log('onresult',      ev),
      onnomatch:     (ev: SpeechRecognitionEvent) => console.log('onnomatch',     ev),
      onerror:       (ev: SpeechRecognitionError) => console.log('onerror',       ev),
      onstart:       (ev: Event)                  => console.log('onstart',       ev),
      onend:         (ev: Event)                  => console.log('onend',         ev),
    }),
  ],
  providers: [],
  bootstrap: [DemoComponent]
})
export class DemoModule { }


参考