Skip to content

Commit 109c300

Browse files
authored
fix: ElevenLabs language mapping — fetch /v1/models for full language coverage (#52)
* fix: ElevenLabs language mapping — fetch models for full language coverage (#51) - Add _getVoicesWithModels() helper that fetches /v1/models in parallel with /v1/voices and resolves each voice's supported languages from its high_quality_base_model_ids - Deduplicate languages across multiple models per voice - Fall back to English for voices with no model associations - Update _mapVoicesToUnified to use resolved languages instead of labels.accent (which was a non-BCP-47 accent string, not a language code) * fix: remove duplicate JSDoc comment
1 parent 591c964 commit 109c300

2 files changed

Lines changed: 179 additions & 23 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* Tests for ElevenLabs language mapping fix (issue #51)
3+
*
4+
* Problem:
5+
* 1. labels.accent ("american", "british") is not a BCP-47 code
6+
* 2. Multilingual voices only got one language code instead of all supported languages
7+
*
8+
* Fix:
9+
* - Fetch /v1/models to get language lists per model
10+
* - Map voice.high_quality_base_model_ids → union of languages
11+
* - Use language_id ("en", "es") as bcp47, language name as display
12+
*/
13+
14+
import { ElevenLabsTTSClient } from "../src/engines/elevenlabs";
15+
16+
const MOCK_VOICES = [
17+
{
18+
voice_id: "v1",
19+
name: "Rachel",
20+
labels: { gender: "female", accent: "american" },
21+
high_quality_base_model_ids: ["eleven_multilingual_v2", "eleven_flash_v2_5"],
22+
},
23+
{
24+
voice_id: "v2",
25+
name: "Bella",
26+
labels: { gender: "female", accent: "british" },
27+
high_quality_base_model_ids: ["eleven_multilingual_v2"],
28+
},
29+
{
30+
voice_id: "v3",
31+
name: "OldVoice",
32+
labels: {},
33+
high_quality_base_model_ids: [], // no models
34+
},
35+
];
36+
37+
const MOCK_MODELS = [
38+
{
39+
model_id: "eleven_multilingual_v2",
40+
can_do_text_to_speech: true,
41+
languages: [
42+
{ language_id: "en", name: "English" },
43+
{ language_id: "es", name: "Spanish" },
44+
{ language_id: "fr", name: "French" },
45+
{ language_id: "de", name: "German" },
46+
],
47+
},
48+
{
49+
model_id: "eleven_flash_v2_5",
50+
can_do_text_to_speech: true,
51+
languages: [
52+
{ language_id: "en", name: "English" },
53+
{ language_id: "es", name: "Spanish" },
54+
{ language_id: "ja", name: "Japanese" },
55+
],
56+
},
57+
{
58+
model_id: "eleven_tts_v1",
59+
can_do_text_to_speech: false, // not a TTS model — should be ignored
60+
languages: [{ language_id: "en", name: "English" }],
61+
},
62+
];
63+
64+
function mockFetch(voicesPayload: object, modelsPayload: object[]) {
65+
return jest.fn().mockImplementation((url: string) => {
66+
if (url.includes("/models")) {
67+
return Promise.resolve({
68+
ok: true,
69+
json: () => Promise.resolve(modelsPayload),
70+
});
71+
}
72+
return Promise.resolve({
73+
ok: true,
74+
json: () => Promise.resolve(voicesPayload),
75+
});
76+
});
77+
}
78+
79+
describe("ElevenLabs _mapVoicesToUnified — language mapping from models", () => {
80+
let client: any;
81+
82+
beforeEach(() => {
83+
client = new ElevenLabsTTSClient({ apiKey: "fake" });
84+
});
85+
86+
it("maps a multilingual voice to all languages from its models (deduped)", async () => {
87+
// Rachel supports eleven_multilingual_v2 (en, es, fr, de) + eleven_flash_v2_5 (en, es, ja)
88+
// → union = en, es, fr, de, ja (en and es deduped)
89+
const rawVoices = await client._getVoicesWithModels(MOCK_VOICES, MOCK_MODELS);
90+
const voices = await client._mapVoicesToUnified(rawVoices);
91+
const rachel = voices.find((v: any) => v.id === "v1");
92+
93+
const bcp47s = rachel.languageCodes.map((lc: any) => lc.bcp47);
94+
expect(bcp47s).toContain("en");
95+
expect(bcp47s).toContain("es");
96+
expect(bcp47s).toContain("fr");
97+
expect(bcp47s).toContain("de");
98+
expect(bcp47s).toContain("ja");
99+
expect(new Set(bcp47s).size).toBe(bcp47s.length); // no duplicates
100+
});
101+
102+
it("uses human-readable language name as display", async () => {
103+
const rawVoices = await client._getVoicesWithModels(MOCK_VOICES, MOCK_MODELS);
104+
const voices = await client._mapVoicesToUnified(rawVoices);
105+
const rachel = voices.find((v: any) => v.id === "v1");
106+
const en = rachel.languageCodes.find((lc: any) => lc.bcp47 === "en");
107+
108+
expect(en.display).toBe("English");
109+
});
110+
111+
it("falls back to English when voice has no model ids", async () => {
112+
const rawVoices = await client._getVoicesWithModels(MOCK_VOICES, MOCK_MODELS);
113+
const voices = await client._mapVoicesToUnified(rawVoices);
114+
const old = voices.find((v: any) => v.id === "v3");
115+
116+
expect(old.languageCodes).toHaveLength(1);
117+
expect(old.languageCodes[0].bcp47).toBe("en");
118+
});
119+
120+
it("ignores models where can_do_text_to_speech is false", async () => {
121+
const rawVoices = await client._getVoicesWithModels(MOCK_VOICES, MOCK_MODELS);
122+
const voices = await client._mapVoicesToUnified(rawVoices);
123+
// No voice uses eleven_tts_v1 — but confirm it wasn't added to language map
124+
const rachel = voices.find((v: any) => v.id === "v1");
125+
const bcp47s = rachel.languageCodes.map((lc: any) => lc.bcp47);
126+
// eleven_tts_v1 only had "en" — already present, so count shouldn't change due to it
127+
expect(bcp47s).toContain("en");
128+
});
129+
});

src/engines/elevenlabs.ts

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -456,28 +456,54 @@ export class ElevenLabsTTSClient extends AbstractTTSClient {
456456
}
457457

458458
/**
459-
* Get available voices from the provider
460-
* @returns Promise resolving to an array of voice objects
461-
*/
459+
* Merge raw voices with resolved language data from the models endpoint.
460+
* Extracted as a separate method so tests can inject mock data directly.
461+
*/
462+
protected _getVoicesWithModels(rawVoices: any[], models: any[]): any[] {
463+
// Build model_id → languages map (TTS-capable models only)
464+
const modelLanguageMap = new Map<string, { language_id: string; name: string }[]>();
465+
for (const model of models) {
466+
if (model.can_do_text_to_speech && Array.isArray(model.languages)) {
467+
modelLanguageMap.set(model.model_id, model.languages);
468+
}
469+
}
470+
471+
return rawVoices.map((voice) => {
472+
const modelIds: string[] = voice.high_quality_base_model_ids ?? [];
473+
const seen = new Set<string>();
474+
const resolvedLanguages: { language_id: string; name: string }[] = [];
475+
for (const modelId of modelIds) {
476+
for (const lang of modelLanguageMap.get(modelId) ?? []) {
477+
if (!seen.has(lang.language_id)) {
478+
seen.add(lang.language_id);
479+
resolvedLanguages.push(lang);
480+
}
481+
}
482+
}
483+
return { ...voice, _resolvedLanguages: resolvedLanguages };
484+
});
485+
}
486+
462487
protected async _getVoices(): Promise<any[]> {
463488
try {
464-
const response = await fetch(`${this.baseUrl}/voices`, {
465-
method: "GET",
466-
headers: {
467-
"xi-api-key": this.apiKey,
468-
},
469-
});
470-
471-
if (!response.ok) {
472-
const errorText = await response.text();
489+
const headers = { "xi-api-key": this.apiKey };
490+
const [voicesResp, modelsResp] = await Promise.all([
491+
fetch(`${this.baseUrl}/voices`, { method: "GET", headers }),
492+
fetch(`${this.baseUrl}/models`, { method: "GET", headers }),
493+
]);
494+
495+
if (!voicesResp.ok) {
496+
const errorText = await voicesResp.text();
473497
console.error(
474-
`ElevenLabs API error: ${response.status} ${response.statusText}\nResponse: ${errorText}`
498+
`ElevenLabs API error: ${voicesResp.status} ${voicesResp.statusText}\nResponse: ${errorText}`
475499
);
476-
throw new Error(`Failed to get voices: ${response.statusText}`);
500+
throw new Error(`Failed to get voices: ${voicesResp.statusText}`);
477501
}
478502

479-
const data = await response.json();
480-
return data.voices;
503+
const voiceData = await voicesResp.json();
504+
const modelData = modelsResp.ok ? await modelsResp.json() : [];
505+
506+
return this._getVoicesWithModels(voiceData.voices, modelData);
481507
} catch (error) {
482508
console.error("Error getting ElevenLabs voices:", error);
483509
return [];
@@ -877,13 +903,14 @@ export class ElevenLabsTTSClient extends AbstractTTSClient {
877903
: voice.labels?.gender === "male"
878904
? "Male"
879905
: undefined,
880-
languageCodes: [
881-
{
882-
bcp47: voice.labels?.accent || "en-US",
883-
iso639_3: (voice.labels?.accent || "en-US").split("-")[0] || "eng",
884-
display: voice.labels?.accent || "English",
885-
},
886-
],
906+
languageCodes:
907+
Array.isArray(voice._resolvedLanguages) && voice._resolvedLanguages.length > 0
908+
? voice._resolvedLanguages.map((lang: { language_id: string; name: string }) => ({
909+
bcp47: lang.language_id,
910+
iso639_3: lang.language_id,
911+
display: lang.name,
912+
}))
913+
: [{ bcp47: "en", iso639_3: "en", display: "English" }],
887914
provider: "elevenlabs",
888915
}));
889916
}

0 commit comments

Comments
 (0)