Skip to content

Fix ElevenLabs voice discovery and TTS pickers#4151

Merged
SpicyMarinara merged 4 commits into
stagingfrom
fix/elevenlabs-voice-discovery
Jul 26, 2026
Merged

Fix ElevenLabs voice discovery and TTS pickers#4151
SpicyMarinara merged 4 commits into
stagingfrom
fix/elevenlabs-voice-discovery

Conversation

@SpicyMarinara

@SpicyMarinara SpicyMarinara commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Linked issue

Closes #4150

Why this change

ElevenLabs preview failures could expose compressed response bytes as unreadable text. Account voice discovery could also appear to load only defaults, and the per-character assignment controls were inconsistent and cramped for larger libraries.

Root cause

  • The ElevenLabs TTS, voice, model, and game-audio requests did not opt into compressed-response decoding.
  • Voice discovery needed to paginate the current v2 endpoint and merge the complete catalog with the saved voice collection.
  • The character selector still used a native select, while the voice popover was positioned and sized inside a narrow assignment column.

What changed

  • Decode compressed ElevenLabs success and error responses, including readable provider details for 401 failures.
  • Fetch every /v2/voices page and merge the unfiltered catalog with voice_type=saved, deduplicating overlapping entries.
  • Guard against repeated pagination tokens and retain readable provider errors in the client.
  • Use one localized searchable picker treatment for characters and voices.
  • Render picker menus in a viewport-safe portal with a visible scrollbar and a practical minimum width.
  • Add regression coverage for pagination, saved custom voices, gzip-compressed 401 responses, and the picker layout.

Automated validation run

  • pnpm check
  • pnpm regression:tts
  • pnpm exec playwright test -c playwright.config.ts -g "ElevenLabs keeps models visible" --project=desktop-chromium
  • git diff --check

One unrelated existing lint warning remains in packages/client/src/components/noodle/NoodleHome.tsx for a missing localizeUi hook dependency.

Manual verification

  • Preview speech with a valid ElevenLabs key and a saved custom voice.
  • Preview speech with an invalid or restricted ElevenLabs key and confirm the error is readable.
  • Verify a large voice library in single and per-character modes.
  • Verify character and voice menus in light and dark themes and on a narrow viewport.

Documentation and release impact

No user documentation or release metadata changes are required. The new picker copy is localized through the canonical English catalog, with normal fallback behavior for partial community locales.

Summary by CodeRabbit

  • New Features

    • Added searchable voice and character selectors in TTS settings.
    • Added per-character voice assignment with improved character selection.
    • Voice discovery now includes all available ElevenLabs voice categories.
  • Bug Fixes

    • Improved handling of voice and model refresh failures with more informative error messages.
    • Improved dropdown positioning, scrolling, and overlay behavior.
    • Added clearer messaging when character searches return no matches.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: abf1389d-2b3a-407d-a86f-d3868eb6a21e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/elevenlabs-voice-discovery

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.

@github-actions github-actions Bot added the bugfix Bug fix label Jul 26, 2026
@SpicyMarinara SpicyMarinara self-assigned this Jul 26, 2026
@SpicyMarinara
SpicyMarinara marked this pull request as ready for review July 26, 2026 20:55
@coderabbitai coderabbitai Bot added feature New feature or enhancement refactor Tracks work against the Tauri/refactor branch labels Jul 26, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (3)
packages/client/src/components/panels/settings/TTSConfigCard.tsx (2)

171-181: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider handling object-shaped detail payloads.

ElevenLabs returns detail as an object ({ status, message }) — as your own regression mock in scripts/regressions/tts-source-persistence.regression.ts (lines 33-36) demonstrates. If that payload ever reaches the client unflattened, the string check silently discards it and the subject is left with only the generic message. A trivial specimen extension:

