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
+174 -3
View File
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react'
import { I18nContext, I18nProvider } from '@/lib/i18n-context'
import { en } from '@/lib/locales'
import { PROTOCOL_VERSION } from '@hapi/protocol'
@@ -95,6 +95,40 @@ vi.mock('@/lib/languages', () => ({
getLanguageDisplayName: (lang: { code: string | null; name: string }) => lang.name,
}))
// Use vi.hoisted so these mocks are available when vi.mock factories run
const { mockFetchVoices, mockApi } = vi.hoisted(() => {
const mockFetchVoices = vi.fn(() => Promise.resolve<unknown[]>([]))
const mockApi = {
fetchVoices: vi.fn(() => Promise.resolve({ voices: [] })),
}
return { mockFetchVoices, mockApi }
})
// Mock static voices list
vi.mock('@/lib/voices', () => ({
VOICES: [{ id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' }],
DEFAULT_VOICE_ID: 'voice1',
getVoiceById: (id: string | null) =>
id === 'voice1' ? { id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' } : undefined,
getFallbackVoices: () => [{ id: 'voice1', name: 'Jessica', gender: 'female', description: 'Default' }],
}))
// Mock fetchVoices to return a resolved list by default
vi.mock('@/api/voice', () => ({
fetchVoices: mockFetchVoices,
fetchVoiceToken: vi.fn(() => Promise.resolve({ allowed: true, token: 'tok' })),
}))
// Mock useAppContext so the page doesn't throw "AppContext is not available"
vi.mock('@/lib/app-context', () => ({
useAppContext: () => ({ api: mockApi, token: 'test', baseUrl: '' }),
AppContextProvider: ({ children }: { children: React.ReactNode }) => children,
}))
afterEach(() => {
cleanup()
})
function renderWithProviders(ui: React.ReactElement) {
return render(
<I18nProvider>
@@ -117,9 +151,11 @@ function renderWithSpyT(ui: React.ReactElement) {
describe('SettingsPage', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset fetchVoices mock to return empty list by default
mockFetchVoices.mockResolvedValue([])
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(() => 'en'),
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
@@ -231,4 +267,139 @@ describe('SettingsPage', () => {
expect(calledKeys).toContain('settings.chat.userMessageBackground')
expect(calledKeys).toContain('settings.chat.surfaceColor.default')
})
// Voice picker tests
it('renders the Voice section with "Voice" label', () => {
renderWithProviders(<SettingsPage />)
expect(screen.getAllByText('Voice').length).toBeGreaterThanOrEqual(1)
})
it('uses correct i18n keys for the voice picker', () => {
const spyT = renderWithSpyT(<SettingsPage />)
const calledKeys = spyT.mock.calls.map((call) => call[0])
expect(calledKeys).toContain('settings.voice.voice')
expect(calledKeys).toContain('settings.voice.voiceDefault')
})
it('voice picker shows "Default" option when opened', () => {
renderWithProviders(<SettingsPage />)
// The current value "Default" is shown in the closed picker button
expect(screen.getAllByText('Default').length).toBeGreaterThanOrEqual(1)
})
it('opens voice picker and shows "Default" option in the list', () => {
renderWithProviders(<SettingsPage />)
// Click the voice picker button (aria-label target via the label text)
const voiceButtons = screen.getAllByRole('button', { name: /Default/i })
// Find the button that has aria-haspopup — that's the voice picker trigger
const pickerButton = voiceButtons.find(btn => btn.getAttribute('aria-haspopup') === 'listbox')
expect(pickerButton).toBeTruthy()
fireEvent.click(pickerButton!)
// The listbox should appear with a "Default" option inside
const listbox = screen.getByRole('listbox', { name: 'Voice' })
expect(listbox).toBeInTheDocument()
expect(listbox.textContent).toContain('Default')
})
it('shows dynamic voices in picker when fetchVoices returns a list', async () => {
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' },
{ id: 'dyn2', name: 'Bob', previewUrl: 'https://example.com/bob.mp3', category: 'premade' },
])
renderWithProviders(<SettingsPage />)
const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
await waitFor(() => {
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(screen.getByText('Bob')).toBeInTheDocument()
})
})
it('shows a disabled preview button with tooltip when previewUrl is missing', async () => {
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' },
])
renderWithProviders(<SettingsPage />)
const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
const previewButton = await screen.findByLabelText('Preview voice')
expect(previewButton).toBeDisabled()
expect(previewButton).toHaveAttribute('title', 'Preview unavailable without an ElevenLabs API key')
})
it('shows a play button for voices with a previewUrl', async () => {
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: 'https://example.com/alice.mp3', category: 'premade' },
])
renderWithProviders(<SettingsPage />)
const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
await screen.findByText('Alice')
expect(screen.getByLabelText('Preview voice')).toBeInTheDocument()
expect(screen.getByLabelText('Preview voice')).not.toBeDisabled()
})
it('stops preview audio on unmount', async () => {
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: 'https://example.com/alice.mp3', category: 'premade' },
])
const pause = vi.fn()
const play = vi.fn(() => Promise.resolve())
const addEventListener = vi.fn()
class MockAudio {
pause = pause
play = play
addEventListener = addEventListener
constructor(_url: string) {}
}
const OriginalAudio = globalThis.Audio
const OriginalWindowAudio = window.Audio
// @ts-expect-error test override
globalThis.Audio = MockAudio
// @ts-expect-error test override
window.Audio = MockAudio
const view = renderWithProviders(<SettingsPage />)
const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
const aliceLabel = await screen.findByText('Alice')
const optionRow = aliceLabel.closest('[role="option"]')
expect(optionRow).toBeTruthy()
const enabledPreview = optionRow?.querySelector('button[aria-label="Preview voice"]') as HTMLButtonElement | null
expect(enabledPreview).toBeTruthy()
expect(enabledPreview?.disabled).toBe(false)
fireEvent.click(enabledPreview as HTMLElement)
view.unmount()
expect(pause).toHaveBeenCalled()
globalThis.Audio = OriginalAudio
window.Audio = OriginalWindowAudio
})
it('selecting a voice calls localStorage.setItem with the voice id', async () => {
mockFetchVoices.mockResolvedValue([
{ id: 'dyn1', name: 'Alice', previewUrl: '', category: 'premade' },
])
renderWithProviders(<SettingsPage />)
const pickerButton = screen.getByRole('button', { name: /Voice\s*Default/i })
fireEvent.click(pickerButton)
const alice = await screen.findByText('Alice')
fireEvent.click(alice)
expect(window.localStorage.setItem).toHaveBeenCalledWith('hapi-voice-id', 'dyn1')
})
})
+198 -4
View File
@@ -2,6 +2,9 @@ import { useState, useRef, useEffect } from 'react'
import { useTranslation, type Locale } from '@/lib/use-translation'
import { useAppGoBack } from '@/hooks/useAppGoBack'
import { getElevenLabsSupportedLanguages, getLanguageDisplayName, type Language } from '@/lib/languages'
import { VOICES, getFallbackVoices } from '@/lib/voices'
import { useAppContext } from '@/lib/app-context'
import { fetchVoices, type VoiceInfo } from '@/api/voice'
import { getFontScaleOptions, useFontScale, type FontScale } from '@/hooks/useFontScale'
import { getTerminalFontSizeOptions, useTerminalFontSize, type TerminalFontSize } from '@/hooks/useTerminalFontSize'
import { getComposerEnterBehaviorOptions, useComposerEnterBehavior, type ComposerEnterBehavior } from '@/hooks/useComposerEnterBehavior'
@@ -88,6 +91,36 @@ function ChevronDownIcon(props: { className?: string }) {
)
}
function PlayIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
>
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
)
}
function StopIcon(props: { className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="currentColor"
className={props.className}
>
<rect x="3" y="3" width="18" height="18" rx="2" />
</svg>
)
}
function MinusIcon(props: { className?: string }) {
return (
<svg
@@ -266,6 +299,7 @@ function ChatSurfaceColorControl(props: {
export default function SettingsPage() {
const { t, locale, setLocale } = useTranslation()
const { api } = useAppContext()
const goBack = useAppGoBack()
const [isOpen, setIsOpen] = useState(false)
const [isAppearanceOpen, setIsAppearanceOpen] = useState(false)
@@ -274,6 +308,7 @@ export default function SettingsPage() {
const [isChatOpen, setIsChatOpen] = useState(false)
const [isTerminalToolDisplayOpen, setIsTerminalToolDisplayOpen] = useState(false)
const [isVoiceOpen, setIsVoiceOpen] = useState(false)
const [isVoicePickerOpen, setIsVoicePickerOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const appearanceContainerRef = useRef<HTMLDivElement>(null)
const fontContainerRef = useRef<HTMLDivElement>(null)
@@ -281,6 +316,7 @@ export default function SettingsPage() {
const chatContainerRef = useRef<HTMLDivElement>(null)
const terminalToolDisplayContainerRef = useRef<HTMLDivElement>(null)
const voiceContainerRef = useRef<HTMLDivElement>(null)
const voicePickerContainerRef = useRef<HTMLDivElement>(null)
const { fontScale, setFontScale } = useFontScale()
const { terminalFontSize, setTerminalFontSize } = useTerminalFontSize()
const { sessionPreviewLimit, setSessionPreviewLimit } = useSessionPreviewLimit()
@@ -299,6 +335,16 @@ export default function SettingsPage() {
return localStorage.getItem('hapi-voice-lang')
})
// Voice ID state - read from localStorage
const [voiceId, setVoiceId] = useState<string | null>(() => {
return localStorage.getItem('hapi-voice-id')
})
// Dynamic voice list fetched from hub (includes user's cloned voices)
const [dynamicVoices, setDynamicVoices] = useState<VoiceInfo[] | null>(null)
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null)
const currentAudioRef = useRef<HTMLAudioElement | null>(null)
const fontScaleOptions = getFontScaleOptions()
const terminalFontSizeOptions = getTerminalFontSizeOptions()
const composerEnterBehaviorOptions = getComposerEnterBehaviorOptions()
@@ -312,6 +358,16 @@ export default function SettingsPage() {
const currentTerminalToolDisplayModeLabel = terminalToolDisplayModeOptions.find((opt) => opt.value === terminalToolDisplayMode)?.labelKey ?? 'settings.chat.terminalToolDisplay.compact'
const currentVoiceLanguage = voiceLanguages.find((lang) => lang.code === voiceLanguage)
// Voice list: dynamic (from ElevenLabs API, includes clones) or static fallback
const fallbackVoices = getFallbackVoices(locale)
const voiceOptions: VoiceInfo[] = dynamicVoices && dynamicVoices.length > 0
? dynamicVoices
: fallbackVoices.map(v => ({ id: v.id, name: v.name, previewUrl: '', category: 'premade' }))
const currentVoiceName = voiceId
? (voiceOptions.find(v => v.id === voiceId)?.name ?? fallbackVoices.find(v => v.id === voiceId)?.name ?? voiceId)
: null
const handleLocaleChange = (newLocale: Locale) => {
setLocale(newLocale)
setIsOpen(false)
@@ -352,9 +408,56 @@ export default function SettingsPage() {
setIsVoiceOpen(false)
}
const handleVoiceChange = (id: string | null) => {
setVoiceId(id)
if (id === null) {
localStorage.removeItem('hapi-voice-id')
} else {
localStorage.setItem('hapi-voice-id', id)
}
setIsVoicePickerOpen(false)
}
// Fetch available voices from hub on mount
useEffect(() => {
fetchVoices(api).then(voices => {
if (voices.length > 0) setDynamicVoices(voices)
})
}, [api])
const handleVoicePreview = (previewUrl: string, voiceId: string, event: React.MouseEvent) => {
event.stopPropagation()
if (!previewUrl) return
if (playingVoiceId === voiceId) {
currentAudioRef.current?.pause()
currentAudioRef.current = null
setPlayingVoiceId(null)
return
}
currentAudioRef.current?.pause()
const audio = new Audio(previewUrl)
currentAudioRef.current = audio
setPlayingVoiceId(voiceId)
audio.play().catch(() => setPlayingVoiceId(null))
audio.addEventListener('ended', () => {
setPlayingVoiceId(null)
currentAudioRef.current = null
})
}
useEffect(() => {
return () => {
currentAudioRef.current?.pause()
currentAudioRef.current = null
setPlayingVoiceId(null)
}
}, [])
// Close dropdown when clicking outside
useEffect(() => {
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isVoiceOpen) return
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isVoiceOpen && !isVoicePickerOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (isOpen && containerRef.current && !containerRef.current.contains(event.target as Node)) {
@@ -378,15 +481,18 @@ export default function SettingsPage() {
if (isVoiceOpen && voiceContainerRef.current && !voiceContainerRef.current.contains(event.target as Node)) {
setIsVoiceOpen(false)
}
if (isVoicePickerOpen && voicePickerContainerRef.current && !voicePickerContainerRef.current.contains(event.target as Node)) {
setIsVoicePickerOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isVoiceOpen])
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isVoiceOpen, isVoicePickerOpen])
// Close on escape key
useEffect(() => {
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isVoiceOpen) return
if (!isOpen && !isAppearanceOpen && !isFontOpen && !isTerminalFontOpen && !isChatOpen && !isTerminalToolDisplayOpen && !isVoiceOpen && !isVoicePickerOpen) return
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
@@ -397,12 +503,13 @@ export default function SettingsPage() {
setIsChatOpen(false)
setIsTerminalToolDisplayOpen(false)
setIsVoiceOpen(false)
setIsVoicePickerOpen(false)
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isVoiceOpen])
}, [isOpen, isAppearanceOpen, isFontOpen, isTerminalFontOpen, isChatOpen, isTerminalToolDisplayOpen, isVoiceOpen, isVoicePickerOpen])
return (
<div className="flex h-full min-h-0 flex-col">
@@ -813,6 +920,93 @@ export default function SettingsPage() {
</div>
)}
</div>
<div ref={voicePickerContainerRef} className="relative">
<button
type="button"
onClick={() => setIsVoicePickerOpen(!isVoicePickerOpen)}
className="flex w-full items-center justify-between px-3 py-3 text-left transition-colors hover:bg-[var(--app-subtle-bg)]"
aria-expanded={isVoicePickerOpen}
aria-haspopup="listbox"
>
<span className="text-[var(--app-fg)]">{t('settings.voice.voice')}</span>
<span className="flex items-center gap-1 text-[var(--app-hint)]">
<span>{currentVoiceName ?? t('settings.voice.voiceDefault')}</span>
<ChevronDownIcon className={`transition-transform ${isVoicePickerOpen ? 'rotate-180' : ''}`} />
</span>
</button>
{isVoicePickerOpen && (
<div
className="absolute right-3 top-full mt-1 min-w-[220px] max-h-[300px] overflow-y-auto rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] shadow-lg z-50"
role="listbox"
aria-label={t('settings.voice.voice')}
>
<div
role="option"
aria-selected={voiceId === null}
className={`flex items-center w-full text-base transition-colors ${
voiceId === null
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
}`}
>
<button
type="button"
onClick={() => handleVoiceChange(null)}
className="flex flex-1 items-center justify-between px-3 py-2 text-left"
>
<span>{t('settings.voice.voiceDefault')}</span>
{voiceId === null && <span className="ml-2"><CheckIcon /></span>}
</button>
</div>
{voiceOptions.map((voice) => {
const isSelected = voiceId === voice.id
const isPlaying = playingVoiceId === voice.id
return (
<div
key={voice.id}
role="option"
aria-selected={isSelected}
className={`flex items-center w-full text-base transition-colors ${
isSelected
? 'text-[var(--app-link)] bg-[var(--app-subtle-bg)]'
: 'text-[var(--app-fg)] hover:bg-[var(--app-subtle-bg)]'
}`}
>
<button
type="button"
onClick={() => handleVoiceChange(voice.id)}
className="flex flex-1 items-center justify-between px-3 py-2 text-left min-w-0"
>
<span className="truncate">
{voice.name}
{voice.category === 'cloned' && (
<span className="ml-2 text-xs text-[var(--app-hint)]">clone</span>
)}
</span>
{isSelected && <span className="ml-2 shrink-0"><CheckIcon /></span>}
</button>
<button
type="button"
onClick={(e) => handleVoicePreview(voice.previewUrl, voice.id, e)}
aria-label={isPlaying ? 'Stop preview' : 'Preview voice'}
title={voice.previewUrl ? (isPlaying ? 'Stop preview' : 'Preview voice') : 'Preview unavailable without an ElevenLabs API key'}
disabled={!voice.previewUrl}
className={`flex h-full shrink-0 items-center px-3 py-2 ${
voice.previewUrl
? 'text-[var(--app-hint)] hover:text-[var(--app-fg)]'
: 'text-[var(--app-divider)] cursor-not-allowed'
}`}
>
{isPlaying ? <StopIcon /> : <PlayIcon />}
</button>
</div>
)
})}
</div>
)}
</div>
</div>
{/* About section */}