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(), } } |