feat(voice): dynamic settings voice picker with safe fallback + preview (#690)

* feat(voice): dynamic settings voice picker with safe fallback + preview

* fix(voice): honor picker with configured agent and stop preview on unmount

* fix(voice): apply PR review feedback for agent selection and preview cleanup
This commit is contained in:
HeavyGee
2026-05-27 11:17:01 +08:00
committed by GitHub
parent de5dc97988
commit d5a67b717c
16 changed files with 921 additions and 50 deletions
+2
View File
@@ -403,6 +403,8 @@ export default {
'settings.voice.title': 'Voice Assistant',
'settings.voice.language': 'Voice Language',
'settings.voice.autoDetect': 'Auto-detect',
'settings.voice.voice': 'Voice',
'settings.voice.voiceDefault': 'Default',
'settings.about.title': 'About',
'settings.about.website': 'Website',
'settings.about.appVersion': 'App Version',
+2
View File
@@ -405,6 +405,8 @@ export default {
'settings.voice.title': '语音助手',
'settings.voice.language': '语音语言',
'settings.voice.autoDetect': '自动检测',
'settings.voice.voice': '声音',
'settings.voice.voiceDefault': '默认',
'settings.about.title': '关于',
'settings.about.website': '官方网站',
'settings.about.appVersion': '应用版本',
+3 -2
View File
@@ -40,11 +40,12 @@ export function VoiceProvider({ children }: { children: ReactNode }) {
setCurrentSessionId(sessionId)
const initialContext = voiceHooks.onVoiceStarted(sessionId)
// Read voice language preference from localStorage
// Read voice preferences from localStorage
const voiceLang = localStorage.getItem('hapi-voice-lang')
const elevenLabsLang = getElevenLabsCodeFromPreference(voiceLang)
const voiceId = localStorage.getItem('hapi-voice-id') ?? undefined
await startRealtimeSession(sessionId, initialContext, elevenLabsLang)
await startRealtimeSession(sessionId, initialContext, elevenLabsLang, voiceId)
}, [])
const stopVoice = useCallback(async () => {
+14
View File
@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest'
import { getFallbackVoices } from './voices'
describe('getFallbackVoices', () => {
it('returns localized Chinese aliases for zh-CN fallback list', () => {
const voices = getFallbackVoices('zh-CN')
expect(voices.some(v => /杰西卡|瑞秋|贝拉|乔什|亚当/.test(v.name))).toBe(true)
})
it('keeps canonical English names for en fallback list', () => {
const voices = getFallbackVoices('en')
expect(voices.some(v => v.name === 'Jessica')).toBe(true)
})
})
+65
View File
@@ -0,0 +1,65 @@
import type { Locale } from '@/lib/use-translation'
export interface Voice {
id: string
name: string
gender: 'female' | 'male'
description: string
aliases?: Partial<Record<Locale, string>>
}
export const VOICES: Voice[] = [
{
id: 'cgSgspJ2msm6clMCkdW9',
name: 'Jessica',
aliases: { 'zh-CN': '杰西卡' },
gender: 'female',
description: 'Default — warm, conversational',
},
{
id: '21m00Tcm4TlvDq8ikWAM',
name: 'Rachel',
aliases: { 'zh-CN': '瑞秋' },
gender: 'female',
description: 'Calm, professional',
},
{
id: 'EXAVITQu4vr4xnSDxMaL',
name: 'Bella',
aliases: { 'zh-CN': '贝拉' },
gender: 'female',
description: 'Soft, warm',
},
{
id: 'TxGEqnHWrfWFTfGW9XjX',
name: 'Josh',
aliases: { 'zh-CN': '乔什' },
gender: 'male',
description: 'Deep, smooth',
},
{
id: 'pNInz6obpgDQGcFmaJgB',
name: 'Adam',
aliases: { 'zh-CN': '亚当' },
gender: 'male',
description: 'Narration, clear',
},
{ id: 'AZnzlk1XvdvUeBnXmlld', name: 'Domi', gender: 'female', description: 'Strong, confident' },
{ id: 'MF3mGyEYCl7XYWbV9V6O', name: 'Elli', gender: 'female', description: 'Young, clear' },
{ id: 'VR6AewLTigWG4xSOukaG', name: 'Arnold', gender: 'male', description: 'Crisp, authoritative' },
{ id: 'ErXwobaYiN019PkySvjV', name: 'Antoni', gender: 'male', description: 'Well-rounded' },
{ id: 'yoZ06aMxZJJ28mfd3POQ', name: 'Sam', gender: 'male', description: 'Raspy, dynamic' },
]
export const DEFAULT_VOICE_ID = 'cgSgspJ2msm6clMCkdW9'
export function getVoiceById(id: string | null): Voice | undefined {
return VOICES.find(v => v.id === id)
}
export function getFallbackVoices(locale: Locale): Voice[] {
return VOICES.map((voice) => ({
...voice,
name: voice.aliases?.[locale] ?? voice.name,
}))
}