feat(know-your-world): add speech synthesis for hints with auto-hint/auto-speak

- Add speech synthesis utilities with voice selection algorithm
  - Prefers high-quality voices (Google, Microsoft, Siri)
  - Language fallback chains for unsupported languages
  - Region-to-language mapping for Europe and Africa
  - Voice quality threshold (75+) for accent option

- Add useSpeakHint hook for React integration
  - Manages available voices across browsers
  - Supports speaking with region accent or user locale

- Add hint bubble controls:
  - Listen button to read hint aloud
  - Auto-hint checkbox: auto-opens hint on region advance
  - Auto-speak checkbox: auto-reads hint when bubble opens
  - With accent checkbox: uses region-specific voice (when available)
  - All settings persisted in localStorage

- Add pointer lock support for all hint bubble controls
  - Document requirements in .claude/POINTER_LOCK_UI.md

- Fix HintMap type to include 'europe' and 'africa'

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-26 14:29:42 -06:00
parent 46e5c6b99b
commit cd841ff7dc
5 changed files with 1064 additions and 8 deletions

View File

@@ -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
<button
ref={myButton.refCallback}
onClick={(e) => {
e.stopPropagation()
handleClick()
}}
style={{
// Apply hover styles when fake cursor is over element
...(myButton.isHovered
? { backgroundColor: '#...' }
: {}),
}}
>
Click Me
</button>
```
## 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

View File

@@ -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<string | null>(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 */}
<div className={css({ marginBottom: '3', lineHeight: '1.5' })}>{hintText}</div>
{/* Controls section */}
<div
className={css({
borderTop: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
paddingTop: '3',
})}
>
{/* Speech row - speak button with accent option */}
{isSpeechSupported && (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
marginBottom: hasAccentOption ? '2' : '3',
})}
>
{/* Speak button */}
<button
ref={speakButton.refCallback}
data-action="speak-hint"
onClick={(e) => {
e.stopPropagation()
handleSpeakClick()
}}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '1.5',
paddingX: '3',
paddingY: '1.5',
rounded: 'md',
bg: isSpeaking
? isDark
? 'blue.600'
: 'blue.500'
: isDark
? 'gray.700'
: 'gray.100',
color: isSpeaking
? 'white'
: isDark
? 'gray.300'
: 'gray.600',
border: '1px solid',
borderColor: isSpeaking
? isDark
? 'blue.500'
: 'blue.400'
: isDark
? 'gray.600'
: 'gray.300',
cursor: 'pointer',
transition: 'all 0.15s',
fontSize: 'xs',
fontWeight: 'medium',
_hover: {
bg: isSpeaking
? isDark
? 'blue.500'
: 'blue.600'
: isDark
? 'gray.600'
: 'gray.200',
},
})}
style={{
...(speakButton.isHovered
? {
backgroundColor: isSpeaking
? isDark
? '#3b82f6'
: '#2563eb'
: isDark
? '#4b5563'
: '#e5e7eb',
}
: {}),
}}
title={isSpeaking ? 'Stop' : 'Read aloud'}
>
{isSpeaking ? '⏹' : '🔊'}
<span>{isSpeaking ? 'Stop' : 'Listen'}</span>
</button>
{/* With accent checkbox - inline with speak button */}
{hasAccentOption && (
<label
ref={withAccentCheckbox.refCallback}
onClick={(e) => {
e.stopPropagation()
}}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1.5',
cursor: 'pointer',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
padding: '1',
rounded: 'sm',
transition: 'all 0.15s',
_hover: {
color: isDark ? 'gray.200' : 'gray.700',
},
})}
style={{
...(withAccentCheckbox.isHovered
? { color: isDark ? '#e5e7eb' : '#374151' }
: {}),
}}
>
<input
type="checkbox"
checked={withAccent}
onChange={(e) => handleWithAccentChange(e.target.checked)}
className={css({
width: '12px',
height: '12px',
cursor: 'pointer',
accentColor: isDark ? '#3b82f6' : '#2563eb',
})}
/>
With accent
</label>
)}
</div>
)}
{/* Auto options row - horizontal compact layout */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
<span className={css({ fontWeight: 'medium' })}>Auto:</span>
{/* Auto-hint checkbox */}
<label
ref={autoHintCheckbox.refCallback}
onClick={(e) => {
e.stopPropagation()
}}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1',
cursor: 'pointer',
padding: '0.5',
rounded: 'sm',
transition: 'all 0.15s',
_hover: {
color: isDark ? 'gray.200' : 'gray.700',
},
})}
style={{
...(autoHintCheckbox.isHovered
? { color: isDark ? '#e5e7eb' : '#374151' }
: {}),
}}
>
<input
type="checkbox"
checked={autoHint}
onChange={(e) => handleAutoHintChange(e.target.checked)}
className={css({
width: '12px',
height: '12px',
cursor: 'pointer',
accentColor: isDark ? '#3b82f6' : '#2563eb',
})}
/>
Hint
</label>
{/* Auto-speak checkbox */}
{isSpeechSupported && (
<label
ref={autoSpeakCheckbox.refCallback}
onClick={(e) => {
e.stopPropagation()
}}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1',
cursor: 'pointer',
padding: '0.5',
rounded: 'sm',
transition: 'all 0.15s',
_hover: {
color: isDark ? 'gray.200' : 'gray.700',
},
})}
style={{
...(autoSpeakCheckbox.isHovered
? { color: isDark ? '#e5e7eb' : '#374151' }
: {}),
}}
>
<input
type="checkbox"
checked={autoSpeak}
onChange={(e) => handleAutoSpeakChange(e.target.checked)}
className={css({
width: '12px',
height: '12px',
cursor: 'pointer',
accentColor: isDark ? '#3b82f6' : '#2563eb',
})}
/>
Speak
</label>
)}
</div>
</div>
</div>
)}
</>

View File

@@ -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<string, string> = {
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<SpeechSynthesisVoice[]>([])
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,
}
}

View File

@@ -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<string, string[]>
world: Record<string, string[]>
europe: Record<string, string[]>
africa: Record<string, string[]>
}
}

View File

@@ -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<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. 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<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.
*/
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(),
}
}