【ハンズオン】ReactとGPT-4oでキャラと話せる音声チャットを作ろう【TypeScript/TailwindCSS】

# はじめに

**GPT-4oのリリースで大きく世界が変わった。時代についていくにはAIを使いこなす必要がある**

GPT-4oの登場は過去のGPTの中でも特に印象に残っています。とにかく返答が早く、スマホアプリでの音声チャットはほぼ人と話しているのと感覚が変わりませんでした。

今後AI技術を活かしながら新しいサービスを作ることはどんどん広がり、それがビジネスにつながっていくとはずです。**AIを使ってどのような価値が作れるのかという視点をもてることが重要です**

AIを使って何ができるのかを思いつくには、AIを試してみて何を実現することができるのかを理解していないといけません。

今回はChatGPTの音声入力がとても印象的だったので、それを真似てキャラクターとお話ができる**リアルタイム音声チャットアプリ**をReactを用いてハンズオンで作成していきます。

# 動画での解説

細かいところなどは動画でより詳しく解説していますので、合わせてご利用ください！

https://youtu.be/0IvxAv9Ry6k?si=vIhDrVWkRZW2eA68

# 1.開発環境の用意

まずはReactとTailwindCSSの環境を用意します。
ここではNode環境があることを前提にして行います。

```
$ npm create vite@latest
✔ Project name: … voice-chat-qiita
✔ Select a framework: › React
✔ Select a variant: › TypeScript
$ cd voice-chat-qiita
$ npm i
$ npm run dev
```

[localhost:5173](https://localhost:5173)を開きます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/98ec6c26-9fbb-4488-1cd3-953b3c7a24ad.png)


Reactの環境を作成することができました。続いてTailwindCSSを導入します。

```
$ npm install -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
```

VSCodeで作成したプロジェクトを開きます。

`tailwind.config.js`を以下にします

```js:tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: ["./src/**/*.{js,jsx,ts,tsx}"],
  theme: {
    extend: {},
  },
  plugins: [],
};
```

`src/index.css`を以下にします

```css:src/index.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```

tailwindが正しく反映されているかを確認します。
`src/app.tsx`を以下に変更します

```tsx:src/App.tsx
import "./App.css";

function App() {
  return (
    <>
      <h1 className="text-3xl font-bold underline">Hello world!</h1>
    </>
  );
}

export default App;
```

サーバーを再起動して確認するとスタイルがあたっているので導入がうまくいきました


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/8dee5dcb-6538-a0a9-86a3-89157e4d834a.png)

# 2. 音声入力の実装

音声入力をして入力したテキストを表示する実装をします
今回は`react-speech-recognition`というライブラリを使います。

```
$ npm i react-speech-recognition
$ npm i --save-dev @types/react-speech-recognition
$ npm i regenerator-runtime
```

App.tsxを以下に変更します。

```tsx:src/App.tsx
import "regenerator-runtime";
import SpeechRecognition, {
  useSpeechRecognition,
} from "react-speech-recognition";
import { useState } from "react";

function App() {
  const {
    listening,
    transcript,
    resetTranscript,
    browserSupportsSpeechRecognition,
  } = useSpeechRecognition();
  const [message, setMessage] = useState("");

  const handleClickStart = () => {
    SpeechRecognition.startListening();
  };

  const handleClickStop = () => {
    SpeechRecognition.stopListening;
    setMessage(transcript);
    resetTranscript();
  };

  if (!browserSupportsSpeechRecognition) {
    return <span>Browser doesn't support speech recognition.</span>;
  }

  return (
    <>
      <p>{message}</p>
      <button onClick={handleClickStart}>Start</button>
      <button onClick={handleClickStop}>Stop</button>
    </>
  );
}

export default App;

```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/d5b00f47-ecf6-929d-a5b4-7cf0cfd2f776.png)

ここまで実装すると`start`を押して、こんにちはと言い、`stop`を押すと`こんにちは`と表示されます

ここですこし音声入力の説明をしていきます。

