🐨

Azure AI Speech Serviceのキーワード認識をHololens2上で使ってみる

2024/02/19に公開2

やりたいこと

Appleの「Hey, Siri」やAmazonの「Alexa」みたいな感じで、キーワードを発声することで処理をできるようにしたいです。

現在ハンドトラッキングやアイトラッキングに代わり、音声で色々操作できないか研究しています。
音声操作するための音声認識の開始と終了をボタンのオンオフでしてましたが、どうせならここも音声でできるようにしたいです。
そのためにAzureのキーワード認識を試してみることにしました。

使うサービス

・Azure AI Speech Service(カスタムキーワード)
・Unity 2022.3.15LTS
・Hololens2

リソース作成

「AI Service」と検索すると出てくるので、サイドバーから「音声サービス」を選択します。


リソースは「East US」を選択します。
名前の「xxxx」の箇所は適当につけてください。
価格はFreeで大丈夫です。


横に「4」がついているリージョンで「カスタム キーワードの高度なモデルがサポート」がされています。
ですので、東日本リージョンなどにしてしまうとカスタムキーワードの精度が落ちるので、4がついているリージョンにしてください。

https://learn.microsoft.com/ja-jp/azure/ai-services/speech-service/regions#speech-service

カスタムキーワードを作成

今回はアイアンマンでも登場する「ジャーヴィス(JARVIS)」にしてみました。
日常会話でジャーヴィスに近い言葉を発声することがないので、頭に「Hey」とか「OK」は入れる必要はなさそうなので、入れてません。


リソースを作成すると「Speech Studio」というのがあるので、クリックして起動してください。

Speech Studio内をスクロールすると下の方に「Custom Keyword」というのがあるので選択。


「+プロジェクトを作成する」を選択して、新しいプロジェクトを作ります。
名前は適当でいいです。
言語は英語か中国語しか選択できないので、「英語」にします。


プロジェクトを作成すると以下の画面に遷移するので、「新しいモデルを作成する」を選択。


名前(今回はジャーヴィス)とキーワードの英語を入力します。


次に進むと発音の候補が出てきます。
今回は一つしか出てきませんでしたが、単語によっては4つ出てくるので、理想に近い発音以外のチェックを外します。


最後にモデルの種類は「詳細」にします。
「基本」にするとすぐモデルが作成されますが、モデルの精度がイマイチ良くないです。
特に日本人が話す英語なので、うまく認識してくれません。

モデルの作成が成功するとモデル一覧に出てくると思います。


モデルのテストをしてみます。
先ほど作成したモデルを選択して、構成は「正しい受け入れの増加」にします。
そうすることで、多少イントネーションや発音が違っても認識してくれるようになります。



最後にモデルのチューニングをして、ローカルにダウンロードします。

実装

実装は以下の記事で実装しているところから進めていきますので、GPTへのリクエストと音声認識 + 文字起こしの実装の説明は省きます。

https://zenn.dev/headwaters/articles/2dd879438294c1

処理の流れ

  1. 初期の段階では言葉を発しても音声認識が検知しないようにする。
  2. その後「ジャーヴィス」という言葉をキーワード認識で検知できるようにする
  3. キーワードを検知した後は音声認識処理が走って、発する言葉を認識できるようにする
  4. 発した言葉をGPTにリクエスト文として送って、レスポンスを音声出力する

先に全体のコード

AISpeechManger.cs
using System;
using System.Collections;
using System.IO;
using System.Threading.Tasks;

using UnityEngine;
using UnityEngine.UI;
using TMPro;

using Microsoft.CognitiveServices.Speech;
using Microsoft.CognitiveServices.Speech.Audio;
using Azure.AI.OpenAI;
using Azure;


public class AISpeechManager : MonoBehaviour
{
    private SpeechConfig speechConfig;
    private SpeechRecognizer recognizer;
    private KeywordRecognizer keywordRecognizer;
    private KeywordRecognitionModel keywordModel;
    private SpeechSynthesizer speechSynthesizer;

    public TMP_Text spokenText;
    public TMP_Text gptResponseTime;
    public TMP_Text gptResponseText;

    private string recognizeText;
    private string responseTextFromGPT;
    private string responseTimeFromGPT;
    private bool recognitionStarted = false;
    private bool isWait = false;
   
    System.Diagnostics.Stopwatch stopWatch = new System.Diagnostics.Stopwatch();
    System.Diagnostics.Stopwatch recognitionWaitTime = new System.Diagnostics.Stopwatch();

