React: コピペで喋れ！ずんだもん！！

## はじめに
ずんだもんって知ってます？

最近Youtubeよく見かける枝豆の妖精(?)らしいんですが、こいつを自由に喋らせたらちょっと楽しくないですか？

入力した文章をずんだもんに喋らせることが出来たら理由あって声を出せない方、喋るのがめんどい方等がはっぴーはっぴーになるんじゃないかと思い記事を書くことにしました。

この記事では全てコピペで実装することができますので、初学者の方はこの記事で紹介するコードベースにどんどん拡張してみて遊んでください！

(※この記事では**1ファイル**しか触りません)

## 開発環境構築
本アプリはReactで実装します。
ターミナルとエディタを起動して次の手順を実行しましょう。

1. 以下コマンドをコピペしてターミナルにペーストします。
    ```
    npx create-react-app speak-zundamon --template typescript
    ```
    上記コマンドを実行するとReactプロジェクトが作成されます。
    `speak-zundamon`の箇所はアプリの名前になりますので、よしなに変えちゃって大丈夫です。


2. エディタで作成したプロジェクトを開きましょう。
    以下画像通りの構成になっていればOKです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569593/818a90f2-c4f5-dae6-3788-8d9f387314e5.png)

## 音声再生箇所実装
冒頭で申し訳程度に記述しましたが、このプロジェクトでは`src/App.tsx`の1ファイルしか触りません！
エディタで`src/App.tsx`を開いて次の手順を実行しましょう。

1. 以下をコピペして`src/App.tsx`の中身を書き換ます。
    ```js
    import React, { useState, ChangeEvent, KeyboardEvent } from 'react';
    
    const App: React.FC = () => {
      const [message, setMessage] = useState<string>('');
      const [inputText, setInputText] = useState<string>('');
      const [pending, setPending] = useState<boolean>(false);
    
      const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
        setInputText(event.target.value);
      };
    
      const handleClick = async () => {
        if (inputText.trim() === '') {
          console.error('Oops! text is empty.');
          return;
        }
    
        setPending(true);
        setMessage(inputText);
        // await handleMessage(inputText);
        setInputText('');
        setPending(false);
      };
    
      const handleKeyPress = async (event: KeyboardEvent<HTMLTextAreaElement>) => {
        if (event.key === 'Enter' && !pending) {
          event.preventDefault();
          await handleClick();
        }
      };
    
      return (
        <div>
          <h1>音声再生</h1>
          <textarea
            value={inputText}
            onChange={handleChange}
            onKeyPress={handleKeyPress}
            placeholder='おしゃべりする内容を入力してね'
          />
          <button onClick={handleClick} disabled={pending}>
            {pending ? '待機中...' : '話す'}
          </button>
          {message && <p>{message}</p>}
        </div>
      );
    };
    
    export default App;

    ```
    以下のコメントアウトしてる箇所は後ほど使用しますので一旦そのままにしてください。
    `// await handleMessage(inputText);`

    
2. ファイルを保存してローカル環境立ち上げます。
    ```
    npm run start
    ```

3. `http://localhost:3000`にアクセスし、`src/App.tsx`にコピペした内容を確認します。
    以下動画の通りに表示できていれば成功です！

    ※ この段階では、クリックorエンター押下時に入力した内容がinpuエリア下部に表示されるだけの実装となります。

    ![ダウンロード.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569593/f8f27e36-6cca-33bd-3899-239292ec5f2c.gif)

    引き続き以下手順を実行してずんだもんを喋らせましょう！

    

## ずんだもんとの契約
ずんだもんを喋らすには[voicevox](https://voicevox.su-shiki.com/su-shikiapis/)というwebサービスを使う必要があります。

1. apiKeyの取得
    ずんだもんAPIを使用するため[apiKeyの取得](https://su-shiki.com/api/)にアクセスしてkeyを取得しましょう。(無料)

    サイトにアクセスすると以下の画面が表示されると思うので、`apiKeyを生成`ボタンを押下してkeyを生成してください
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569593/94493b6f-b4f8-ab37-56d5-77d35417380d.png)

2. keyのコピー
    上記手順を終えると**API利用登録画面**が表示されます。
    当該画面の以下画像箇所`apiKeyをコピー`項目に表示されている`apiKey`をコピーします。(こちらは後ほど使うので、必ずメモしておいてください。)
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569593/30d733d4-99f0-4994-d979-8b793174db96.png)