```tsx
  const {
    listening,
    transcript,
    resetTranscript,
    browserSupportsSpeechRecognition,
  } = useSpeechRecognition();
  ```

  ここでhookを使っています
  - litening : 音声入力中かを表すフラグ (trueなら入力中)
  - transcript : 音声入力が終わったら、入力した文章が入る
  - resetTranscript : transcriptを初期化する
  - browserSupportsSpeechRecognition : ブラウザで音声入力が使えるかを表すフラグ(falseなら利用できない)

```tsx
  const handleClickStart = () => {
    SpeechRecognition.startListening();
  };

  const handleClickStop = () => {
    SpeechRecognition.stopListening;
    setMessage(transcript);
    resetTranscript();
  };

  if (!browserSupportsSpeechRecognition) {
    return <span>Browser doesn't support speech recognition.</span>;
  }
```

Startを押したら、SpeechRecognitionのスタートを実行して音声を入力するようにして、stopを押したら入力を停止してから入力した文章をMessageにセットして初期化を行います

# 3. キャラクターが話せるようにする

今回は「ずんだもん」を利用して入力した音声を読んでもらうように実装していきます。

ずんだもんはAPIを利用することで簡単に使うことができます

https://voicevox.su-shiki.com/su-shikiapis/

まずはAPIキーを生成します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/2b476d5d-d796-ac34-8124-4c93f62273ea.png)

やり方に沿ってキーを取得してください

`.env`を作成してキーの情報を環境変数として読み込めるようにします

```
$ touch .env
```

```.env
VITE_VOICEVOX_API_KEY=あなたのAPIキー
```

GithPushしないように`.gitignore`に.envを追加しておきます

```.gitignore
.env //最後の行に追加
```

VOICEVOXを使うにはGETリクエストをして、音声ファイルを再生することで簡単に利用が可能です。

```tsx:src/App.tsx
  const handleClickStop = () => {
    SpeechRecognition.stopListening;
    setMessage(transcript);
    resetTranscript();
    playVoice(transcript);
  };

  const playVoice = async (message: string) => {
    const response = await fetch(
      `https://deprecatedapis.tts.quest/v2/voicevox/audio/?key=${
        import.meta.env.VITE_VOICEVOX_API_KEY
      }&speaker=0&pitch=0&intonationScale=1&speed=1&text=${message}`
    );

    const blob = await response.blob();
    const audio = new Audio(URL.createObjectURL(blob));
    audio.play();
  };
```

`stop`を押したら音声入力の内容を`playVoice`に渡しています。
palyVoiceではAPIを叩いて、音声ファイルを再生します

ここまで実装した上でアプリに音声入力をすると入力した内容をずんだもんが復唱してくれるアプリが完成します

# 4. ChatGPTの実装

ここからはChatGPTを活用して会話を行えるようにし、返答をすべてずんだもんが読み上げるように実装をします。

まずは、ChatGPTのAPIキーを取得します

https://platform.openai.com/

左メニューの`API Keys`をクリック

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/2afa6f57-fb73-e95c-4e9d-29f4ca64476f.png)

`create new Secret Key`を押してキーを取得します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/aa1b51fc-39d7-c178-3fb2-0a207ae06d50.png)

`.env`に環境変数として設定します

```.env
VITE_VOICEVOX_API_KEY=あなたのキー
VITE_OPENAI_API_KEY=あなたのキー
```

ChatGPTの実装をしていきます

