🤖

Teams 会議中にも AI と話したい!

2023/12/23に公開

Teams 会議中に「 AI に聞いてその回答を共有したいな、でもチャット打つのはちょっと面倒だな」って思ったことありませんか?

今回はそんなお悩みを解決します。

0. 初めに

0.1. 何をするの?

Teams 会議中の音声を拾い、特定の単語の後の質問を Azure OpenAI へ投げてその回答をチャットに投稿するといった web アプリを作成します。

0.2. どうやって作るの?

本記事では簡単のために React を用いて作成していきます。
作成する web アプリでは以下の様なフローを実行します。

  1. Azure Communication Services を使用して会議に参加し、音声を取得
  2. 取得した音声を Azure AI Services の Speech Service を使用してテキストに変換
  3. 取得したテキストを Azure AI Services の Azure OpenAI に投げて回答を得る
  4. Azure Communication Services を使用してチャットに投稿

ほぼほぼ Azure に頼り切ってますね。

1. 作成していく

1.0 前提条件

本記事では React を使用するため、 node.js および npm をインストールしています。

$ node --version
v18.17.1
$ npm --version
9.6.7

1.1 環境構築

まず React の環境構築をします。
以下のコマンドで react-app というプロジェクトが作成されます。

npx create-react-app react-app --template typescript
cd react-app
npm run start

ブラウザから http://localhost:3000 にアクセスし、以下の様に表示されたら OK です。

環境構築の最後に今回使用するライブラリを追加しておきます。

npm install @azure/communication-calling @azure/communication-chat @azure/communication-common @azure/openai microsoft-cognitiveservices-speech-sdk --save

1.2 Azure Communication Services を使用して会議に参加

次は、 Azure Communications Services を使用して会議に参加します。
会議への参加は以下の手順で行います。

  1. Azure Communication Services のリソースを作成する
  2. CallClient の作成
  3. AzureCommunicationTokenCreadential の作成
  4. CallAgent の作成

実際に会議に参加するのは 4. の CallAgent ですね。

1.2.1 Azure Communication Services のリソース作成

それでは Azure Portal にアクセスしてリソースを作成していきましょう。

"Communication Service" で検索し、空欄を埋め、 [ レビューと作成 ] でリソースを作成します。

作成後、 Azure Communication Services に接続するための 2 つの文字列を保存します。

リソースの [ 概要 ] にあるエンド ポイントと

[ ID およびユーザー アクセス トークン ] より、 [ 音声およびビデオ通話 (VoIP) ] と [ チャット ] にチェックを入れて [ 生成 ] をクリックすると作成される [ ユーザー アクセス トークン ] を保存しておきます。
※ このユーザー アクセス トークンは 24 時間で使用できなくなりますが、今回は簡単のためこちらを使用します。

1.2.2 Azure Communication Services で会議に参加

プロジェクト直下に .env ファイルを作成し、以下のように書きます。
環境変数に入れるので名前はなんでもいいのですが、先頭に REACT_APP_ を入れないと React 側で読み取ってくれなくなります。

REACT_APP_ACS_TOKEN=<< Azure Communication Service のユーザー アクセス トークン >>
REACT_APP_ACS_ENDPOINT=<< Azure Communication Service のエンド ポイント >>

src/App.tsx を以下のように変更します。

import React from "react";
import { CallAgent, CallClient } from "@azure/communication-calling";
import { AzureCommunicationTokenCredential } from "@azure/communication-common";

const callClient = new CallClient();
const tokenCredential = new AzureCommunicationTokenCredential(
 process.env.REACT_APP_ACS_TOKEN || ""
);
let callAgent: CallAgent | undefined;

function App() {
 const [meetingUrl, setMeetingUrl] = React.useState("");
 const [submitButtonDisabled, setSubmitButtonDisabled] = React.useState(false);

 return (
   <div className="App">
     <div>
       <input
         type="text"
         placeholder="Teams meeting link"
         value={meetingUrl}
         onChange={(e) => setMeetingUrl(e.target.value)}
       />
       <button
         onClick={async () => {
           console.log("join");
           console.log(!callAgent);
           if (!callAgent) {
             callAgent = await callClient.createCallAgent(tokenCredential, {
               displayName: "Azure Open AI 君",
             });
           }
           callAgent.join({ meetingLink: meetingUrl });
       setSubmitButtonDisabled(true);
         }}
     disabled={submitButtonDisabled}
       >
         submit
       </button>
     </div>
   </div>
 );
}

