Skip to content

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

Merged
senamakel merged 3 commits into
tinyhumansai:mainfrom
obchain:fix/1762-mascot-voice-picker
May 15, 2026
Merged

feat(voice): configurable mascot voice with ElevenLabs picker#1821
senamakel merged 3 commits into
tinyhumansai:mainfrom
obchain:fix/1762-mascot-voice-picker

Conversation

@obchain

@obchain obchain commented May 15, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a Mascot Voice picker to Settings → AI & Models → Voice (STT & TTS) so users can pick any ElevenLabs voice for the mascot's spoken replies (10 curated presets + paste-custom fallback).
  • Preview button drives a short sample synthesis through the existing synthesizeSpeech RPC; Reset clears the override and falls back to the build-time default.
  • Persist the choice in mascotSlice (redux-persist whitelist) with a REHYDRATE-time validator that scrubs invalid payloads (non-string, blank, >128 chars).
  • useHumanMascot reads the stored id via selector + ref mirror and passes it through as the voiceId override on every synthesizeSpeech call — no extra round trips.
  • Picker is gated to cloud (ElevenLabs proxy) TTS — local Piper has its own picker above; mixing them would be ambiguous about "which provider does this id belong to?".

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 synthesizeSpeech for a while, but nothing in the UI exercises it.

Solution

