From ba3b5eef342a38d21d9db0570e00647e26e5cacc Mon Sep 17 00:00:00 2001 From: Ross Cadogan Date: Thu, 2 Jul 2026 22:40:40 +0100 Subject: [PATCH] fix(voices): give identically-named catalog rows a distinguishing subtitle (#474) The pi catalog serves two distinct voices both named "Paola" (classic + eleven_v3); only the v3 variant carries a server description, so the settings catalog showed two identical bare rows. Rows without a description now fall back to a language-coverage subtitle ("Speaks N languages") built from the /voices `languages` field, which is exactly what separates the twins (33 vs 75 languages) and is useful copy on any description-less row. Closes #474 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012DZR4Vp9B1RiKVrkfYiM1o --- _locales/en/messages.json | 10 ++++ .../settings/tabs/chat/voices-controller.ts | 18 ++++++- src/tts/SpeechModel.ts | 1 + test/data/Voices.ts | 4 +- test/settings/tabs/voices-controller.spec.tsx | 51 +++++++++++++++++++ 5 files changed, 81 insertions(+), 3 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e3b3bcb09c..d35f7fb933 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1008,6 +1008,16 @@ "message": "Voices couldn't be loaded right now. Please try again later.", "description": "Shown in the settings voice catalog when the voice list is empty for a signed-in user (e.g. a network problem)" }, + "voiceSpeaksNLanguages": { + "message": "Speaks $count$ languages", + "description": "Fallback subtitle for a voice row in the settings voice catalog when the voice has no description; $count$ is the number of languages the voice supports.", + "placeholders": { + "count": { + "content": "$1", + "example": "33" + } + } + }, "hdVoicesAllowanceNote": { "message": "HD voices use your monthly allowance about 20× faster.", "description": "One-line note shown in voice menus when premium (HD) and value voices are both available" diff --git a/entrypoints/settings/tabs/chat/voices-controller.ts b/entrypoints/settings/tabs/chat/voices-controller.ts index ec7b107c4d..a50fc9ceaf 100644 --- a/entrypoints/settings/tabs/chat/voices-controller.ts +++ b/entrypoints/settings/tabs/chat/voices-controller.ts @@ -185,10 +185,11 @@ export class VoicesController { name.classList.add("voice-row-name"); name.textContent = voice.name; main.appendChild(name); - if (voice.description) { + const subtitle = voice.description || this.languagesSubtitle(voice); + if (subtitle) { const description = document.createElement("span"); description.classList.add("voice-row-desc", "description"); - description.textContent = voice.description; + description.textContent = subtitle; main.appendChild(description); } row.appendChild(main); @@ -214,6 +215,19 @@ export class VoicesController { return row; } + /** + * Metadata fallback for rows whose voice carries no server description. + * Language coverage is what genuinely separates otherwise identically-named + * variants — the pi catalog serves two "Paola"s (#474) — and it is honest, + * useful copy on any description-less row. + */ + private languagesSubtitle(voice: SpeechSynthesisVoiceRemote): string { + const count = voice.languages?.length ?? 0; + return count > 1 + ? getMessage("voiceSpeaksNLanguages", [String(count)]) + : ""; + } + private async useVoice(voice: SpeechSynthesisVoiceRemote): Promise { const host = this.host; await this.deps.setVoice(voice, host); diff --git a/src/tts/SpeechModel.ts b/src/tts/SpeechModel.ts index e91311366e..22da1cd3f0 100644 --- a/src/tts/SpeechModel.ts +++ b/src/tts/SpeechModel.ts @@ -276,6 +276,7 @@ interface SpeechSynthesisVoiceRemote extends SpeechSynthesisVoice { gender?: string; // e.g., "F", "M" (or descriptive string) accent?: string; // e.g., "American", "British" description?: string; // short human-friendly description + languages?: string[]; // ISO codes the voice can speak, e.g. ["en", "ja"] } interface MatchableVoice { diff --git a/test/data/Voices.ts b/test/data/Voices.ts index 8a417a9537..41fdc94e40 100644 --- a/test/data/Voices.ts +++ b/test/data/Voices.ts @@ -38,12 +38,14 @@ class ElevenLabsVoice extends Voice implements SpeechSynthesisVoiceRemote { gender?: string; accent?: string; description?: string; + languages?: string[]; - constructor(id: string, name: string, gender?: string, accent?: string, description?: string) { + constructor(id: string, name: string, gender?: string, accent?: string, description?: string, languages?: string[]) { super(id, name, 0.3, 1000, "ElevenLabs"); this.gender = gender; this.accent = accent; this.description = description; + this.languages = languages; } default: boolean = false; localService: boolean = false; diff --git a/test/settings/tabs/voices-controller.spec.tsx b/test/settings/tabs/voices-controller.spec.tsx index 350d4de0c3..4399b5822b 100644 --- a/test/settings/tabs/voices-controller.spec.tsx +++ b/test/settings/tabs/voices-controller.spec.tsx @@ -3,6 +3,7 @@ import { render, cleanup } from "@testing-library/preact"; import { ChatPanel } from "../../../entrypoints/settings/tabs/chat/ChatPanel"; import { VoicesController } from "../../../entrypoints/settings/tabs/chat/voices-controller"; import { + ElevenLabsVoice, claudeMockVoices, openAiMockVoices, mockVoices, @@ -131,4 +132,54 @@ describe("VoicesController", () => { expect(empty).toBeTruthy(); expect(empty?.getAttribute("data-i18n")).toBe("voicesNoneAvailable"); }); + + // The live pi catalog carries two distinct voices both named "Paola" + // (classic + eleven_v3). Only one has a server description, so rows + // without one must fall back to language-coverage metadata — otherwise the + // user sees two identical bare rows (#474). + describe("identically-named voices (#474)", () => { + const paolaClassic = new ElevenLabsVoice( + "ig1TeITnnNlsJtfHxJlW", + "Paola", + undefined, + undefined, + undefined, + ["en", "ja", "zh"] + ); + const paolaV3 = new ElevenLabsVoice( + "paola-v3", + "Paola", + "F", + undefined, + "Paola on ElevenLabs' most expressive model, with expanded language coverage.", + ["en", "ja", "zh", "is"] + ); + + it("falls back to a languages subtitle so twin names stay distinguishable", async () => { + const deps = makeDeps({ + getVoices: vi.fn(async () => [paolaClassic, paolaV3]), + }); + const { container } = await mount(deps); + const subtitleOf = (id: string) => + container.querySelector( + `#voice-catalog [data-voice-id='${id}'] .voice-row-desc` + )?.textContent ?? ""; + expect(subtitleOf("paola-v3")).toContain("most expressive"); + expect(subtitleOf("ig1TeITnnNlsJtfHxJlW")).not.toBe(""); + expect(subtitleOf("ig1TeITnnNlsJtfHxJlW")).not.toBe( + subtitleOf("paola-v3") + ); + }); + + it("shows no subtitle when there is neither description nor language data", async () => { + const bare = new ElevenLabsVoice("v1", "Solo"); + const deps = makeDeps({ getVoices: vi.fn(async () => [bare]) }); + const { container } = await mount(deps); + expect( + container.querySelector( + "#voice-catalog [data-voice-id='v1'] .voice-row-desc" + ) + ).toBeNull(); + }); + }); });