export default App;

npm run start 後、 Teams 会議のリンクを入力して submit をクリックすると、、、

会議に "Azure OpenAI 君" が入ってきます。

1.3 会議の音声を取得する

次は会議の音声を取得しましょう。
会議の音声は、 callAgent.join() の返り値の Call の中, remoteAudioStreams に MediaStream として格納されています。
この remoteAudioStreams は会議に接続後すぐは空の配列になってしまっているため、定期的に取得を試みます。

import { Call, CallAgent, CallClient } from "@azure/communication-calling"; // 変更
// ...
 const [call, setCall] = React.useState<Call>(); // 変更

  // -- 変更 --
  React.useEffect(() => {
    if (call) {
      const intervalId = setInterval(async () => {
        if (call.remoteAudioStreams.length === 0) return;
        const remoteAudioStream = call.remoteAudioStreams[0];
        const mediaStream = await remoteAudioStream.getMediaStream();
        console.log(mediaStream);
        clearInterval(intervalId);
      }, 1000);
    }
  }, [call]);
  // -- 変更 --
  //...
          <button
            onClick={async () => {
              console.log("join");
              console.log(!callAgent);
              if (!callAgent) {
                callAgent = await callClient.createCallAgent(tokenCredential, {
                  displayName: "Azure Open AI 君",
                });
              }
              const _call = callAgent.join({ meetingLink: meetingUrl }); // 変更
              setCall(_call); // 変更
	      setSubmitButtonDisabled(true);
          }}
	  disabled={submitButtonDisabled}
        >
// ...

1.4 会議の音声をテキストに変換する

では、取得した音声をテキストに変換しましょう。
ここでは、Azure AI Services の Speech Service を使用します。

1.4.1 Speech Service リソースの作成

ではでは、また Azure Portal にアクセスしてリソースを作成します。

[ Azure AI services ] と検索し、[ 概要 ] から [ 音声サービス ] の [ 作成 ] を選択します。

こちらも空欄を埋めて [ 確認と作成 ] でリソースの作成が可能です。

作成後、 Speech Services に接続するための 2 つの文字列を保存します。

[ キーとエンドポイント ] にある、キー と 場所/地域 を保存しておきます。
※ キーはキー 1 とキー 2 がありますがどちらでも大丈夫です。

1.4.2 Speech Service で音声テキスト変換

.env 内に以下のように追記します。

# ... 以下追記
REACT_APP_SPEECH_KEY=<< Speech Service のキー >>
REACT_APP_SPEECH_REGION=<< Speech Service の場所/地域 >>

src/App.tsx を以下のように変更します。

import React from "react";
import { Call, CallAgent, CallClient } from "@azure/communication-calling";
import { AzureCommunicationTokenCredential } from "@azure/communication-common";
const sdk = require("microsoft-cognitiveservices-speech-sdk"); // 変更
// ...
// -- 変更 --
  const startSpeechToText = async (mediaStream: MediaStream) => {
    const speechConfig = sdk.SpeechConfig.fromSubscription(
      process.env.REACT_APP_SPEECH_KEY,
      process.env.REACT_APP_SPEECH_REGION
    );
    speechConfig.speechRecognitionLanguage = "ja-JP";
    const audioConfig = sdk.AudioConfig.fromStreamInput(mediaStream);
    const speechRecognizer = new sdk.SpeechRecognizer(
      speechConfig,
      audioConfig
    );
    speechRecognizer.recognizing = (_: any, e: any) => {
      console.log("RECOGNIZING: " + e.result.text);
    };
    speechRecognizer.recognized = (_: any, e: any) => {
      console.log("RECOGNIZED: " + e.result.text);
    };
    await speechRecognizer.startContinuousRecognitionAsync();
  };

  React.useEffect(() => {
    if (call) {
      const intervalId = setInterval(async () => {
        if (call.remoteAudioStreams.length === 0) return;
        const remoteAudioStream = call.remoteAudioStreams[0];
        const mediaStream = await remoteAudioStream.getMediaStream();
        await startSpeechToText(mediaStream);
        clearInterval(intervalId);
      }, 1000);
    }
  }, [call]);
