feat(voice): configurable mascot voice with ElevenLabs picker#1821
Conversation
Extend mascotSlice with an optional voiceId field, validated and trimmed (<=128 chars), and add it to the redux-persist whitelist so the choice survives restart. REHYDRATE scrubs invalid payloads. Ship a curated ELEVENLABS_VOICE_PRESETS list for the picker UI to consume. Refs tinyhumansai#1762
Add a Mascot Voice subsection to Settings > Voice (visible under cloud TTS only; Piper has its own picker above). Curated preset dropdown plus "Other (paste voice id)" fallback, Preview button that hits synthesizeSpeech with the selected voice, and Reset to the build-time default. useHumanMascot now reads the stored voice id via selector + ref mirror and passes it through as the voiceId override on every synthesizeSpeech call. Closes tinyhumansai#1762
📝 WalkthroughWalkthroughThis PR implements the mascot voice selection feature from issue ChangesMascot Voice Customization
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
app/src/components/settings/panels/__tests__/VoicePanel.test.tsx (1)
566-566: ⚡ Quick winRemove debug console.log before merging.
This console.log appears to be leftover debugging code that should be removed.
🧹 Proposed cleanup
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;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/components/settings/panels/__tests__/VoicePanel.test.tsx` at line 566, Remove the leftover debug statement console.log('SECTION HTML:', section.outerHTML.slice(0, 2000)) from the VoicePanel.test.tsx tests; locate the occurrence in the test that references section.outerHTML and delete that console.log line so tests don't emit debug output before merging.app/src/store/mascotSlice.ts (1)
80-93: 💤 Low valueConsider extracting the trimmed value to avoid redundant trim() calls.
The validator
isMascotVoiceIdalready callsvalue.trim()twice (lines 39-40), and then line 86 calls it again. While correct, extractingconst trimmed = action.payload.trim()and using it for both validation and storage would be more efficient.♻️ Optional refactor to reduce redundant trim calls
setMascotVoiceId(state, action: PayloadAction<string | null>) { if (action.payload == null) { state.voiceId = null; return; } - if (isMascotVoiceId(action.payload)) { - state.voiceId = action.payload.trim(); + const trimmed = action.payload.trim(); + if (trimmed.length > 0 && trimmed.length <= MAX_MASCOT_VOICE_ID_LEN) { + state.voiceId = trimmed; } else {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@app/src/store/mascotSlice.ts` around lines 80 - 93, Compute a single trimmed string from action.payload and reuse it to avoid redundant trim() calls: inside setMascotVoiceId, do const trimmed = action.payload.trim() and then call isMascotVoiceId(trimmed) (or adjust the validator call site accordingly) and assign state.voiceId = trimmed when valid; keep the existing null reset behavior when payload is null or invalid. Reference: setMascotVoiceId, isMascotVoiceId, state.voiceId, action.payload.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/src/components/settings/panels/VoicePanel.tsx`:
- Around line 391-417: The onMascotVoicePreview function calls audio.play()
without handling its returned Promise, which can produce unhandled rejections;
update onMascotVoicePreview (and use previewAudioRef) to await audio.play() or
attach a .catch handler inside the existing try/catch so playback failures are
caught, call setMascotVoicePreviewError with the error message on failure, and
ensure previewAudioRef is cleaned up (pause/reset/null) if play rejects so rapid
clicks don't leave a dangling audio element.
In `@docs/TEST-COVERAGE-MATRIX.md`:
- Line 216: The TEST-COVERAGE-MATRIX.md row for "5.3.4 | Mascot Voice Selection"
is missing the lipsync test path; update that table row to include
app/src/features/human/useHumanMascot.lipsync.test.ts alongside the existing
paths (`app/src/store/__tests__/mascotSlice.test.ts`,
`app/src/components/settings/panels/__tests__/VoicePanel.test.tsx`,
`app/src/features/human/useHumanMascot.test.ts`) so the coverage entry
accurately reflects the PR's tests for useHumanMascot and lipsync.
---
Nitpick comments:
In `@app/src/components/settings/panels/__tests__/VoicePanel.test.tsx`:
- Line 566: Remove the leftover debug statement console.log('SECTION HTML:',
section.outerHTML.slice(0, 2000)) from the VoicePanel.test.tsx tests; locate the
occurrence in the test that references section.outerHTML and delete that
console.log line so tests don't emit debug output before merging.
In `@app/src/store/mascotSlice.ts`:
- Around line 80-93: Compute a single trimmed string from action.payload and
reuse it to avoid redundant trim() calls: inside setMascotVoiceId, do const
trimmed = action.payload.trim() and then call isMascotVoiceId(trimmed) (or
adjust the validator call site accordingly) and assign state.voiceId = trimmed
when valid; keep the existing null reset behavior when payload is null or
invalid. Reference: setMascotVoiceId, isMascotVoiceId, state.voiceId,
action.payload.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 19ad9632-794d-4895-b43c-fbf6d71ce8e6
📒 Files selected for processing (11)
app/src/components/settings/panels/VoicePanel.tsxapp/src/components/settings/panels/__tests__/VoicePanel.test.tsxapp/src/components/settings/panels/elevenlabsVoicePresets.tsapp/src/features/human/useHumanMascot.lipsync.test.tsapp/src/features/human/useHumanMascot.test.tsapp/src/features/human/useHumanMascot.tsapp/src/store/__tests__/mascotSlice.test.tsapp/src/store/index.tsapp/src/store/mascotSlice.tsapp/src/test/test-utils.tsxdocs/TEST-COVERAGE-MATRIX.md
| 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); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Handle potential unhandled rejection from audio.play().
Line 410 calls audio.play() without awaiting or catching. If playback is blocked by the browser's autoplay policy or fails for another reason, this creates an unhandled promise rejection.
🛡️ Proposed fix to handle play() rejection
const src = `data:${tts.audio_mime || 'audio/mpeg'};base64,${tts.audio_base64}`;
const audio = new window.Audio(src);
previewAudioRef.current = audio;
- await audio.play();
+ // Don't await — keeps the user-gesture chain intact for autoplay policy.
+ // Attach a catch handler so failures don't surface as unhandledrejection.
+ audio.play().catch(err => {
+ const message = err instanceof Error ? err.message : 'Preview playback blocked';
+ setMascotVoicePreviewError(message);
+ });
} catch (err) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@app/src/components/settings/panels/VoicePanel.tsx` around lines 391 - 417,
The onMascotVoicePreview function calls audio.play() without handling its
returned Promise, which can produce unhandled rejections; update
onMascotVoicePreview (and use previewAudioRef) to await audio.play() or attach a
.catch handler inside the existing try/catch so playback failures are caught,
call setMascotVoicePreviewError with the error message on failure, and ensure
previewAudioRef is cleaned up (pause/reset/null) if play rejects so rapid clicks
don't leave a dangling audio element.
| | 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 | |
There was a problem hiding this comment.
Add the lipsync test path to keep this row complete.
This row omits app/src/features/human/useHumanMascot.lipsync.test.ts, which is part of the mascot voice coverage in this PR context.
📌 Suggested doc update
-| 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 |
+| 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`, `app/src/features/human/useHumanMascot.lipsync.test.ts` (this PR) | ✅ | Slice validation + persist REHYDRATE, Settings picker UI (`#1762`), `synthesizeSpeech` voiceId override propagation |📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| | 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 | | |
| | 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`, `app/src/features/human/useHumanMascot.lipsync.test.ts` (this PR) | ✅ | Slice validation + persist REHYDRATE, Settings picker UI (`#1762`), `synthesizeSpeech` voiceId override propagation | |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/TEST-COVERAGE-MATRIX.md` at line 216, The TEST-COVERAGE-MATRIX.md row
for "5.3.4 | Mascot Voice Selection" is missing the lipsync test path; update
that table row to include app/src/features/human/useHumanMascot.lipsync.test.ts
alongside the existing paths (`app/src/store/__tests__/mascotSlice.test.ts`,
`app/src/components/settings/panels/__tests__/VoicePanel.test.tsx`,
`app/src/features/human/useHumanMascot.test.ts`) so the coverage entry
accurately reflects the PR's tests for useHumanMascot and lipsync.
Summary
synthesizeSpeechRPC; Reset clears the override and falls back to the build-time default.mascotSlice(redux-persist whitelist) with a REHYDRATE-time validator that scrubs invalid payloads (non-string, blank, >128 chars).useHumanMascotreads the stored id via selector + ref mirror and passes it through as thevoiceIdoverride on everysynthesizeSpeechcall — no extra round trips.Problem
Issue #1762: the mascot voice is hard-coded to the shipped ElevenLabs default. Users with their own ElevenLabs catalogue (cloned voices, alternative narrators, language variants) have no in-product way to switch — they have to fork the repo and rebuild. The voice override has been threaded through
synthesizeSpeechfor a while, but nothing in the UI exercises it.Solution
Front the existing
voiceIdoverride with a dedicated Settings section and a slice-backed user preference:mascotSlice): new optionalvoiceId: string | nullfield.setMascotVoiceIdtrims, length-validates (≤128), and coerces invalid inputs tonullso a bad value never reaches the TTS payload. REHYDRATE handler scrubs persisted state on cold boot.voiceIdis added to the mascot persist whitelist alongsidecolor. Survives restart.elevenlabsVoicePresets.tsships a 10-voice curated list (Rachel, Bella, Adam, Antoni, Arnold, Josh, Elli, Freya, Domi, plus the shipped default).isCuratedVoicePreset()is the canonical "is this id one of ours?" check used by the picker.VoicePanelgrows a Mascot Voice subsection — preset<select>, anOther (paste voice id)…option that reveals a sticky paste input + Save button, plus Preview and Reset. The paste-mode flag is local UI state (not in the slice) so picking "Other" reveals the input even before any commit.useHumanMascotreadsselectMascotVoiceIdand mirrors it through a ref, so the latest value is read inside thesynthesizeSpeechcall without recreating the callback closure on every keystroke. When non-null, it's passed as thevoiceIdoverride; otherwisesynthesizeSpeechuses the build-time default.Design tradeoffs:
ttsProvider !== 'piper'(not=== 'cloud') so the picker shows on first paint with an unseeded provider —cloudis the shipped default. Avoids a flash-of-no-picker while the first poll resolves.Submission Checklist
5.3.4 Mascot Voice Selectionindocs/TEST-COVERAGE-MATRIX.md.## RelatedsynthesizeSpeechRPC; tests mock it).pnpm dev:app): section renders, preset switching works, Preview plays audio against ElevenLabs, Reset returns to default, choice persists across restart.Closes #NNNin the## RelatedsectionImpact
synthesizeSpeechalready accepted an optionalvoiceId; we now populate it from a redux ref read.mascotstate withoutvoiceIdrehydrates asvoiceId: null(default behavior).Related
AI Authored PR Metadata (required for Codex/Linear PRs)
Linear Issue
Commit & Branch
Validation Run
pnpm --filter openhuman-app format:checkpnpm typecheckpnpm exec vitest run --config test/vitest.config.ts src/store/__tests__/mascotSlice.test.ts src/features/human/useHumanMascot.test.ts src/features/human/useHumanMascot.lipsync.test.ts src/components/settings/panels/__tests__/VoicePanel.test.tsx— 80 passedValidation Blocked
command:N/Aerror:N/Aimpact:N/ABehavior Changes
Parity Contract
synthesizeSpeechis called without avoiceIdargument — identical payload to today.null; gated on TTS provider so Piper users see no change.Duplicate / Superseded PR Handling
Summary by CodeRabbit
New Features
Tests