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:
@@ -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
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
145
apps/web/src/arcade-games/know-your-world/hooks/useSpeakHint.ts
Normal file
145
apps/web/src/arcade-games/know-your-world/hooks/useSpeakHint.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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[]>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user