// -- 変更 --
// ...

SpeechRecognizer で音声テキスト変換を実施しているイメージですね。
SpeechRecognizer.recognizing は、変換中の中間結果が返され、 SpeechRecognizer.recognized では変換が完了した結果が返されます。
そのため、 recoginizing と recognied では結果が多少異なる場合がありますね。

1.5 変換した音声を Azure OpenAI に投げて回答をもらう

次に、変換した音声を使って Azure OpenAI に質問して、回答をもらいましょう。
※ 現在 (2023/12/23) Azure OpenAI の使用には事前の申請が必要です。詳しくはこちら

1.5.1 Azure OpenAI リソースの作成

またまた Azure Portal にアクセスしてリソースを作成します。

[ Azure OpenAI ] で検索し、[ 作成 ] から空欄を埋めて [ 次へ ] で複数回進んだのち [ 作成 ] で作成できます。

作成後、 Azure OpenAI に接続するための 2 つの文字列を保存します。

[ キーとエンドポイント ] にある、キー と エンドポイントを保存しておきます。
※ キーはキー 1 とキー 2 がありますがどちらでも大丈夫です。

1.5.2 Azure OpenAI に質問を投げる

.env 内に以下のように追記します。

# ... 以下追記
REACT_APP_AZURE_OPENAI_KEY=<< Azure OpenAI のキー >>
REACT_APP_AZURE_OPENAI_ENDPOINT=<< Azure OpenAI のエンドポイント >>

src/App.tsx を以下のように変更します。

import React from "react";
import { Call, CallAgent, CallClient } from "@azure/communication-calling";
import { AzureCommunicationTokenCredential } from "@azure/communication-common";
// -- 変更 --
import { 
  AzureKeyCredential,
  ChatRequestMessage,
  OpenAIClient,
} from "@azure/openai";
// -- 変更 --
const sdk = require("microsoft-cognitiveservices-speech-sdk");
// ...
// -- 変更 --
const client = new OpenAIClient(
  process.env.REACT_APP_AZURE_OPENAI_ENDPOINT || "",
  new AzureKeyCredential(process.env.REACT_APP_AZURE_OPENAI_KEY || "")
);
const keyword = "こんにちは"; // 変更
const prompt =
  "次の文章は、音声認識システムによって認識された質問文です。 \
  正しい文章を推定し、質問に回答してください。 \
  回答の際には「推定した正しい質問文」と「回答」の2つを答えてください。 \
  また、回答は「推定した正しい質問文」に対しての回答である必要があります。 \
  必ず、 1 つの質問文に対して 1 つの回答をし質問を返さないでください。 \
  なお、可能な限り自然に回答してください。 \
  回答の際には以下のテンプレートに沿って回答してください。: \
  「質問文: {推定した正しい質問文}, 回答: {回答}」 \
  ";
// -- 変更 --
// ...
// -- 変更 --
    let isKeywordDetected = false;
    speechRecognizer.recognizing = (_: any, e: any) => {
      console.log("RECOGNIZING: " + e.result.text);
      if (!e.result.text) return;
      if (e.result.text.includes(keyword)) {
        isKeywordDetected = true;
      }
    };
    speechRecognizer.recognized = async (_: any, e: any) => {
      console.log("RECOGNIZED: " + e.result.text);
      if (!e.result.text) return;
      if (isKeywordDetected && e.result.text.includes(keyword)) {
        console.log("keyword detected");
        isKeywordDetected = false;
        const messages = [
          {
            role: "user",
            content: prompt + e.result.text.replace(keyword, ""),
          },
        ] as ChatRequestMessage[];
        const result = await client.getChatCompletions(
          "gpt-35-turbo",
          messages
        );
        if (!result.choices) return;
        if (!result.choices[0].message) return;
        console.log(result.choices[0].message.content);
      }
    };
// -- 変更 --
// ...