    void Awake()
    {
        speechConfig = SpeechConfig.FromSubscription("<your subscription>", "<your region>");
        speechConfig.SpeechSynthesisVoiceName = "ja-JP-NanamiNeural";
        speechConfig.SpeechRecognitionLanguage = "ja-JP";
        AudioConfig audioConfig = AudioConfig.FromDefaultMicrophoneInput();
        recognizer = new SpeechRecognizer(speechConfig, audioConfig);
        recognizer.Recognized += HandleRecognizedEvent;
        recognizer.Canceled += (s, e) => {
            recognizeText = e.ErrorDetails.ToString();
            Debug.Log("CanceledHandler: " + recognizeText);
        };

        speechSynthesizer = new SpeechSynthesizer(speechConfig);

        string path = "";
#if WINDOWS_UWP
        path = Windows.Storage.ApplicationData.Current.LocalFolder.Path;
#elif UNITY_EDITOR
        path =  Application.dataPath + "/Model";
#endif

        keywordModel = KeywordRecognitionModel.FromFile(path + "/<your table file>");
        keywordRecognizer = new KeywordRecognizer(audioConfig);
    }

    void Start()
    {
        StartRecognitionWithKeywords();
    }

    void Update()
    {
        if (recognitionWaitTime.ElapsedMilliseconds > 10000)
        {
            recognitionWaitTime.Reset();
            StopRecognition();
            StartRecognitionWithKeywords();
        }

        if (recognitionStarted)
        {
            spokenText.text = recognizeText;
            gptResponseText.text = responseTextFromGPT;
            gptResponseTime.text = responseTimeFromGPT;
        }
    }

    void OnDestroy()
    {
        speechSynthesizer.StopSpeakingAsync();
        speechSynthesizer.Dispose();
        recognizer.Recognized -= HandleRecognizedEvent;
        recognizer.Dispose();
#if UNITY_EDITOR
        UnityEditor.EditorApplication.isPlaying = false;
#endif
    }


    private void HandleRecognizedEvent(object sender, SpeechRecognitionEventArgs e)
    {
        try
        {
            if (isWait || !recognitionStarted) return;
            recognitionWaitTime.Reset();

            if (stopWatch.ElapsedMilliseconds == 0)
            {
                stopWatch.Start();
            } else
            {
                stopWatch.Restart();
            }

            recognizeText = e.Result.Text;
            isWait = true;
            Debug.Log("RecognizedHandler: " + recognizeText);

            AskGPTQuestion();
            SpeakResponseTextFromGPT(responseTextFromGPT);
        }
        catch (Exception ex)
        {
            Debug.Log(ex.Message);
        }
    }

    void AskGPTQuestion()
    {
        OpenAIClient client = new OpenAIClient(new Uri("<your aoai endpoint>"), new AzureKeyCredential("<your access key>"));

        var chatCompletionsOptions = new ChatCompletionsOptions()
        {
            DeploymentName = "<your gpt model>",
            Messages =
                {
                    new ChatMessage(ChatRole.System, @"あなたはジャーヴィスという名の優秀なAIアシスタントです。"),
                    new ChatMessage(ChatRole.User, recognizeText),
                },
            MaxTokens = 300
        };

        Response<ChatCompletions> response = client.GetChatCompletions(chatCompletionsOptions);

        stopWatch.Stop();
        responseTimeFromGPT = $"GPT Reponse.. {stopWatch.ElapsedMilliseconds}ms";
        var text = response.Value.Choices[0].Message.Content;
        responseTextFromGPT = text;
    }

    async void SpeakResponseTextFromGPT(String text)
    {
        var speechSynthesisResult = await speechSynthesizer.SpeakTextAsync(text);
        switch (speechSynthesisResult.Reason)
        {
            case ResultReason.SynthesizingAudioCompleted:
                recognitionWaitTime.Start();
                isWait = false;
                break;
            case ResultReason.Canceled:
                var cancellation = SpeechSynthesisCancellationDetails.FromResult(speechSynthesisResult);
                Debug.Log($"CANCELED: Reason={cancellation.Reason}");

                if (cancellation.Reason == CancellationReason.Error)
                {
                    Debug.Log($"CANCELED: Did you set the speech resource key and region values?");
                }
                break;
            default:
                break;
        }
    }

    async void StopRecognition()
    {
        await recognizer.StopContinuousRecognitionAsync().ConfigureAwait(true);
        recognitionStarted = false;
        spokenText.text = "Disconnected";
        gptResponseText.text = "「JARVIS」と喋って起動させてください";
        gptResponseTime.text = "Sleep.....";
    }

    async void StartRecognitionWithKeywords()
    {
        Debug.Log("キーワード認識開始");
        KeywordRecognitionResult result = await keywordRecognizer.RecognizeOnceAsync(keywordModel);

        if (result.Text != null)
        {
            await recognizer.StartContinuousRecognitionAsync().ConfigureAwait(false);
            recognitionStarted = true;
            recognizeText = "Recognization...";
            responseTimeFromGPT = "JARVIS Connected!";

        }
    }
}

1. キーワード認識処理をするための初期化設定

KeywordRecognizer」と「KeywordRecognitionModel」を定義します。