♻️ Suggested extraction of nested detail
-    const detail = typeof payload?.detail === "string" ? payload.detail.trim() : "";
+    const rawDetail = payload?.detail;
+    const detail =
+      typeof rawDetail === "string"
+        ? rawDetail.trim()
+        : rawDetail && typeof rawDetail === "object" && typeof (rawDetail as { message?: unknown }).message === "string"
+          ? (rawDetail as { message: string }).message.trim()
+          : "";
🤖 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 `@packages/client/src/components/panels/settings/TTSConfigCard.tsx` around
lines 171 - 181, Update getTtsRequestErrorMessage to handle object-shaped
payload.detail values, extracting a meaningful nested message such as
detail.message when it is a string, while preserving the existing string-detail
and fallback behavior. Ensure ApiError messages combine the generic message with
the extracted detail when available.

465-486: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Throttle the capture-phase scroll repositioning.

updatePosition is bound to scroll with capture: true, so every scroll event anywhere in the document performs a layout read and an unconditional setPosition — a fresh object each time, so React re-renders the portal on each frame's worth of events. On a settings panel nested in scrollable containers this is measurable jank. An rAF gate (or an equality bail-out before setPosition) suffices.

⚡ Suggested rAF-gated scheduler
     updatePosition();
     const frame = window.requestAnimationFrame(updatePosition);
-    window.addEventListener("resize", updatePosition);
-    window.addEventListener("scroll", updatePosition, true);
+    let scheduled = 0;
+    const schedulePosition = () => {
+      if (scheduled) return;
+      scheduled = window.requestAnimationFrame(() => {
+        scheduled = 0;
+        updatePosition();
+      });
+    };
+    window.addEventListener("resize", schedulePosition);
+    window.addEventListener("scroll", schedulePosition, true);
     return () => {
       window.cancelAnimationFrame(frame);
-      window.removeEventListener("resize", updatePosition);
-      window.removeEventListener("scroll", updatePosition, true);
+      if (scheduled) window.cancelAnimationFrame(scheduled);
+      window.removeEventListener("resize", schedulePosition);
+      window.removeEventListener("scroll", schedulePosition, true);
     };
🤖 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 `@packages/client/src/components/panels/settings/TTSConfigCard.tsx` around
lines 465 - 486, Throttle the capture-phase scroll repositioning around
updatePosition by scheduling at most one layout update per animation frame,
rather than running it for every scroll event. Preserve immediate initial
positioning and resize behavior, cancel any pending frame during cleanup, and
ensure the scroll listener uses the gated scheduler while updatePosition remains
the actual measurement function.
scripts/regressions/tts-source-persistence.regression.ts (1)

48-50: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assertions thrown inside the request handler degrade into timeouts.

When assert.equal throws here, no response is ever written. The client then waits out AbortSignal.timeout(10_000) in fetchElevenLabsVoiceOptions, and because fetchAllElevenLabsVoiceOptions wraps both calls in Promise.allSettled, the failure is swallowed into a partial result. A precise diagnostic becomes a ten-second riddle. Prefer answering with a 500 carrying the mismatch and asserting on the client side.

🧪 Suggested handler-safe failure reporting
-  assert.equal(apiKey, "test-elevenlabs-key");
-  assert.equal(url.pathname, "/v2/voices");
-  assert.equal(url.searchParams.get("page_size"), "100");
+  if (apiKey !== "test-elevenlabs-key" || url.pathname !== "/v2/voices" || url.searchParams.get("page_size") !== "100") {
+    response.writeHead(500, { "Content-Type": "application/json" });
+    response.end(JSON.stringify({ detail: `unexpected request: ${apiKey} ${url.pathname}${url.search}` }));
+    return;
+  }
🤖 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 `@scripts/regressions/tts-source-persistence.regression.ts` around lines 48 -
50, Update the request handler in the regression test to catch validation
failures and send an HTTP 500 response containing the mismatch details instead
of allowing assertions to escape and leave the request unresolved. Move the
corresponding assertions to the client-side test flow around
fetchAllElevenLabsVoiceOptions or fetchElevenLabsVoiceOptions, preserving
validation of apiKey, pathname, and page_size while ensuring failures surface
immediately rather than being swallowed by Promise.allSettled.
🤖 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 `@e2e/core-flows.e2e.ts`:
- Line 4200: Make disposal of “ElevenLabs Voice Picker Character” unconditional
in the test flow surrounding the assertions before the existing
page.request.delete call. Move the deletion into a finally block or equivalent
afterEach cleanup so it runs whether assertions pass or throw, while preserving
the current character identifier and delete request.