取得した音声全てを Azure OpenAI に渡してしまうのは無駄が多すぎるため、音声アシスタントのように wakeup word の後のみ渡す感じにしています。
(単純に変換後のテキスト内にキーワードがあるか否かを見ているだけなので難しいキーワードだとうまく動かないかもです。)
また、音声テキスト変換での誤差を軽減するため、プロンプト内で正しい質問文の推定を含めています。

これで「こんにちは、五月雨の意味を教えて」というとこんな感じでブラウザ上のコンソールにひょうじされます。

※ ブラウザ コンソールは Ctrl + Shift + C でコンソールを選択すると見れます。

1.6 もらった回答をチャットに投稿する

それでは最後に、 1.5 で得た回答をチャットに投稿しましょう。
最初の方に出てきた Azure Communication Services で実施可能です。
src/App.tsx を以下のように変更します。

import React from "react";
import { Call, CallAgent, CallClient } from "@azure/communication-calling";
import { AzureCommunicationTokenCredential } from "@azure/communication-common";
import {
  AzureKeyCredential,
  ChatRequestMessage,
  OpenAIClient,
} from "@azure/openai";
import { ChatClient, ChatThreadClient } from "@azure/communication-chat"; // 変更
const sdk = require("microsoft-cognitiveservices-speech-sdk");
// ...
// -- 変更 --
const chatClient = new ChatClient(
  process.env.REACT_APP_ACS_ENDPOINT || "",
  tokenCredential
);
// -- 変更 --
// ...
  const [meetingUrl, setMeetingUrl] = React.useState("");
  const [submitButtonDisabled, setSubmitButtonDisabled] = React.useState(false);
  const [call, setCall] = React.useState<Call>();
  const [chatThreadClient, setChatThreadClient] = React.useState<ChatThreadClient>(); // 変更
// ...
speechRecognizer.recognized = async (_: any, e: any) => {
      console.log("RECOGNIZED: " + e.result.text);
      if (!e.result.text) return;
      if (isKeywordDetected && e.result.text.includes(keyword)) {
        console.log("keyword detected");
        isKeywordDetected = false;
        const messages = [
          {
            role: "user",
            content: prompt + e.result.text.replace(keyword, ""),
          },
        ] as ChatRequestMessage[];
        const result = await client.getChatCompletions(
          "gpt-35-turbo",
          messages
        );
	// -- 変更 --
        if (!result.choices) return;
        if (!result.choices[0].message) return;
        if (!result.choices[0].message.content) return;
        if (!chatThreadClient) return;
        await chatThreadClient.sendMessage(
          { content: result.choices[0].message.content },
          { senderDisplayName: "Azure OpenAI" }
        );
	// -- 変更 --
      }
    };
// ...
        <button
          onClick={async () => {
            console.log("join");
            console.log(!callAgent);
            if (!callAgent) {
              callAgent = await callClient.createCallAgent(tokenCredential, {
                displayName: "Azure Open AI 君",
              });
            }
            const _call = callAgent.join({ meetingLink: meetingUrl });
            setCall(_call);
            // -- 変更 --
            const threadIdInput = meetingUrl.split("/")[5];
            if (!chatThreadClient) {
              const _chatThreadClient =
                chatClient.getChatThreadClient(threadIdInput);
              setChatThreadClient(_chatThreadClient);
            }
	    // -- 変更 --
            setSubmitButtonDisabled(true);
          }}
          disabled={submitButtonDisabled}
        >
// ...

Teams 会議に入った時に chatThread も一緒に取得している感じですね。
chatThread を作成するときにはチャットのスレッド ID が人うようになります。
しかし、実は Teams 会議のリンク内に会議チャットのスレッド ID が含まれているのでそれを利用しています。
https://teams.microsoft.com/l/meetup-join/<チャットのスレッド ID>/0?context=hoge

これで、 Teams 会議内でした質問の回答を Azure OpenAI からもらえることができました。

2. まとめ

今回は、「Teams 会議中の音声を拾い、特定の単語の後の質問を Azure OpenAI へ投げてその回答をチャットに投稿するといった web アプリ」を作成しました。
とはいえ、実は今回実装したのって、 Teams 会議に参加する web アプリなんですよね。
そのため、実行しているブラウザ側からも音声を入力可能だったりします。

今回作成した web アプリはこちらにあります。(github を公開予定)

Discussion