Skip to content

Commit d54460d

Browse files
committed
feat: add selectable Kokoro Chinese voices
1 parent aefd316 commit d54460d

9 files changed

Lines changed: 285 additions & 20 deletions

File tree

‎src-tauri/src/voice.rs‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,13 @@ pub async fn download_voice_asset(
7070
let kind = VoiceAssetKind::parse(&kind)?;
7171
let runtime = state.inner().clone();
7272
tauri::async_runtime::spawn_blocking(move || {
73-
runtime
74-
.assets
75-
.install(kind, github_mirror.as_deref(), |progress: VoiceAssetProgress| {
73+
runtime.assets.install(
74+
kind,
75+
github_mirror.as_deref(),
76+
|progress: VoiceAssetProgress| {
7677
let _ = app.emit("voice-asset-progress", progress);
77-
})?;
78+
},
79+
)?;
7880
Ok::<VoiceRuntimeStatus, String>(status(&runtime))
7981
})
8082
.await
@@ -111,11 +113,14 @@ pub fn speak_text(
111113
state: tauri::State<Arc<VoiceRuntime>>,
112114
text: String,
113115
speed: Option<f32>,
116+
speaker_id: Option<i32>,
114117
) -> Result<u64, String> {
115118
let callback = Arc::new(move |event: SpeechPlaybackEvent| {
116119
let _ = app.emit("voice-playback", event);
117120
});
118-
state.speech.speak(text, speed.unwrap_or(1.0), callback)
121+
state
122+
.speech
123+
.speak(text, speed.unwrap_or(1.0), speaker_id, callback)
119124
}
120125

121126
#[tauri::command]

‎src/components/chat/shared/SpeechButton.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useEffect, useState } from 'react'
22
import { LoaderCircle, Square, Volume2 } from 'lucide-react'
3+
import { useAppStore } from '../../../stores/appStore'
34
import { openVoiceInputSettings } from '../../../utils/voiceInput'
45
import {
56
getVoiceRuntimeStatus,
@@ -16,6 +17,7 @@ interface SpeechButtonProps {
1617
}
1718

1819
export function SpeechButton({ text, label }: SpeechButtonProps) {
20+
const kokoroSpeakerId = useAppStore((state) => state.kokoroSpeakerId)
1921
const [installed, setInstalled] = useState(false)
2022
const [requestId, setRequestId] = useState<number | null>(null)
2123
const [event, setEvent] = useState<SpeechPlaybackEvent | null>(null)
@@ -51,7 +53,7 @@ export function SpeechButton({ text, label }: SpeechButtonProps) {
5153
setRequestId(null)
5254
return
5355
}
54-
const nextRequest = await speakText(text)
56+
const nextRequest = await speakText(text, 1, kokoroSpeakerId)
5557
setRequestId(nextRequest)
5658
}
5759

‎src/components/settings/VoiceRuntimeSettings.tsx‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useMemo, useState } from 'react'
22
import {
33
Activity,
4+
Check,
45
Download,
56
LoaderCircle,
67
Radio,
@@ -10,6 +11,10 @@ import {
1011
X,
1112
} from 'lucide-react'
1213
import i18n from '../../i18n'
14+
import {
15+
getKokoroVoice,
16+
KOKORO_CHINESE_VOICES,
17+
} from '../../config/kokoroVoices'
1318
import { useAppStore } from '../../stores/appStore'
1419
import {
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) => {
8194
export 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 && (

‎src/config/kokoroVoices.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
export const KOKORO_CHINESE_VOICES = [
2+
{ id: 45, nameZh: '晓北', nameEn: 'Xiaobei', gender: 'female' },
3+
{ id: 46, nameZh: '晓妮', nameEn: 'Xiaoni', gender: 'female' },
4+
{ id: 47, nameZh: '晓晓', nameEn: 'Xiaoxiao', gender: 'female' },
5+
{ id: 48, nameZh: '晓伊', nameEn: 'Xiaoyi', gender: 'female' },
6+
{ id: 49, nameZh: '云健', nameEn: 'Yunjian', gender: 'male' },
7+
{ id: 50, nameZh: '云希', nameEn: 'Yunxi', gender: 'male' },
8+
{ id: 51, nameZh: '云夏', nameEn: 'Yunxia', gender: 'male' },
9+
{ id: 52, nameZh: '云扬', nameEn: 'Yunyang', gender: 'male' },
10+
] as const
11+
12+
export type KokoroChineseVoice = (typeof KOKORO_CHINESE_VOICES)[number]
13+
export type KokoroSpeakerId = KokoroChineseVoice['id']
14+
15+
export const DEFAULT_KOKORO_SPEAKER_ID: KokoroSpeakerId = 47
16+
17+
export function isKokoroSpeakerId(value: unknown): value is KokoroSpeakerId {
18+
return KOKORO_CHINESE_VOICES.some((voice) => voice.id === value)
19+
}
20+
21+
export function normalizeKokoroSpeakerId(value: unknown): KokoroSpeakerId {
22+
return isKokoroSpeakerId(value) ? value : DEFAULT_KOKORO_SPEAKER_ID
23+
}
24+
25+
export function getKokoroVoice(id: unknown): KokoroChineseVoice {
26+
const normalized = normalizeKokoroSpeakerId(id)
27+
return KOKORO_CHINESE_VOICES.find((voice) => voice.id === normalized)!
28+
}

‎src/stores/appStore.ts‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type { Session, ProviderConfig, Companion, ProviderInfo, KiloMessage, Mes
55
import * as db from '../utils/database'
66
import { enrichSession } from '../utils/sessionMeta'
77
import { DEFAULT_COLOR_SCHEME } from '../themes'
8+
import {
9+
DEFAULT_KOKORO_SPEAKER_ID,
10+
normalizeKokoroSpeakerId,
11+
type KokoroSpeakerId,
12+
} from '../config/kokoroVoices'
813

914
interface AppState {
1015
// Sessions (cached from DB)
@@ -35,6 +40,7 @@ interface AppState {
3540
workspaceCollapsed: boolean
3641
workspaceDir: string | null
3742
fileTreeVersion: number
43+
kokoroSpeakerId: KokoroSpeakerId
3844

3945
// Skill mirror config
4046
githubMirror: string
@@ -88,6 +94,7 @@ interface AppState {
8894
toggleWorkspace: () => void
8995
setWorkspaceDir: (dir: string | null) => void
9096
incrementFileTreeVersion: () => void
97+
setKokoroSpeakerId: (speakerId: KokoroSpeakerId) => void
9198

9299
// Actions - Companion
93100
setCompanion: (companion: Companion | null) => void
@@ -127,6 +134,7 @@ export const useAppStore = create<AppState>()(
127134
workspaceCollapsed: false,
128135
workspaceDir: null,
129136
fileTreeVersion: 0,
137+
kokoroSpeakerId: DEFAULT_KOKORO_SPEAKER_ID,
130138
githubMirror: 'https://mirror.ghproxy.com/https://github.com/',
131139
npmRegistryMirror: 'https://registry.npmmirror.com',
132140
companion: null,
@@ -505,6 +513,9 @@ export const useAppStore = create<AppState>()(
505513
toggleWorkspace: () => set((state) => ({ workspaceCollapsed: !state.workspaceCollapsed })),
506514
setWorkspaceDir: (dir) => set({ workspaceDir: dir }),
507515
incrementFileTreeVersion: () => set((state) => ({ fileTreeVersion: state.fileTreeVersion + 1 })),
516+
setKokoroSpeakerId: (speakerId) => set({
517+
kokoroSpeakerId: normalizeKokoroSpeakerId(speakerId),
518+
}),
508519

509520
// ---- Companion actions ----
510521

@@ -525,25 +536,33 @@ export const useAppStore = create<AppState>()(
525536
workspaceDir: state.workspaceDir,
526537
githubMirror: state.githubMirror,
527538
npmRegistryMirror: state.npmRegistryMirror,
539+
kokoroSpeakerId: state.kokoroSpeakerId,
528540
}),
529-
version: 5,
541+
version: 6,
530542
migrate: (persistedState, version) => {
543+
let nextState = persistedState as any
531544
if (version < 4) {
532-
return {
533-
...(persistedState as any),
545+
nextState = {
546+
...nextState,
534547
sessions: [],
535548
sessionMessages: {},
536549
streamingMessage: {},
537550
}
538551
}
539552
if (version < 5) {
540553
// Messages are loaded from SQLite; do not keep full chat history in localStorage.
541-
return {
542-
...(persistedState as any),
554+
nextState = {
555+
...nextState,
543556
sessionMessages: {},
544557
}
545558
}
546-
return persistedState as any
559+
if (version < 6) {
560+
nextState = {
561+
...nextState,
562+
kokoroSpeakerId: normalizeKokoroSpeakerId(nextState.kokoroSpeakerId),
563+
}
564+
}
565+
return nextState
547566
},
548567
},
549568
),

‎src/utils/voiceConversation.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { speakText, stopSpeaking } from './voiceRuntime'
2+
import { useAppStore } from '../stores/appStore'
23

34
export type VoiceConversationState =
45
| 'inactive'
@@ -72,7 +73,7 @@ export const speakVoiceConversationResponse = async (markdown: string) => {
7273
}
7374
setVoiceConversationState('speaking')
7475
try {
75-
await speakText(text)
76+
await speakText(text, 1, useAppStore.getState().kokoroSpeakerId)
7677
return true
7778
} catch (reason) {
7879
setVoiceConversation(false, 'error', String(reason))

‎src/utils/voiceRuntime.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { invoke } from '@tauri-apps/api/core'
22
import { listen } from '@tauri-apps/api/event'
3+
import {
4+
DEFAULT_KOKORO_SPEAKER_ID,
5+
type KokoroSpeakerId,
6+
} from '../config/kokoroVoices'
37

48
export type VoiceAssetKind = 'streaming-asr' | 'vad' | 'tts'
59

@@ -63,8 +67,11 @@ export const cancelVoiceAssetDownload = () =>
6367
export const deleteVoiceAsset = (kind: VoiceAssetKind) =>
6468
invoke<VoiceRuntimeStatus>('delete_voice_asset', { kind })
6569

66-
export const speakText = (text: string, speed = 1) =>
67-
invoke<number>('speak_text', { text, speed })
70+
export const speakText = (
71+
text: string,
72+
speed = 1,
73+
speakerId: KokoroSpeakerId = DEFAULT_KOKORO_SPEAKER_ID,
74+
) => invoke<number>('speak_text', { text, speed, speakerId })
6875

6976
export const stopSpeaking = () =>
7077
invoke<void>('stop_speaking')

0 commit comments

Comments
 (0)