11import { useEffect , useMemo , useState } from 'react'
22import {
33 Activity ,
4+ Check ,
45 Download ,
56 LoaderCircle ,
67 Radio ,
@@ -10,6 +11,10 @@ import {
1011 X ,
1112} from 'lucide-react'
1213import i18n from '../../i18n'
14+ import {
15+ getKokoroVoice ,
16+ KOKORO_CHINESE_VOICES ,
17+ } from '../../config/kokoroVoices'
1318import { useAppStore } from '../../stores/appStore'
1419import {
1520 cancelVoiceAssetDownload ,
@@ -38,6 +43,10 @@ const text = {
3843 vadHint : 'Silero detects speech boundaries for continuous conversation.' ,
3944 tts : 'Text-to-speech' ,
4045 ttsHint : 'Kokoro speaks assistant responses locally in Chinese and English.' ,
46+ voice : 'Chinese voice' ,
47+ voiceHint : 'The selected voice is used for previews, read-aloud, and voice conversations.' ,
48+ female : 'Female' ,
49+ male : 'Male' ,
4150 download : 'Download' ,
4251 remove : 'Delete' ,
4352 cancel : 'Cancel' ,
@@ -59,6 +68,10 @@ const text = {
5968 vadHint : 'Silero 用于判断开始说话、结束说话和连续对话轮次。' ,
6069 tts : '文字转语音' ,
6170 ttsHint : 'Kokoro 在本地朗读助手的中文和英文回答。' ,
71+ voice : '中文音色' ,
72+ voiceHint : '选择后会统一用于试听、消息朗读和实时语音对话。' ,
73+ female : '女声' ,
74+ male : '男声' ,
6275 download : '下载' ,
6376 remove : '删除' ,
6477 cancel : '取消' ,
@@ -81,6 +94,9 @@ const formatBytes = (bytes: number) => {
8194export function VoiceRuntimeSettings ( ) {
8295 const copy = i18n . resolvedLanguage ?. startsWith ( 'zh' ) ? text . zh : text . en
8396 const githubMirror = useAppStore ( ( state ) => state . githubMirror )
97+ const kokoroSpeakerId = useAppStore ( ( state ) => state . kokoroSpeakerId )
98+ const setKokoroSpeakerId = useAppStore ( ( state ) => state . setKokoroSpeakerId )
99+ const selectedVoice = getKokoroVoice ( kokoroSpeakerId )
84100 const [ status , setStatus ] = useState < VoiceRuntimeStatus | null > ( null )
85101 const [ progress , setProgress ] = useState < VoiceAssetProgress | null > ( null )
86102 const [ busy , setBusy ] = useState < VoiceAssetKind | null > ( null )
@@ -155,7 +171,7 @@ export function VoiceRuntimeSettings() {
155171 const sample = i18n . resolvedLanguage ?. startsWith ( 'zh' )
156172 ? '你好,我是 S-Loop。本地语音播放已经准备好了。'
157173 : 'Hello, this is S-Loop. Local voice playback is ready.'
158- await speakText ( sample )
174+ await speakText ( sample , 1 , kokoroSpeakerId )
159175 }
160176 } catch ( reason ) {
161177 setError ( String ( reason ) )
@@ -218,6 +234,64 @@ export function VoiceRuntimeSettings() {
218234 { hint }
219235 </ p >
220236
237+ { asset ?. installed && kind === 'tts' && (
238+ < fieldset className = "mt-4 rounded-lg border border-border/80 bg-surface p-3" >
239+ < legend className = "sr-only" > { copy . voice } </ legend >
240+ < div className = "flex items-start justify-between gap-3" >
241+ < div >
242+ < div className = "text-xs font-semibold text-text" >
243+ { copy . voice }
244+ </ div >
245+ < p className = "mt-0.5 text-[11px] leading-4 text-text-tertiary" >
246+ { copy . voiceHint }
247+ </ p >
248+ </ div >
249+ < span className = "shrink-0 rounded-full bg-accent-subtle px-2 py-1 text-[10px] font-bold text-accent" >
250+ Kokoro · { selectedVoice . id }
251+ </ span >
252+ </ div >
253+ < div
254+ className = "mt-3 grid grid-cols-2 gap-2 sm:grid-cols-4"
255+ role = "radiogroup"
256+ aria-label = { copy . voice }
257+ >
258+ { KOKORO_CHINESE_VOICES . map ( ( voice ) => {
259+ const selected = voice . id === kokoroSpeakerId
260+ const gender = voice . gender === 'female' ? copy . female : copy . male
261+ return (
262+ < button
263+ key = { voice . id }
264+ type = "button"
265+ role = "radio"
266+ aria-checked = { selected }
267+ aria-label = { `${ voice . nameZh } · ${ gender } ` }
268+ onClick = { ( ) => setKokoroSpeakerId ( voice . id ) }
269+ className = { `relative rounded-lg border px-3 py-2.5 text-left transition-colors ${
270+ selected
271+ ? 'border-accent bg-accent-subtle text-accent'
272+ : 'border-border bg-surface-secondary/60 text-text hover:border-accent/45 hover:bg-surface-secondary'
273+ } `}
274+ >
275+ { selected && (
276+ < Check
277+ size = { 12 }
278+ className = "absolute right-2 top-2"
279+ aria-hidden = "true"
280+ />
281+ ) }
282+ < span className = "block text-sm font-semibold" >
283+ { voice . nameZh }
284+ </ span >
285+ < span className = "mt-0.5 block text-[10px] opacity-65" >
286+ { gender } · ID { voice . id }
287+ </ span >
288+ </ button >
289+ )
290+ } ) }
291+ </ div >
292+ </ fieldset >
293+ ) }
294+
221295 { downloading && currentProgress && (
222296 < div className = "mt-3" >
223297 < div className = "mb-1 flex justify-between text-[11px] text-text-tertiary" >
@@ -277,7 +351,7 @@ export function VoiceRuntimeSettings() {
277351 ? copy . installing
278352 : playback ?. state === 'speaking'
279353 ? copy . stop
280- : copy . test }
354+ : ` ${ copy . test } · ${ selectedVoice . nameZh } ` }
281355 </ button >
282356 ) }
283357 { asset ?. installed && (
0 commit comments