All files / web/src/arcade-games/know-your-world/utils speechSynthesis.ts

0% Statements 0/443
0% Branches 0/1
0% Functions 0/1
0% Lines 0/443

Press n or j to go to the next uncovered block, b, p or k for the previous block.

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 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       
/**
 * Speech synthesis utilities for Know Your World hints
 *
 * Provides cross-browser voice selection with intelligent fallbacks
 * for regions/countries that may not have native TTS support.
 */

export interface VoiceMatch {
  voice: SpeechSynthesisVoice
  quality: 'exact' | 'language' | 'fallback' | 'default'
}

/**
 * Fallback chains for languages with limited TTS support.
 * Maps base language code to ordered list of fallback languages.
 */
const LANGUAGE_FALLBACKS: Record<string, string[]> = {
  // Balkan languages
  sq: ['it', 'el', 'en'], // Albanian → Italian, Greek
  mk: ['bg', 'sr', 'en'], // Macedonian → Bulgarian, Serbian
  sl: ['hr', 'de', 'en'], // Slovenian → Croatian, German
  sr: ['hr', 'ru', 'en'], // Serbian → Croatian, Russian
  hr: ['sl', 'sr', 'en'], // Croatian → Slovenian, Serbian
  bs: ['hr', 'sr', 'en'], // Bosnian → Croatian, Serbian
  me: ['sr', 'hr', 'en'], // Montenegrin → Serbian, Croatian

  // Baltic languages
  et: ['fi', 'ru', 'en'], // Estonian → Finnish, Russian
  lv: ['lt', 'ru', 'en'], // Latvian → Lithuanian, Russian
  lt: ['lv', 'pl', 'en'], // Lithuanian → Latvian, Polish

  // Nordic languages
  is: ['da', 'no', 'en'], // Icelandic → Danish, Norwegian
  fo: ['da', 'no', 'en'], // Faroese → Danish, Norwegian

  // Eastern European
  uk: ['ru', 'pl', 'en'], // Ukrainian → Russian, Polish
  be: ['ru', 'pl', 'en'], // Belarusian → Russian, Polish
  bg: ['ru', 'sr', 'en'], // Bulgarian → Russian, Serbian
  ro: ['it', 'fr', 'en'], // Romanian → Italian, French
  md: ['ro', 'ru', 'en'], // Moldovan → Romanian, Russian

  // Central European
  hu: ['de', 'en'], // Hungarian → German
  sk: ['cs', 'pl', 'en'], // Slovak → Czech, Polish
  cs: ['sk', 'pl', 'en'], // Czech → Slovak, Polish

  // Mediterranean
  mt: ['it', 'en'], // Maltese → Italian
  el: ['en'], // Greek → English
  ca: ['es', 'fr', 'en'], // Catalan → Spanish, French

  // Celtic
  ga: ['en'], // Irish → English
  cy: ['en'], // Welsh → English
  gd: ['en'], // Scottish Gaelic → English

  // African languages
  sw: ['en'], // Swahili → English
  am: ['ar', 'en'], // Amharic → Arabic
  zu: ['en'], // Zulu → English
  xh: ['en'], // Xhosa → English
  st: ['en'], // Sotho → English
  tn: ['en'], // Tswana → English
  rw: ['fr', 'en'], // Kinyarwanda → French
  rn: ['fr', 'en'], // Kirundi → French
  mg: ['fr', 'en'], // Malagasy → French
  so: ['ar', 'en'], // Somali → Arabic
  ha: ['en'], // Hausa → English
  yo: ['en'], // Yoruba → English
  ig: ['en'], // Igbo → English

  // Afrikaans (Dutch-based)
  af: ['nl', 'en'], // Afrikaans → Dutch
}

/**
 * Score a voice based on quality indicators.
 * Higher scores indicate better quality voices.
 */
function scoreVoice(voice: SpeechSynthesisVoice): number {
  let score = 0
  const name = voice.name.toLowerCase()

  // Quality indicators by vendor/type (cross-browser)
  if (name.includes('google')) score += 100 // Chrome cloud voices
  if (name.includes('microsoft')) score += 90 // Edge/Windows
  if (name.includes('siri')) score += 90 // iOS
  if (name.includes('samantha')) score += 85 // macOS default
  if (name.includes('alex')) score += 85 // macOS
  if (name.includes('premium')) score += 80 // Various premium
  if (name.includes('enhanced')) score += 70 // Enhanced versions
  if (name.includes('natural')) score += 70 // Natural sounding
  if (name.includes('neural')) score += 75 // Neural TTS
  if (name.includes('wavenet')) score += 80 // Google WaveNet

  // Cloud voices usually have better quality
  if (!voice.localService) score += 30

  // Penalize robotic/low-quality voices
  if (name.includes('espeak')) score -= 50
  if (name.includes('festival')) score -= 50
  if (name.includes('mbrola')) score -= 40

  return score
}

