98 lines
2.4 KiB
TypeScript
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() |