feat(voice): configurable mascot voice with ElevenLabs picker (#1821)

This commit is contained in:
obchain
2026-05-15 13:57:15 -07:00
committed by GitHub
parent 3ef86a4112
commit 05451f5c33
11 changed files with 681 additions and 12 deletions
@@ -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<string>(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<boolean>(false);
const [isPreviewingMascotVoice, setIsPreviewingMascotVoice] = useState(false);
const [mascotVoicePreviewError, setMascotVoicePreviewError] = useState<string | null>(null);
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
const [settings, setSettings] = useState<VoiceServerSettings | null>(null);
const [savedSettings, setSavedSettings] = useState<VoiceServerSettings | null>(null);
const [serverStatus, setServerStatus] = useState<VoiceServerStatus | null>(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 = () => {
</div>
</section>
{/* 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' && (
<section className="space-y-3" data-testid="mascot-voice-section">
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
<div>
<h3 className="text-sm font-semibold text-stone-900">Mascot Voice</h3>
<p className="text-xs text-stone-500 mt-1">
Pick the ElevenLabs voice the mascot uses for spoken replies. Switch among the
curated presets, paste any voice id you have access to under{' '}
<strong>Other</strong>, or hit <strong>Reset</strong> to fall back to the shipped
default.
</p>
</div>
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Voice preset</span>
<select
aria-label="Mascot voice preset"
data-testid="mascot-voice-select"
value={isCustomMascotVoice ? '__custom__' : effectiveMascotVoiceId}
onChange={e => onMascotVoicePresetChange(e.target.value)}
className="w-full rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 focus:outline-none focus:ring-1 focus:ring-primary-400">
{ELEVENLABS_VOICE_PRESETS.map(v => (
<option key={v.id} value={v.id}>
{v.label}
</option>
))}
<option value="__custom__">Other (paste voice id)</option>
</select>
</label>
{isCustomMascotVoice && (
<label className="block space-y-1">
<span className="text-xs font-medium text-stone-600">Custom voice id</span>
<div className="flex gap-2">
<input
aria-label="Custom ElevenLabs voice id"
data-testid="mascot-voice-input"
value={mascotVoiceDraft}
placeholder="e.g. 21m00Tcm4TlvDq8ikWAM"
onChange={e => setMascotVoiceDraft(e.target.value)}
className="flex-1 rounded-md border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:outline-none focus:ring-1 focus:ring-primary-400"
/>
<button
type="button"
data-testid="mascot-voice-save-paste"
onClick={onMascotVoiceSavePaste}
disabled={mascotVoiceDraft.trim() === (storedMascotVoiceId ?? '').trim()}
className="px-3 py-1.5 text-xs rounded-md bg-primary-600 hover:bg-primary-700 disabled:opacity-60 text-white">
Save
</button>
</div>
<p className="text-[11px] text-stone-500">
Find voice ids at <code className="font-mono">api.elevenlabs.io/v1/voices</code>{' '}
or your ElevenLabs dashboard. Only the id is stored your API key stays on the
backend.
</p>
</label>
)}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
data-testid="mascot-voice-preview"
onClick={() => void onMascotVoicePreview()}
disabled={isPreviewingMascotVoice}
className="px-3 py-1.5 text-xs rounded-md bg-emerald-600 hover:bg-emerald-700 disabled:opacity-60 text-white">
{isPreviewingMascotVoice ? 'Previewing…' : 'Preview voice'}
</button>
<button
type="button"
data-testid="mascot-voice-reset"
onClick={onMascotVoiceReset}
disabled={storedMascotVoiceId == null}
title={
storedMascotVoiceId == null
? 'Already using the shipped default voice'
: 'Restore the shipped default mascot voice'
}
className="px-3 py-1.5 text-xs rounded-md border border-stone-300 hover:border-stone-400 disabled:opacity-60 text-stone-700">
Reset to default
</button>
<span
data-testid="mascot-voice-current"
className="ml-1 text-[11px] text-stone-500 truncate max-w-[18rem]"
title={effectiveMascotVoiceId}>
current: <code className="font-mono">{effectiveMascotVoiceId}</code>
</span>
</div>
{mascotVoicePreviewError && (
<div
data-testid="mascot-voice-preview-error"
className="rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
Voice preview failed: {mascotVoicePreviewError}. Reply speech will fall back to
the default voice on the next reply.
</div>
)}
</div>
</section>
)}
<section className={`space-y-3 ${disabled ? 'opacity-60' : ''}`}>
<div className="bg-stone-50 rounded-lg border border-stone-200 p-4 space-y-4">
<div>
@@ -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<typeof import('../../../../features/human/voice/ttsClient')>(
'../../../../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(<VoicePanel />, { 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(<VoicePanel />, { 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(<VoicePanel />, { 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(<VoicePanel />, { 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(<VoicePanel />, { 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(<VoicePanel />, { 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(<VoicePanel />, { 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(<VoicePanel />, { 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();
});
});
});
@@ -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);
}
@@ -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<typeof import('react-redux')>('react-redux');
return {
...actual,
useSelector: <T>(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<typeof import('./voice/ttsClient')>('./voice/ttsClient');
return { ...actual, synthesizeSpeech: vi.fn() };
@@ -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<typeof import('react-redux')>('react-redux');
return {
...actual,
useSelector: <T>(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<typeof vi.fn>).mockResolvedValueOnce({
audio_base64: 'AAA=',
audio_mime: 'audio/mpeg',
visemes: [{ viseme: 'aa', start_ms: 0, end_ms: 100 }],
});
(playBase64Audio as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
audio_base64: 'AAA=',
audio_mime: 'audio/mpeg',
visemes: [{ viseme: 'aa', start_ms: 0, end_ms: 100 }],
});
(playBase64Audio as ReturnType<typeof vi.fn>).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);
});
});
});
+16 -1
View File
@@ -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<string | null>(storedMascotVoiceId);
mascotVoiceIdRef.current = storedMascotVoiceId;
const [face, setFace] = useState<MascotFace>('idle');
const targetRef = useRef<VisemeShape>(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;
@@ -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();
});
});
});
+6 -3
View File
@@ -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({
+78 -8
View File
@@ -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<string | null>) {
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;
+6
View File
@@ -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,
});
+1
View File
@@ -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 |
---