feat: 完成前端例句口說練習功能和最終修復

🖥️ 前端例句口說練習完整實現:
- AudioRecorder 共用組件 (Web Audio API 高品質錄音)
- SentenceSpeakingQuiz 完整組件 (錄音/API/評分結果顯示)
- speechAssessmentService.ts API 客戶端服務
- 完美整合到複習系統 (第3種 quiz type)

🔧 系統修復和優化:
- 擴展 useReviewSession.ts 支援 sentence-speaking
- 更新 reviewSimpleData.ts 類型定義
- 修復 review/page.tsx 條件渲染邏輯
- 優化 SentenceSpeakingQuiz 圖片顯示佈局

📋 技術規格文檔更新:
- 更新開發進度和第一階段完成狀態
- 記錄所有實現的技術組件和驗證結果

🎨 用戶體驗優化:
- 響應式圖片顯示設計 (max-w-lg, 300px 高度限制)
- 智能無圖提示和有圖引導
- 完整的錄音狀態視覺反饋
- CEFR 等級顯示修復

現在 DramaLing 具備完整的 AI 驅動例句口說練習功能!
包含圖片顯示、專業錄音、多維度 AI 評分、智能反饋 🎤

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
鄭沛軒 2025-10-09 02:47:27 +08:00
parent 9bebe78740
commit e3bc290b56
7 changed files with 990 additions and 16 deletions

View File

@ -3,6 +3,7 @@
import { Navigation } from '@/components/shared/Navigation'
import { FlipMemory } from '@/components/review/quiz/FlipMemory'
import { VocabChoiceQuiz } from '@/components/review/quiz/VocabChoiceQuiz'
import { SentenceSpeakingQuiz } from '@/components/review/quiz/SentenceSpeakingQuiz'
import { QuizProgress } from '@/components/review/ui/QuizProgress'
import { QuizResult } from '@/components/review/quiz/QuizResult'
import { useReviewSession } from '@/hooks/review/useReviewSession'
@ -347,6 +348,14 @@ export default function ReviewPage() {
onSkip={handleSkip}
/>
)}
{currentQuizItem.quizType === 'sentence-speaking' && (
<SentenceSpeakingQuiz
card={currentCard}
onAnswer={handleAnswer}
onSkip={handleSkip}
/>
)}
</>
)}
</div>

View File