/**
 * Find the best voice for a specific language code.
 * Returns null if no voices match the language.
 */
function findVoiceForLanguage(
  voices: SpeechSynthesisVoice[],
  langCode: string
): SpeechSynthesisVoice | null {
  const baseLang = langCode.split('-')[0].toLowerCase()

  // Find all voices matching this language
  const matches = voices.filter((v) => {
    const voiceBase = v.lang.split('-')[0].toLowerCase()
    return voiceBase === baseLang
  })

  if (matches.length === 0) return null

  // Sort by quality score (highest first)
  matches.sort((a, b) => scoreVoice(b) - scoreVoice(a))

  // Among top scorers, prefer exact locale match
  const exactMatch = matches.find((v) => v.lang.toLowerCase() === langCode.toLowerCase())

  return exactMatch || matches[0]
}

/**
 * Select the best voice for a target language with fallbacks.
 * Returns the voice and a quality indicator.
 */
export function selectVoice(voices: SpeechSynthesisVoice[], targetLang: string): VoiceMatch | null {
  if (voices.length === 0) return null

  const baseLang = targetLang.split('-')[0].toLowerCase()

  // 1. Try exact/base language match
  const directMatch = findVoiceForLanguage(voices, targetLang)
  if (directMatch) {
    const isExact = directMatch.lang.toLowerCase() === targetLang.toLowerCase()
    return {
      voice: directMatch,
      quality: isExact ? 'exact' : 'language',
    }
  }

  // 2. Try fallback chain for this language
  const fallbacks = LANGUAGE_FALLBACKS[baseLang] || ['en']
  for (const fallbackLang of fallbacks) {
    const fallbackVoice = findVoiceForLanguage(voices, fallbackLang)
    if (fallbackVoice) {
      return { voice: fallbackVoice, quality: 'fallback' }
    }
  }

  // 3. Last resort: any English voice
  const anyEnglish = findVoiceForLanguage(voices, 'en-US')
  if (anyEnglish) {
    return { voice: anyEnglish, quality: 'default' }
  }

  // 4. Absolute last resort: best available voice
  const sorted = [...voices].sort((a, b) => scoreVoice(b) - scoreVoice(a))
  return { voice: sorted[0], quality: 'default' }
}

/**
 * Get detailed voice selection info for a language.
 * Returns the voice, language match quality, and voice synthesis quality score.
 */
export interface VoiceSelectionInfo {
  voice: SpeechSynthesisVoice | null
  matchQuality: VoiceMatch['quality'] | null
  voiceScore: number // Higher = better quality voice (Google, Microsoft, etc.)
  isGoodQuality: boolean // True if voice score is above threshold
}

// Minimum voice score to consider "good quality" for accent feature
// Score breakdown: Google=100, Microsoft=90, Siri=90, premium=80, neural=75, etc.
// A score of 75+ means we have a good quality voice
const MINIMUM_VOICE_QUALITY_SCORE = 75

/**
 * Get voice selection info for a target language.
 * This checks both the language match AND the voice synthesis quality.
 */
export function getVoiceSelectionInfo(
  voices: SpeechSynthesisVoice[],
  targetLang: string
): VoiceSelectionInfo {
  const match = selectVoice(voices, targetLang)

  if (!match) {
    return {
      voice: null,
      matchQuality: null,
      voiceScore: 0,
      isGoodQuality: false,
    }
  }

  const voiceScore = scoreVoice(match.voice)

  return {
    voice: match.voice,
    matchQuality: match.quality,
    voiceScore,
    isGoodQuality: voiceScore >= MINIMUM_VOICE_QUALITY_SCORE,
  }
}

/**
 * Check if the accent option should be shown for a region's language.
 * Returns true only if:
 * 1. The region's locale differs from the user's locale (e.g., en-GB vs en-US)
 * 2. We have a voice that matches the language (not a fallback)
 * 3. The voice quality is good enough (not espeak/low-quality)
 */
