Files
cruciverb/frontend/src/narrator.ts
T

98 lines
2.4 KiB
TypeScript

export interface NarratorSettings {
enabled: boolean
rate: number
pitch: number
voice: string
verbosity: 'brief' | 'normal' | 'verbose'
mode: 'voice' | 'screenreader' | 'both'
sounds: boolean
}
export const defaultNarratorSettings: NarratorSettings = {
enabled: false,
rate: 1.0,
pitch: 1.0,
voice: '',
verbosity: 'normal',
mode: 'voice',
sounds: true,
}
let settings: NarratorSettings = { ...defaultNarratorSettings }
export function updateSettings(s: NarratorSettings) {
settings = s
}
export function isSupported(): boolean {
return 'speechSynthesis' in window
}
export function getVoices(): SpeechSynthesisVoice[] {
if (!isSupported()) return []
return window.speechSynthesis.getVoices()
}
export function cancel() {
if (!isSupported()) return
window.speechSynthesis.cancel()
}
// a polite live region that screen readers announce from, created on demand
let srRegion: HTMLElement | null = null
function getSrRegion(): HTMLElement {
if (!srRegion) {
srRegion = document.createElement('div')
srRegion.setAttribute('aria-live', 'polite')
srRegion.setAttribute('role', 'status')
srRegion.className = 'visually-hidden'
document.body.appendChild(srRegion)
}
return srRegion
}
function announceToScreenReader(text: string) {
const region = getSrRegion()
const msg = document.createElement('span')
msg.textContent = `${text} `
region.appendChild(msg)
while (region.children.length > 6) {
const first = region.firstChild
if (first) region.removeChild(first)
}
setTimeout(() => msg.remove(), 15000)
}
export function speak(text: string, interrupt = true) {
if (!settings.enabled) return
const mode = settings.mode
const wantsVoice = (mode === 'voice' || mode === 'both') && isSupported()
const wantsSr = mode === 'screenreader' || mode === 'both' || !isSupported()
if (wantsSr) announceToScreenReader(text)
if (!wantsVoice) return
if (interrupt) cancel()
const utterance = new SpeechSynthesisUtterance(text)
utterance.rate = settings.rate
utterance.pitch = settings.pitch
if (settings.voice) {
const voices = getVoices()
const match = voices.find(v => v.name === settings.voice)
if (match) utterance.voice = match
}
window.speechSynthesis.speak(utterance)
}
// voices load async - call this early so they're ready
export function preloadVoices() {
if (!isSupported()) return
getVoices()
}
preloadVoices()