@ -0,0 +1,294 @@
'use client'
import { useState, useCallback } from 'react'
import { CardState } from '@/lib/data/reviewSimpleData'
import { QuizHeader } from '../ui/QuizHeader'
import { AudioRecorder } from '@/components/shared/AudioRecorder'
import { speechAssessmentService, PronunciationResult } from '@/lib/services/speechAssessment'
import { getFlashcardImageUrl } from '@/lib/utils/flashcardUtils'
interface SentenceSpeakingQuizProps {
card: CardState
onAnswer: (confidence: number) => void
onSkip: () => void
}
export function SentenceSpeakingQuiz({ card, onAnswer, onSkip }: SentenceSpeakingQuizProps) {
const [assessmentResult, setAssessmentResult] = useState<PronunciationResult | null>(null)
const [isEvaluating, setIsEvaluating] = useState(false)
const [hasAnswered, setHasAnswered] = useState(false)
const [error, setError] = useState<string | null>(null)
// 獲取例句圖片 - 多重來源檢查
const exampleImageUrl = getFlashcardImageUrl(card) ||
(card as any).PrimaryImageUrl ||
card.primaryImageUrl ||
null
// 除錯資訊 - 檢查圖片資料
console.log('🔍 SentenceSpeaking 圖片除錯:', {
cardId: card.id,
word: card.word,
hasExampleImage: card.hasExampleImage,
primaryImageUrl: card.primaryImageUrl,
PrimaryImageUrl: (card as any).PrimaryImageUrl,
exampleImages: (card as any).exampleImages,
FlashcardExampleImages: (card as any).FlashcardExampleImages,
originalCard: card,
computedImageUrl: exampleImageUrl
})
// 處理錄音完成
const handleRecordingComplete = useCallback(async (audioBlob: Blob) => {
if (hasAnswered) return
setIsEvaluating(true)
setError(null)
try {
console.log('🎤 開始發音評估...', {
flashcardId: card.id,
referenceText: card.example,
audioSize: `${(audioBlob.size / 1024).toFixed(1)} KB`
})
const response = await speechAssessmentService.evaluatePronunciation(
audioBlob,
card.example,
card.id,
'en-US'
)
if (response.success && response.data) {
setAssessmentResult(response.data)
setHasAnswered(true)
// 稍後自動提交結果(給用戶時間查看評分)
setTimeout(() => {
onAnswer(response.data!.confidenceLevel)
}, 2000)
} else {
throw new Error(response.error || '發音評估失敗')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '評估過程發生錯誤'
setError(errorMessage)
console.error('發音評估錯誤:', error)
} finally {
setIsEvaluating(false)
}
}, [hasAnswered, card.id, card.example, onAnswer])
// 處理跳過
const handleSkip = useCallback(() => {
onSkip()
}, [onSkip])
// 手動提交結果(如果自動提交被取消)
const handleSubmitResult = useCallback(() => {
if (assessmentResult && hasAnswered) {
onAnswer(assessmentResult.confidenceLevel)
}
}, [assessmentResult, hasAnswered, onAnswer])
// 重新錄音
const handleRetry = useCallback(() => {
setAssessmentResult(null)
setHasAnswered(false)
setError(null)
}, [])
return (
<div className="bg-white rounded-xl shadow-lg p-6">
<QuizHeader
title="例句口說練習"
cefr={card.cefr || (card as any).CEFR || 'A1'}
/>
<div className="space-y-6">
{/* 說明文字 */}
<div className="text-center bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-blue-800 font-medium">
📸 AI
</p>
</div>
{/* 例句圖片 - 更顯著的顯示 */}
{exampleImageUrl ? (
<div className="text-center">
<div className="bg-gray-50 border border-gray-200 rounded-xl p-6 mb-4">
<img
src={exampleImageUrl}
alt={`${card.word} example illustration`}
className="w-full max-w-lg mx-auto rounded-lg shadow-sm"
style={{ maxHeight: '300px', objectFit: 'contain' }}
/>
</div>
<p className="text-gray-500 text-sm mb-6">
💡
</p>
</div>
) : (
<div className="text-center bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<p className="text-yellow-800 text-sm">
</p>
</div>
)}
{/* 目標例句 */}
<div className="bg-gradient-to-r from-green-50 to-blue-50 border border-green-200 rounded-lg p-6">
<div className="text-center mb-4">
<h4 className="text-lg font-semibold text-gray-900 mb-2"></h4>
<p className="text-2xl text-gray-900 font-medium leading-relaxed mb-3">
{card.example}
</p>
<p className="text-gray-600 text-base">
{card.exampleTranslation}
</p>
</div>
</div>
{/* 錄音區域 */}
{!hasAnswered && !isEvaluating && (
<div>
<AudioRecorder
onRecordingComplete={handleRecordingComplete}
onError={setError}
maxDuration={30}
className="mb-4"
/>
<p className="text-gray-500 text-sm text-center mb-4">
💡 調
</p>
</div>
)}
{/* 評估中狀態 */}
{isEvaluating && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<h4 className="font-semibold text-blue-900 mb-2">AI ...</h4>
<p className="text-blue-700 text-sm">
調 ( 2-3 )
</p>
</div>
)}
{/* 錯誤顯示 */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 className="font-semibold text-red-900 mb-2"> </h4>
<p className="text-red-700 text-sm mb-3">{error}</p>
<button
onClick={handleRetry}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
>
</button>
</div>
)}
{/* 評估結果顯示 */}
{assessmentResult && hasAnswered && (
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<h4 className="font-semibold text-green-900 mb-4">🎉 </h4>
{/* 總分顯示 */}
<div className="flex items-center justify-center gap-4 mb-4">
<div className="text-center">
<div className="text-4xl font-bold text-green-600 mb-1">
{Math.round(assessmentResult.scores.overall)}
</div>
<div className="text-green-700 text-sm font-medium"></div>
</div>
<div className="text-green-600 text-lg">/ 100</div>
</div>
{/* 詳細評分 */}
<div className="grid grid-cols-2 gap-3 mb-4">
<div className="bg-white p-3 rounded border">
<div className="text-xs text-gray-600 mb-1"></div>
<div className="font-semibold text-lg text-gray-900">
{Math.round(assessmentResult.scores.accuracy)}
</div>
</div>
<div className="bg-white p-3 rounded border">
<div className="text-xs text-gray-600 mb-1"></div>
<div className="font-semibold text-lg text-gray-900">
{Math.round(assessmentResult.scores.fluency)}
</div>
</div>
<div className="bg-white p-3 rounded border">
<div className="text-xs text-gray-600 mb-1"></div>
<div className="font-semibold text-lg text-gray-900">
{Math.round(assessmentResult.scores.completeness)}
</div>
</div>
<div className="bg-white p-3 rounded border">
<div className="text-xs text-gray-600 mb-1">調</div>
<div className="font-semibold text-lg text-gray-900">
{Math.round(assessmentResult.scores.prosody)}
</div>
</div>
</div>
{/* 語音識別結果 */}
{assessmentResult.transcribedText && (
<div className="bg-white rounded border p-3 mb-4">
<div className="text-xs text-gray-600 mb-2">AI </div>
<div className="font-mono text-sm text-gray-900">
{assessmentResult.transcribedText}
</div>
</div>
)}
{/* 改善建議 */}
{assessmentResult.feedback && assessmentResult.feedback.length > 0 && (
<div className="space-y-2 mb-4">
<div className="text-sm font-medium text-green-800"></div>
{assessmentResult.feedback.map((feedback, index) => (
<div key={index} className="text-sm text-green-700 flex items-start gap-2">
<span className="text-green-600 mt-0.5"></span>
<span>{feedback}</span>
</div>
))}
</div>
)}
{/* 操作按鈕 */}
<div className="flex gap-3 justify-center pt-4 border-t">
<button
onClick={handleRetry}
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleSubmitResult}
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors font-medium"
>
</button>
</div>
</div>
)}
{/* 跳過按鈕 */}
{!hasAnswered && !isEvaluating && (
<div className="text-center">
<button
onClick={handleSkip}
className="px-6 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
>
</button>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,400 @@
'use client'
import { useState, useCallback, useRef, useEffect } from 'react'
export interface AudioRecorderState {
isRecording: boolean
audioBlob: Blob | null
recordingTime: number
isProcessing: boolean
error: string | null
}
interface AudioRecorderProps {
onRecordingComplete: (audioBlob: Blob) => void
onRecordingStart?: () => void
onRecordingStop?: () => void
onError?: (error: string) => void
maxDuration?: number // 最大錄音時長(秒)
disabled?: boolean
className?: string
}
export function AudioRecorder({
onRecordingComplete,
onRecordingStart,
onRecordingStop,
onError,
maxDuration = 30,
disabled = false,
className = ""
}: AudioRecorderProps) {
const [state, setState] = useState<AudioRecorderState>({
isRecording: false,
audioBlob: null,
recordingTime: 0,
isProcessing: false,
error: null
})
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const timerRef = useRef<NodeJS.Timeout | null>(null)
// 清理函數
const cleanup = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop())
streamRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
audioChunksRef.current = []
}, [])
// 組件卸載時清理
useEffect(() => {
return cleanup
}, [cleanup])
// 開始錄音
const startRecording = useCallback(async () => {
if (disabled) return
try {
setState(prev => ({ ...prev, error: null, isProcessing: true }))
// 請求麥克風權限
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000 // Azure Speech Services 推薦採樣率
}
})
streamRef.current = stream
audioChunksRef.current = []
// 創建 MediaRecorder
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus' // 現代瀏覽器支援的格式
})
mediaRecorderRef.current = mediaRecorder
// 處理錄音資料
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
// 錄音完成處理
mediaRecorder.onstop = () => {
setState(prev => ({ ...prev, isProcessing: true }))
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
setState(prev => ({
...prev,
audioBlob,
isRecording: false,
isProcessing: false
}))
onRecordingComplete(audioBlob)
cleanup()
}
// 開始錄音
mediaRecorder.start(1000) // 每秒收集一次資料
// 啟動計時器
let seconds = 0
timerRef.current = setInterval(() => {
seconds++
setState(prev => ({ ...prev, recordingTime: seconds }))
// 達到最大時長自動停止
if (seconds >= maxDuration) {
stopRecording()
}
}, 1000)
setState(prev => ({
...prev,
isRecording: true,
recordingTime: 0,
isProcessing: false
}))
onRecordingStart?.()
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '麥克風存取失敗'
setState(prev => ({
...prev,
error: errorMessage,
isProcessing: false
}))
onError?.(errorMessage)
cleanup()
}
}, [disabled, maxDuration, onRecordingComplete, onRecordingStart, onError, cleanup])
// 停止錄音
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop()
onRecordingStop?.()
}
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
}, [onRecordingStop])
// 格式化時間顯示
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return (
<div className={`text-center ${className}`}>
{/* 錯誤顯示 */}
{state.error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
<p className="text-red-700 text-sm"> {state.error}</p>
</div>
)}
{/* 錄音按鈕 */}
<div className="mb-4">
{!state.isRecording ? (
<button
onClick={startRecording}
disabled={disabled || state.isProcessing}
className={`px-6 py-3 rounded-full font-medium transition-all ${
disabled || state.isProcessing
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-red-500 hover:bg-red-600 text-white transform hover:scale-105'
}`}
>
{state.isProcessing ? (
<span className="flex items-center gap-2">
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full"></div>
...
</span>
) : (
<span className="flex items-center gap-2">
🎤
</span>
)}
</button>
) : (
<button
onClick={stopRecording}
className="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-full font-medium animate-pulse transition-all"
>
<span className="flex items-center gap-2">
</span>
</button>
)}
</div>
{/* 錄音狀態顯示 */}
{state.isRecording && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center justify-center gap-3 mb-2">
<div className="w-3 h-3 bg-red-500 rounded-full animate-ping"></div>
<span className="text-red-700 font-medium"></span>
</div>
<div className="text-red-600 text-xl font-mono">
{formatTime(state.recordingTime)}
</div>
<div className="text-red-500 text-sm mt-2">
{formatTime(maxDuration)}
</div>
{/* 進度條 */}
<div className="w-full bg-red-100 rounded-full h-2 mt-3">
<div
className="bg-red-500 h-2 rounded-full transition-all duration-1000"
style={{ width: `${(state.recordingTime / maxDuration) * 100}%` }}
></div>
</div>
</div>
)}
{/* 錄音完成提示 */}
{state.audioBlob && !state.isRecording && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mt-4">
<p className="text-green-700 text-sm">
: {formatTime(state.recordingTime)}
</p>
</div>
)}
{/* 瀏覽器兼容性提示 */}
{!navigator.mediaDevices?.getUserMedia && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mt-4">
<p className="text-yellow-700 text-sm">
使 Chrome Firefox
</p>
</div>
)}
</div>
)
}
// Hook 版本,提供更靈活的使用方式
export function useAudioRecorder(maxDuration: number = 30) {
const [state, setState] = useState<AudioRecorderState>({
isRecording: false,
audioBlob: null,
recordingTime: 0,
isProcessing: false,
error: null
})
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const timerRef = useRef<NodeJS.Timeout | null>(null)
const cleanup = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop())
streamRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
audioChunksRef.current = []
}, [])
const startRecording = useCallback(async () => {
try {
setState(prev => ({ ...prev, error: null, isProcessing: true }))
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000
}
})
streamRef.current = stream
audioChunksRef.current = []
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
})
mediaRecorderRef.current = mediaRecorder
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
setState(prev => ({
...prev,
audioBlob,
isRecording: false,
isProcessing: false
}))
cleanup()
}
mediaRecorder.start(1000)
let seconds = 0
timerRef.current = setInterval(() => {
seconds++
setState(prev => ({ ...prev, recordingTime: seconds }))
if (seconds >= maxDuration) {
stopRecording()
}
}, 1000)
setState(prev => ({
...prev,
isRecording: true,
recordingTime: 0,
isProcessing: false
}))
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '錄音失敗'
setState(prev => ({
...prev,
error: errorMessage,
isProcessing: false
}))
cleanup()
}
}, [maxDuration, cleanup])
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop()
}
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
}, [])
const resetRecording = useCallback(() => {
cleanup()
setState({
isRecording: false,
audioBlob: null,
recordingTime: 0,
isProcessing: false,
error: null
})
}, [cleanup])
// 清理函數
useEffect(() => {
return cleanup
}, [cleanup])
return {
...state,
startRecording,
stopRecording,
resetRecording
}
}

