【開発ログ】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" }
]
}
実装ポイント
// 省略: 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