AISpeechManager.cs
public class AISpeechManager : MonoBehaviour
{
    ...
    private KeywordRecognizer keywordRecognizer;
    private KeywordRecognitionModel keywordModel;
    ...


Awake関数内で、キーワードモデルの読み込みと初期化を行います。
Unity Editor上とHololens2上ではキーワードモデルの格納場所が違うので、条件式で分けてパスを設定します。

AISpeechManager.cs
    void Awake()
    {
        ...
        string path = "";

#if WINDOWS_UWP
        path = Windows.Storage.ApplicationData.Current.LocalFolder.Path;
#elif UNITY_EDITOR
        path =  Application.dataPath + "/Model";
#endif

        keywordModel = KeywordRecognitionModel.FromFile(path + "/<your table file>");
        keywordRecognizer = new KeywordRecognizer(audioConfig);
    }


2. キーワード認識できるようにする

Awake関数で初期化ができているので、Start関数で「StartRecognitionWithKeywords」関数を実行します。

KeywordRecognizerクラスの「RecognizeOnceAsync」メソッドを使うことで、キーワードを認識したタイミングでレスポンスを返してくれるようにします。

レスポンスが返ってきた = キーワードを認識したら音声認識を開始するための「StartContinuousRecognitionAsync」メソッドを実行するようにします。

ついでにUIでもキーワード認識されたことを分かりやすくするために、文言とかも変更します。

AISpeechManager.cs
...
    void Start()
    {
        StartRecognitionWithKeywords();
    }
....
     async void StartRecognitionWithKeywords()
    {
        Debug.Log("キーワード認識開始");
        KeywordRecognitionResult result = await keywordRecognizer.RecognizeOnceAsync(keywordModel);

        if (result.Text != null)
        {
            await recognizer.StartContinuousRecognitionAsync().ConfigureAwait(false);
            recognitionStarted = true;
            recognizeText = "Recognization...";
            responseTimeFromGPT = "JARVIS Connected!";

        }
    }


3. 一定期間ユーザーが喋らなかったら音声認識処理を止める

ここはなくてもいいですが、一定期間喋らなかったら再度「ジャーヴィス」と言わないと音声認識が走らないようにしたいです。

ユーザーが喋ってない期間を計測するためのstopWatchを追加で定義します。
stopWatchの開始はGPTからのレスポンスを音声出力で喋り終わった後からにしたいので、「SpeakResponseTextFromGPT」関数内の、「ResultReason.SynthesizingAudioCompleted:」のタイミングで、Startさせます。

AISpeechManager.cs
public class AISpeechManager : MonoBehaviour
{
    ....
    System.Diagnostics.Stopwatch recognitionWaitTime = new System.Diagnostics.Stopwatch();

...

    async void SpeakResponseTextFromGPT(String text)
    {
        var speechSynthesisResult = await speechSynthesizer.SpeakTextAsync(text);
        switch (speechSynthesisResult.Reason)
        {
            case ResultReason.SynthesizingAudioCompleted:
                recognitionWaitTime.Start();

...


次にユーザーが再度喋った時は計測をリセットする必要があるので、「HandleRecognizedEvent」関数内にResetメソッドを入れます。

AISpeechManager.cs
    private void HandleRecognizedEvent(object sender, SpeechRecognitionEventArgs e)
    {
        try
        {
            if (isWait || !recognitionStarted) return;
            recognitionWaitTime.Reset();
...


最後に10秒間たったタイミングで音声認識処理を止めるようにします。

AISpeechManager.cs
    void Update()
    {
        if (recognitionWaitTime.ElapsedMilliseconds > 10000)
        {
            recognitionWaitTime.Reset();
            StopRecognition();
            StartRecognitionWithKeywords();
        }

...

    }

...

    async void StopRecognition()
    {
        await recognizer.StopContinuousRecognitionAsync().ConfigureAwait(true);
        recognitionStarted = false;
        spokenText.text = "Disconnected";
        gptResponseText.text = "「JARVIS」と喋って起動させてください";
        gptResponseTime.text = "Sleep.....";
    }


4. カスタムキーワードのファイルを配置

Unity Editor上で動かす場合は、Assetsフォルダ配下に「Model」フォルダを作成して、その中に入れてください。

Hololens2上で動かしたい場合は、Hololens2のIPアドレスをブラウザ上で入力してDevice Portalを起動してください。
サイドバーから「System」→「File explorer」→「LocalAppData」→「対象のアプリ名」→「LocalState」の順番に移動して、そこに格納してください。

検証

https://www.youtube.com/watch?v=tLAvS84VDX0

ひと工夫

カスタムキーワードが英語なので、どうしても英語に慣れてない日本人には発音が難しいです。
ですのでキーワードを作成するときに英単語の綴りのまま入れるより、日本人が発しやすい英単語にちょくちょく変えていくと精度がより向上します。

例えば「v」を「b」に変えるとか...
ここら辺はカスタムキーワードを作るタイミングでテストできるので色々試しみてください。

ヘッドウォータース

Discussion