diff --git a/apps/web/src/arcade-games/know-your-world/.claude/POINTER_LOCK_UI.md b/apps/web/src/arcade-games/know-your-world/.claude/POINTER_LOCK_UI.md new file mode 100644 index 00000000..e49b4256 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/.claude/POINTER_LOCK_UI.md @@ -0,0 +1,97 @@ +# Pointer Lock UI Controls + +## Critical Rule + +**Any interactive UI element (buttons, checkboxes, links) within the pointer-locked map region MUST work in both:** +1. **Regular mode** - normal mouse cursor, standard click events +2. **Pointer lock mode** - fake cursor, custom hit detection via `usePointerLockButton` hook + +## Why This Matters + +When pointer lock is active: +- The real mouse cursor is hidden +- A fake cursor is rendered based on accumulated mouse movement +- Standard `onClick` events do NOT fire because the browser's click detection doesn't know where the fake cursor is +- We must manually check if the fake cursor position intersects with UI elements + +## How to Implement + +### 1. Use the `usePointerLockButton` Hook + +```typescript +import { usePointerLockButton, usePointerLockButtonRegistry } from './usePointerLockButton' + +// Create a hook for your control +const myButton = usePointerLockButton({ + id: 'my-button', // Unique identifier + disabled: false, // Whether control is disabled + active: true, // Whether control is visible/mounted + pointerLocked, // From usePointerLock hook + cursorPosition, // Current fake cursor position + containerRef, // Container element ref + onClick: handleClick, // Click handler +}) +``` + +### 2. Register with Button Registry + +```typescript +const buttonRegistry = usePointerLockButtonRegistry() + +useEffect(() => { + buttonRegistry.register('my-button', myButton.checkClick, handleClick) + return () => buttonRegistry.unregister('my-button') +}, [buttonRegistry, myButton.checkClick, handleClick]) +``` + +### 3. Attach Ref and Hover Styles in JSX + +```tsx + +``` + +## Currently Implemented Controls + +The following UI elements in MapRenderer have pointer lock support: + +| Control | ID | Description | +|---------|----|----| +| Give Up button | `give-up` | Reveals the correct answer | +| Hint button | `hint` | Shows/hides the hint bubble | +| Speak button | `speak-hint` | Reads hint aloud | +| Auto-hint checkbox | `auto-hint-checkbox` | Toggle auto-open hint on region advance | +| Auto-speak checkbox | `auto-speak-checkbox` | Toggle auto-speak on hint open | +| With accent checkbox | `with-accent-checkbox` | Toggle regional accent for speech | + +## Checklist for Adding New Controls + +When adding any new interactive element in the pointer-locked region: + +- [ ] Create a `usePointerLockButton` hook for the element +- [ ] Create a `useCallback` handler if the onClick logic needs memoization +- [ ] Register the button with `buttonRegistry` in the useEffect +- [ ] Attach `refCallback` to the element +- [ ] Add hover styles that respond to `isHovered` +- [ ] Test in both regular and pointer lock modes +- [ ] Ensure `e.stopPropagation()` is called to prevent map interaction + +## Common Gotchas + +1. **Stale closures** - Make sure your onClick handlers use `useCallback` with proper dependencies +2. **Active state** - Set `active: false` when the control is not visible (prevents phantom clicks) +3. **Disabled state** - Set `disabled: true` to prevent hover/click while disabled +4. **Ref timing** - The `refCallback` updates bounds on each render; if the element moves, bounds update automatically diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx index 316bcd78..cf383ce0 100644 --- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx +++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx @@ -30,6 +30,7 @@ import { useRegionDetection } from '../hooks/useRegionDetection' import { usePointerLock } from '../hooks/usePointerLock' import { useMagnifierZoom } from '../hooks/useMagnifierZoom' import { useRegionHint, useHasRegionHint } from '../hooks/useRegionHint' +import { useSpeakHint } from '../hooks/useSpeakHint' import { usePointerLockButton, usePointerLockButtonRegistry } from './usePointerLockButton' import { DevCropTool } from './DevCropTool' import type { HintMap } from '../messages' @@ -445,9 +446,62 @@ export function MapRenderer({ // Hint feature state const [showHintBubble, setShowHintBubble] = useState(false) + // Determine which hint map to use: + // - For USA map, use 'usa' + // - For World map with specific continent, use the continent name (e.g., 'europe', 'africa') + // - For World map with 'all' continents, use 'world' + const hintMapKey: HintMap = + selectedMap === 'usa' + ? 'usa' + : selectedContinent !== 'all' + ? (selectedContinent as HintMap) + : 'world' // Get hint for current region (if available) - const hintText = useRegionHint(selectedMap as HintMap, currentPrompt) - const hasHint = useHasRegionHint(selectedMap as HintMap, currentPrompt) + const hintText = useRegionHint(hintMapKey, currentPrompt) + const hasHint = useHasRegionHint(hintMapKey, currentPrompt) + + // Speech synthesis for reading hints aloud + const { speak: speakHint, stop: stopSpeaking, isSpeaking, isSupported: isSpeechSupported, hasAccentOption } = useSpeakHint( + hintMapKey, + currentPrompt + ) + + // Auto-speak setting persisted in localStorage + const [autoSpeak, setAutoSpeak] = useState(() => { + if (typeof window === 'undefined') return false + return localStorage.getItem('knowYourWorld.autoSpeakHint') === 'true' + }) + + // With accent setting persisted in localStorage (default true) + const [withAccent, setWithAccent] = useState(() => { + if (typeof window === 'undefined') return true + const stored = localStorage.getItem('knowYourWorld.withAccent') + return stored === null ? true : stored === 'true' + }) + + // Auto-hint setting persisted in localStorage (auto-opens hint on region advance) + const [autoHint, setAutoHint] = useState(() => { + if (typeof window === 'undefined') return false + return localStorage.getItem('knowYourWorld.autoHint') === 'true' + }) + + // Persist auto-speak setting + const handleAutoSpeakChange = useCallback((enabled: boolean) => { + setAutoSpeak(enabled) + localStorage.setItem('knowYourWorld.autoSpeakHint', String(enabled)) + }, []) + + // Persist with-accent setting + const handleWithAccentChange = useCallback((enabled: boolean) => { + setWithAccent(enabled) + localStorage.setItem('knowYourWorld.withAccent', String(enabled)) + }, []) + + // Persist auto-hint setting + const handleAutoHintChange = useCallback((enabled: boolean) => { + setAutoHint(enabled) + localStorage.setItem('knowYourWorld.autoHint', String(enabled)) + }, []) // Pointer lock button registry and hooks for Give Up and Hint buttons const buttonRegistry = usePointerLockButtonRegistry() @@ -474,20 +528,121 @@ export function MapRenderer({ onClick: () => setShowHintBubble((prev) => !prev), }) + // Speak hint button pointer lock support + const handleSpeakClick = useCallback(() => { + if (isSpeaking) { + stopSpeaking() + } else if (hintText) { + speakHint(hintText, withAccent) + } + }, [isSpeaking, stopSpeaking, hintText, speakHint, withAccent]) + + const speakButton = usePointerLockButton({ + id: 'speak-hint', + disabled: !hintText, + active: showHintBubble && isSpeechSupported, + pointerLocked, + cursorPosition, + containerRef, + onClick: handleSpeakClick, + }) + + // Auto-speak checkbox pointer lock support + const handleAutoSpeakToggle = useCallback(() => { + handleAutoSpeakChange(!autoSpeak) + }, [autoSpeak, handleAutoSpeakChange]) + + const autoSpeakCheckbox = usePointerLockButton({ + id: 'auto-speak-checkbox', + disabled: false, + active: showHintBubble && isSpeechSupported, + pointerLocked, + cursorPosition, + containerRef, + onClick: handleAutoSpeakToggle, + }) + + // With accent checkbox pointer lock support + const handleWithAccentToggle = useCallback(() => { + handleWithAccentChange(!withAccent) + }, [withAccent, handleWithAccentChange]) + + const withAccentCheckbox = usePointerLockButton({ + id: 'with-accent-checkbox', + disabled: false, + active: showHintBubble && isSpeechSupported && hasAccentOption, + pointerLocked, + cursorPosition, + containerRef, + onClick: handleWithAccentToggle, + }) + + // Auto-hint checkbox pointer lock support + const handleAutoHintToggle = useCallback(() => { + handleAutoHintChange(!autoHint) + }, [autoHint, handleAutoHintChange]) + + const autoHintCheckbox = usePointerLockButton({ + id: 'auto-hint-checkbox', + disabled: false, + active: showHintBubble, + pointerLocked, + cursorPosition, + containerRef, + onClick: handleAutoHintToggle, + }) + // Register buttons with the registry for centralized click handling useEffect(() => { buttonRegistry.register('give-up', giveUpButton.checkClick, onGiveUp) buttonRegistry.register('hint', hintButton.checkClick, () => setShowHintBubble((prev) => !prev)) + buttonRegistry.register('speak-hint', speakButton.checkClick, handleSpeakClick) + buttonRegistry.register('auto-speak-checkbox', autoSpeakCheckbox.checkClick, handleAutoSpeakToggle) + buttonRegistry.register('with-accent-checkbox', withAccentCheckbox.checkClick, handleWithAccentToggle) + buttonRegistry.register('auto-hint-checkbox', autoHintCheckbox.checkClick, handleAutoHintToggle) return () => { buttonRegistry.unregister('give-up') buttonRegistry.unregister('hint') + buttonRegistry.unregister('speak-hint') + buttonRegistry.unregister('auto-speak-checkbox') + buttonRegistry.unregister('with-accent-checkbox') + buttonRegistry.unregister('auto-hint-checkbox') } - }, [buttonRegistry, giveUpButton.checkClick, hintButton.checkClick, onGiveUp]) + }, [buttonRegistry, giveUpButton.checkClick, hintButton.checkClick, speakButton.checkClick, autoSpeakCheckbox.checkClick, withAccentCheckbox.checkClick, autoHintCheckbox.checkClick, onGiveUp, handleSpeakClick, handleAutoSpeakToggle, handleWithAccentToggle, handleAutoHintToggle]) - // Close hint bubble when the prompt changes (new region to find) + // Track previous showHintBubble state to detect when it opens + const prevShowHintBubbleRef = useRef(false) + + // Auto-speak hint when bubble opens (if enabled) + // Only triggers when bubble transitions from closed to open, not when hintText changes useEffect(() => { - setShowHintBubble(false) - }, [currentPrompt]) + const justOpened = showHintBubble && !prevShowHintBubbleRef.current + prevShowHintBubbleRef.current = showHintBubble + + if (justOpened && autoSpeak && hintText && isSpeechSupported) { + speakHint(hintText, withAccent) + } + }, [showHintBubble, autoSpeak, hintText, isSpeechSupported, speakHint, withAccent]) + + // Track previous prompt to detect region changes + const prevPromptRef = useRef(null) + + // Handle hint bubble and auto-speak when the prompt changes (new region to find) + useEffect(() => { + const isNewRegion = prevPromptRef.current !== null && prevPromptRef.current !== currentPrompt + prevPromptRef.current = currentPrompt + + if (autoHint && hasHint) { + setShowHintBubble(true) + // If region changed and both auto-hint and auto-speak are enabled, speak immediately + // This handles the case where the bubble was already open + if (isNewRegion && autoSpeak && hintText && isSpeechSupported) { + speakHint(hintText, withAccent) + } + } else { + setShowHintBubble(false) + } + }, [currentPrompt, autoHint, hasHint, autoSpeak, hintText, isSpeechSupported, speakHint, withAccent]) // Configuration const MAX_ZOOM = 1000 // Maximum zoom level (for Gibraltar at 0.08px!) @@ -3942,7 +4097,231 @@ export function MapRenderer({ }, })} > - {hintText} + {/* Hint text */} +
{hintText}
+ + {/* Controls section */} +
+ {/* Speech row - speak button with accent option */} + {isSpeechSupported && ( +
+ {/* Speak button */} + + + {/* With accent checkbox - inline with speak button */} + {hasAccentOption && ( + + )} +
+ )} + + {/* Auto options row - horizontal compact layout */} +
+ Auto: + + {/* Auto-hint checkbox */} + + + {/* Auto-speak checkbox */} + {isSpeechSupported && ( + + )} +
+
)} diff --git a/apps/web/src/arcade-games/know-your-world/hooks/useSpeakHint.ts b/apps/web/src/arcade-games/know-your-world/hooks/useSpeakHint.ts new file mode 100644 index 00000000..1f5894cb --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/hooks/useSpeakHint.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useLocale } from 'next-intl' +import { + getLanguageForRegion, + speakText, + shouldShowAccentOption, +} from '../utils/speechSynthesis' + +// Map app locales to BCP 47 language tags for speech synthesis +const LOCALE_TO_LANG: Record = { + en: 'en-US', + de: 'de-DE', + es: 'es-ES', + ja: 'ja-JP', + hi: 'hi-IN', + la: 'it-IT', // Latin fallback to Italian (closest available) + goh: 'de-DE', // Old High German fallback to German +} + +/** + * Hook to manage available speech synthesis voices. + * Handles the async loading of voices across different browsers. + */ +export function useAvailableVoices(): SpeechSynthesisVoice[] { + const [voices, setVoices] = useState([]) + + useEffect(() => { + // Check if speech synthesis is available + if (typeof window === 'undefined' || !window.speechSynthesis) { + return + } + + const updateVoices = () => { + const availableVoices = speechSynthesis.getVoices() + setVoices(availableVoices) + } + + // Try immediately (works in some browsers like Firefox) + updateVoices() + + // Listen for async load (required in Chrome, Safari, etc.) + speechSynthesis.addEventListener('voiceschanged', updateVoices) + + return () => { + speechSynthesis.removeEventListener('voiceschanged', updateVoices) + } + }, []) + + return voices +} + +/** + * Hook to speak hints with region-appropriate voices. + * + * Returns: + * - speak: function to speak text (respects withAccent param) + * - stop: function to stop speaking + * - isSpeaking: whether currently speaking + * - isSupported: whether speech synthesis is available + * - hasAccentOption: whether region language differs from user's locale + */ +export function useSpeakHint(map: string, regionId: string | null) { + const [isSpeaking, setIsSpeaking] = useState(false) + const cancelRef = useRef<(() => void) | null>(null) + const voices = useAvailableVoices() + const locale = useLocale() + + // Check if speech synthesis is supported + const isSupported = + typeof window !== 'undefined' && 'speechSynthesis' in window + + // Get language codes + const userLang = LOCALE_TO_LANG[locale] || 'en-US' + const regionLang = regionId ? getLanguageForRegion(map, regionId) : userLang + + // Check if accent option should be shown + // This considers both language difference AND voice quality + const hasAccentOption = useMemo(() => { + return shouldShowAccentOption(voices, regionLang, userLang) + }, [voices, regionLang, userLang]) + + // Clean up on unmount or when region changes + useEffect(() => { + return () => { + if (cancelRef.current) { + cancelRef.current() + cancelRef.current = null + } + } + }, [regionId]) + + // Internal speak function that takes a language + const speakWithLang = useCallback( + (text: string, targetLang: string) => { + if (!isSupported) return + + // Cancel any ongoing speech + if (cancelRef.current) { + cancelRef.current() + } + + const { cancel } = speakText(text, targetLang, { + rate: 0.85, // Slower for children + onStart: () => setIsSpeaking(true), + onEnd: () => { + setIsSpeaking(false) + cancelRef.current = null + }, + onError: () => { + setIsSpeaking(false) + cancelRef.current = null + }, + }) + + cancelRef.current = cancel + }, + [isSupported] + ) + + // Speak text, optionally with region accent + const speak = useCallback( + (text: string, withAccent: boolean = false) => { + const targetLang = withAccent && hasAccentOption ? regionLang : userLang + speakWithLang(text, targetLang) + }, + [hasAccentOption, regionLang, userLang, speakWithLang] + ) + + const stop = useCallback(() => { + if (cancelRef.current) { + cancelRef.current() + cancelRef.current = null + } + setIsSpeaking(false) + }, []) + + return { + speak, + stop, + isSpeaking, + isSupported, + hasVoices: voices.length > 0, + hasAccentOption, + } +} diff --git a/apps/web/src/arcade-games/know-your-world/messages.ts b/apps/web/src/arcade-games/know-your-world/messages.ts index 49fe30b3..3aa5004f 100644 --- a/apps/web/src/arcade-games/know-your-world/messages.ts +++ b/apps/web/src/arcade-games/know-your-world/messages.ts @@ -25,11 +25,13 @@ export const knowYourWorldMessages = { /** * Type for hint lookup */ -export type HintMap = 'usa' | 'world' +export type HintMap = 'usa' | 'world' | 'europe' | 'africa' export type HintsData = { hints: { usa: Record world: Record + europe: Record + africa: Record } } diff --git a/apps/web/src/arcade-games/know-your-world/utils/speechSynthesis.ts b/apps/web/src/arcade-games/know-your-world/utils/speechSynthesis.ts new file mode 100644 index 00000000..1d7c74a2 --- /dev/null +++ b/apps/web/src/arcade-games/know-your-world/utils/speechSynthesis.ts @@ -0,0 +1,433 @@ +/** + * 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 = { + // 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. We have a voice that matches the language (not a fallback) + * 2. The voice quality is good enough (not espeak/low-quality) + */ +export function shouldShowAccentOption( + voices: SpeechSynthesisVoice[], + regionLang: string, + userLang: string +): boolean { + // First check: languages must differ + if (regionLang.split('-')[0] === userLang.split('-')[0]) { + 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> = { + 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. + */ +export function getLanguageForRegion(map: string, regionId: string): string { + const mapLangs = REGION_LANGUAGES[map] + if (!mapLangs) return 'en-US' + + return mapLangs[regionId] || 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 + } +): { 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 and start new + speechSynthesis.cancel() + speechSynthesis.speak(utterance) + + return { + cancel: () => speechSynthesis.cancel(), + } +}