export function shouldShowAccentOption(
  voices: SpeechSynthesisVoice[],
  regionLang: string,
  userLang: string
): boolean {
  // First check: locales must differ (including same-language variants like en-GB vs en-US)
  if (regionLang.toLowerCase() === userLang.toLowerCase()) {
    return false
  }

  // Second check: get voice info for region language
  const info = getVoiceSelectionInfo(voices, regionLang)

  // Only show accent if we have a good language match AND good voice quality
  const hasGoodLanguageMatch = info.matchQuality === 'exact' || info.matchQuality === 'language'

  return hasGoodLanguageMatch && info.isGoodQuality
}

/**
 * Region to language mapping for each map.
 * Maps region IDs to BCP 47 language tags.
 */
export const REGION_LANGUAGES: Record<string, Record<string, string>> = {
  usa: {
    // All US states use American English
    _default: 'en-US',
  },

  europe: {
    al: 'sq-AL', // Albania → Albanian
    ad: 'ca-ES', // Andorra → Catalan
    at: 'de-AT', // Austria → German (Austrian)
    by: 'ru-RU', // Belarus → Russian
    be: 'nl-BE', // Belgium → Dutch (Flemish)
    ba: 'hr-HR', // Bosnia → Croatian
    bg: 'bg-BG', // Bulgaria → Bulgarian
    hr: 'hr-HR', // Croatia → Croatian
    cy: 'el-GR', // Cyprus → Greek
    cz: 'cs-CZ', // Czechia → Czech
    dk: 'da-DK', // Denmark → Danish
    ee: 'et-EE', // Estonia → Estonian
    fi: 'fi-FI', // Finland → Finnish
    fr: 'fr-FR', // France → French
    de: 'de-DE', // Germany → German
    gr: 'el-GR', // Greece → Greek
    hu: 'hu-HU', // Hungary → Hungarian
    is: 'is-IS', // Iceland → Icelandic
    ie: 'en-IE', // Ireland → Irish English
    it: 'it-IT', // Italy → Italian
    xk: 'sq-AL', // Kosovo → Albanian
    lv: 'lv-LV', // Latvia → Latvian
    li: 'de-DE', // Liechtenstein → German
    lt: 'lt-LT', // Lithuania → Lithuanian
    lu: 'fr-FR', // Luxembourg → French
    mt: 'mt-MT', // Malta → Maltese
    md: 'ro-RO', // Moldova → Romanian
    mc: 'fr-FR', // Monaco → French
    me: 'sr-RS', // Montenegro → Serbian
    nl: 'nl-NL', // Netherlands → Dutch
    mk: 'mk-MK', // North Macedonia → Macedonian
    no: 'nb-NO', // Norway → Norwegian Bokmål
    pl: 'pl-PL', // Poland → Polish
    pt: 'pt-PT', // Portugal → Portuguese
    ro: 'ro-RO', // Romania → Romanian
    ru: 'ru-RU', // Russia → Russian
    sm: 'it-IT', // San Marino → Italian
    rs: 'sr-RS', // Serbia → Serbian
    sk: 'sk-SK', // Slovakia → Slovak
    si: 'sl-SI', // Slovenia → Slovenian
    es: 'es-ES', // Spain → Spanish
    se: 'sv-SE', // Sweden → Swedish
    ch: 'de-CH', // Switzerland → German
    ua: 'uk-UA', // Ukraine → Ukrainian
    gb: 'en-GB', // UK → British English
    va: 'it-IT', // Vatican → Italian
  },

  africa: {
    dz: 'ar-DZ', // Algeria → Arabic
    ao: 'pt-PT', // Angola → Portuguese
    bj: 'fr-FR', // Benin → French
    bw: 'en-ZA', // Botswana → English (SA)
    bf: 'fr-FR', // Burkina Faso → French
    bi: 'fr-FR', // Burundi → French
    cm: 'fr-FR', // Cameroon → French
    cv: 'pt-PT', // Cape Verde → Portuguese
    cf: 'fr-FR', // Central African Rep → French
    td: 'fr-FR', // Chad → French
    km: 'ar-SA', // Comoros → Arabic
    cg: 'fr-FR', // Congo → French
    cd: 'fr-FR', // DR Congo → French
    dj: 'fr-FR', // Djibouti → French
    eg: 'ar-EG', // Egypt → Arabic (Egyptian)
    gq: 'es-ES', // Equatorial Guinea → Spanish
    er: 'ar-SA', // Eritrea → Arabic
    sz: 'en-ZA', // Eswatini → English (SA)
    et: 'am-ET', // Ethiopia → Amharic
    ga: 'fr-FR', // Gabon → French
    gm: 'en-GB', // Gambia → English
    gh: 'en-GB', // Ghana → English
    gn: 'fr-FR', // Guinea → French
    gw: 'pt-PT', // Guinea-Bissau → Portuguese
    ci: 'fr-FR', // Ivory Coast → French
    ke: 'en-GB', // Kenya → English
    ls: 'en-ZA', // Lesotho → English (SA)
    lr: 'en-US', // Liberia → English (US)
    ly: 'ar-LY', // Libya → Arabic
    mg: 'fr-FR', // Madagascar → French
    mw: 'en-GB', // Malawi → English
    ml: 'fr-FR', // Mali → French
    mr: 'ar-SA', // Mauritania → Arabic
    mu: 'en-GB', // Mauritius → English
    ma: 'ar-MA', // Morocco → Arabic
    mz: 'pt-PT', // Mozambique → Portuguese
    na: 'en-ZA', // Namibia → English (SA)
    ne: 'fr-FR', // Niger → French
    ng: 'en-GB', // Nigeria → English
    rw: 'fr-FR', // Rwanda → French
    st: 'pt-PT', // São Tomé → Portuguese
    sn: 'fr-FR', // Senegal → French
    sc: 'en-GB', // Seychelles → English
    sl: 'en-GB', // Sierra Leone → English
    so: 'so-SO', // Somalia → Somali
    za: 'en-ZA', // South Africa → English (SA)
    ss: 'en-GB', // South Sudan → English
    sd: 'ar-SD', // Sudan → Arabic
    tz: 'sw-TZ', // Tanzania → Swahili
    tg: 'fr-FR', // Togo → French
    tn: 'ar-TN', // Tunisia → Arabic
    ug: 'en-GB', // Uganda → English
    zm: 'en-GB', // Zambia → English
    zw: 'en-GB', // Zimbabwe → English
  },

  world: {
    // World map countries - add as needed
    _default: 'en-US',
  },
}

