Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions _locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
18 changes: 16 additions & 2 deletions entrypoints/settings/tabs/chat/voices-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<void> {
const host = this.host;
await this.deps.setVoice(voice, host);
Expand Down
1 change: 1 addition & 0 deletions src/tts/SpeechModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion test/data/Voices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
51 changes: 51 additions & 0 deletions test/settings/tabs/voices-controller.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
});
});
});
Loading