3. googleフォーム回答
    APIを利用するためにはgoogleアンケートへの回答が必須になります。
    以下画像箇所に手順が記載されているので、それに従ってgoogleフォームへ回答しましょう。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569593/e52d47b8-b2b5-ff5d-8a34-2d5c85a945c1.png)


これでずんだもんとの契約は完了しました！
最後はコードにずんだもんを喋らす実装を追加すれば完了となります。もう少しです！

## 音声再生実装
前手順で取得した`apiKey`を使用してVOICEVOXのAPIにリクエストを送信しましょう。
VOICEVOX APIレスポンスを元に入力した文章をずんだモンに喋らせてみましょう。

1. リクエスト関数の作成
    以下コードをコピペして`src/App.tsx`に`handleMessage`という非同期関数を作成します。
    ```js
      const handleMessage = async (text: string) => {
        try {
          const response = await fetch(
            `https://deprecatedapis.tts.quest/v2/voicevox/audio/?key=${'apiKey'}&speaker=1&pitch=0&intonationScale=1&speed=1.2&text=${encodeURIComponent(
              text
            )}`
          );
    
          if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
          }
    
          const blob = await response.blob();
          const audio = new Audio(URL.createObjectURL(blob));
          await audio.play();
        } catch (error) {
          console.error('Failed to fetch or play audio:', error); 
        }
      };
    ```
    
2. apiKey設定
    上記の`key=${'apiKey'}`箇所に先ほどコピペ(メモ)した`apiKey`をペーストしましょう。
    (以下の`123411bb`はダミー値なので、この箇所に実際にコピペしたkeyをペーストしてください。)
    ```js
    fetch(
        `https://deprecatedapis.tts.quest/v2/voicevox/audio/?key=${'123411bb'}&speaker=1&pitch=0&intonationScale=1&speed=1.2&text=${encodeURIComponent(
          text
        )}`
      );
    ```
    
3. 処理説明(スキップしても実装に影響はありません。)
    `handleMessage`個所の実装がこのアプリの肝となります。
    この個所を理解することで機能拡張等が可能になりますので、何を行なっているか簡単に説明します。
    - 非同期関数の定義
        ```js
        const handleMessage = async (text: string)
        ```
        `handleMessage`という非同期関数を定義し、この関数には`text`という引数を受け取っています。
        (入力した文字が`text`格納されています。)

    - リクエストの送信
        ```js
        try {
          const response = await fetch(
            `https://deprecatedapis.tts.quest/v2/voicevox/audio/?key=${'apiKey'}&speaker=1&pitch=0&intonationScale=1&speed=1.2&text=${encodeURIComponent(
              text
            )}`
          );
        ```
        `fetch`関数を使用して、VOICEVOX APIにリクエストを送信しています。
        以下クエリパラメータ設定してずんだもんの調整等を行なっています。
        ```
        key: apiKey
        speaker: キャラ設定(ずんだもんを喋らせたいため`1`を指定。)
        pitch: ピッチ調整
        intonationScale: イントネーション調整
        speed: 速度調整
        text: encodeURIComponent関数を用いてメッセージをエンコート
        ```
        上記は公式サイトに詳細に記述されていますので、気になる方は確認してみてください。
        https://voicevox.su-shiki.com/su-shikiapis/

    - レスポンスデータの加工
        ```
        const blob = await response.blob();
        const audio = new Audio(URL.createObjectURL(blob));
        await audio.play();
        ```
        **blob**
        response.blob() メソッドを使用してレスポンスのbodyを Blob(画像や音声ファイルなどのバイナリデータを扱うためのオブジェクト) として取得しています。
            今回blob には音声データが含まれた Blob オブジェクトが格納されています。

        **audio**
        Blob データをブラウザ内でアクセス可能なURLに変換し、そのURLを元に Audio オブジェクトを作成しています。
        `URL.createObjectURL(blob)`メソッドは、Blob または File オブジェクトから一時的なURLを生成します。
        このURL用いてブラウザ内でデータにアクセスしています。

        この辺りは実際に`console.log`を使って確認すると何を行なっているのか分かり易いです。
        ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/569593/eec5cb1d-9ac2-e785-ab6d-e4b50062bb3a.png)

        
    
    
