profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM
tts.ts 1,727 bytes
1 export interface SpeakCallbacks {
2 onStart?: () => void
3 onEnd?: () => void
4 }
5
6 export function selectVoice(
7 voices: readonly SpeechSynthesisVoice[],
8 preferredLanguage: string,
9 ): SpeechSynthesisVoice | null {
10 const preferred = preferredLanguage.trim().toLowerCase()
11 const preferredBase = preferred.split('-')[0] ?? ''
12 let best: SpeechSynthesisVoice | null = null
13 let bestScore = -1
14
15 for (const voice of voices) {
16 const language = voice.lang.toLowerCase()
17 const languageBase = language.split('-')[0] ?? ''
18 let score = 0
19 if (preferred && language === preferred) score += 120
20 else if (preferredBase && languageBase === preferredBase) score += 80
21 if (voice.default) score += 20
22 if (/natural/i.test(voice.name)) score += 1
23
24 if (score > bestScore) {
25 bestScore = score
26 best = voice
27 }
28 }
29
30 return best
31 }
32
33 function pickVoice(): SpeechSynthesisVoice | null {
34 return selectVoice(window.speechSynthesis.getVoices(), navigator.language)
35 }
36
37 export function speak(text: string, callbacks: SpeakCallbacks = {}): void {
38 if (!('speechSynthesis' in window)) return
39 window.speechSynthesis.cancel()
40 const utterance = new SpeechSynthesisUtterance(text)
41 const voice = pickVoice()
42 if (voice) {
43 utterance.voice = voice
44 utterance.lang = voice.lang
45 } else if (navigator.language) {
46 utterance.lang = navigator.language
47 }
48 utterance.rate = 1
49 utterance.pitch = 1
50 utterance.onstart = () => callbacks.onStart?.()
51 utterance.onend = () => callbacks.onEnd?.()
52 utterance.onerror = () => callbacks.onEnd?.()
53 window.speechSynthesis.speak(utterance)
54 }
55
56 export function stopSpeaking(): void {
57 if ('speechSynthesis' in window) window.speechSynthesis.cancel()
58 }
59