In `@packages/client/src/components/panels/settings/TTSConfigCard.tsx`:
- Around line 546-601: Implement focus restoration for the settings selector by
attaching triggerRef to the trigger button and using closePanel() in the Escape
handler and both option onClick handlers, so closing or selecting returns focus
to the trigger. Use closePanel(false) in the outside-pointer handler to preserve
its existing non-focus-restoring behavior; defer arrow-key and Enter navigation
changes.

In `@packages/server/src/routes/tts.routes.ts`:
- Around line 717-733: Update fetchAllElevenLabsVoiceOptions so that when both
provider requests reject, the thrown error preserves every collected message
instead of only errors[0]. Join the errors with "; " or use an AggregateError,
while retaining the existing fallback message when no error details are
available.

---

Nitpick comments:
In `@packages/client/src/components/panels/settings/TTSConfigCard.tsx`:
- Around line 171-181: Update getTtsRequestErrorMessage to handle object-shaped
payload.detail values, extracting a meaningful nested message such as
detail.message when it is a string, while preserving the existing string-detail
and fallback behavior. Ensure ApiError messages combine the generic message with
the extracted detail when available.
- Around line 465-486: Throttle the capture-phase scroll repositioning around
updatePosition by scheduling at most one layout update per animation frame,
rather than running it for every scroll event. Preserve immediate initial
positioning and resize behavior, cancel any pending frame during cleanup, and
ensure the scroll listener uses the gated scheduler while updatePosition remains
the actual measurement function.

In `@scripts/regressions/tts-source-persistence.regression.ts`:
- Around line 48-50: Update the request handler in the regression test to catch
validation failures and send an HTTP 500 response containing the mismatch
details instead of allowing assertions to escape and leave the request
unresolved. Move the corresponding assertions to the client-side test flow
around fetchAllElevenLabsVoiceOptions or fetchElevenLabsVoiceOptions, preserving
validation of apiKey, pathname, and page_size while ensuring failures surface
immediately rather than being swallowed by Promise.allSettled.
🪄 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 Plus

Run ID: 1306d37e-830b-482b-af35-3df65744e9d7

📥 Commits

Reviewing files that changed from the base of the PR and between 272fea2 and 478de3e.

📒 Files selected for processing (5)
  • e2e/core-flows.e2e.ts
  • packages/client/src/components/panels/settings/TTSConfigCard.tsx
  • packages/client/src/localization/locales/en.json
  • packages/server/src/routes/tts.routes.ts
  • scripts/regressions/tts-source-persistence.regression.ts