/**
 * Get the language code for a region on a specific map.
 * Falls back to checking other maps if the region isn't defined in the specified map.
 */
export function getLanguageForRegion(map: string, regionId: string): string {
  // First try the specified map
  const mapLangs = REGION_LANGUAGES[map]
  if (mapLangs && mapLangs[regionId]) {
    return mapLangs[regionId]
  }

  // Fall back to checking other maps (useful for world map which doesn't define all countries)
  for (const otherMap of Object.values(REGION_LANGUAGES)) {
    if (otherMap[regionId]) {
      return otherMap[regionId]
    }
  }

  // Last resort: use map default or global default
  return mapLangs?._default || 'en-US'
}

/**
 * Speak text using the best available voice for the given language.
 * Returns a promise that resolves when speech is complete or rejects on error.
 */
export function speakText(
  text: string,
  targetLang: string,
  options?: {
    rate?: number
    pitch?: number
    volume?: number
    onStart?: () => void
    onEnd?: () => void
    onError?: (error: SpeechSynthesisErrorEvent) => void
    queue?: boolean // If true, don't cancel ongoing speech - add to queue
  }
): { cancel: () => void } {
  const voices = speechSynthesis.getVoices()
  const match = selectVoice(voices, targetLang)

  const utterance = new SpeechSynthesisUtterance(text)

  if (match) {
    utterance.voice = match.voice
    utterance.lang = match.voice.lang
  } else {
    utterance.lang = targetLang
  }

  // Apply options
  utterance.rate = options?.rate ?? 0.9 // Slightly slower for kids
  utterance.pitch = options?.pitch ?? 1.0
  utterance.volume = options?.volume ?? 1.0

  // Event handlers
  if (options?.onStart) {
    utterance.onstart = options.onStart
  }
  if (options?.onEnd) {
    utterance.onend = options.onEnd
  }
  if (options?.onError) {
    utterance.onerror = options.onError
  }

  // Cancel any ongoing speech unless queuing
  if (!options?.queue) {
    speechSynthesis.cancel()
  }
  speechSynthesis.speak(utterance)

  return {
    cancel: () => speechSynthesis.cancel(),
  }
}