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