[英語リスニング学習]ディクテーション用Webアプリを作成しました

# イントロダクション
## 概要
英語リスニング練習用のWebアプリを作成しました．

https://dictation-app-frontend.vercel.app/

![ディクテーションアプリ動作イメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/9b40b48d-fc30-2f19-b8f8-71092c811cf7.gif "ディクテーションアプリ動作イメージ")
1. トピックを選択
2. 音声を再生
3. 聞きとった内容をテキストボックスに入力
4. 聞き取れない部分はリピートしながら回答

## 動機
TOEICのリスニング勉強の一環でディクテーションをしていた時，
”聞き取りづらいとこだけ何回も繰り返す方法ないかな～”と思ったのがきっかけ．
ユーザが指定した部分をリピートし続けるプレイヤーを作る目的で作成しました．

# 要件

## 機能要件
ディクテーションするにあたって必要と考えた機能は下記の通り．
- 音声プレイヤーと回答テキストボックス
    - 音声プレイヤー
        - 基本的な機能(再生/停止，シークバー，数秒戻る/進む…など)
        - 開始/終了時間を指定し，対象区間をループする機能
    - 回答テキストボックス
        - テキスト入力機能
        - 解答の表示機能
        - (できれば)回答の正解/不正解表示機能

## その他の要件
音声ファイルと原稿となるテキストファイルが必要．
ずっと同じ問題では困るので，原稿&音声の内容は定期的に更新されるようにしたい．
トピックについてはとくにこだわりないので，数種類から選べればOK．

## 使用技術・API
- React
    - 機能要件を満たす音声プレイヤー，回答テキストボックスのコンポーネントを作成
- Google Cloud
    - Cloud Run
        - バックエンドAPIの実行に利用
    - Gemini
        - 原稿テキスト作成に利用
    - Text-to-Speech API(Vertex AI)
        - 原稿テキストの音声化に利用
    - Cloud Storage
        - 音声&原稿テキストの保存に利用
    - Cloud Scheduler
        - 原稿&音声の定期更新に利用

最終的に下記のような構成としました．
- ページアクセス時のフロー

![Webアプリの構成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/71575c4b-d52a-fcb2-58d6-aeb826ab3004.png "Webアプリの構成")

- 原稿&音声更新時のフロー
![Webアプリの構成 ファイル更新時](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/66ab49aa-6fed-faf7-584d-d9ad5dec756c.png "Webアプリの構成 ファイル更新時")

# バックエンド

## API

下記のAPIを用意しました．

<table>
    <thead>
        <tr>
            <th>名称</th> <th>説明</th>
        </tr>
    </thead>
    <tr>
        <td> 音声&原稿URL取得API </td>
        <td>引数：ファイル保存日,トピック <br> 
        対象トピックの音声&原稿ファイルのURLを取得するAPI <br> 
        原稿&音声を毎日更新しているため，日付も引数としています</td>
    </tr>
    <tr>
        <td> 音声&原稿ファイル生成API </td>
        <td>引数：トピック <br> 
        GeminiとText-To-Speech APIにアクセスし，音声と原稿ファイルを生成するAPI <br>
        Storageに保存するAPICloud Schedulerによる定期実行用 <br> 
        OIDCトークンによって認証し，Cloud Scheduler以外からの実行を拒否</td>
    </tr>
</table>

# フロントエンド

ランディングページと問題ページの2つで構成しました．

## ランディングページ

トピックを選択して，対応する問題ページに遷移します．

## 問題ページ

アクセス時にバックエンドの音声&原稿URL取得APIを実行．
取得したURLにアクセスして音声&原稿データを取得し，各コンポーネントに渡します．
Reactで機能要件を満たす音声プレイヤー，回答テキストボックスのコンポーネントを作成しました．

### 音声プレイヤーコンポーネント

基本的な機能に加え，下のスライダーからループ開始/終了時間を指定できます．

![音声プレイヤーコンポーネント](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/d99ed7ae-464f-6538-ed9a-d88311b170f5.png "音声プレイヤーコンポーネント")

### 回答テキストボックスコンポーネント

各単語ごとに入力ボックスを分けています．

![回答テキストボックスコンポーネント](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/9ff6a1f2-a6bf-25d9-189f-242f42b9f0b5.png "回答テキストボックスコンポーネント")

解答表示はボタンでON/OFF可能．

![解答表示状態の回答テキストボックスコンポーネント](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/c2bcaa4a-44b4-1517-c500-a16fd2e8f267.png "解答表示状態の回答テキストボックスコンポーネント")


回答の正解/不正解表示機能については単語ごとに区切って判定しています．

![回答テキストボックスコンポーネントの入力例](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/1204a2d7-ec06-e235-9fd8-ee40fe1cdc1c.gif "回答テキストボックスコンポーネントの入力例")


# 音声/原稿の自動更新機能

バックエンドの音声&原稿ファイル生成APIを1日1回実行し，新しい音声&原稿ファイルを作成しています．

## Cloud Scheduler側の対応

ターゲットタイプ”HTTP”を選択し，音声&原稿ファイル生成APIのURLを対象URLに設定します．

また，今回は認証を行いたいのでAuthヘッダーに”OIDCトークンを追加”を設定します．

![Cloud Schedulerの設定画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/4e3d0c12-3b39-ce65-2866-ecb8d34d52aa.png "Cloud Schedulerの設定画面")

サービスアカウントにはCloud Scheduler Job実行者のロールを設定する必要があります．
必要に応じてIAMと管理からロールを割り当てましょう．

## バックエンド(API)側の対応

バックエンド側では認証を行うための対応が必要です．

今回は，OIDCトークンのペイロードに含まれるemailを利用して下記のようなフローでアクセス制御を行います．

OIDCトークンには設定したサービスアカウントのメールアドレスが含まれるので，これを用いて認証を行います．

1. [Cloud Scheduler側処理] OIDCトークンを含んだリクエストをバックエンドAPIに投げる
2. [バックエンド側処理] OIDCトークンのペイロードに含まれるemailと，事前に設定しておいたメールアドレスを比較
3. [バックエンド側処理] メールアドレスが一致していればファイル生成処理継続，一致していなければ拒否する

実装は下記のようになります．
- 環境変数

```
ENDPOINT={APIのURL}
SERVICE_ACCOUNT_EMAIL={サービスアカウントのメールアドレス}
```

- 認証処理のコード

```jsx
import { OAuth2Client } from 'google-auth-library';
import dotenv from 'dotenv';

//環境変数にENDPOINT={APIのURL}を設定しておく
const client = new OAuth2Client(process.env.ENDPOINT);

export const authenticate = async (req, res, next) => {
    try {
		//authorizationヘッダーがないなら拒否
        if (!req.headers['authorization']) {
            throw new Error('Forbidden - No token provided');
        }
        
        //OIDCトークンからペイロードを取り出す
        const idToken = req.headers['authorization'].split(' ')[1];
        const ticket = await client.verifyIdToken({
            idToken,
            audience: process.env.ENDPOINT,
        });
        const payload = ticket.getPayload();
        
    if (payload) {
      // サービスアカウントのメールアドレスを検証
      if (payload.email === process.env.SERVICE_ACCOUNT_EMAIL) {
		//一致していれば次の処理
        next();
      } else {
    	//不一致なので拒否
        throw new Error('Forbidden - Invalid service account');
      }
    } else {
      throw new Error('Forbidden');
    }
  } catch (error) {
    console.error(error);
    res.status(403).send('Forbidden');
  }
};
```

- サーバのコード

```jsx
import express from 'express';

const app = express();
const port = 8080;

app.post('/{エンドポイントの名前(任意)}', authenticate, async (_, res) => { //引数に↑で書いたauthenticateを追加
  /*
		ファイル生成処理のコード  
  */
});
```

# デプロイ方法

## バックエンド

バックエンドのコードはCloud Runでデプロイしました．
GitHubでリポジトリを作成し，Cloud Runの”リポジトリを接続”からデプロイできます．
フロントエンドを後述のようにVercelにデプロイしているので，”未認証の呼び出しを許可”を選択します．

![Cloud Run設定画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/38ffb62a-c53f-7f5c-1278-29c855357ac9.png "Cloud Run設定画面")

環境変数にURLとサービスアカウントのメールアドレスを設定しておきます．
設定ページ下部の”コンテナ，ボリューム…”の”変数とシークレット”から設定できます．

![Cloud Run環境変数設定画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/04e695f2-7cb1-d767-7298-58138b3f710a.png "Cloud Run環境変数設定画面")

## フロントエンド

ホスティングサービスはVercelを利用しました．
GitHubでリポジトリを作成し，Vercelのページから連携すれば完了です．

![Vercelデプロイ画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/fa951ed6-0b78-88f3-57bd-858afc01dcd3.png "Vercelデプロイ画面")


![Vercelデプロイ画面 リポジトリのインポート](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/70b3a0fd-07d1-fda4-0c05-25327050e952.png "Vercelデプロイ画面 リポジトリのインポート")
環境変数にバックエンドAPIのURLを設定しておきます．
![Vercelデプロイ画面 環境変数設定](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3978367/df07dd75-5d62-fdc6-8a95-496871322c7b.png "Vercelデプロイ画面 環境変数設定")

# まとめ

ディクテーション用アプリの開発内容についてまとめました．

## 補足

- 原稿テキストは下記のようなニュースAPIから取得しても良いかもしれません
https://www.microsoft.com/en-us/bing/apis/bing-news-search-api
- Vercel cronを使えばバックエンドのAPIをフロント側に統合できそう？
https://vercel.com/docs/cron-jobs/manage-cron-jobs

## 参考リンク

### バックエンド

https://cloud.google.com/nodejs/docs/reference/google-auth-library/latest/google-auth-library/oauth2client#google_auth_library_OAuth2Client_verifyIdToken_member_1_


https://cloud.google.com/scheduler/docs/tut-gcf-http?hl=ja

https://cloud.google.com/scheduler/docs/http-target-auth?hl=ja