```tsx:src/App.tsx
import "regenerator-runtime";
import SpeechRecognition, {
  useSpeechRecognition,
} from "react-speech-recognition";
import { useState } from "react";

type Message = {
  role: "user" | "assistant";
  content: string;
};

function App() {
  const {
    listening,
    transcript,
    resetTranscript,
    browserSupportsSpeechRecognition,
  } = useSpeechRecognition();
  const [message, setMessage] = useState("");
  const [history, setHistory] = useState<Message[]>([]);

  const handleClickStart = () => {
    SpeechRecognition.startListening();
  };

  const handleClickStop = () => {
    SpeechRecognition.stopListening;
    setMessage(transcript);
    resetTranscript();
    sendGPT(transcript); // ChatGPTに送る
  };

  const sendGPT = async (message: string) => {
    const body = JSON.stringify({
      messages: [...history, { role: "user", content: message }],
      model: "gpt-4o", // GPT-4oを使用
    });
    const response = await fetch(`https://api.openai.com/v1/chat/completions`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
      },
      body,
    });

    const data = await response.json();
    const choice = data.choices[0].message.content;

    setHistory([...history, { role: "assistant", content: choice }]);
    playVoice(choice);
  };

  const playVoice = async (message: string) => {
    const response = await fetch(
      `https://deprecatedapis.tts.quest/v2/voicevox/audio/?key=${
        import.meta.env.VITE_VOICEVOX_API_KEY
      }&speaker=0&pitch=0&intonationScale=1&speed=1&text=${message}`
    );

    const blob = await response.blob();
    const audio = new Audio(URL.createObjectURL(blob));
    audio.play();
  };

  if (!browserSupportsSpeechRecognition) {
    return <span>Browser doesn't support speech recognition.</span>;
  }

  return (
    <>
      <p>{message}</p>
      <button onClick={handleClickStart}>Start</button>
      <button onClick={handleClickStop}>Stop</button>
    </>
  );
}

export default App;
```

ポイントを解説していきます

```tsx
  const sendGPT = async (message: string) => {
    const body = JSON.stringify({
      messages: [...history, { role: "user", content: message }],
      model: "gpt-4o", // GPT-4oを使用
    });
    const response = await fetch(`https://api.openai.com/v1/chat/completions`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
      },
      body,
    });

    const data = await response.json();
    const choice = data.choices[0].message.content;

    setHistory([...history, { role: "assistant", content: choice }]);
    playVoice(choice);
  };
```

ここではChatGPTに入力した内容を投げています。
ポイントは`body`の`messages`です。

messagesは過去のメッセージと今回送りたいメッセージを配列にして送ることで、過去の知識をもったまま今回の質問に答えてくれるようになります。

1つの質問は`Message型`で送る必要があります

```tsx
type Message = {
  role: "user" | "assistant";
  content: string;
};
```

ここでroleは誰がその発言をしたのかを表します。userは私たちで、assistantはChatGPTの発言ということを表します。

過去の質問は保存しておく必要があるので、historyを利用しています

```tsx
setHistory([...history, { role: "assistant", content: choice }]);
```

ここまで行うと、質問に対して返答を音声で返してくれるようになりました。

# 5. スタイルを整える

最後にスマートフォンで利用することを前提にスタイルを整えていきます。
ここは本質ではないので以下のスタイルをコピーして利用してみてください。


まずはアイコンを利用するために`react-icons`をインストールします

```
$ npm i react-icons
```

App.tsxを以下にします

```tsx:App.tsx
import "regenerator-runtime";
import SpeechRecognition, {
  useSpeechRecognition,
} from "react-speech-recognition";
import { useState } from "react";
import { FaSquare } from "react-icons/fa";
import { AiOutlineAudio } from "react-icons/ai";

type Message = {
  role: "user" | "assistant";
  content: string;
};

