Skip to content

Commit e755448

Browse files
committed
feat: refactor speech settings and introduce voice selector component
- Replace the existing voice selection UI with a new VoiceSelector component for improved usability. - Enhance voice loading logic to handle asynchronous updates and loading states. - Optimize voice selection handling with memoization for better performance. - Update speech settings to ensure compatibility with the new voice selection approach.
1 parent 29c3ebc commit e755448

File tree

4 files changed

+325
-68
lines changed

4 files changed

+325
-68
lines changed

src/features/chat/components/speech-settings.tsx

Lines changed: 43 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from "react"
1+
import { useEffect, useMemo, useState } from "react"
22

33
import { Badge } from "@/components/ui/badge"
44
import {
@@ -9,15 +9,9 @@ import {
99
CardTitle
1010
} from "@/components/ui/card"
1111
import { Label } from "@/components/ui/label"
12-
import {
13-
Select,
14-
SelectContent,
15-
SelectItem,
16-
SelectTrigger,
17-
SelectValue
18-
} from "@/components/ui/select"
1912
import { Slider } from "@/components/ui/slider"
2013
import { Textarea } from "@/components/ui/textarea"
14+
import { VoiceSelector } from "@/features/chat/components/voice-selector"
2115
import { useSpeechSettings } from "@/features/chat/hooks/use-speech-settings"
2216
import { useVoices } from "@/features/chat/hooks/use-voice"
2317
import { Mic, Settings, Volume2 } from "@/lib/lucide-icon"
@@ -39,30 +33,34 @@ const getPitchDescription = (pitch: number) => {
3933
}
4034

4135
export const SpeechSettings = () => {
42-
const voices = useVoices()
36+
const { voices, isLoading: isLoadingVoices } = useVoices()
4337
const { rate, setRate, pitch, setPitch, voiceURI, setVoiceURI } =
4438
useSpeechSettings()
4539
const [testText, setTestText] = useState("")
4640

41+
const selectedVoice = useMemo(
42+
() => voices.find((v) => v.voiceURI === voiceURI),
43+
[voices, voiceURI]
44+
)
45+
4746
useEffect(() => {
48-
if (!voiceURI && voices.length > 0) {
49-
const defaultVoice = voices.find((v) => v.default) ?? voices[0]
50-
if (defaultVoice) {
51-
setVoiceURI(defaultVoice.voiceURI)
52-
}
53-
} else if (
54-
voiceURI &&
55-
voices.length > 0 &&
56-
!voices.find((v) => v.voiceURI === voiceURI)
57-
) {
58-
const defaultVoice = voices.find((v) => v.default) ?? voices[0]
59-
if (defaultVoice) {
60-
setVoiceURI(defaultVoice.voiceURI)
47+
if (!isLoadingVoices && voices.length > 0) {
48+
if (!voiceURI) {
49+
const defaultVoice = voices.find((v) => v.default) ?? voices[0]
50+
if (defaultVoice) {
51+
setVoiceURI(defaultVoice.voiceURI)
52+
}
53+
} else {
54+
const voiceExists = voices.some((v) => v.voiceURI === voiceURI)
55+
if (!voiceExists) {
56+
const defaultVoice = voices.find((v) => v.default) ?? voices[0]
57+
if (defaultVoice) {
58+
setVoiceURI(defaultVoice.voiceURI)
59+
}
60+
}
6161
}
6262
}
63-
}, [voices, voiceURI, setVoiceURI])
64-
65-
const selectedVoice = voices.find((v) => v.voiceURI === voiceURI)
63+
}, [voices, voiceURI, setVoiceURI, isLoadingVoices])
6664

6765
return (
6866
<div className="mx-auto space-y-4">
@@ -93,33 +91,12 @@ export const SpeechSettings = () => {
9391
</Badge>
9492
)}
9593
</div>
96-
<Select
97-
value={voiceURI}
98-
onValueChange={setVoiceURI}
99-
aria-label="Select speech synthesis voice">
100-
<SelectTrigger className="h-9">
101-
<SelectValue placeholder="Select a voice" />
102-
</SelectTrigger>
103-
<SelectContent>
104-
{voices.map((voice) => (
105-
<SelectItem key={voice.voiceURI} value={voice.voiceURI}>
106-
<div className="flex w-full items-center justify-between">
107-
<span>{voice.name}</span>
108-
<div className="ml-3 flex items-center gap-2">
109-
<Badge variant="secondary" className="text-xs">
110-
{voice.lang}
111-
</Badge>
112-
{voice.default && (
113-
<Badge variant="outline" className="text-xs">
114-
default
115-
</Badge>
116-
)}
117-
</div>
118-
</div>
119-
</SelectItem>
120-
))}
121-
</SelectContent>
122-
</Select>
94+
<VoiceSelector
95+
voices={voices}
96+
selectedVoiceURI={voiceURI || null}
97+
onVoiceChange={setVoiceURI}
98+
isLoading={isLoadingVoices}
99+
/>
123100
</div>
124101

125102
{/* Rate Control */}
@@ -218,6 +195,9 @@ export const SpeechSettings = () => {
218195
className="rounded-md bg-secondary px-3 py-1 text-xs transition-colors hover:bg-secondary/80"
219196
onClick={() => {
220197
if ("speechSynthesis" in window) {
198+
// Cancel any ongoing speech
199+
window.speechSynthesis.cancel()
200+
221201
const textToSpeak =
222202
testText.trim() ||
223203
"Hello, this is a test of your speech settings."
@@ -226,12 +206,19 @@ export const SpeechSettings = () => {
226206
)
227207
utterance.rate = rate
228208
utterance.pitch = pitch
209+
210+
// Use the selected voice if available
229211
if (selectedVoice) {
230-
utterance.voice =
231-
window.speechSynthesis
232-
.getVoices()
233-
.find((v) => v.voiceURI === voiceURI) || null
212+
// Get fresh voice reference from speechSynthesis API
213+
// This ensures compatibility across browsers
214+
const freshVoice = window.speechSynthesis
215+
.getVoices()
216+
.find((v) => v.voiceURI === selectedVoice.voiceURI)
217+
if (freshVoice) {
218+
utterance.voice = freshVoice
219+
}
234220
}
221+
235222
window.speechSynthesis.speak(utterance)
236223
}
237224
}}>
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { useMemo, useState } from "react"
2+
import { Badge } from "@/components/ui/badge"
3+
import { Button } from "@/components/ui/button"
4+
import {
5+
Command,
6+
CommandEmpty,
7+
CommandGroup,
8+
CommandInput,
9+
CommandItem,
10+
CommandList
11+
} from "@/components/ui/command"
12+
import {
13+
Popover,
14+
PopoverContent,
15+
PopoverTrigger
16+
} from "@/components/ui/popover"
17+
import { Check, ChevronsUpDown, Loader2, Mic } from "@/lib/lucide-icon"
18+
import { cn } from "@/lib/utils"
19+
20+
interface VoiceSelectorProps {
21+
voices: SpeechSynthesisVoice[]
22+
selectedVoiceURI: string | null
23+
onVoiceChange: (voiceURI: string) => void
24+
isLoading?: boolean
25+
}
26+
27+
export const VoiceSelector = ({
28+
voices,
29+
selectedVoiceURI,
30+
onVoiceChange,
31+
isLoading = false
32+
}: VoiceSelectorProps) => {
33+
const [open, setOpen] = useState(false)
34+
const [searchQuery, setSearchQuery] = useState("")
35+
36+
const selectedVoice = useMemo(
37+
() => voices.find((v) => v.voiceURI === selectedVoiceURI),
38+
[voices, selectedVoiceURI]
39+
)
40+
41+
// Group voices by language for better organization
42+
const groupedVoices = useMemo(() => {
43+
const grouped = voices.reduce(
44+
(acc, voice) => {
45+
const lang = voice.lang || "Unknown"
46+
if (!acc[lang]) {
47+
acc[lang] = []
48+
}
49+
acc[lang].push(voice)
50+
return acc
51+
},
52+
{} as Record<string, SpeechSynthesisVoice[]>
53+
)
54+
55+
// Sort languages alphabetically
56+
return Object.keys(grouped)
57+
.sort()
58+
.map((lang) => ({
59+
lang,
60+
voices: grouped[lang].sort((a, b) => a.name.localeCompare(b.name))
61+
}))
62+
}, [voices])
63+
64+
// Filter voices based on search query
65+
const filteredGroups = useMemo(() => {
66+
if (!searchQuery.trim()) {
67+
return groupedVoices
68+
}
69+
70+
const query = searchQuery.toLowerCase()
71+
return groupedVoices
72+
.map((group) => ({
73+
...group,
74+
voices: group.voices.filter(
75+
(voice) =>
76+
voice.name.toLowerCase().includes(query) ||
77+
voice.lang.toLowerCase().includes(query) ||
78+
voice.voiceURI.toLowerCase().includes(query)
79+
)
80+
}))
81+
.filter((group) => group.voices.length > 0)
82+
}, [groupedVoices, searchQuery])
83+
84+
return (
85+
<Popover open={open} onOpenChange={setOpen}>
86+
<PopoverTrigger asChild>
87+
<Button
88+
variant="outline"
89+
role="combobox"
90+
aria-expanded={open}
91+
aria-label="Select voice"
92+
className={cn(
93+
"h-10 w-full justify-between gap-2 border-input bg-background px-3 text-sm font-normal shadow-sm transition-colors",
94+
"hover:bg-accent hover:text-accent-foreground",
95+
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
96+
"disabled:cursor-not-allowed disabled:opacity-50",
97+
open && "ring-1 ring-ring"
98+
)}
99+
disabled={isLoading || voices.length === 0}>
100+
<div className="flex items-center gap-2 min-w-0 flex-1">
101+
{isLoading ? (
102+
<>
103+
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground shrink-0" />
104+
<span className="text-muted-foreground text-sm truncate">
105+
Loading voices...
106+
</span>
107+
</>
108+
) : selectedVoice ? (
109+
<>
110+
<span className="text-sm font-medium truncate">
111+
{selectedVoice.name}
112+
</span>
113+
<Badge
114+
variant="secondary"
115+
className="text-[10px] h-5 px-1.5 font-normal shrink-0">
116+
{selectedVoice.lang}
117+
</Badge>
118+
</>
119+
) : (
120+
<span className="text-muted-foreground text-sm truncate">
121+
Select a voice...
122+
</span>
123+
)}
124+
</div>
125+
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
126+
</Button>
127+
</PopoverTrigger>
128+
<PopoverContent
129+
className="w-[440px] p-0 shadow-lg"
130+
align="center"
131+
sideOffset={6}>
132+
<Command className="rounded-lg border-0" shouldFilter={false}>
133+
<div className="flex items-center border-b px-3">
134+
<CommandInput
135+
placeholder="Search by name, language, or URI..."
136+
value={searchQuery}
137+
onValueChange={setSearchQuery}
138+
className="h-11 border-0 focus:outline-none focus:ring-0"
139+
/>
140+
</div>
141+
<CommandList className="max-h-[320px] overflow-y-auto">
142+
<CommandEmpty className="py-8">
143+
<div className="flex flex-col items-center gap-2 text-center">
144+
<div className="rounded-full bg-muted p-3">
145+
<Mic className="h-5 w-5 text-muted-foreground" />
146+
</div>
147+
<div className="space-y-1">
148+
<p className="text-sm font-medium">No voices found</p>
149+
<p className="text-xs text-muted-foreground">
150+
{searchQuery
151+
? "Try a different search term"
152+
: "No voices available on this device"}
153+
</p>
154+
</div>
155+
</div>
156+
</CommandEmpty>
157+
<div className="p-1">
158+
{filteredGroups.map((group, groupIndex) => (
159+
<CommandGroup
160+
key={group.lang}
161+
className={cn("px-0 py-0", groupIndex > 0 && "mt-2")}>
162+
<div className="flex items-center justify-between px-3 py-2 mb-1">
163+
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
164+
{group.lang}
165+
</span>
166+
<Badge
167+
variant="secondary"
168+
className="text-[10px] h-4 px-1.5 font-normal">
169+
{group.voices.length}
170+
</Badge>
171+
</div>
172+
{group.voices.map((voice) => {
173+
const isSelected = selectedVoiceURI === voice.voiceURI
174+
return (
175+
<CommandItem
176+
key={voice.voiceURI}
177+
value={voice.voiceURI}
178+
onSelect={() => {
179+
onVoiceChange(voice.voiceURI)
180+
setOpen(false)
181+
setSearchQuery("")
182+
}}
183+
className={cn(
184+
"group mx-1 flex items-center gap-3 rounded-md px-3 py-2.5 cursor-pointer transition-all",
185+
"aria-selected:bg-accent/50",
186+
isSelected
187+
? "bg-accent text-accent-foreground shadow-sm"
188+
: "hover:bg-accent/50"
189+
)}>
190+
<div className="flex flex-1 items-center justify-between gap-3 min-w-0">
191+
<div className="flex flex-col min-w-0">
192+
<span
193+
className={cn(
194+
"text-sm truncate leading-tight transition-colors",
195+
isSelected ? "font-semibold" : "font-medium"
196+
)}>
197+
{voice.name}
198+
</span>
199+
{voice.localService === false && (
200+
<span className="text-[10px] text-muted-foreground mt-0.5">
201+
Network voice
202+
</span>
203+
)}
204+
</div>
205+
<div className="flex items-center gap-2 shrink-0">
206+
{voice.default && (
207+
<Badge
208+
variant="outline"
209+
className="text-[10px] h-4 px-1.5 font-normal border-muted-foreground/30">
210+
default
211+
</Badge>
212+
)}
213+
{isSelected && (
214+
<div className="flex items-center justify-center">
215+
<Check className="h-4 w-4 text-primary" />
216+
</div>
217+
)}
218+
</div>
219+
</div>
220+
</CommandItem>
221+
)
222+
})}
223+
</CommandGroup>
224+
))}
225+
</div>
226+
</CommandList>
227+
</Command>
228+
</PopoverContent>
229+
</Popover>
230+
)
231+
}

src/features/chat/hooks/use-speech-synthesis.ts

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,8 @@ import { markdownToSpeechText } from "@/lib/utils"
55

66
export const useSpeechSynthesis = () => {
77
const [speaking, setSpeaking] = useState(false)
8-
const [isLoadingVoices, setIsLoadingVoices] = useState(true)
9-
const voices = useVoices()
8+
const { voices, isLoading: isLoadingVoices } = useVoices()
109
const { rate, pitch, voiceURI } = useSpeechSettings()
11-
useEffect(() => {
12-
if (voices.length > 0 && isLoadingVoices) {
13-
setIsLoadingVoices(false)
14-
}
15-
}, [voices, isLoadingVoices])
1610

1711
useEffect(() => {
1812
const handleEnd = () => setSpeaking(false)

0 commit comments

Comments
 (0)