【開発ログ】TTSを活用したリスニングクイズ機能の実装

に公開

今回は、単語学習にゲーム性を加えることで「聞いて・選んで・覚える」学習体験を目指しました。
次回は、これを「学習データ」として残せるよう、履歴保存・時間計測に取り組みます。

前回

  • TTS機能の実装
  • 勉強開始ボタンの設置
  • 学習終了タイミングの修正

前回の記事:TTS機能の実装及び学習開始・終了のタイミング


今回

  • Quiz機能の実装
     - 音声を聞いて、正しい絵を選ぶゲーム形式にしました。
     - 「リスニング × 選択」で、学んだ単語の理解度をチェックできる仕組みです。

① API設計

Quizは、学習済み単語の中からランダムに出題されます。
正解の単語を1つ、そして不正解の単語を2つランダムで選び、選択肢として返す仕組みです。

{
  "question": {
    "id": "1",
    "ja": "りんご",
    "ko": "사과",
    "lang": "ja"
  },
  "options": [
    { "id": "1", "img": "/apple.png" },
    { "id": "2", "img": "/banana.png" },
    { "id": "3", "img": "/grape.png" }
  ]
}

実装ポイント

api/learn/quiz/route.ts
// 省略: prismaインポートなど

export async function GET() {
  try {
    /** ユーザ情報を取得 */
    const allWords = await prisma.word.findMany({
      where: { userId: user.id, status: "published" },
      include: { image: true },
    });

    if (allWords.length < 3) {
      return NextResponse.json({ error: "Not enough words for quiz" }, { status: 400 });
    }

    // 正解1つ + 不正解2つをランダムに抽出
    const correctWord = allWords[Math.floor(Math.random() * allWords.length)];
    const incorrectWords = allWords
      .filter((w) => w.id !== correctWord.id)
      .sort(() => 0.5 - Math.random())
      .slice(0, 2);

    // 選択肢をランダムに並び替え
    const options = [correctWord, ...incorrectWords].sort(() => 0.5 - Math.random());

    const res = {
      question: {
        id: correctWord.id,
        ko: correctWord.koSurface,
        jp: correctWord.jaSurface,
        lang: correctWord.jaSurface ? "ja" : "ko",
      },
      options: options.map((word) => ({
        id: word.id,
        imageUrl: word.image?.imageUrl ?? "https://via.placeholder.com/300x200?text=No+Image",
      })),
    };

    return NextResponse.json(res, { status: 200 });
  } catch (error) {
    console.error("Error generating quiz:", error);
    return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
  }
}

APIでは配列のシャッフルsort(() => 0.5 - Math.random())で簡潔に行い、
同じ単語が重複しないようにフィルタ処理をしています。


② Quizコンポーネント

/api/learn/quiz からデータを取得し、
音声を再生 → 選択肢クリック → 正誤判定
という流れで動作します。

TTS(Text to Speech)機能を利用して、韓国語 → 日本語の順で自動的に読み上げるようにしました。

実装コード

"use client";

import { AudioQuizOption, AudioQuizQuestion } from "@/types/lesson";
import { useEffect, useState } from "react";

export default function AudioQuiz() {
  const [optionsWord, setOptionsWord] = useState<AudioQuizOption[]>([]);
  const [correctedWord, setCorrectedWord] = useState<AudioQuizQuestion | null>(null);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [isCorrect, setIsCorrect] = useState<boolean | null>(null);

  // Quizデータ取得
  useEffect(() => {
    const fetchQuiz = async () => {
      try {
        const res = await fetch("/api/learn/quiz", { cache: "no-store" });
        if (!res.ok) throw new Error("情報を読み取れませんでした");
        const data = await res.json();
        setCorrectedWord(data.question);
        setOptionsWord(data.options);
      } catch (err) {
        console.error(err);
      }
    };
    fetchQuiz();
  }, []);

  // TTS再生
  const playAudio = () => {
    if (!correctedWord) return;
    window.speechSynthesis.cancel();

    const koUtter = new SpeechSynthesisUtterance(correctedWord.ko);
    koUtter.lang = "ko-KR";
    koUtter.rate = 0.5;

    const jpUtter = new SpeechSynthesisUtterance(correctedWord.jp);
    jpUtter.lang = "ja-JP";
    jpUtter.rate = 0.5;

    koUtter.onend = () => window.speechSynthesis.speak(jpUtter);
    window.speechSynthesis.speak(koUtter);
  };

  // 正解チェック
  const selectOption = (id: string) => {
    setSelectedId(id);
    if (!correctedWord) return;
    setIsCorrect(id === correctedWord.id);
  };

  return (
    <div className="p-4">
      {correctedWord && (
        <div>
          <h3 className="font-bold mb-2">クイズ</h3>
          <button
            onClick={playAudio}
            className="px-4 py-2 bg-blue-500 text-white rounded-md"
          >
            🔊発音を聞く
          </button>
        </div>
      )}

      <div className="mt-4 grid grid-cols-3 gap-4">
        {optionsWord.map((option) => (
          <div
            key={option.id}
            onClick={() => selectOption(option.id)}
            className={`border p-2 rounded cursor-pointer transition-all ${
              selectedId === option.id
                ? isCorrect
                  ? "border-green-500 scale-105"
                  : "border-red-500 opacity-70"
                : "hover:border-blue-300"
            }`}
          >
            <img
              src={option.imageUrl}
              alt="option"
              className="w-full h-24 object-cover rounded-md"
            />
          </div>
        ))}
      </div>

      {selectedId && (
        <div className="mt-3 text-lg">
          {isCorrect ? "✅ 正解!" : "❌ 残念…"}
        </div>
      )}
    </div>
  );
}

悩んだこと・学んだこと

  • ランダム選択の実装方法
    JavaScriptで手軽にランダムソートするsort(() => 0.5 - Math.random())の使い方を改めて確認。
    ただし、大量データでは偏りが出る可能性があるため、将来的にはFisher–Yatesアルゴリズムを検討。

  • 音声再生タイミングの調整
    2言語(韓・日)を順番に再生する際、onendイベントを利用すると自然に繋げられる。

  • UIのレスポンス
    正解・不正解の視覚的な区別を即座に出すことで、学習モチベーションを維持できることを実感。


次回やること

  • Quiz完了処理
     - 正解した場合のみ「学習完了」として記録。
     - 学習履歴APIと連携し、学習イベントを保存予定。

  • 学習時間の記録
     - 開始時間・終了時間をサーバーで記録し、
      単語練習+クイズ時間を統合した「総学習時間」を取得。

  • Firebaseセッションの安定化
     - Cookieの有効期限切れ時に自動再認証できるよう改善。

  • UI/UX改善
     - ダッシュボードとエディタのデザインを統一。
     - スマホ表示時の余白・カード間のレイアウトを再調整。

Discussion