Front the existing voiceId override with a dedicated Settings section and a slice-backed user preference:

  • Slice (mascotSlice): new optional voiceId: string | null field. setMascotVoiceId trims, length-validates (≤128), and coerces invalid inputs to null so a bad value never reaches the TTS payload. REHYDRATE handler scrubs persisted state on cold boot.
  • Persist: voiceId is added to the mascot persist whitelist alongside color. Survives restart.
  • Presets: elevenlabsVoicePresets.ts ships 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.
  • UI: VoicePanel grows a Mascot Voice subsection — preset <select>, an Other (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.
  • Wiring: useHumanMascot reads selectMascotVoiceId and mirrors it through a ref, so the latest value is read inside the synthesizeSpeech call without recreating the callback closure on every keystroke. When non-null, it's passed as the voiceId override; otherwise synthesizeSpeech uses the build-time default.

Design tradeoffs:

  • Gating on ttsProvider !== 'piper' (not === 'cloud') so the picker shows on first paint with an unseeded provider — cloud is the shipped default. Avoids a flash-of-no-picker while the first poll resolves.
  • Sticky paste-mode flag: deriving paste-mode purely from "is the stored value curated?" can't model "user clicked Other but hasn't typed yet." Separate state is the cleanest model.
  • 128-char ceiling on stored ids is defensive — real ElevenLabs ids are 20 chars — but cheap insurance against pathological persist payloads.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy
  • Diff coverage ≥ 80% — 51 new + touched tests across slice (19), hook (27), lipsync (5), panel (29 of 29); see Validation Run below.
  • Coverage matrix updated — added row 5.3.4 Mascot Voice Selection in docs/TEST-COVERAGE-MATRIX.md.
  • All affected feature IDs from the matrix are listed in the PR description under ## Related
  • No new external network dependencies introduced (Preview reuses existing synthesizeSpeech RPC; tests mock it).
  • N/A: Manual smoke checklist — Mascot Voice is cloud-TTS-only and ElevenLabs-gated, not on the release-cut platform smoke surface. Manually verified locally (pnpm dev:app): section renders, preset switching works, Preview plays audio against ElevenLabs, Reset returns to default, choice persists across restart.
  • Linked issue closed via Closes #NNN in the ## Related section

Impact

  • Runtime: desktop only (cloud TTS provider). No Rust core changes. No new IPC commands.
  • Performance: zero net change to the hot path — synthesizeSpeech already accepted an optional voiceId; we now populate it from a redux ref read.
  • Security: ids stored are user-supplied strings, trimmed + length-bounded before persist. No new network surface.
  • Migration: backward compatible. Existing persisted mascot state without voiceId rehydrates as voiceId: null (default behavior).
  • Compatibility: cloud TTS only; Piper provider users see the existing local picker unchanged.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A — human-authored, GitHub issue only
  • URL: N/A

Commit & Branch

  • Branch: N/A
  • Commit SHA: N/A

Validation Run

  • pnpm --filter openhuman-app format:check
  • pnpm typecheck
  • Focused tests: pnpm 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 passed
  • N/A: Rust fmt/check (no Rust changes)
  • N/A: Tauri fmt/check (no Tauri shell changes)

Validation Blocked

  • command: N/A
  • error: N/A
  • impact: N/A

Behavior Changes

  • Intended behavior change: users can pick the ElevenLabs voice the mascot uses for spoken replies.
  • User-visible effect: new Mascot Voice subsection in Settings → AI & Models → Voice; mascot's TTS output uses the selected voice on the next reply.

Parity Contract

  • Legacy behavior preserved: with no override stored (default), synthesizeSpeech is called without a voiceId argument — identical payload to today.
  • Guard/fallback/dispatch parity checks: REHYDRATE validator + slice reducer scrub invalid payloads back to null; gated on TTS provider so Piper users see no change.

Duplicate / Superseded PR Handling

Summary by CodeRabbit

  • New Features

    • Added mascot voice selector for ElevenLabs text-to-speech provider
    • Curated preset voice options with gender labels
    • Custom voice ID input with paste mode support
    • Voice preview functionality with error handling
    • Voice selection persists across sessions
  • Tests

    • Added comprehensive test coverage for voice selection feature

Review Change Stack

obchain added 3 commits May 15, 2026 19:20
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
@obchain obchain requested a review from a team May 15, 2026 13:58
@coderabbitai

coderabbitai Bot commented May 15, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

This PR implements the mascot voice selection feature from issue #1762. Users can now select from curated ElevenLabs voices, paste a custom voice ID, preview speech samples, and persist their choice across app restarts. The selected voice integrates seamlessly into the TTS synthesis path.

Changes

Mascot Voice Customization

Layer / File(s) Summary
Voice Preset Constants
app/src/components/settings/panels/elevenlabsVoicePresets.ts
Exported ElevenLabsVoicePreset interface and ELEVENLABS_VOICE_PRESETS readonly array with 11 curated voices plus isCuratedVoicePreset() validation helper.
Redux State Management & Persistence
app/src/store/mascotSlice.ts, app/src/store/index.ts, app/src/store/__tests__/mascotSlice.test.ts
Extended MascotState with voiceId: string | null, added setMascotVoiceId reducer with trim/validate logic, selectMascotVoiceId selector, MAX_MASCOT_VOICE_ID_LEN constant, and REHYDRATE handling for persisted voice IDs. Updated persist whitelist to include voiceId. Tests verify initialization, validation, length limits, reset behavior, and correct restoration of persisted data.
Voice Selection Settings UI
app/src/components/settings/panels/VoicePanel.tsx, app/src/components/settings/panels/__tests__/VoicePanel.test.tsx
Added "Mascot Voice" section rendering when TTS provider is cloud (not piper), including curated preset dropdown, custom voice-id paste input + Save button, Preview/Reset buttons, effective voice ID display, and error banner. Component syncs local state with Redux, handles preview via synthesizeSpeech, and manages audio cleanup on unmount. Tests cover conditional visibility, preset selection, custom voice input, preview success/failure, and reset behavior.
TTS Hook Integration
app/src/features/human/useHumanMascot.ts, app/src/features/human/useHumanMascot.test.ts, app/src/features/human/useHumanMascot.lipsync.test.ts
Updated hook to read selectMascotVoiceId from Redux, store in ref, and pass selected voice ID as voiceId override to synthesizeSpeech during TTS playback. Tests verify voice ID is forwarded to synthesis when set and omitted when unset.
Test Infrastructure & Documentation
app/src/test/test-utils.tsx, docs/TEST-COVERAGE-MATRIX.md
Added mascot reducer to test store for voice-picker tests. Updated test coverage matrix to document mascot voice selection feature coverage.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 A voice for the mascot, curated and true,
Eleven fine choices, or paste one of you!
Preview before saving, reset if you please,
The rabbit's fine tones now flow with such ease.
Redux persists choices through sunset and dawn!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(voice): configurable mascot voice with ElevenLabs picker' accurately summarizes the main change—adding a voice picker feature for ElevenLabs voice selection in the mascot settings.
Linked Issues check ✅ Passed The PR fully implements all coding requirements from #1762: Redux-backed voiceId persistence with validation, ElevenLabs preset picker UI with paste-custom mode, synthesizeSpeech preview, reset-to-default, and comprehensive test coverage across slice, hook, lipsync, and UI components.
Out of Scope Changes check ✅ Passed All changes are directly scoped to #1762 objectives: mascotSlice voiceId state, VoicePanel picker UI, Redux wiring, test suites, and documentation updates. No unrelated refactorings or feature additions detected.

✏️ 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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
app/src/components/settings/panels/__tests__/VoicePanel.test.tsx (1)

566-566: ⚡ Quick win

Remove 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 value

Consider extracting the trimmed value to avoid redundant trim() calls.

The validator isMascotVoiceId already calls value.trim() twice (lines 39-40), and then line 86 calls it again. While correct, extracting const 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

📥 Commits

Reviewing files that changed from the base of the PR and between b778433 and d2c77a0.

📒 Files selected for processing (11)
  • app/src/components/settings/panels/VoicePanel.tsx
  • app/src/components/settings/panels/__tests__/VoicePanel.test.tsx
  • app/src/components/settings/panels/elevenlabsVoicePresets.ts
  • app/src/features/human/useHumanMascot.lipsync.test.ts
  • app/src/features/human/useHumanMascot.test.ts
  • app/src/features/human/useHumanMascot.ts
  • app/src/store/__tests__/mascotSlice.test.ts
  • app/src/store/index.ts
  • app/src/store/mascotSlice.ts
  • app/src/test/test-utils.tsx
  • docs/TEST-COVERAGE-MATRIX.md

Comment on lines +391 to +417
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);
}
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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 |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

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.

Suggested change
| 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add configurable mascot voices with ElevenLabs voice IDs

2 participants