mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(voice): configurable mascot voice with ElevenLabs picker (#1821)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user