4. クリックイベント作成
    上記手順でコピペした`handleClick`関数の中の`await handleMessage(inputText)`のコメントウト(`//`箇所)を削除してクリック時に`handleMessage`関数を呼び出しましょう。
    
    ```js
      const handleClick = async () => {
        if (inputText.trim() === '') {
          console.error('Oops! text is empty.');
          return;
        }
    
        setPending(true);
        setMessage(inputText);
        // await handleMessage(inputText); ← ここです。
        setInputText('');
        setPending(false);
      };
    ```

    これで全ての作業は終了しました！
    文字を入力してずんだもんを喋らせてみましょう！

## おまけ
ここまで出来るだけシンプルで分かり易い形にするために無駄を極力省いた形で紹介しました。
もちろん上記の形をベースに各々拡張して頂くのも素敵なのですが、折角ですのでスタイルを整えた成果物も共有させて頂きます。

```js
// Reactと必要なhookをインポート
import React, { useState, ChangeEvent, KeyboardEvent } from 'react';

// メインコンポーネント
const App: React.FC = () => {
  // メッセージの状態を管理するためのステートフック
  const [message, setMessage] = useState<string>(''); // 現在のメッセージを保持
  // 入力フィールドのテキストを管理するためのステートフック
  const [inputText, setInputText] = useState<string>(''); // 入力されたテキストを保持
  // 処理中かどうかを示すフラグを管理するためのステートフック
  const [pending, setPending] = useState<boolean>(false); // 処理中であるかを示すフラグ

  // 入力フィールドの値が変更されたときに呼び出されるハンドラ関数
  const handleChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
    setInputText(event.target.value); // 入力されたテキストをステートに反映
  };

  // テキストを音声で再生する非同期関数
  const handleMessage = async (text: string) => {
    try {
      // テキストを音声に変換するAPIにリクエストを送信
      const response = await fetch(
        `https://deprecatedapis.tts.quest/v2/voicevox/audio/?key=${'apiKey'}&speaker=1&pitch=0&intonationScale=1&speed=1.2&text=${encodeURIComponent(
          text
        )}`
      );

      // レスポンスが正常でない場合はエラーをスロー
      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      // レスポンスから音声ファイルのデータを取得
      const blob = await response.blob();
      console.log('blob', blob); // デバッグ用のログ出力
      // BlobデータをURLオブジェクトとして生成し、Audioオブジェクトに設定
      const audio = new Audio(URL.createObjectURL(blob));
      console.log('audio', audio); // デバッグ用のログ出力
      // 音声を再生
      await audio.play();
    } catch (error) {
      // エラーが発生した場合はコンソールにエラーメッセージを出力
      console.error('Failed to fetch or play audio:', error);
    }
  };

  // 「話す」ボタンがクリック時に入力されたテキストを音声で再生するハンドラ
  const handleClick = async () => {
    // 入力フィールドが空の場合はエラーメッセージを出力して終了
    if (inputText.trim() === '') {
      console.error('Input text is empty, cannot play voice.');
      return;
    }

    // 処理中に設定
    setPending(true);
    // 入力されたテキストをメッセージに設定
    setMessage(inputText);
    // テキストを音声で再生
    await handleMessage(inputText);
    // 入力フィールドをリセット
    setInputText('');
    // 処理完了に設定
    setPending(false);
  };

  // エンターキーが押されたときに再生処理をトリガーするハンドラ
  const handleKeyPress = async (event: KeyboardEvent<HTMLTextAreaElement>) => {
    // エンターキーが押され、かつ処理中でない場合`handleClick`を実行
    if (event.key === 'Enter' && !pending) {
      // エンターキーによる改行を防止
      event.preventDefault();
      // 再生処理をトリガー
      await handleClick();
    }
  };

  // コンポーネントのレンダリング
  return (
    <div
      style={{
        display: 'flex', // フレックスボックスレイアウトを使用
        flexDirection: 'column', // 子要素を縦方向に配置
        alignItems: 'center', // 子要素を中央に揃える
        justifyContent: 'center', // 子要素を縦方向に中央揃え
        minHeight: '100vh', // ビューポートの高さを占める
        backgroundColor: '#f7fafc', // 背景色を設定
        padding: '16px', // パディングを設定
      }}
    >
      <h1
        style={{
          fontSize: '2.25rem', // フォントサイズを設定
          fontWeight: 'bold', // フォントの太さを設定
          marginBottom: '24px', // 下マージンを設定
          color: '#2d3748', // テキストの色を設定
        }}
      >
        音声再生
      </h1>
      <textarea
        value={inputText} // テキストエリアの値をステートと同期
        onChange={handleChange} // 値が変更されたときのハンドラーを設定
        onKeyPress={handleKeyPress} // キーが押されたときのハンドラーを設定
        placeholder='おしゃべりする内容を入力してね' // プレースホルダーを設定
        rows={4} // テキストエリアの行数を設定
        style={{
          width: '100%', // 幅を100%に設定
          maxWidth: '32rem', // 最大幅を設定
          padding: '12px', // パディングを設定
          marginBottom: '16px', // 下マージンを設定
          borderWidth: '1px', // ボーダー幅を設定
          borderRadius: '0.5rem', // ボーダーの角を丸く設定
          resize: 'none', // テキストエリアのリサイズを禁止
          boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', // ボックスシャドウを設定
          outline: 'none', // フォーカス時のアウトラインを非表示
          borderColor: '#e2e8f0', // ボーダーの色を設定
          transition: 'box-shadow 0.2s', // ボックスシャドウのトランジションを設定
        }}
        // フォーカス時のボックスシャドウを設定
        onFocus={(e) =>
          (e.currentTarget.style.boxShadow =
            '0 0 0 4px rgba(66, 153, 225, 0.6)')
        }
        // フォーカスが外れたときのボックスシャドウを設定
        onBlur={(e) =>
          (e.currentTarget.style.boxShadow = '0 1px 3px rgba(0, 0, 0, 0.1)')
        }
      />
      <button
        onClick={handleClick} // クリック時のハンドラーを設定
        disabled={pending} // 処理中はボタンを無効化
        style={{
          paddingLeft: '24px', // 左パディングを設定
          paddingRight: '24px', // 右パディングを設定
          paddingTop: '8px', // 上パディングを設定
          paddingBottom: '8px', // 下パディングを設定
          fontSize: '1.125rem', // フォントサイズを設定
          color: '#fff', // テキストの色を白に設定
          borderRadius: '0.5rem', // ボーダーの角を丸く設定
          boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', // ボックスシャドウを設定
          outline: 'none', // フォーカス時のアウトラインを非表示
          transition: 'background-color 0.3s', // 背景色のトランジションを設定
          backgroundColor: pending ? '#cbd5e0' : '#4299e1', // 処理中はグレー、そうでない場合は青に設定
          cursor: pending ? 'not-allowed' : 'pointer', // 処理中はカーソルを禁止アイコンに設定
        }}
        // ホバー時の背景色を設定
        onMouseOver={(e) =>
          !pending && (e.currentTarget.style.backgroundColor = '#2b6cb0')
        }
        // ホバーが外れたときの背景色を設定
        onMouseOut={(e) =>
          !pending && (e.currentTarget.style.backgroundColor = '#4299e1')
        }
      >
        {/* ボタンのテキストを処理中かどうかで切り替え */}
        {pending ? '待機中...' : '話す'}
      </button>
      {/* メッセージが存在する場合に表示 */}
      {message && (
        <p
          style={{
            marginTop: '24px', // 上マージンを設定
            padding: '12px', // パディングを設定
            width: '100%', // 幅を100%に設定
            maxWidth: '32rem', // 最大幅を設定
            backgroundColor: '#fff', // 背景色を白に設定
            borderWidth: '1px', // ボーダー幅を設定
            borderRadius: '0.5rem', // ボーダーの角を丸く設定
            boxShadow: '0 1px 3px rgba(0, 0, 0, 0.1)', // ボックスシャドウを設定
            color: '#2d3748', // テキストの色を設定
          }}
        >
          {/* メッセージを表示 */}
          {message}
        </p>
      )}
    </div>
  );
};

// コンポーネントをエクスポート
export default App;

```

(`apiKey`だけはご自身のものを使用してください。)



## おわりに
どうでしたか？
コピペだけで簡単に枝豆を喋らすことが出来たのではないでしょうか？

実は喋らすだけなら公式のアプリを使った方が細かい設定もできます。
https://voicevox.hiroshiba.jp/

今回敢えて簡略化した機能を記事として残した理由は、流行を形にする楽しみを知って欲しい。
みなさんにこの最低限のアプリを拡張して是非革新的なアイディアを実装してみて欲しい。
そんな気持ちを原動に今回執筆させて頂きました。

...嘘です。
シンプルにずんだもんAPIの存在を知って触ってみたいと思っただけです←