Comment thread e2e/core-flows.e2e.ts Outdated
await refreshButton.click();
await expect.poll(() => saveCount).toBeGreaterThan(0);
await expect.poll(() => voiceFetchCount).toBeGreaterThan(fetchesBeforeRefresh);
await page.request.delete(`/api/characters/${character.id}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The specimen is only disposed of on a successful trial.

Every assertion between line 4108 and 4199 can throw, and each such throw abandons "ElevenLabs Voice Picker Character" in the database. Subsequent tests that enumerate characters — and reruns of this very test — then operate on a contaminated sample. Bind the disposal to a finally or an afterEach hook so cleanup is unconditional.

🤖 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 `@e2e/core-flows.e2e.ts` at line 4200, Make disposal of “ElevenLabs Voice
Picker Character” unconditional in the test flow surrounding the assertions
before the existing page.request.delete call. Move the deletion into a finally
block or equivalent afterEach cleanup so it runs whether assertions pass or
throw, while preserving the current character identifier and delete request.

Comment on lines +546 to +601
<div
id={listboxId}
role="listbox"
aria-label={ariaLabel}
data-testid={testId}
className="min-h-0 overflow-x-hidden overflow-y-scroll pr-1 [scrollbar-color:var(--primary)_var(--secondary)] [scrollbar-gutter:stable] [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[var(--primary)] [&::-webkit-scrollbar-track]:bg-[var(--secondary)] [&::-webkit-scrollbar]:w-2"
>
<span className="min-w-0 flex-1 truncate">{formatVoiceOptionLabel(option)}</span>
{option.id === value && <Check size="0.75rem" className="shrink-0" />}
</button>
))}
{filteredOptions.length === 0 && (
<p className="px-2.5 py-3 text-center text-xs text-[var(--muted-foreground)]">
{localizeUi("ui.panels.ttsconfigcard.noMatchingVoices")}
</p>
)}
</div>
</div>
)}
<button
type="button"
role="option"
aria-selected={!value}
onClick={() => {
onChange("");
setOpen(false);
setSearch("");
}}
className={cn(
"flex w-full min-w-0 items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs hover:bg-[var(--secondary)]",
!value && "bg-[var(--primary)]/10 text-[var(--primary)]",
)}
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[var(--secondary)] text-[var(--primary)]">
{optionKind === "character" ? <UserRound size="0.75rem" /> : <Volume2 size="0.75rem" />}
</span>
<span className="min-w-0 flex-1 truncate">{placeholder}</span>
{!value && <Check size="0.75rem" className="shrink-0" />}
</button>
{filteredOptions.map((option) => (
<button
key={option.id}
type="button"
role="option"
aria-selected={option.id === value}
aria-disabled={option.disabled || undefined}
disabled={option.disabled}
title={option.label}
onClick={() => {
onChange(option.id);
setOpen(false);
setSearch("");
}}
className={cn(
"flex w-full min-w-0 items-center gap-2 rounded-lg px-2.5 py-2 text-left text-xs hover:bg-[var(--secondary)] disabled:cursor-not-allowed disabled:opacity-40",
option.id === value && "bg-[var(--primary)]/10 text-[var(--primary)]",
)}
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-[var(--secondary)] text-[var(--primary)]">
{optionKind === "character" ? <UserRound size="0.75rem" /> : <Volume2 size="0.75rem" />}
</span>
<span className="min-w-0 flex-1 truncate">{option.label}</span>
{option.id === value && <Check size="0.75rem" className="shrink-0" />}
</button>
))}
{filteredOptions.length === 0 && (
<p className="px-2.5 py-3 text-center text-xs text-[var(--muted-foreground)]">{emptyText}</p>
)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keyboard subjects are left stranded: no focus return, no arrow navigation.

Two deficiencies in the specimen's nervous system:

  1. On Escape (line 431-434) and on selection, focus is never returned to the trigger. Since the panel lives in a document.body portal, the vanished focus falls back to <body> and the tab order restarts at the document head — a jarring regression versus the native <select> this replaces.
  2. The listbox offers no ArrowUp/ArrowDown/Enter traversal; role="listbox" advertises a keyboard contract the implementation does not honor.

The first is trivial to remedy and worth doing now; the second can be deferred.

♿ Minimal focus restoration
+  const triggerRef = useRef<HTMLButtonElement>(null);
+  const closePanel = (restoreFocus = true) => {
+    setOpen(false);
+    setSearch("");
+    if (restoreFocus) triggerRef.current?.focus();
+  };

Then use closePanel() in the Escape handler and in the option onClick handlers, closePanel(false) for the outside-pointer handler, and attach ref={triggerRef} to the trigger button.

🤖 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 `@packages/client/src/components/panels/settings/TTSConfigCard.tsx` around
lines 546 - 601, Implement focus restoration for the settings selector by
attaching triggerRef to the trigger button and using closePanel() in the Escape
handler and both option onClick handlers, so closing or selecting returns focus
to the trigger. Use closePanel(false) in the outside-pointer handler to preserve
its existing non-focus-restoring behavior; defer arrow-key and Enter navigation
changes.

Comment on lines +717 to +733
export async function fetchAllElevenLabsVoiceOptions(baseUrl: string, apiKey: string): Promise<VoiceOption[]> {
const results = await Promise.allSettled([
fetchElevenLabsVoiceOptions(baseUrl, apiKey),
fetchElevenLabsVoiceOptions(baseUrl, apiKey, { voice_type: "saved" }),
]);
const successfulResults = results.filter(
(result): result is PromiseFulfilledResult<VoiceOption[]> => result.status === "fulfilled",
);
if (successfulResults.length === 0) {
const errors = results
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.map((result) => (result.reason instanceof Error ? result.reason.message : String(result.reason)));
throw new Error(errors[0] || "ElevenLabs voice discovery failed");
}
return mergeVoiceOptions(successfulResults.flatMap((result) => result.value));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Do not discard the second provider failure.

When both requests reject, errors contains both messages but errors[0] is thrown. The resulting 502 hides useful provider diagnostics, especially for the saved-voice query. Preserve all messages with errors.join("; ") or an AggregateError.

Suggested fix
-    throw new Error(errors[0] || "ElevenLabs voice discovery failed");
+    throw new Error(
+      errors.length > 0
+        ? `ElevenLabs voice discovery failed: ${errors.join("; ")}`
+        : "ElevenLabs voice discovery failed",
+    );
📝 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
export async function fetchAllElevenLabsVoiceOptions(baseUrl: string, apiKey: string): Promise<VoiceOption[]> {
const results = await Promise.allSettled([
fetchElevenLabsVoiceOptions(baseUrl, apiKey),
fetchElevenLabsVoiceOptions(baseUrl, apiKey, { voice_type: "saved" }),
]);
const successfulResults = results.filter(
(result): result is PromiseFulfilledResult<VoiceOption[]> => result.status === "fulfilled",
);
if (successfulResults.length === 0) {
const errors = results
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.map((result) => (result.reason instanceof Error ? result.reason.message : String(result.reason)));
throw new Error(errors[0] || "ElevenLabs voice discovery failed");
}
return mergeVoiceOptions(successfulResults.flatMap((result) => result.value));
}
export async function fetchAllElevenLabsVoiceOptions(baseUrl: string, apiKey: string): Promise<VoiceOption[]> {
const results = await Promise.allSettled([
fetchElevenLabsVoiceOptions(baseUrl, apiKey),
fetchElevenLabsVoiceOptions(baseUrl, apiKey, { voice_type: "saved" }),
]);
const successfulResults = results.filter(
(result): result is PromiseFulfilledResult<VoiceOption[]> => result.status === "fulfilled",
);
if (successfulResults.length === 0) {
const errors = results
.filter((result): result is PromiseRejectedResult => result.status === "rejected")
.map((result) => (result.reason instanceof Error ? result.reason.message : String(result.reason)));
throw new Error(
errors.length > 0
? `ElevenLabs voice discovery failed: ${errors.join("; ")}`
: "ElevenLabs voice discovery failed",
);
}
return mergeVoiceOptions(successfulResults.flatMap((result) => result.value));
}
🤖 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 `@packages/server/src/routes/tts.routes.ts` around lines 717 - 733, Update
fetchAllElevenLabsVoiceOptions so that when both provider requests reject, the
thrown error preserves every collected message instead of only errors[0]. Join
the errors with "; " or use an AggregateError, while retaining the existing
fallback message when no error details are available.

@SpicyMarinara
SpicyMarinara merged commit e4b95b2 into staging Jul 26, 2026
6 checks passed
@SpicyMarinara
SpicyMarinara deleted the fix/elevenlabs-voice-discovery branch July 26, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bugfix Bug fix client feature New feature or enhancement refactor Tracks work against the Tauri/refactor branch server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant