ReactNative - expo | supabase を使用した発音をテキストに起こす英語フレーズアプリ開発 (Hi-Speak)

# 使用技術
- ReactNative(expo)
- Supabase
- tanstack/react-query
- expo-av
- admob

今回、expoとSupabaseを使用して自身の好みの英語フレーズを単語帳のように登録でき、GoogleのSpeechToTextを使用して自分の発音が正しいか練習することができるアプリ「Hi-Speak」を開発いたしましたので、感想を述べます！
[Hi-Speak](https://apps.apple.com/jp/app/hi-speak/id6744645799)


# expoについて
expoはReactNative版のNext.jsのような印象がありました.ルーティングをフォルダ構成で作成することができ、Next.jsで言うところの``layout.tsx``で該当のフォルダ配下の共通レイアウトも作成することができます.

![Group 17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4072963/031190b4-4440-40b8-a1c8-682374ccd77e.png)


```tsx:layout.tsx
<QueryClientProvider client={queryClient}>
    <ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
        <ToastProvider>
          <Stack
            screenOptions={{
              headerShown: false,
            }}
          >
            <Stack.Screen name="(routes)/index" />
            <Stack.Screen name="(auth)" />
            <Stack.Screen name="(tabs)" />
            <Stack.Screen name="random" options={{ gestureEnabled: false }} />
            <Stack.Screen name="ranking" options={{ gestureEnabled: false }} />
            <Stack.Screen name="+not-found" />
          </Stack>
          <StatusBar style="auto" />
          <Confirm.Root />
        </ToastProvider>
    </ThemeProvider>
</QueryClientProvider>
```


# TailwindCSSの設定
今回CSSにはTailwindCSSを採用いたしましたが、expoでTailwindを設定する際に少々苦戦しました.
私の作成したexpoのプロジェクトではデフォルトでbabel.config.jsやmetro.config.jsが存在しなかったので、まずはこれを作成してあげる必要があります.

```js:babel.config.js
module.exports = (api) => {
  api.cache(true);
  return {
    presets: [
      ["babel-preset-expo", { jsxImportSource: "nativewind" }],
    ],
    sourceMaps: true,
    plugins: ["react-native-reanimated/plugin"],
  };
};
```

```js:metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const { withNativeWind } = require("nativewind/metro");

const config = getDefaultConfig(__dirname,);

module.exports = withNativeWind(config, { input: "./global.css" });

```

```json:tsconfig.json
{
  "extends": "expo/tsconfig.base",
  "compilerOptions": {
    "strict": true,
    "paths": {
      "@/*": ["./*"]
    },
    "target": "esnext",
    "module": "commonjs",
    "jsx": "react-native",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": [
    "**/*.ts",
    "**/*.tsx",
    ".expo/types/**/*.ts",
    "expo-env.d.ts",
    "nativewind-env.d.ts"
  ],
  "exclude": [
    "node_modules",
    "babel.config.js",
    "metro.config.js",
    "jest.config.js"
  ]
}
```

```ts:nativewind-env.d.ts
/// <reference types="nativewind/types" />
```

あとはルートディレクトリに``global.css``と``tailwind.config.js``を作成します.

# Supabaseの設定
### 1.インストール
Supabaseクライアントをインストールします.
```bash
$ npm install @supabase/supabase-js
```

### 2.ローカルデーターベース構築
Supabase CLI コマンドを実行し、Supabase のローカル開発環境に必要な初期設定を構築します.
```bash
$ npx supabase init
```

### 3.Dockerの起動
supabaseのローカル環境ではDockerを使用しますので、事前にDockerを起動しておく必要があります.

### 4.Supabaseをローカル環境で起動
```bash
$ npx supabase start
```

:::note info
ローカルデータベース用コマンド
ローカルのデータベースを起動するコマンドです.
```bash
$ npx supabase start
```

ローカルのデータベースを停止するコマンドです.(Docker側から停止しようとすると全く停止する気配がなかったのでこのコマンドで停止させるのがベターです)
```bash
$ npx supabase stop
```

ローカルのデータベースをリセットするコマンドです.(seedを用意しておくと自動で流れてくれるので便利です)
```bash
$ npx supabase db reset
```

:::

起動コマンドで起動した際に表示されるメッセージの内容を``env``ファイルに設定します.

```:env
NEXT_PUBLIC_SUPABASE_URL=your-supabase-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
```

### 5.Supabase Studio
Supabase StudioはローカルのGUIでテーブルの設定やその他管理を行うことができる管理コンソールになります.
```
http://127.0.0.1:54323
```

GUIで変更したテーブルの内容などをマイグレーションファイルで作成する
```bash
supabase db diff -f {ファイル名}
```

ローカルに反映

```bash
supabase db reset
```

Supabaseのスキーマの型定義ファイルを作成するコマンド

```bash
supabase gen types typescript --local  > {ファイル名}.ts
```

上記コマンドで作成した型定義では少々使用しにくい状態ですので、``better-supabase-types``ライブラリを使用して型を整えます.

通常では、例えばEmployeeテーブルの型を使用する際に下記のようになります.
```ts
type Employee = Database["public"]["Tables"]["employees"]["Row"];
```

#### 型を扱いやすく変更する

```bash
npx better-supabase-types -i {ファイル名}.ts -f
```

このコマンドを使用すると
直接``Employee``という型を使用できます
```ts
import { Employee } from {ファイル名}.ts
```

### 6.Supabaseとの接続
今回繋ぎ込みには``@tanstack/react-query``を使用しました.
queryKeyは同じデータを用いる際、以外は別のキーを設定します.

- 一意な識別子: queryKey はデータ取得リクエストを一意に識別し、キャッシュ管理を容易にする
- 柔軟な指定: 配列や文字列で指定でき、パラメータやフィルタリング条件を含む複雑なキーも扱える
- キャッシュ・更新管理: 同じキーを使うことでキャッシュの再利用、無効化、または更新のトリガーが行われる

たとえば本当は違う「ユーザー詳細」を取るクエリなのに、queryKey を全部 'user' だけにしちゃったら…
	•	1人目（user id=1）を取ってきたときにキャッシュされる
	•	2人目（user id=2）を取りにいったとき、React Queryは「もうあるじゃん」と思って1人目のデータを返してしまう

```ts
  const { data, isLoading, isError } = useQuery({
    queryKey: [QUERY_KEYS.RANDOM_PHRASES],
    queryFn: () => getRandomPhrase(),
    placeholderData: (previousData) => previousData,
  });
```

SupabaseClientの使用
ジェネリクスに使用している``<Database>``はSupabaseのスキーマの型定義ファイルを作成するコマンドで作成したファイルからimportします.

```ts:supabase.ts
const supabaseUrl =
  process.env.EXPO_PUBLIC_SUPABASE_URL || "http://localhost:54321";
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;

export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey, {
  auth: {
    storage: AsyncStorage,
    autoRefreshToken: true,
    persistSession: true,
    detectSessionInUrl: false,
  },
});
```

### 実際にSupabaseClientを使用する
Supabaseはsql文のように使用することができます.

```ts
const session = await getSession();
if (!session) throw new Error("User not authenticated");
const { data, error } = await supabase
    .from("deck")
    .select("*")
    .eq("id", deckId)
    .single();
if (error) throw error;
```

### 7.Supabaseのrpc
SupabaseClientはトランザクションを張る機能がなく(私が調べた限りですが....)、今回PrismaのようなORMも使用していないためSupabaseにfunctionsを登録して対応しました.

下記はユーザー削除処理でユーザーを削除する際にリレーションのあるテーブルからもデータを削除するfunctionです.
```sql
CREATE OR REPLACE FUNCTION "public"."delete_user_and_data"("user_id" "uuid") RETURNS "void"
    LANGUAGE "plpgsql"
    SET search_path = public
    AS $$
BEGIN
  -- トランザクション開始
  BEGIN;

  -- auth.usersからユーザーを削除
  DELETE FROM auth.users WHERE id = user_id;

  -- public.userからデータを削除
  DELETE FROM public.user WHERE id = user_id;

  -- 関連テーブルからデータを削除
  DELETE FROM public.deck WHERE user_id = user_id;
  DELETE FROM public.history WHERE user_id = user_id;
  DELETE FROM public.favorite WHERE user_id = user_id;
  DELETE FROM public.phrase WHERE user_id = user_id;

  -- コミット
  COMMIT;

EXCEPTION
  WHEN OTHERS THEN
    -- エラー時にロールバック
    ROLLBACK;
    RAISE;
END;
$$;
```

このように``supabase.rpc(関数名, {引数})``のように使用できます.
```ts
export async function deleteUser(user: User) {
  try {
    await supabase.rpc("delete_user_and_data", { user_id: user.id });
  } catch (error) {
    throw new Error(
      `Failed to delete user: ${(error as PostgrestError)?.message || ""}`,
    );
  }
}
```

# TextToSpeech
今回音声読み上げにはGoogleの提供してくれている``Text-to-Speech``を使用しました.
GCP(Google Cloud Platform)からApi Keyを取得して、``.env``ファイルに設定します.
bodyに言語や性別を設定することができます.

```ts
const body = {
    input: {
        text: 'i have a pen'
    },
    voice: {
        languageCode: "en-US",
        name: "en-US-Wavenet-D",
        ssmlGender: "MALE",
    },
    audioConfig: {
        audioEncoding: "MP3",
    }
}
```

# SpeechToText
こちらは音声読み上げとは逆になり、読み上げた音声をテキストに変換する機能になります.
まずexpo-avの``Audio``を使用してレコード機能を作成し,レコードファイルをbase64に変換した後、GoogleのSpeech-To-Textを使用します.

```ts
const body = {
    audio: {
        content: base64
    },
    config: {
        encoding:
          Platform.OS === "android"
            ? "AMR_WB"
            : Platform.OS === "web"
              ? "WEBM_OPUS"
              : "LINEAR16",
        sampleRateHertz:
          Platform.OS === "android"
            ? 16000
            : Platform.OS === "web"
              ? 48000
              : 41000,
        languageCode: "en-US",
    }
}
```


# まとめ
勉強を主目的としてexpoとSupabaseを用いてアプリを作成してみましたが、次に作成するアプリではもっとexpoの深い内容やメッセージ機能を入れてみてSupabaseのRealtimeなど触ってみたいと思いました。