function App() {
  const {
    listening,
    transcript,
    resetTranscript,
    browserSupportsSpeechRecognition,
  } = useSpeechRecognition();
  const [history, setHistory] = useState<Message[]>([]);

  const handleClickStart = () => {
    SpeechRecognition.startListening();
  };

  const handleClickStop = () => {
    SpeechRecognition.stopListening;
    resetTranscript();
    sendGPT(transcript); // ChatGPTに送る
  };

  const sendGPT = async (message: string) => {
    const body = JSON.stringify({
      messages: [...history, { role: "user", content: message }],
      model: "gpt-4o", // GPT-4oを使用
    });
    const response = await fetch(`https://api.openai.com/v1/chat/completions`, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${import.meta.env.VITE_OPENAI_API_KEY}`,
      },
      body,
    });

    const playVoice = async (message: string) => {
      const response = await fetch(
        `https://deprecatedapis.tts.quest/v2/voicevox/audio/?key=${
          import.meta.env.VITE_VOICEVOX_API_KEY
        }&speaker=0&pitch=0&intonationScale=1&speed=1&text=${message}`
      );

      const blob = await response.blob();
      const audio = new Audio(URL.createObjectURL(blob));
      audio.play();
    };

    if (!browserSupportsSpeechRecognition) {
      return <span>Browser doesn't support speech recognition.</span>;
    }

    const data = await response.json();
    const choice = data.choices[0].message.content;

    setHistory([...history, { role: "assistant", content: choice }]);
    playVoice(choice);
  };

  const buttonClasses = `mt-4 w-[60px] h-[60px] flex items-center justify-center text-2xl ${
    listening ? "bg-red-500" : "bg-blue-500"
  } rounded-full text-white`;
  const iconClass = listening ? <FaSquare /> : <AiOutlineAudio />;

  return (
    <>
      <div className="flex flex-col items-center w-full min-h-screen">
        <img
          src="https://ucarecdn.com/b6b3f827-c521-4835-a7d4-5db0c87c9818/-/format/auto/"
          alt="キャラクターの画像"
          className="w-full h-auto sm:w-full object-cover"
        />
        <div className="w-full max-w-[400px] sm:max-w-full sm:px-4 mt-4">
          <FramedImage
            characterName="ずんだもん"
            dialogueText={
              history[history.length - 1]
                ? history[history.length - 1].content
                : ""
            }
          />
        </div>
        {listening ? (
          <button onClick={handleClickStop} className={buttonClasses}>
            <i>{iconClass}</i>
          </button>
        ) : (
          <button onClick={handleClickStart} className={buttonClasses}>
            <i>{iconClass}</i>
          </button>
        )}
      </div>
    </>
  );
}

export default App;
```

返答の文章を表示するためのフレームをコンポーネントで用意します

```
$ touch src/FrameImage.tsx
```

```tsx:src/FrameImage.tsx
import React from "react";

interface FramedImageProps {
  characterName: string;
  dialogueText: string;
}

const FramedImage: React.FC<FramedImageProps> = ({
  characterName,
  dialogueText,
}) => {
  return (
    <div className="relative w-full max-w-[360px] h-[190px] border-4 border-pink-500 mx-2 sm:px-2">
      <div className="absolute top-[-12px] left-[-4px] py-1 px-2 bg-pink-700 text-white text-sm font-bold font-roboto">
        {characterName}
      </div>
      <div className="absolute top-6 left-2 right-2 bottom-2 bg-white bg-opacity-80 text-black text-lg px-2 font-roboto overflow-y-auto">
        {dialogueText}
      </div>
    </div>
  );
};

export default FramedImage;
```

コンポーネントを作成したらApp.tsxでインポートしておきましょう

```tsx:App.tsx
import { AiOutlineAudio } from "react-icons/ai";
```

ここまでできたらデベロッパーツールをひらいて`iphone SE`のレイアウトにします

![Peek 2024-05-18 21-32.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/810513/28aa3be7-186f-bf2e-0ffd-ee3f7dba9a7f.gif)

音声入力するとずんだもんが答えてくれます！
ChatGPTが素早いので会話も比較的スムーズです！


# おわりに

今回はChatGPTを利用してお手軽な音声チャットを実装しました
キャラクター変更などの機能を加えたり、ChatGPTに性格情報などを与えることでよりリアルな会話を楽しむことも可能です

ここまで読んでいただけた方はいいねとストックよろしくお願いします。
@Sicut_study をフォローいただけるととてもうれしく思います。

また明日の記事でお会いしましょう！

# JISOUのメンバー募集中！
プログラミングコーチングJISOUでは、新たなメンバーを募集しています。
実践的なカリキュラムで、あなたのエンジニアとしてのキャリアを最短で飛躍させましょう！
実践重視：即戦力を育てるアウトプット中心のプログラム。
モダンなスキル : Reactを中心としたモダンな技術を学べる。
キャリアアップ：スキルアップだけでなく、キャリアパスのサポートも充実。
興味のある方は、ぜひホームページからお気軽にカウンセリングをお申し込みください！
▼▼▼

https://projisou.jp