Spaces:
Paused
Paused
| /** | |
| * Shadowing segment helpers — scoring, tokenization, YouTube ID. | |
| */ | |
| const YT_PATTERNS = [ | |
| /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/, | |
| /^([a-zA-Z0-9_-]{11})$/, | |
| ] | |
| export function extractYoutubeVideoId(url) { | |
| const s = (url || '').trim() | |
| for (const pat of YT_PATTERNS) { | |
| const m = s.match(pat) | |
| if (m) return m[1] | |
| } | |
| return null | |
| } | |
| /** YouTube thumbnail URL (mqdefault ≈ 320×180). */ | |
| export function youtubeThumbnail(videoId, quality = 'mqdefault') { | |
| if (!videoId) return '' | |
| return `https://i.ytimg.com/vi/${videoId}/${quality}.jpg` | |
| } | |
| export function tokenizeWords(text) { | |
| return (text || '') | |
| .trim() | |
| .split(/\s+/) | |
| .filter(Boolean) | |
| } | |
| function normalizeToken(w) { | |
| return w.toLowerCase().replace(/[^\w']/g, '') | |
| } | |
| /** | |
| * Dictation scoring — ignore case & punctuation. | |
| * @returns {{ correct: string[], wrong: string[], missing: string[], score: number }} | |
| */ | |
| export function scoreAnswer(userInput, correct) { | |
| const target = tokenizeWords(correct).map(normalizeToken).filter(Boolean) | |
| const user = tokenizeWords(userInput).map(normalizeToken).filter(Boolean) | |
| const correctWords = [] | |
| const wrong = [] | |
| const missing = [] | |
| const maxLen = Math.max(target.length, user.length) | |
| for (let i = 0; i < maxLen; i++) { | |
| const t = target[i] | |
| const u = user[i] | |
| if (!t && u) { | |
| wrong.push(user[i] ?? u) | |
| } else if (t && !u) { | |
| missing.push(correct.split(/\s+/)[i] ?? t) | |
| } else if (t && u) { | |
| if (t === u) correctWords.push(correct.split(/\s+/)[i] ?? t) | |
| else wrong.push(user[i] ?? u) | |
| } | |
| } | |
| const score = target.length | |
| ? Math.round((correctWords.length / target.length) * 100) | |
| : 0 | |
| return { correct: correctWords, wrong, missing, score } | |
| } | |
| /** | |
| * Pronunciation scoring from Web Speech transcript vs target. | |
| */ | |
| export function scorePronunciation(spokenText, targetText) { | |
| const target = tokenizeWords(targetText).map(normalizeToken).filter(Boolean) | |
| const spoken = tokenizeWords(spokenText).map(normalizeToken).filter(Boolean) | |
| const wordResults = target.map((t, i) => { | |
| const s = spoken[i] | |
| return { | |
| word: tokenizeWords(targetText)[i] || t, | |
| ok: s === t, | |
| spoken: spoken[i] || null, | |
| } | |
| }) | |
| const extra = spoken.slice(target.length) | |
| const correctCount = wordResults.filter((w) => w.ok).length | |
| const score = target.length | |
| ? Math.round((correctCount / target.length) * 100) | |
| : 0 | |
| return { wordResults, extra, score } | |
| } | |
| export function formatTimestamp(seconds) { | |
| const s = Math.max(0, Number(seconds) || 0) | |
| const m = Math.floor(s / 60) | |
| const sec = (s % 60).toFixed(1) | |
| return `${m}:${sec.padStart(4, '0')}` | |
| } | |