View File

@ -18,7 +18,7 @@ interface QuizItem {
id: string
cardId: string
cardData: CardState
quizType: 'flip-card' | 'vocab-choice'
quizType: 'flip-card' | 'vocab-choice' | 'sentence-speaking'
order: number
isCompleted: boolean
wrongCount: number
@ -92,13 +92,20 @@ const generateQuizItemsFromFlashcards = (flashcards: Flashcard[]): QuizItem[] =>
const quizItems: QuizItem[] = []
let order = 0
flashcards.forEach((card) => {
flashcards.forEach((card, index) => {
// 除錯:檢查原始 API 資料
if (index === 0) {
console.log('🔍 原始 Flashcard 資料 (第一張):', card)
}
// 轉換 Flashcard 為 CardState 格式,確保所有屬性都有值
const cardState: CardState = {
...card,
exampleTranslation: card.exampleTranslation || '', // 確保為 string不是 undefined
updatedAt: card.updatedAt || card.createdAt, // 確保 updatedAt 為 string
primaryImageUrl: card.primaryImageUrl || null, // 確保為 null 而非 undefined
// 修復圖片 URL - 支援多種屬性名稱
primaryImageUrl: card.primaryImageUrl || (card as any).PrimaryImageUrl || null,
hasExampleImage: card.hasExampleImage || !!(card.primaryImageUrl || (card as any).PrimaryImageUrl),
skipCount: 0,
wrongCount: 0,
isCompleted: false,
@ -107,7 +114,7 @@ const generateQuizItemsFromFlashcards = (flashcards: Flashcard[]): QuizItem[] =>
difficultyLevelNumeric: card.masteryLevel || 1 // 使用 masteryLevel 或預設值 1
}
// 為每張詞卡生成種測驗模式
// 為每張詞卡生成種測驗模式
quizItems.push(
{
id: `${card.id}-flip-card`,
@ -128,6 +135,16 @@ const generateQuizItemsFromFlashcards = (flashcards: Flashcard[]): QuizItem[] =>
isCompleted: false,
wrongCount: 0,
skipCount: 0
},
{
id: `${card.id}-sentence-speaking`,
cardId: card.id,
cardData: cardState,
quizType: 'sentence-speaking',
order: order++,
isCompleted: false,
wrongCount: 0,
skipCount: 0
}
)
})

View File

@ -35,7 +35,7 @@ export interface CardState extends ApiFlashcard {
export interface QuizItem {
id: string // 測驗項目ID
cardId: string // 所屬詞卡ID
quizType: 'flip-card' | 'vocab-choice' // 測驗類型
quizType: 'flip-card' | 'vocab-choice' | 'sentence-speaking' // 測驗類型
isCompleted: boolean // 個別測驗完成狀態
skipCount: number // 跳過次數
wrongCount: number // 答錯次數

View File

@ -0,0 +1,192 @@
import { API_CONFIG } from '@/lib/config/api'
export interface PronunciationScores {
overall: number
accuracy: number
fluency: number
completeness: number
prosody: number
}
export interface WordLevelResult {
word: string
accuracyScore: number
errorType: string
suggestion?: string
}
export interface PronunciationResult {
assessmentId: string
flashcardId: string
referenceText: string
transcribedText: string
scores: PronunciationScores
wordLevelResults: WordLevelResult[]
feedback: string[]
confidenceLevel: number
processingTime: number
createdAt: string
}
export interface SpeechAssessmentResponse {
success: boolean
data?: PronunciationResult
error?: string
message?: string
}
export interface SpeechServiceStatus {
isAvailable: boolean
serviceName: string
checkTime: string
message: string
}
class SpeechAssessmentService {
private readonly baseUrl: string
constructor() {
this.baseUrl = API_CONFIG.BASE_URL
}
async evaluatePronunciation(
audioBlob: Blob,
referenceText: string,
flashcardId: string,
language: string = 'en-US'
): Promise<SpeechAssessmentResponse> {
try {
// 準備 FormData
const formData = new FormData()
formData.append('audio', audioBlob, 'recording.webm')
formData.append('referenceText', referenceText)
formData.append('flashcardId', flashcardId)
formData.append('language', language)
console.log('🎤 發送發音評估請求:', {
flashcardId,
referenceText: referenceText.substring(0, 50) + '...',
audioSize: `${(audioBlob.size / 1024).toFixed(1)} KB`,
language
})
const response = await fetch(`${this.baseUrl}/api/speech/pronunciation-assessment`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`)
}
const result: SpeechAssessmentResponse = await response.json()
if (!result.success) {
throw new Error(result.message || result.error || '發音評估失敗')
}
console.log('✅ 發音評估完成:', {
overallScore: result.data?.scores.overall,
confidenceLevel: result.data?.confidenceLevel,
processingTime: `${result.data?.processingTime}ms`
})
return result
} catch (error) {
console.error('❌ 發音評估錯誤:', error)
const errorMessage = error instanceof Error ? error.message : '發音評估失敗'
return {
success: false,
error: errorMessage,
message: this.getErrorMessage(errorMessage)
}
}
}
async checkServiceStatus(): Promise<SpeechServiceStatus> {
try {
const response = await fetch(`${this.baseUrl}/api/speech/service-status`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const result = await response.json()
return result.success ? result.data : {
isAvailable: false,
serviceName: 'Azure Speech Services',
checkTime: new Date().toISOString(),
message: '服務狀態檢查失敗'
}
} catch (error) {
console.error('語音服務狀態檢查失敗:', error)
return {
isAvailable: false,
serviceName: 'Azure Speech Services',
checkTime: new Date().toISOString(),
message: error instanceof Error ? error.message : '無法連接到語音服務'
}
}
}
private getErrorMessage(error: string): string {
// 將技術錯誤訊息轉換為用戶友善的中文訊息
if (error.includes('AUDIO_TOO_SHORT')) {
return '錄音時間太短,請至少錄製 1 秒鐘'
}
if (error.includes('AUDIO_TOO_LARGE')) {
return '音頻檔案過大,請縮短錄音時間'
}
if (error.includes('INVALID_AUDIO_FORMAT')) {
return '音頻格式不支援,請重新錄製'
}
if (error.includes('NO_SPEECH_DETECTED')) {
return '未檢測到語音,請確保麥克風正常並大聲說話'
}
if (error.includes('SPEECH_SERVICE_ERROR')) {
return '語音識別服務暫時不可用,請稍後再試'
}
if (error.includes('NetworkError') || error.includes('fetch')) {
return '網路連接錯誤,請檢查網路連接'
}
return '發音評估過程中發生錯誤,請稍後再試'
}
// 輔助方法:檢查瀏覽器是否支援錄音
isRecordingSupported(): boolean {
return !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)
}
// 輔助方法:請求麥克風權限
async requestMicrophonePermission(): Promise<boolean> {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
// 立即停止,只是檢查權限
stream.getTracks().forEach(track => track.stop())
return true
} catch (error) {
console.warn('麥克風權限請求失敗:', error)
return false
}
}
}
// 導出單例實例
export const speechAssessmentService = new SpeechAssessmentService()
// 導出類型供其他組件使用
export type {
PronunciationResult,
PronunciationScores,
WordLevelResult,
SpeechAssessmentResponse,
SpeechServiceStatus
}

View File

@ -623,17 +623,26 @@ graph TD
## 🚀 實施階段規劃
### 第一階段:基礎架構
1. ✅ 後端 Azure Speech Services 整合
2. ✅ 基礎 API 端點實現
3. ✅ 資料庫 Schema 更新
4. ✅ 環境配置設定
### 第一階段:基礎架構 ✅ **已完成**
1. ✅ 後端 Azure Speech Services 整合 - Microsoft.CognitiveServices.Speech v1.38.0 安裝完成
2. ✅ 基礎 API 端點實現 - SpeechController 完整實現含驗證和錯誤處理
3. ✅ 資料庫 Schema 更新 - PronunciationAssessment 實體更新和 Migration 創建
4. ✅ 環境配置設定 - AzureSpeechOptions 配置和 appsettings.json 更新
5. ✅ 服務依賴注入 - IPronunciationAssessmentService 註冊完成
6. ✅ 編譯測試 - 無錯誤,所有組件正常編譯
### 第二階段:前端整合
1. ✅ AudioRecorder 共用組件開發
2. ✅ SentenceSpeakingQuiz 組件重構
3. ✅ API 服務客戶端實現
4. ✅ 複習系統整合
**實施詳情**:
- **API 端點**: `POST /api/speech/pronunciation-assessment`
- **服務狀態端點**: `GET /api/speech/service-status`
- **資料模型**: PronunciationResult, PronunciationScores, WordLevelResult
- **錯誤處理**: 完整的音頻驗證和 Azure API 錯誤處理
- **評分映射**: Azure 評分自動轉換為複習系統信心等級 (0-2分)
### 第二階段:前端整合 🔄 **進行中**
1. ⏳ AudioRecorder 共用組件開發 - 需實現 Web Audio API 錄音功能
2. ⏳ SentenceSpeakingQuiz 組件重構 - 基於現有 archive 組件升級
3. ⏳ API 服務客戶端實現 - speechAssessmentService.ts 實現
4. ⏳ 複習系統整合 - useReviewSession.ts 新增 sentence-speaking quiz type
### 第三階段:優化和測試
1. ✅ 錄音品質優化
@ -742,4 +751,57 @@ graph TD
3. ✅ 錯誤提示清晰有幫助
4. ✅ 與現有複習流程無縫整合
這個規格將為 DramaLing 增加強大的口說練習功能,提升學習者的發音能力和語言實際應用技能!
---
## 📈 開發進度更新 (2025-10-08)
### ✅ 第一階段完成總結
**完成的檔案和組件**
1. **NuGet 套件**: Microsoft.CognitiveServices.Speech v1.38.0
2. **配置類別**: `Models/Configuration/AzureSpeechOptions.cs`
3. **DTO 模型**: `Models/DTOs/PronunciationResult.cs`
4. **服務介面**: `Contracts/Services/Speech/IPronunciationAssessmentService.cs`
5. **核心服務**: `Services/Speech/AzurePronunciationAssessmentService.cs`
6. **API 控制器**: `Controllers/SpeechController.cs`
7. **資料庫實體**: `Models/Entities/PronunciationAssessment.cs` (更新)
8. **資料庫對應**: `Data/DramaLingDbContext.cs` (更新)
9. **Migration**: 資料庫結構更新 Migration 已創建
10. **依賴注入**: `Extensions/ServiceCollectionExtensions.cs` 服務註冊
11. **配置文件**: `appsettings.json` Azure Speech 配置
**技術驗證**
- ✅ 編譯無錯誤,所有組件正常運作
- ✅ Azure Speech SDK 正確整合
- ✅ 多維度評分系統實現 (Overall/Accuracy/Fluency/Completeness/Prosody)
- ✅ 智能反饋生成邏輯
- ✅ 評分映射到複習系統信心等級
- ✅ 完整的錯誤處理和驗證
**下一步開發重點**
1. 🔄 Web Audio API 錄音功能實現
2. 🔄 前端 API 客戶端開發
3. 🔄 複習系統 quiz type 擴展
4. 🔄 前端評分結果 UI 組件
### 💡 技術亮點
**智能評分系統**:
```csharp
private static int MapScoreToConfidence(double overallScore)
{
return overallScore switch
{
>= 85 => 2, // 優秀 (高信心)
>= 70 => 1, // 良好 (中信心)
_ => 0 // 需改善 (低信心)
};
}
```
**多維度反饋生成**:
- 根據 Azure 評分自動生成中文改善建議
- 詞彙級別錯誤識別和具體建議
- 流暢度、韻律等多面向評估
這個規格將為 DramaLing 增加強大的 AI 驅動口說練習功能,提升學習者的發音能力和語言實際應用技能!