diff --git a/app/src/components/settings/panels/VoicePanel.tsx b/app/src/components/settings/panels/VoicePanel.tsx index cc2dbd106..45cf60e9d 100644 --- a/app/src/components/settings/panels/VoicePanel.tsx +++ b/app/src/components/settings/panels/VoicePanel.tsx @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { synthesizeSpeech } from '../../../features/human/voice/ttsClient'; import { installPiper, installWhisper, @@ -7,6 +9,8 @@ import { type VoiceInstallStatus, whisperInstallStatus, } from '../../../services/api/voiceInstallApi'; +import { selectMascotVoiceId, setMascotVoiceId } from '../../../store/mascotSlice'; +import { MASCOT_VOICE_ID } from '../../../utils/config'; import { openhumanGetVoiceServerSettings, openhumanLocalAiAssetsStatus, @@ -23,6 +27,7 @@ import { } from '../../../utils/tauriCommands'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; +import { ELEVENLABS_VOICE_PRESETS, isCuratedVoicePreset } from './elevenlabsVoicePresets'; // Curated Piper voice presets — a handful of well-known English voices // covering male/female and US/GB accents at the recommended `medium` @@ -43,6 +48,26 @@ const PIPER_VOICE_PRESETS: ReadonlyArray<{ id: string; label: string }> = [ const VoicePanel = () => { const { navigateBack, navigateToSettings, breadcrumbs } = useSettingsNavigation(); + const dispatch = useDispatch(); + // Issue #1762 — user-selected ElevenLabs voice id for the mascot's + // reply speech. `null` means "use the build-time default", which is + // exactly what `synthesizeSpeech` falls back to when called without a + // voiceId override. Stored in mascotSlice + persisted via redux- + // persist so the choice survives restart. + const storedMascotVoiceId = useSelector(selectMascotVoiceId); + // Local edit buffer for the custom-paste field. Mirrors the Piper + // voice editor pattern below — typing does not commit, only the + // explicit Save / Apply paths dispatch into the slice so a half-typed + // id can never reach the TTS payload. + const [mascotVoiceDraft, setMascotVoiceDraft] = useState(storedMascotVoiceId ?? ''); + // Sticky paste-mode flag: when the user picks "Other (paste voice id)" + // we need the input to appear even though `storedMascotVoiceId` is + // still null (or a curated id). Deriving paste-mode purely from the + // stored value can't model that intent. + const [mascotVoicePasteMode, setMascotVoicePasteMode] = useState(false); + const [isPreviewingMascotVoice, setIsPreviewingMascotVoice] = useState(false); + const [mascotVoicePreviewError, setMascotVoicePreviewError] = useState(null); + const previewAudioRef = useRef(null); const [settings, setSettings] = useState(null); const [savedSettings, setSavedSettings] = useState(null); const [serverStatus, setServerStatus] = useState(null); @@ -307,6 +332,90 @@ const VoicePanel = () => { void persistProviders({ tts_provider: next }); }; + // ── Mascot voice picker (issue #1762) ────────────────────────────── + // Keep the local edit buffer aligned with the slice when the slice + // changes from outside this component (e.g. another tab updates it, + // or a Reset action clears it). Without this the paste editor would + // strand the previous value after a Reset. + useEffect(() => { + setMascotVoiceDraft(storedMascotVoiceId ?? ''); + setMascotVoicePreviewError(null); + }, [storedMascotVoiceId]); + + // Stop any in-flight preview audio when the panel unmounts so a user + // who navigates away mid-clip doesn't keep hearing the sample. + useEffect(() => { + return () => { + if (previewAudioRef.current) { + previewAudioRef.current.pause(); + previewAudioRef.current.src = ''; + previewAudioRef.current = null; + } + }; + }, []); + + /** + * Effective mascot voice id sent to the TTS RPC: the user override + * if set, otherwise the build-time default. Used by the dropdown's + * `value=` so the UI always reflects the actual id the next reply + * will be synthesised with. + */ + const effectiveMascotVoiceId: string = storedMascotVoiceId ?? MASCOT_VOICE_ID; + const isCustomMascotVoice = mascotVoicePasteMode || !isCuratedVoicePreset(effectiveMascotVoiceId); + + const onMascotVoicePresetChange = (next: string) => { + if (next === '__custom__') { + // Switch into paste mode without committing. The text input below + // becomes the editor; an explicit Save click writes through. + setMascotVoicePasteMode(true); + setMascotVoiceDraft(storedMascotVoiceId ?? ''); + return; + } + setMascotVoicePasteMode(false); + setMascotVoicePreviewError(null); + dispatch(setMascotVoiceId(next)); + }; + + const onMascotVoiceSavePaste = () => { + setMascotVoicePreviewError(null); + const trimmed = mascotVoiceDraft.trim(); + dispatch(setMascotVoiceId(trimmed.length > 0 ? trimmed : null)); + }; + + const onMascotVoiceReset = () => { + setMascotVoicePreviewError(null); + setMascotVoicePasteMode(false); + dispatch(setMascotVoiceId(null)); + }; + + const onMascotVoicePreview = async () => { + setIsPreviewingMascotVoice(true); + setMascotVoicePreviewError(null); + // Stop any prior playback so rapid clicks don't stack samples. + if (previewAudioRef.current) { + previewAudioRef.current.pause(); + previewAudioRef.current.src = ''; + previewAudioRef.current = null; + } + try { + // Short sample — ElevenLabs charges per character, and the panel + // is interactive so users may click Preview repeatedly. Keep this + // string in lockstep with the test fixture in VoicePanel.test.tsx. + const tts = await synthesizeSpeech("Hi, I'm your assistant. This is a voice preview.", { + voiceId: effectiveMascotVoiceId, + }); + const src = `data:${tts.audio_mime || 'audio/mpeg'};base64,${tts.audio_base64}`; + const audio = new window.Audio(src); + previewAudioRef.current = audio; + await audio.play(); + } catch (err) { + const message = err instanceof Error ? err.message : 'Voice preview failed'; + setMascotVoicePreviewError(message); + } finally { + setIsPreviewingMascotVoice(false); + } + }; + /** * Map an install status snapshot to a button label. Single source of * truth for the four states the UI surfaces: Not installed / Install / @@ -612,6 +721,115 @@ const VoicePanel = () => { + {/* Mascot Voice picker (issue #1762) — only meaningful when the + cloud (ElevenLabs proxy) TTS provider is selected. Local + Piper has its own voice picker above; bundling them would + confuse "which provider does this id belong to?". The check + is inclusive of the unseeded initial state (empty string) + so the picker shows on first paint instead of popping in + after the first voice-status poll resolves — `cloud` is the + shipped default. */} + {ttsProvider !== 'piper' && ( +
+
+
+

Mascot Voice

+

+ Pick the ElevenLabs voice the mascot uses for spoken replies. Switch among the + curated presets, paste any voice id you have access to under{' '} + Other…, or hit Reset to fall back to the shipped + default. +

+
+ + + + {isCustomMascotVoice && ( + + )} + +
+ + + + current: {effectiveMascotVoiceId} + +
+ + {mascotVoicePreviewError && ( +
+ Voice preview failed: {mascotVoicePreviewError}. Reply speech will fall back to + the default voice on the next reply. +
+ )} +
+
+ )} +
diff --git a/app/src/components/settings/panels/__tests__/VoicePanel.test.tsx b/app/src/components/settings/panels/__tests__/VoicePanel.test.tsx index 3a5936bf5..49b57a5e0 100644 --- a/app/src/components/settings/panels/__tests__/VoicePanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/VoicePanel.test.tsx @@ -44,6 +44,19 @@ vi.mock('../../../../services/api/voiceInstallApi', () => ({ piperInstallStatus: vi.fn(), })); +// Mascot voice preview path (issue #1762) goes through the existing +// `synthesizeSpeech` TTS RPC, which is heavy + makes real network calls +// in production. Mocked here so the Preview button click is observable +// without standing up a backend. Other ttsClient exports are +// passed-through so transitive importers (e.g. `useHumanMascot`) still +// resolve their cleanup paths. +vi.mock('../../../../features/human/voice/ttsClient', async () => { + const actual = await vi.importActual( + '../../../../features/human/voice/ttsClient' + ); + return { ...actual, synthesizeSpeech: vi.fn() }; +}); + type RuntimeHarness = { settings: VoiceServerSettings; serverStatus: VoiceServerStatus; @@ -497,4 +510,142 @@ describe('VoicePanel', () => { ) ); }); + + // Issue #1762 — Mascot Voice picker tests. Nested inside the outer + // describe so the runtime mocks (openhumanVoiceStatus seeded with + // tts_provider='cloud', etc.) are inherited. The section only renders + // when the cloud (ElevenLabs proxy) TTS provider is active; local + // Piper has its own picker above. The slice handles validation + + // persistence; these tests pin the UI surface that drives it. + describe('Mascot Voice picker (#1762)', () => { + beforeEach(async () => { + // Stub a fast successful TTS so the Preview happy-path doesn't + // wedge on a hanging promise. Individual tests override per case. + const { synthesizeSpeech } = await import('../../../../features/human/voice/ttsClient'); + vi.mocked(synthesizeSpeech).mockResolvedValue({ + audio_base64: 'AAAA', + audio_mime: 'audio/mpeg', + visemes: [], + }); + }); + + it('omits the Mascot Voice section when TTS provider is piper', async () => { + // Bias the voice status snapshot so the panel mounts in piper mode + // — the section should be hidden in that case (local voices use the + // Piper picker above, not the ElevenLabs one). + const { default: VoicePanel } = await import('../VoicePanel'); + vi.mocked(openhumanVoiceStatus).mockResolvedValueOnce({ + stt_available: true, + tts_available: true, + stt_model_id: 'ggml-tiny-q5_1.bin', + tts_voice_id: 'en_US-lessac-medium', + whisper_binary: null, + piper_binary: null, + stt_model_path: '/tmp/stt.bin', + tts_voice_path: '/tmp/tts.onnx', + whisper_in_process: true, + llm_cleanup_enabled: true, + stt_provider: 'cloud', + tts_provider: 'piper', + }); + renderWithProviders(, { initialEntries: ['/settings/voice'] }); + // Give the panel a tick to read provider state before asserting. + await waitFor(() => { + expect(screen.queryByTestId('mascot-voice-section')).toBeNull(); + }); + }); + + it('renders the Mascot Voice section under cloud TTS with the default voice selected', async () => { + const { default: VoicePanel } = await import('../VoicePanel'); + renderWithProviders(, { initialEntries: ['/settings/voice'] }); + // Wait for the panel to load and seed ttsProvider so the section + // appears. The dropdown is the gate-keeper of the section. + const section = await screen.findByTestId('mascot-voice-section'); + // DEBUG: full DOM if section appears empty + // eslint-disable-next-line no-console + console.log('SECTION HTML:', section.outerHTML.slice(0, 2000)); + const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement; + // With no override stored, the picker reflects the build-time default. + expect(select.value).toBe('ljX1ZrXuDIIRVcmiVSyR'); + const reset = await screen.findByTestId('mascot-voice-reset'); + expect(reset).toBeDisabled(); + }); + + it('switching to a preset voice updates the picker + enables Reset', async () => { + const { default: VoicePanel } = await import('../VoicePanel'); + renderWithProviders(, { initialEntries: ['/settings/voice'] }); + const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement; + fireEvent.change(select, { target: { value: '21m00Tcm4TlvDq8ikWAM' } }); + await waitFor(() => expect(select.value).toBe('21m00Tcm4TlvDq8ikWAM')); + expect(screen.getByTestId('mascot-voice-reset')).not.toBeDisabled(); + expect(screen.getByTestId('mascot-voice-current').textContent).toContain( + '21m00Tcm4TlvDq8ikWAM' + ); + }); + + it('selecting "Other (paste voice id)" reveals the custom paste input', async () => { + const { default: VoicePanel } = await import('../VoicePanel'); + renderWithProviders(, { initialEntries: ['/settings/voice'] }); + const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement; + fireEvent.change(select, { target: { value: '__custom__' } }); + expect(await screen.findByTestId('mascot-voice-input')).toBeInTheDocument(); + }); + + it('Save commits the pasted voice id and surfaces it as current', async () => { + const { default: VoicePanel } = await import('../VoicePanel'); + renderWithProviders(, { initialEntries: ['/settings/voice'] }); + const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement; + fireEvent.change(select, { target: { value: '__custom__' } }); + const input = (await screen.findByTestId('mascot-voice-input')) as HTMLInputElement; + fireEvent.change(input, { target: { value: 'custom-paste-id' } }); + fireEvent.click(screen.getByTestId('mascot-voice-save-paste')); + await waitFor(() => + expect(screen.getByTestId('mascot-voice-current').textContent).toContain('custom-paste-id') + ); + }); + + it('Preview calls synthesizeSpeech with the effective voice id', async () => { + const { synthesizeSpeech } = await import('../../../../features/human/voice/ttsClient'); + const { default: VoicePanel } = await import('../VoicePanel'); + renderWithProviders(, { initialEntries: ['/settings/voice'] }); + const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'pNInz6obpgDQGcFmaJgB' } }); // Adam + fireEvent.click(screen.getByTestId('mascot-voice-preview')); + await waitFor(() => + expect(vi.mocked(synthesizeSpeech)).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ voiceId: 'pNInz6obpgDQGcFmaJgB' }) + ) + ); + }); + + it('Preview failure surfaces a recoverable error banner without dropping the selection', async () => { + const { synthesizeSpeech } = await import('../../../../features/human/voice/ttsClient'); + vi.mocked(synthesizeSpeech).mockRejectedValueOnce(new Error('Backend unreachable')); + const { default: VoicePanel } = await import('../VoicePanel'); + renderWithProviders(, { initialEntries: ['/settings/voice'] }); + const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'EXAVITQu4vr4xnSDxMaL' } }); // Bella + fireEvent.click(screen.getByTestId('mascot-voice-preview')); + const banner = await screen.findByTestId('mascot-voice-preview-error'); + expect(banner.textContent).toContain('Backend unreachable'); + expect(banner.textContent).toContain('fall back'); + // Selection survived the failed preview — fallback only applies to + // the next reply if the chosen voice itself proves unavailable. + expect((screen.getByTestId('mascot-voice-select') as HTMLSelectElement).value).toBe( + 'EXAVITQu4vr4xnSDxMaL' + ); + }); + + it('Reset clears the override and reflects the build-time default in the picker', async () => { + const { default: VoicePanel } = await import('../VoicePanel'); + renderWithProviders(, { initialEntries: ['/settings/voice'] }); + const select = (await screen.findByTestId('mascot-voice-select')) as HTMLSelectElement; + fireEvent.change(select, { target: { value: 'pNInz6obpgDQGcFmaJgB' } }); + await waitFor(() => expect(select.value).toBe('pNInz6obpgDQGcFmaJgB')); + fireEvent.click(screen.getByTestId('mascot-voice-reset')); + await waitFor(() => expect(select.value).toBe('ljX1ZrXuDIIRVcmiVSyR')); + expect(screen.getByTestId('mascot-voice-reset')).toBeDisabled(); + }); + }); }); diff --git a/app/src/components/settings/panels/elevenlabsVoicePresets.ts b/app/src/components/settings/panels/elevenlabsVoicePresets.ts new file mode 100644 index 000000000..8f80ff18f --- /dev/null +++ b/app/src/components/settings/panels/elevenlabsVoicePresets.ts @@ -0,0 +1,54 @@ +/** + * Curated ElevenLabs voice ids exposed in the Mascot Voice picker + * (issue #1762). Picked from the public ElevenLabs voice library so + * users can swap to a different tone (incl. female voices) without + * pasting an opaque id by hand. + * + * Adding a voice: keep the list short (≤ 12) so the dropdown fits a + * single scroll-free view. Anything beyond this curated set is still + * reachable via the "Other…" paste input — that's the escape hatch for + * voices the user has cloned in their own ElevenLabs account. + * + * Ids match the ones documented at https://api.elevenlabs.io/v1/voices + * (public library) — they are stable across ElevenLabs API versions, so + * we hard-code rather than fetch at runtime (an extra round trip per + * panel mount, plus offline-first considerations, both argue against a + * runtime fetch for what is effectively a static menu). + */ +export interface ElevenLabsVoicePreset { + /** ElevenLabs voice id — opaque alphanumeric, sent verbatim to the TTS RPC. */ + id: string; + /** Display label rendered in the dropdown. Includes accent + gender hints. */ + label: string; + /** Coarse gender bucket for filtering / a11y descriptions. */ + gender: 'male' | 'female'; +} + +export const ELEVENLABS_VOICE_PRESETS: readonly ElevenLabsVoicePreset[] = [ + // Default mascot voice — keep first so the picker always offers a + // path back to the shipped behaviour even when the env override is + // unset. Matches `MASCOT_VOICE_ID` in `app/src/utils/config.ts`. + { id: 'ljX1ZrXuDIIRVcmiVSyR', label: 'Default mascot voice', gender: 'female' }, + // Public ElevenLabs library voices — stable ids, mix of accents. + { id: '21m00Tcm4TlvDq8ikWAM', label: 'Rachel · US (female)', gender: 'female' }, + { id: 'EXAVITQu4vr4xnSDxMaL', label: 'Bella · US (female)', gender: 'female' }, + { id: 'AZnzlk1XvdvUeBnXmlld', label: 'Domi · US (female)', gender: 'female' }, + { id: 'MF3mGyEYCl7XYWbV9V6O', label: 'Elli · US (female, young)', gender: 'female' }, + { id: 'jsCqWAovK2LkecY7zXl4', label: 'Freya · US (female, expressive)', gender: 'female' }, + { id: 'pNInz6obpgDQGcFmaJgB', label: 'Adam · US (male)', gender: 'male' }, + { id: 'ErXwobaYiN019PkySvjV', label: 'Antoni · US (male)', gender: 'male' }, + { id: 'VR6AewLTigWG4xSOukaG', label: 'Arnold · US (male, mature)', gender: 'male' }, + { id: 'TxGEqnHWrfWFTfGW9XjX', label: 'Josh · US (male, deep)', gender: 'male' }, +]; + +/** + * True iff `id` matches one of the curated presets above. Used by the + * panel to decide whether to render the dropdown selection or fall + * through to the "Other…" custom-paste editor — a custom id picked + * outside the preset set should keep the paste editor open so the user + * can see exactly what is stored. + */ +export function isCuratedVoicePreset(id: string | null | undefined): boolean { + if (!id) return false; + return ELEVENLABS_VOICE_PRESETS.some(p => p.id === id); +} diff --git a/app/src/features/human/useHumanMascot.lipsync.test.ts b/app/src/features/human/useHumanMascot.lipsync.test.ts index 6d012553a..00c4cd6ae 100644 --- a/app/src/features/human/useHumanMascot.lipsync.test.ts +++ b/app/src/features/human/useHumanMascot.lipsync.test.ts @@ -28,6 +28,18 @@ vi.mock('../../services/chatService', () => ({ }, })); +// Stub useSelector so `useHumanMascot`'s `useSelector(selectMascotVoiceId)` +// (issue #1762) returns `null` without needing a Redux Provider — the +// lipsync tests cover frame plumbing, not voice-override behaviour. +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux'); + return { + ...actual, + useSelector: (selector: (state: { mascot: { voiceId: string | null } }) => T): T => + selector({ mascot: { voiceId: null } } as { mascot: { voiceId: string | null } }), + }; +}); + vi.mock('./voice/ttsClient', async () => { const actual = await vi.importActual('./voice/ttsClient'); return { ...actual, synthesizeSpeech: vi.fn() }; diff --git a/app/src/features/human/useHumanMascot.test.ts b/app/src/features/human/useHumanMascot.test.ts index fed6fd4ab..74429ed59 100644 --- a/app/src/features/human/useHumanMascot.test.ts +++ b/app/src/features/human/useHumanMascot.test.ts @@ -16,6 +16,24 @@ vi.mock('../../services/chatService', () => ({ }, })); +// `useHumanMascot` reads the user-selected ElevenLabs voice override +// via `useSelector(selectMascotVoiceId)` (issue #1762). The renderHook +// calls below intentionally don't wrap the hook in a Redux Provider — +// stubbing `useSelector` keeps the existing test surface untouched +// while letting individual specs override the returned voice id to +// pin the override-propagation behaviour. +let mockMascotVoiceId: string | null = null; +vi.mock('react-redux', async () => { + const actual = await vi.importActual('react-redux'); + return { + ...actual, + useSelector: (selector: (state: { mascot: { voiceId: string | null } }) => T): T => + selector({ mascot: { voiceId: mockMascotVoiceId } } as { + mascot: { voiceId: string | null }; + }), + }; +}); + const proceduralVisemesMock = vi.fn( (text: string, durationMs: number): { viseme: string; start_ms: number; end_ms: number }[] => { if (!text) return []; @@ -499,4 +517,55 @@ describe('useHumanMascot TTS playback', () => { }); expect(result.current.face).toBe('idle'); }); + + // Issue #1762 — the user-selected mascot voice id flows through to + // every TTS RPC the hook makes. The store-stub at module scope lets + // these specs pin the prop without standing up a Redux Provider. + describe('mascot voice id override (issue #1762)', () => { + it('passes the stored voice id to synthesizeSpeech when set', async () => { + mockMascotVoiceId = 'voice-custom-123'; + const fake = makeFakePlayback(); + (synthesizeSpeech as ReturnType).mockResolvedValueOnce({ + audio_base64: 'AAA=', + audio_mime: 'audio/mpeg', + visemes: [{ viseme: 'aa', start_ms: 0, end_ms: 100 }], + }); + (playBase64Audio as ReturnType).mockResolvedValueOnce(fake.handle); + + renderHook(() => useHumanMascot({ speakReplies: true })); + await act(async () => { + capturedListeners?.onDone?.(fakeDone('hello')); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(synthesizeSpeech).toHaveBeenCalledWith('hello', { voiceId: 'voice-custom-123' }); + mockMascotVoiceId = null; + }); + + it('omits the voice override when no preference is stored', async () => { + mockMascotVoiceId = null; + const fake = makeFakePlayback(); + (synthesizeSpeech as ReturnType).mockResolvedValueOnce({ + audio_base64: 'AAA=', + audio_mime: 'audio/mpeg', + visemes: [{ viseme: 'aa', start_ms: 0, end_ms: 100 }], + }); + (playBase64Audio as ReturnType).mockResolvedValueOnce(fake.handle); + + renderHook(() => useHumanMascot({ speakReplies: true })); + await act(async () => { + capturedListeners?.onDone?.(fakeDone('hello')); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Second-arg is undefined → synthesizeSpeech falls through to its + // own MASCOT_VOICE_ID default. Locks the no-regression contract + // for users who never opened the picker. + expect(synthesizeSpeech).toHaveBeenCalledWith('hello', undefined); + }); + }); }); diff --git a/app/src/features/human/useHumanMascot.ts b/app/src/features/human/useHumanMascot.ts index 96b0e420c..7e8b36f1d 100644 --- a/app/src/features/human/useHumanMascot.ts +++ b/app/src/features/human/useHumanMascot.ts @@ -1,7 +1,9 @@ import debug from 'debug'; import { useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; import { subscribeChatEvents } from '../../services/chatService'; +import { selectMascotVoiceId } from '../../store/mascotSlice'; import type { MascotFace } from './Mascot'; import { lerpViseme, VISEMES, type VisemeShape } from './Mascot/visemes'; import { type PlaybackHandle, playBase64Audio, swallowAudioStop } from './voice/audioPlayer'; @@ -111,6 +113,14 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas const speakRef = useRef(speakReplies); speakRef.current = speakReplies; + // Issue #1762 — user-selected mascot voice id (or `null` for the + // build-time default). Mirrored into a ref so the inner + // `startTtsPlayback` closure always reads the latest value without + // having to re-create the callback on every re-render. + const storedMascotVoiceId = useSelector(selectMascotVoiceId); + const mascotVoiceIdRef = useRef(storedMascotVoiceId); + mascotVoiceIdRef.current = storedMascotVoiceId; + const [face, setFace] = useState('idle'); const targetRef = useRef(VISEMES.REST); const lastDeltaAtRef = useRef(0); @@ -234,7 +244,12 @@ export function useHumanMascot(options: UseHumanMascotOptions = {}): UseHumanMas setFace('thinking'); let tts; try { - tts = await synthesizeSpeech(text); + // Pass the user-selected mascot voice id when one is stored + // (issue #1762); `undefined` falls through to the existing + // `MASCOT_VOICE_ID` default inside `synthesizeSpeech`, so this + // change is a no-op for users who have never opened the picker. + const voiceOverride = mascotVoiceIdRef.current; + tts = await synthesizeSpeech(text, voiceOverride ? { voiceId: voiceOverride } : undefined); } catch (err) { // Voice path unavailable — degrade cleanly to text-only behavior. if (isStillCurrent()) degraded = true; diff --git a/app/src/store/__tests__/mascotSlice.test.ts b/app/src/store/__tests__/mascotSlice.test.ts index dcc2f0585..cc0cc16bf 100644 --- a/app/src/store/__tests__/mascotSlice.test.ts +++ b/app/src/store/__tests__/mascotSlice.test.ts @@ -3,8 +3,11 @@ import { describe, expect, it } from 'vitest'; import reducer, { DEFAULT_MASCOT_COLOR, + MAX_MASCOT_VOICE_ID_LEN, selectMascotColor, + selectMascotVoiceId, setMascotColor, + setMascotVoiceId, SUPPORTED_MASCOT_COLORS, } from '../mascotSlice'; import { resetUserScopedState } from '../resetActions'; @@ -71,4 +74,71 @@ describe('mascotSlice', () => { expect(state.color).toBe(DEFAULT_MASCOT_COLOR); }); }); + + // Issue #1762 — user-selected ElevenLabs voice id for the mascot's + // reply speech. The slice is the single source of truth; the + // VoicePanel writes through here and `useHumanMascot` reads back. + describe('mascot voice id', () => { + it('starts with no override (null)', () => { + const state = reducer(undefined, { type: '@@INIT' }); + expect(state.voiceId).toBeNull(); + expect(selectMascotVoiceId({ mascot: state })).toBeNull(); + }); + + it('setMascotVoiceId stores a trimmed non-empty id', () => { + const state = reducer(undefined, setMascotVoiceId(' 21m00Tcm4TlvDq8ikWAM ')); + expect(state.voiceId).toBe('21m00Tcm4TlvDq8ikWAM'); + }); + + it('setMascotVoiceId(null) clears the override', () => { + const set = reducer(undefined, setMascotVoiceId('21m00Tcm4TlvDq8ikWAM')); + const cleared = reducer(set, setMascotVoiceId(null)); + expect(cleared.voiceId).toBeNull(); + }); + + it('setMascotVoiceId resets on whitespace-only input rather than storing junk', () => { + const initial = reducer(undefined, setMascotVoiceId('valid-id')); + const blanked = reducer(initial, setMascotVoiceId(' ')); + expect(blanked.voiceId).toBeNull(); + }); + + it('setMascotVoiceId rejects oversize payloads', () => { + const huge = 'x'.repeat(MAX_MASCOT_VOICE_ID_LEN + 1); + const state = reducer(undefined, setMascotVoiceId(huge)); + expect(state.voiceId).toBeNull(); + }); + + it('resetUserScopedState clears any voice id override', () => { + const dirty = reducer(undefined, setMascotVoiceId('custom-voice')); + expect(dirty.voiceId).toBe('custom-voice'); + const reset = reducer(dirty, resetUserScopedState()); + expect(reset.voiceId).toBeNull(); + }); + }); + + describe('REHYDRATE — mascot voice id', () => { + const rehydrate = (key: string, payload?: unknown) => ({ type: REHYDRATE, key, payload }); + + it('restores a valid persisted voice id', () => { + const state = reducer( + undefined, + rehydrate('mascot', { color: 'navy', voiceId: 'persisted-id' }) + ); + expect(state.voiceId).toBe('persisted-id'); + }); + + it('scrubs an invalid persisted voice id back to null', () => { + const state = reducer(undefined, rehydrate('mascot', { color: 'navy', voiceId: ' ' })); + expect(state.voiceId).toBeNull(); + }); + + it('treats a missing voiceId field (older builds) as null', () => { + // Pre-#1762 blobs only carry `color`; the slice must not throw or + // crash on missing keys — that would brick rehydrate for everyone + // on an upgrade. + const state = reducer(undefined, rehydrate('mascot', { color: 'green' })); + expect(state.color).toBe('green'); + expect(state.voiceId).toBeNull(); + }); + }); }); diff --git a/app/src/store/index.ts b/app/src/store/index.ts index a29404386..2bfdbd642 100644 --- a/app/src/store/index.ts +++ b/app/src/store/index.ts @@ -105,9 +105,12 @@ const persistedNotificationReducer = persistReducer(notificationPersistConfig, n const threadPersistConfig = { key: 'thread', storage, whitelist: ['selectedThreadId'] }; const persistedThreadReducer = persistReducer(threadPersistConfig, threadReducer); -// Mascot appearance — color preference is per-user so it travels with the -// account on logout/login rather than leaking across users. -const mascotPersistConfig = { key: 'mascot', storage, whitelist: ['color'] }; +// Mascot appearance + voice — color and voiceId preferences are per-user +// so they travel with the account on logout/login rather than leaking +// across users. `voiceId` is the user's chosen ElevenLabs voice for +// reply speech (issue #1762); `null` falls back to the build-time +// default in `app/src/utils/config.ts::MASCOT_VOICE_ID`. +const mascotPersistConfig = { key: 'mascot', storage, whitelist: ['color', 'voiceId'] }; const persistedMascotReducer = persistReducer(mascotPersistConfig, mascotReducer); export const store = configureStore({ diff --git a/app/src/store/mascotSlice.ts b/app/src/store/mascotSlice.ts index b8ebd5637..fec348dac 100644 --- a/app/src/store/mascotSlice.ts +++ b/app/src/store/mascotSlice.ts @@ -14,11 +14,47 @@ export const SUPPORTED_MASCOT_COLORS: readonly MascotColor[] = [ export const DEFAULT_MASCOT_COLOR: MascotColor = 'yellow'; -export interface MascotState { - color: MascotColor; +/** + * Maximum length of a stored mascot voice id. ElevenLabs voice ids are + * short opaque alphanumeric strings (typically 20 chars); the cap exists + * solely so a stray paste of multi-megabyte clipboard data can never + * land in localStorage and balloon the persisted blob. Anything longer + * is dropped at the reducer boundary. + */ +export const MAX_MASCOT_VOICE_ID_LEN = 128; + +/** + * Loose shape check for a stored mascot voice id. Issue #1762 lets users + * paste a custom ElevenLabs voice id, so we cannot enumerate the valid + * set — instead we accept any non-empty trimmed string under the length + * cap. The TTS path (`synthesizeSpeech` in + * `app/src/features/human/voice/ttsClient.ts`) is the authoritative + * gate: a syntactically valid id that ElevenLabs rejects falls back + * cleanly via the existing TTS error handling, leaving `MASCOT_VOICE_ID` + * as the implicit safe default. + */ +function isMascotVoiceId(value: unknown): value is string { + return ( + typeof value === 'string' && + value.trim().length > 0 && + value.trim().length <= MAX_MASCOT_VOICE_ID_LEN + ); } -const initialState: MascotState = { color: DEFAULT_MASCOT_COLOR }; +export interface MascotState { + color: MascotColor; + /** + * User-selected ElevenLabs voice id for the mascot's reply speech, or + * `null` to use the build-time default (`MASCOT_VOICE_ID` in + * `app/src/utils/config.ts`). Issue #1762: surfaces what was + * previously a build-time-only env var (`VITE_MASCOT_VOICE_ID`) as a + * persisted user preference so the choice survives restarts and a + * reset is just `setMascotVoiceId(null)`. + */ + voiceId: string | null; +} + +const initialState: MascotState = { color: DEFAULT_MASCOT_COLOR, voiceId: null }; function isMascotColor(value: unknown): value is MascotColor { return ( @@ -35,28 +71,62 @@ const mascotSlice = createSlice({ state.color = action.payload; } }, + /** + * Set or clear the user-selected mascot voice id. Whitespace is + * trimmed; empty / oversize / non-string values clear the override + * (falling back to the build-time default voice). Pass `null` from + * the UI's Reset button to explicitly drop the override. + */ + setMascotVoiceId(state, action: PayloadAction) { + if (action.payload == null) { + state.voiceId = null; + return; + } + if (isMascotVoiceId(action.payload)) { + state.voiceId = action.payload.trim(); + } else { + // Invalid input is treated as a reset rather than left in place + // — a half-typed or junk-pasted value would otherwise silently + // poison the TTS path on the next reply. + state.voiceId = null; + } + }, }, extraReducers: builder => { builder.addCase(resetUserScopedState, () => initialState); - // Guard against unknown/missing color values surviving a rehydrate (e.g. + // Guard against unknown/missing values surviving a rehydrate (e.g. // a future build removed a variant that was previously persisted). builder.addCase(REHYDRATE, (state, action) => { const rehydrateAction = action as { type: typeof REHYDRATE; key: string; - payload?: { color?: unknown }; + payload?: { color?: unknown; voiceId?: unknown }; }; if (rehydrateAction.key !== 'mascot') return; - const restored = rehydrateAction.payload?.color; - state.color = isMascotColor(restored) ? restored : DEFAULT_MASCOT_COLOR; + const restoredColor = rehydrateAction.payload?.color; + state.color = isMascotColor(restoredColor) ? restoredColor : DEFAULT_MASCOT_COLOR; + // `voiceId` is optional in older persisted blobs (pre-#1762) — the + // `null` fallback is the intended default and matches a fresh + // install. Invalid values are scrubbed so a corrupted localStorage + // blob can never make it into the TTS payload. + const restoredVoiceId = rehydrateAction.payload?.voiceId; + state.voiceId = + restoredVoiceId == null + ? null + : isMascotVoiceId(restoredVoiceId) + ? (restoredVoiceId as string).trim() + : null; }); }, }); -export const { setMascotColor } = mascotSlice.actions; +export const { setMascotColor, setMascotVoiceId } = mascotSlice.actions; export const selectMascotColor = (state: { mascot: MascotState }): MascotColor => state.mascot.color; +export const selectMascotVoiceId = (state: { mascot: MascotState }): string | null => + state.mascot.voiceId; + export { mascotSlice }; export default mascotSlice.reducer; diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx index a5c1996cb..2b5d9340a 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -10,15 +10,21 @@ import { MemoryRouter } from 'react-router-dom'; import channelConnectionsReducer from '../store/channelConnectionsSlice'; import coreModeReducer from '../store/coreModeSlice'; +import mascotReducer from '../store/mascotSlice'; import socketReducer from '../store/socketSlice'; /** * Creates a fresh Redux store for testing. * Uses raw (non-persisted) reducers to avoid persist complexity in tests. + * + * `mascot` is wired in for the mascot voice picker (issue #1762): the + * VoicePanel reads + dispatches against this slice, and useSelector + * would throw on a missing reducer without a stub here. */ const testRootReducer = combineReducers({ channelConnections: channelConnectionsReducer, coreMode: coreModeReducer, + mascot: mascotReducer, socket: socketReducer, }); diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index b10152ca7..c77583c58 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -216,6 +216,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 5.3.1 | Voice Input Capture | WD | `voice-mode.spec.ts` | ✅ | | | 5.3.2 | Speech-to-Text Processing | WD | `voice-mode.spec.ts` | ✅ | | | 5.3.3 | Voice Command Execution | WD | `voice-mode.spec.ts` | ✅ | | +| 5.3.4 | Mascot Voice Selection | VU | `app/src/store/__tests__/mascotSlice.test.ts`, `app/src/components/settings/panels/__tests__/VoicePanel.test.tsx`, `app/src/features/human/useHumanMascot.test.ts` (this PR) | ✅ | Slice validation + persist REHYDRATE, Settings picker UI (#1762), `synthesizeSpeech` voiceId override propagation | ---