File size: 2,704 Bytes
02f8344
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
/**
 * 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')}`
}