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
96 changes: 96 additions & 0 deletions __tests__/abstract-tts-gender.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Tests for AbstractTTSClient.getVoicesByGender() (issue #44)
*/

import type { UnifiedVoice } from "../src/types";

// Minimal stub so we can instantiate a concrete subclass
jest.mock("../src/core/abstract-tts", () => {
const actual = jest.requireActual("../src/core/abstract-tts");
return actual;
});

// Build a concrete subclass with a fixed voice list
async function makeClient(voices: UnifiedVoice[]) {
const { AbstractTTSClient } = await import("../src/core/abstract-tts");

class TestTTSClient extends AbstractTTSClient {
constructor() {
super({ lang: "en-US" } as any);
}
protected async _getVoices(): Promise<UnifiedVoice[]> {
return voices;
}
async synthToBytes(_text: string): Promise<Uint8Array> {
return new Uint8Array();
}
async synthToBytestream(_text: string): Promise<ReadableStream<Uint8Array>> {
return new ReadableStream();
}
checkCredentials(): boolean {
return true;
}
}

return new TestTTSClient();
}

const VOICES: UnifiedVoice[] = [
{
id: "voice-female-1",
name: "Alice",
gender: "Female",
languageCodes: [{ bcp47: "en-US", iso639_3: "eng", display: "English (US)" }],
provider: "azure",
},
{
id: "voice-female-2",
name: "Beth",
gender: "Female",
languageCodes: [{ bcp47: "en-GB", iso639_3: "eng", display: "English (UK)" }],
provider: "azure",
},
{
id: "voice-male-1",
name: "Charles",
gender: "Male",
languageCodes: [{ bcp47: "en-US", iso639_3: "eng", display: "English (US)" }],
provider: "azure",
},
{
id: "voice-unknown-1",
name: "Robot",
gender: "Unknown",
languageCodes: [{ bcp47: "en-US", iso639_3: "eng", display: "English (US)" }],
provider: "azure",
},
];

describe("AbstractTTSClient.getVoicesByGender()", () => {
it("returns only Female voices when asked for Female", async () => {
const client = await makeClient(VOICES);
const result = await (client as any).getVoicesByGender("Female");
expect(result).toHaveLength(2);
expect(result.every((v: UnifiedVoice) => v.gender === "Female")).toBe(true);
});

it("returns only Male voices when asked for Male", async () => {
const client = await makeClient(VOICES);
const result = await (client as any).getVoicesByGender("Male");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("voice-male-1");
});

it("returns only Unknown voices when asked for Unknown", async () => {
const client = await makeClient(VOICES);
const result = await (client as any).getVoicesByGender("Unknown");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("voice-unknown-1");
});

it("returns an empty array when no voices match the gender", async () => {
const client = await makeClient([VOICES[0]]); // only Female
const result = await (client as any).getVoicesByGender("Male");
expect(result).toHaveLength(0);
});
});
52 changes: 52 additions & 0 deletions __tests__/elevenlabs-gender.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Tests for ElevenLabs engine gender mapping in _mapVoicesToUnified (issue #44)
* The bulk voice list response includes labels.gender as "female" / "male"
*/

jest.mock("../src/core/abstract-tts", () => ({
AbstractTTSClient: class {
voiceId = "some-voice-id";
lang = "en-US";
properties: Record<string, unknown> = { rate: "medium", pitch: "medium", volume: 100 };
timings: unknown[] = [];
on() {}
emit() {}
},
}));

describe("ElevenLabs _mapVoicesToUnified — gender mapping", () => {
let client: any;

beforeEach(async () => {
const { ElevenLabsTTSClient } = await import("../src/engines/elevenlabs");
client = new ElevenLabsTTSClient({ apiKey: "fake" });
});

it("maps labels.gender=female to Female", async () => {
const voices = await client._mapVoicesToUnified([
{ voice_id: "v1", name: "Rachel", labels: { gender: "female", accent: "en-US" } },
]);
expect(voices[0].gender).toBe("Female");
});

it("maps labels.gender=male to Male", async () => {
const voices = await client._mapVoicesToUnified([
{ voice_id: "v2", name: "Adam", labels: { gender: "male", accent: "en-US" } },
]);
expect(voices[0].gender).toBe("Male");
});

it("leaves gender undefined when labels.gender is absent", async () => {
const voices = await client._mapVoicesToUnified([
{ voice_id: "v3", name: "Unnamed", labels: {} },
]);
expect(voices[0].gender).toBeUndefined();
});

it("leaves gender undefined when labels is absent", async () => {
const voices = await client._mapVoicesToUnified([
{ voice_id: "v4", name: "NoLabels" },
]);
expect(voices[0].gender).toBeUndefined();
});
});
60 changes: 60 additions & 0 deletions __tests__/google-gender.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Tests for Google engine gender mapping (issue #44)
* Google API returns ssmlGender as "MALE", "FEMALE", "NEUTRAL", or "SSML_VOICE_GENDER_UNSPECIFIED"
* These must map to "Male", "Female", "Unknown" in UnifiedVoice
*/

jest.mock("../src/core/abstract-tts", () => ({
AbstractTTSClient: class {
voiceId = "en-US-Standard-A";
lang = "en-US";
properties: Record<string, unknown> = { rate: "medium", pitch: "medium", volume: 100 };
timings: unknown[] = [];
on() {}
emit() {}
},
}));

describe("Google _mapVoicesToUnified — gender casing", () => {
let client: any;

beforeEach(async () => {
const { GoogleTTSClient } = await import("../src/engines/google");
client = new GoogleTTSClient({ keyFilename: "fake.json" });
});

it("maps FEMALE to Female", async () => {
const voices = await client._mapVoicesToUnified([
{ name: "en-US-A", ssmlGender: "FEMALE", languageCodes: ["en-US"] },
]);
expect(voices[0].gender).toBe("Female");
});

it("maps MALE to Male", async () => {
const voices = await client._mapVoicesToUnified([
{ name: "en-US-B", ssmlGender: "MALE", languageCodes: ["en-US"] },
]);
expect(voices[0].gender).toBe("Male");
});

it("maps NEUTRAL to Unknown", async () => {
const voices = await client._mapVoicesToUnified([
{ name: "en-US-C", ssmlGender: "NEUTRAL", languageCodes: ["en-US"] },
]);
expect(voices[0].gender).toBe("Unknown");
});

it("maps SSML_VOICE_GENDER_UNSPECIFIED to Unknown", async () => {
const voices = await client._mapVoicesToUnified([
{ name: "en-US-D", ssmlGender: "SSML_VOICE_GENDER_UNSPECIFIED", languageCodes: ["en-US"] },
]);
expect(voices[0].gender).toBe("Unknown");
});

it("maps missing ssmlGender to Unknown", async () => {
const voices = await client._mapVoicesToUnified([
{ name: "en-US-E", languageCodes: ["en-US"] },
]);
expect(voices[0].gender).toBe("Unknown");
});
});
11 changes: 11 additions & 0 deletions src/core/abstract-tts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
import type { AudioFormat } from "../utils/audio-converter";
import { detectAudioFormat } from "../utils/audio-input";
import { isBrowser, isNode } from "../utils/environment";
import { filterByGender } from "./voice-utils";
import { LanguageNormalizer } from "./language-utils";
import * as SSMLUtils from "./ssml-utils";

Expand Down Expand Up @@ -1142,4 +1143,14 @@ export abstract class AbstractTTSClient {
)
);
}

/**
* Get available voices for a specific gender
* @param gender "Male", "Female", or "Unknown"
* @returns Promise resolving to an array of available voices for the specified gender
*/
async getVoicesByGender(gender: "Male" | "Female" | "Unknown"): Promise<UnifiedVoice[]> {
const voices = await this.getVoices();
return filterByGender(voices, gender);
}
}
7 changes: 6 additions & 1 deletion src/engines/elevenlabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -871,7 +871,12 @@ export class ElevenLabsTTSClient extends AbstractTTSClient {
return rawVoices.map((voice) => ({
id: voice.voice_id,
name: voice.name,
gender: undefined, // ElevenLabs doesn't provide gender
gender:
voice.labels?.gender === "female"
? "Female"
: voice.labels?.gender === "male"
? "Male"
: undefined,
languageCodes: [
{
bcp47: voice.labels?.accent || "en-US",
Expand Down
3 changes: 2 additions & 1 deletion src/engines/google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,8 @@ export class GoogleTTSClient extends AbstractTTSClient {
return rawVoices.map((voice: any) => ({
id: voice.name,
name: voice.name || "Unknown",
gender: voice.ssmlGender?.toLowerCase() || undefined,
gender:
voice.ssmlGender === "MALE" ? "Male" : voice.ssmlGender === "FEMALE" ? "Female" : "Unknown",
languageCodes: voice.languageCodes,
provider: "google" as const,
raw: voice, // Keep the original raw voice data
Expand Down
Loading