Blazor WebAssembly で作った Web アプリ "snow catch" ゲームを、🎙️ボイスコマンド (音声認識) で操作できるようにする

## "snow catch" ゲームとは

"snow catch" ゲームとは、画面下の雪だるまをキーボードの左右の矢印キーで操作して、上から降ってくる雪を捕まえよう、っていう只それだけの Web アプリです。
時間制限もゲームオーバーもありません😊

![image](https://camo.qiitausercontent.com/372dd9ecaad06fbe305f1954b0227104550f5653/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f35383435302f63323565653234342d623332322d633639312d633565612d3166323138306531666631312e676966)

年々の Blazor Advent Calendar の題材として、この "snow cach" ゲームを Blazor WebAssembly で実装し、.NET 5 から 6 へ更新したり、ゲームパッド (ジョイスティック) 対応したりしてきました。過去記事へのリンク一覧を以下に貼ります。

- [Blazor WebAssembly で "snow catch" ゲームを作ってみる (前編)](https://qiita.com/jsakamoto/items/8214211c1fd2ff91f46b)
- [Blazor WebAssembly で "snow catch" ゲームを作ってみる (後編)](https://qiita.com/jsakamoto/items/6c96274c75c6ea2a77f7)
- [昨年 Blazor WebAssembly on .NET5 で作った "snow catch" ゲームを .NET6 に更新してみた](https://qiita.com/jsakamoto/items/7a500d8413b142b8f597)
- [Blazor WebAssembly で作った Web アプリ "snow catch" ゲームを、🎮 ゲームパッド (ジョイスティック) で操作できるようにする](https://qiita.com/jsakamoto/items/4705be2e5c6c7f1fccc8)

ソースコードは GitHub リポジトリにて公開しています (下記リンク先)。

https://github.com/sample-by-jsakamoto/SnowCatch-on-BlazorWasm

そして今年、本記事では、この "snow catch" ゲームを、🎙️**ボイスコマンド (音声認識)** で操作できるように改造します!

なお、本記事で説明するボイスコマンド対応版は GitHub Pages に配置もしているので、下記 URL をブラウザで開けば実際に遊ぶこともできます (ブラウザで開けば即開始となります)。

https://sample-by-jsakamoto.github.io/SnowCatch-on-BlazorWasm/

### ...の前に、.NET 8 への更新について

実際に着手する前に、対象フレームワークを当時の .NET 6 から、今日現在の最新版、.NET 8 に更新しておきます。とはいうものの、基本的に、パッケージ参照のバージョンを更新するだけなので、詳細は省きます。

## ブラウザの音声認識機能を Blazor で使う

今回のボイスコマンド対応を実現するために、**近代の Web ブラウザに備わっている音声認識機能、[Speech Recognition API](https://developer.mozilla.org/ja/docs/Web/API/SpeechRecognition)** を使うことにします。昨今の AI ブームによる深層学習系の高精度な音声認識サービスはいろいろあると思うのですが (実のところはよく知りません)、ブラウザの Speech Recognition API であれば、サービス契約や課金など気にせずに利用できるので、今回のような用途にはとても便利です。

ブラウザの Speech Recognition API を Blazor から利用するには、普通は、Blazor の JavaScript 相互運用機能を使って、JavaScript コードを仲介しながら利用することになります。しかしながら、それらの実装を事前に済ませてカプセル化した拙作の NuGet パッケージ、"Blazor Speech Recognition" (下記リンク先) を使うと、自分で JavaScript コードを書くことなく、簡単に ブラウザの Speech Recognition API を Blazor から利用できます。

https://www.nuget.org/packages/Toolbelt.Blazor.SpeechRecognition

ということで、この NuGet パッケージを "snow catch" ゲームのプロジェクトに組み込むところから始めていきましょう。

### NuGet パッケージ参照の追加

まずは "snow catch" Blazor WebAssembly アプリケーションプロジェクトのプロジェクトファイル (`SnowCathc.csproj`) 内に、"Blazor Speech Recognition" NuGet パッケージへのパッケージ参照の行を書き足します。

```xml:SnowCathc.csproj
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
  ...
  <ItemGroup>
    ...
    <!-- 👇 これを追加 -->
    <PackageReference Include="Toolbelt.Blazor.SpeechRecognition" Version="1.0.0" />
  </ItemGroup>
</Project>
```

### DI コンテナへのサービス登録

続けて、`Program.cs` を開き、Blazor Speech Recognition のサービスを DI コンテナにサービス登録します。

```cs:Program.cs
...
builder.Services.AddGamepadList();
builder.Services.AddSpeechRecognition(); // 👈 これを追加
...
```

### コンポーネントに Speech Recognition サービスを注入

そして、こうして DI コンテナにサービス登録した Speech Recognition サービスを、snow catch ゲームのゲーム画面を実装しているコンポーネント (`App.razor`) に注入するようにします。

`App.razor` 内はすっきり短めに書きたかったので、Speech Recognition サービスの名前空間を、"_Imports.razor" で開いておきます。

```cs:_Imports.razor
...
@using Toolbelt.Blazor.SpeechRecognition // 👈 これを追加
```

あとは `App.razor` 内で `@inject` ディレクティブにて、Speech Recognition サービスを注入するようにします。

```cs:App.razor
@implements IDisposable
@inject HotKeys HotKeys
@inject GamepadList GamepadList
@inject SpeechRecognition SpeechRecognition // 👈 これを追加
...
```

### 音声認識の状態と結果をフィールド変数に覚える

現在音声認識中なのか否か、および、どのような音声コマンドが認識されたのかを表現する enum 型を定義し、現在状態を示すフィールド変数を `App.razor` 内の `@code { ...}` コードブロックに設けます。enum 型のメンバーは以下のとおりとします。ゲームスタート時点では `None`、すなわち音声認識中ではない状態とします。

- `None` - 音声認識中ではない
- `Stop` - 音声認識中 / 停止コマンドを認識
- `Left` - 音声認識中 / 左移動コマンドを認識
- `Right` - 音声認識中 / 右移動コマンドを認識

```cs:App.razor
...
@code {
    ...
    private enum VoiceCommand { None, Stop, Left, Right }

    private VoiceCommand _voiceCommandState = VoiceCommand.None;
    ...
```

### Speech Recognition サービスのオプションを構成、イベントハンドラを登録

`App.razor` コンポーネントの初期化時点、`OnInitialized` ライフサイクルメソッド内で、`@inject` ディレクティブにて注入した Speech Recognition サービスオブジェクトのプロパティに値を設定して、音声認識のオプションを構成します。

今回は認識する言語 (`Lang`) は US 英語 ("en-US") を設定してみます。および、認識確定前の認識途中でもイベント発生させるようにし (`InterimResults = true`)、いちど音声認識を開始したら、基本的にはずっと音声認識を継続する (`Continuous = true`) ようにします。

そして、`App.razor` コンポーネント内に、音声認識によって何か単語が認識されたときに発生するイベントのハンドラメソッド、および、音声認識が中止されたときに発生するイベントのハンドラメソッドを、とりあえず中身は空でいいので用意し、それぞれを peech Recognition サービスオブジェクトのイベントに登録します。

```cs:App.razor
  ...
  protected override void OnInitialized()
  {
    ...
    // 👇 下記 5 行を追加
    this.SpeechRecognition.Lang = "en-US";
    this.SpeechRecognition.InterimResults = true;
    this.SpeechRecognition.Continuous = true;
    this.SpeechRecognition.Result += OnSpeechRecognized;
    this.SpeechRecognition.End += OnEndSpeechRecognition;
  }

  // 👇 音声が認識したときに呼び出されるメソッドと...
  private void OnSpeechRecognized(object sender, SpeechRecognitionEventArgs args)
  {
    // TODO
  }

  // 👇 音声認識が中止したときに呼び出されるメソッドを用意
  private void OnEndSpeechRecognition(object sender, EventArgs args)
  {
    // TODO
  }
  ...
```

登録したイベントハンドラは、`App.razor` コンポーネントの破棄時には、登録解除するようにしておきます。

```cs:App.razor
  ...
  public void Dispose()
  {
    // 👇 下記 2 行を追加
    this.SpeechRecognition.Result -= OnSpeechRecognized;
    this.SpeechRecognition.End -= OnEndSpeechRecognition;
    ...
  }
}
```

### 音声認識と開始を行なうメソッドを用意

次は、呼び出すたびに、音声認識の開始と中止を実行するメソッドを実装します。Speech Recognition サービスに対して `StartAsync()` メソッドを呼び出せば音声認識が開始され、`StopAsync()` メソッドを呼び出せば音声認識が中止されます。Speech Recognition サービスのこれらメソッドを呼び出すことに加え、先に用意しておいた、音声認識の状態を示すフィールド変数 `_voiceCommandState` を適宜変更し、音声認識の開始と中止を切り替える判定に使用します。

```cs:App.razor
  ...
  // 👇 このメソッドを追加
  private async Task StartStopListening()
  {
    // 音声認識中でなければ、音声認識を開始し...
    if (_voiceCommandState == VoiceCommand.None)
    {
      _voiceCommandState = VoiceCommand.Stop;
      await this.SpeechRecognition.StartAsync();
    }

    // 音声認識中であれば、音声認識を中止する
    else
    {
      _voiceCommandState = VoiceCommand.None;
      await this.SpeechRecognition.StopAsync();
    }
  }
  ...
```

### 音声認識と開始を行なうユーザーインターフェース (ボタン) を設置

こうして実装した、音声認識の開始と中止を切り替えるメソッドを、ページ上に用意したボタンをクリックしたら呼び出すようにします。

まず `Ap.razor` の HTML マークアップ中、ゲーム画面下部に、ボタンを表示する領域用の `<div>` 要素を設けます。CSS スタイル指定で要素幅をゲームの画面幅に合わせつつ、高さや背景色、flex レイアウトの構成などを指定します。

```html:App.razor
...
</div>

<!-- 👇 この要素を追加 -->
<div style="width: @(Context.GameAreaSize.Width)px; height: 72px; background-color: black; display:flex; align-items:center; justify-content: space-evenly;">
</div>

@code {
  ...
```


次に、音声認識切り替えボタンの表示用に、[マイクの形を表示する SVG ファイル](https://github.com/sample-by-jsakamoto/SnowCatch-on-BlazorWasm/blob/master/wwwroot/assets/microphone.svg)を wwwroot/assets フォルダ内に配置します。そしてこれを参照する形で `<img>` 要素とそれを取り巻く `<button>` 要素を、先にゲーム画面下部に追加した `<div>` 要素内に形成します。

```html:App.razor
...

<div style="...">
    <!-- 👇 先に用意した div 内に button、その中にマイクアイコンを表示する img を配置 -->
    <button>
        <img src="assets/microphone.svg" style="width:32px; height:32px;" />
    </button>
</div>

@code {
  ...
```

CSS スタイル定義でボタンの外観を円形に整え、かつ、音声認識中でなければ薄く表示されるよう、`opacity` CSS 属性を `_voiceCommandState` フィールド変数に応じて切り替えるように動的に構成します。

```html:App.razor
...

<div style="...">
    <!-- 👇 button の透明度を動的に決定、円形になるよう外観を調整 -->
    <button style="opacity:@(_voiceCommandState == VoiceCommand.None ? "0.5": "1"); width:52px; height:52px; background: transparent; outline: none; border: solid 3px #fff; border-radius: 26px;">
        <img .../>
    </button>
</div>

@code {
  ...
```

これで、ゲーム画面下部に、音声認識の開始・中止用のボタンが追加されました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/e821a5d9-c09d-edc0-fcd4-1004cf91c0de.png)

こうして用意した `<button>` 要素のクリックイベントを、その前に用意した、音声認識の開始・中止を切り替えるメソッド呼び出しに結びつけます。

```html:App.razor
...

<div style="...">
    <!-- 👇 button がクリックされたら、音声認識開始・中止を行なうメソッドを呼び出す -->
    <button @onclick="StartStopListening" style="...">
        <img .../>
    </button>
</div>

@code {
  ...
```

これで、このマイクボタンをクリックするたびに、音声認識の開始と中止が実行されるようになります。

![movie-000.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/58450/046f0252-acc3-8b68-92cf-aff24c3bc71d.gif)

なお、初めて実行した場合は、この Web アプリが音声認識機能を利用しようとしているが許可していいかどうかを尋ねるダイアログが表示されます。その場合は許可してあげてください。

### 音声認識の結果で、雪だるまの動きを制御する

この状態で、画面上のマイクボタンをクリックして音声認識を開始した以後、マイクに向かって何か喋ると、ブラウザの音声認識機能によって喋った内容が認識されるたびに、先に実装・イベントハンドラ登録しておいた `OnSpeechRecognized` メソッドが呼び出されるようになります。このとき、メソッドの引数には、認識された結果が独特のオブジェクト構成で渡されます。このイベント引数の中身を見ることで最終的に、音声認識機能によって認識された言葉が文字列で手に入ります。

ということで、認識された言葉に応じて、雪だるまの動きを制御する処理を実装していきます。なお、立て続けにボイスコマンドを喋った場合に備え、認識結果の文字列をさらに、空白や句読点 (英語での認識としたので、カンマやピリオド) で区切り、分割して、文字列の配列にまで噛み砕いておくことにします。

```cs:App.razor
...
@code {
...
  // 音声が認識されるたびに、結果と共にこのメソッドが呼び出される
  private void OnSpeechRecognized(object sender, SpeechRecognitionEventArgs args)
  {
    // Speech Recognition API の認識結果データ構造をたどり、
    // 認識された言葉の文字列 (item.Transcript) を手に入れる
    var result = args.Results[args.ResultIndex];
    var item = result.Items.LastOrDefault();
    if (item == null) return;

    // それをさらに、空白や句読点区切りで分割する
    var terms = item.Transcript.Split(new[] { ' ', ',', '.' }, StringSplitOptions.RemoveEmptyEntries);
  }
  ...
```

こうして単語単位に分割された音声認識結果を走査し、"stop"、"left"、"right" と喋ったことが認識されたら、それぞれに応じた音声認識結果状態に、`_voiceCommandState` フィールド変数を更新します。

```cs:App.razor
@code {
  ...
  private void OnSpeechRecognized(object sender, SpeechRecognitionEventArgs args)
  {
    ...
    // 先に分割した認識結果文字列 (単語) を調べ、
    // 音声認識状態 (_voiceCommandState フィールド変数) を更新
    foreach (var term in terms)
    {
      _voiceCommandState = term.ToLower() switch
      {
        "stop" => VoiceCommand.Stop,
        "left" => VoiceCommand.Left,
        "right" => VoiceCommand.Right,
        
        // 上記以外の単語が認識された場合は無視
        // (_voiceCommandState フィールド変数の値を変更しない)
        _ => _voiceCommandState
      };
    }
  }
  ...
```

こうして音声認識の結果に応じて、`_voiceCommandState` フィールド変数が更新されるようになりましたので、ゲームループの中 (`GameLoopTimer_Elapsed` メソッド呼び出し) で、この `_voiceCommandState` の内容を判定して、雪だるまを移動させるように実装を追加します。

```cs:App.razor
@code {
  ...
  private async void GameLoopTimer_Elapsed(object sender, EventArgs e)
  {
    ...

    // 👇 現在の音声認識結果に応じて、雪だるまを移動させる、以下2行を追加
    if (_voiceCommandState == VoiceCommand.Left) this.Context.MoveSnowManToLeft();
    if (_voiceCommandState == VoiceCommand.Right) this.Context.MoveSnowManToRight();

    this.StateHasChanged();
  }
  ... 
```

最後に、ブラウザ側から音声認識機能が中止されたときに発生するイベントのハンドラメソッドで、その場合の後始末処理、すなわち、`_voiceCommandState` フィールド変数を `None` にリセットする処理を実装します。

```cs:App.razor
@code {
  ...
  private void OnEndSpeechRecognition(object sender, EventArgs args)
  {
    _voiceCommandState = VoiceCommand.None;
    this.StateHasChanged();
  }
  ...
```

これでついに、画面上のマイクボタンをクリック後、**"left"、"right"、"stop" などとマイクに向かって喋る**ことで、**雪だるまを左右に移動・停止**させることが可能になりました!

## ゲームバランスの調整

### 音声認識では反応が遅すぎる!

念願のボイスコマンド機能が実装できたので、さっそく試してみると、**音声が認識されるまでの遅延時間が長く、まったく上手く操作できません!**

"Left!" と喋ってから、実際に雪だるまが動き始めるまで (つまり音声が認識されるまで) に数百ミリ秒～1秒ほどかかり、降ってくる雪に間に合いません。また、慌てて "Stop!" と叫んでも、認識されて実際に停止するより前に、ゲーム画面の端に到達してしまいます。

そこで、今回は、音声認識中は、ゲームの速度を下げることにしました。

雪の結晶および雪だるまの移動距離を調節するのがいいのだと思いますが、今回は雑に、フレームレートを落とす (ゲームループを駆動するタイマーのインターバルを調整する) こととしました。

まずはゲームの状態を管理するゲームコンテキスト (`GameContext` クラス) の実装に手を入れ、ゲームループを駆動するタイマーのインターバルを、ゲームコンテキストの外部から指定できるようにプロパティに公開します。および、マジックナンバーをなるべく使わないようにするため、インターバル時間を指定する用途の定数をまとめた静的クラス `Speed` も設けます。

```cs:GameContext.cs
...
public class GameContext
{
  // 👇 ゲーム速度 (タイマーのインターバル時間) を指定する用の定数を
  //     この Speed クラスを追加して用意します。
  public static class Speed
  {
    public const double Slow = 100; // 通常速度
    public const double Normal = 30; // 低速
  }
  ...
  // 👇 ゲームループ用のタイマーを、上記で用意した定数で初期化するように書き換えます。
  //     既定ではゲーム速度は "通常 (Normal)" とします。
  public System.Timers.Timer GameLoopTimer = new(interval: Speed.Normal);
  ...
  // 👇 ゲームループ用のタイマーのインターバルを、外部 (App.razor) から指定できるよう
  //     public プロパティを追加して公開します。
  public double Interval
  {
    get => this.GameLoopTimer.Interval;
    set => this.GameLoopTimer.Interval = value;
  }
  ...
```

あとは、`App.razor` 内で、音声認識の開始と中止にあわせ、こうして公開したゲームコンテキストのタイマーのインターバル時間を適宜、低速に変更します。

```cs:App.razor
...
@code {
  ..
  private async Task StartStopListening()
  {
    if (_voiceCommandState == VoiceCommand.None)
    {
      ...
      // 音声認識が開始されたら、ゲーム速度を低速にし...
      this.Context.Interval = GameContext.Speed.Slow;
    }
    else
    {
      ...
      // 音声認識が中止されたら、ゲーム速度を通常に戻す
      this.Context.Interval = GameContext.Speed.Normal;
    }
  }
  ...
  private void OnEndSpeechRecognition(object sender, EventArgs args)
  {
    ...
    // ブラウザ都合で音声認識が中止された場合も、ゲーム速度を通常に戻す
    this.Context.Interval = GameContext.Speed.Normal;
    this.StateHasChanged();
  }
  ...
```

以上の措置で、ボイスコマンドでも遊べるようになりました。

### 音声が誤認識される

今回は音声認識の言語を US 英語としたのですが、自分の英語発話はやはりネイティブスピーカーのようにはいかずあまり上手く発音できていないせいでしょう、誤認識されることが多くありました。例えば、"Left" と喋ったつもりが "Lift" と認識されたり、"Right" のつもりが "Just" と認識されたり、といった具合です。

今回は雑に、このように "Lift" や "Just" と認識された場合の場合分けを switch 式に追加して、これら誤認識した場合でも雪だるまの移動操作に反映するようにしました。

```cs:App.razor
...
@code {
  ...
  private void OnSpeechRecognized(object sender, SpeechRecognitionEventArgs args)
  {
    foreach (var term in terms)
    {
      _voiceCommandState = term.ToLower() switch
      {
        ...
        // 👇 "Left" を "Lift" に認識された場合も左移動を発動
        "lift" => VoiceCommand.Left,
        ...
        // 👇 "Right" を "Just" に認識された場合も右移動を発動
        "just" => VoiceCommand.Right,
        ...
      };
    }
  }
 ...
```

これでだいぶん、ボイスコマンドによる操作性も改善されました。

## まとめ

Blazor でも、近代 Web ブラウザが備える音声認識機能、Speech Recognition API を利用することは難しくありません。

ブラウザの Speech Recognition API であれば、もちろん精度の面では昨今の AI ベースのサービスに劣るのかもしれませんが、代わりに、応答速度やオフライン環境での動作、サービス契約と課金の不要など、手軽に扱える利点も多いです。

音声認識機能をどう活用するか、アイディア次第だと思いますので、ぜひ皆さんもいろいろ想像を膨らませて楽しんでみてください。

Happy Coding! :)