feat(tts): enrich speech voice metadata

This commit is contained in:
Peter Steinberger 2026-03-16 20:27:25 -07:00
parent 5f5b409fe9
commit 57f1ab1fca
No known key found for this signature in database
6 changed files with 78 additions and 7 deletions

View File

@ -110,6 +110,39 @@ describe("talk-voice plugin", () => {
});
});
it("surfaces richer provider voice metadata when available", async () => {
const { command, runtime } = createHarness({
talk: {
provider: "microsoft",
providers: {
microsoft: {},
},
},
});
vi.mocked(runtime.tts.listVoices).mockResolvedValue([
{
id: "en-US-AvaNeural",
name: "Ava",
category: "General",
locale: "en-US",
gender: "Female",
personalities: ["Friendly", "Positive"],
description: "Friendly, Positive",
},
]);
const result = await command.handler(createCommandContext("list"));
expect(result).toEqual({
text:
"Microsoft voices: 1\n\n" +
"- Ava · General\n" +
" id: en-US-AvaNeural\n" +
" meta: en-US · Female · Friendly, Positive\n" +
" note: Friendly, Positive",
});
});
it("writes canonical talk provider config and legacy elevenlabs voice id", async () => {
const { command, runtime } = createHarness({
talk: {

View File

@ -31,6 +31,16 @@ function resolveProviderLabel(providerId: string): string {
}
}
function formatVoiceMeta(voice: SpeechVoiceOption): string | undefined {
const parts = [voice.locale, voice.gender];
const personalities = voice.personalities?.filter((value) => value.trim().length > 0) ?? [];
if (personalities.length > 0) {
parts.push(personalities.join(", "));
}
const filtered = parts.filter((part): part is string => Boolean(part?.trim()));
return filtered.length > 0 ? filtered.join(" · ") : undefined;
}
function formatVoiceList(voices: SpeechVoiceOption[], limit: number, providerId: string): string {
const sliced = voices.slice(0, Math.max(1, Math.min(limit, 50)));
const lines: string[] = [];
@ -42,6 +52,14 @@ function formatVoiceList(voices: SpeechVoiceOption[], limit: number, providerId:
const meta = category ? ` · ${category}` : "";
lines.push(`- ${name}${meta}`);
lines.push(` id: ${v.id}`);
const details = formatVoiceMeta(v);
if (details) {
lines.push(` meta: ${details}`);
}
const description = (v.description ?? "").trim();
if (description) {
lines.push(` note: ${description}`);
}
}
if (voices.length > sliced.length) {
lines.push("");

View File

@ -27,6 +27,14 @@ function findSpeechProviderIdsForPlugin(pluginId: string) {
.toSorted((left, right) => left.localeCompare(right));
}
function findSpeechProviderForPlugin(pluginId: string) {
const entry = speechProviderContractRegistry.find((candidate) => candidate.pluginId === pluginId);
if (!entry) {
throw new Error(`speech provider contract missing for ${pluginId}`);
}
return entry.provider;
}
function findRegistrationForPlugin(pluginId: string) {
const entry = pluginRegistrationContractRegistry.find(
(candidate) => candidate.pluginId === pluginId,
@ -97,4 +105,10 @@ describe("plugin contract registry", () => {
speechProviderIds: ["microsoft"],
});
});
it("keeps bundled speech voice-list support explicit", () => {
expect(findSpeechProviderForPlugin("openai").listVoices).toEqual(expect.any(Function));
expect(findSpeechProviderForPlugin("elevenlabs").listVoices).toEqual(expect.any(Function));
expect(findSpeechProviderForPlugin("microsoft").listVoices).toEqual(expect.any(Function));
});
});

View File

@ -42,6 +42,9 @@ export type SpeechVoiceOption = {
name?: string;
category?: string;
description?: string;
locale?: string;
gender?: string;
personalities?: string[];
};
export type SpeechListVoicesRequest = {

View File

@ -35,7 +35,10 @@ describe("listMicrosoftVoices", () => {
id: "en-US-AvaNeural",
name: "Microsoft Ava Online (Natural) - English (United States)",
category: "General",
description: "en-US · Female · Friendly, Positive",
description: "Friendly, Positive",
locale: "en-US",
gender: "Female",
personalities: ["Friendly", "Positive"],
},
]);
expect(globalThis.fetch).toHaveBeenCalledWith(

View File

@ -39,13 +39,8 @@ function buildMicrosoftVoiceHeaders(): Record<string, string> {
}
function formatMicrosoftVoiceDescription(entry: MicrosoftVoiceListEntry): string | undefined {
const parts = [entry.Locale, entry.Gender];
const personalities = entry.VoiceTag?.VoicePersonalities?.filter(Boolean) ?? [];
if (personalities.length > 0) {
parts.push(personalities.join(", "));
}
const filtered = parts.filter((part): part is string => Boolean(part?.trim()));
return filtered.length > 0 ? filtered.join(" · ") : undefined;
return personalities.length > 0 ? personalities.join(", ") : undefined;
}
export async function listMicrosoftVoices(): Promise<SpeechVoiceOption[]> {
@ -67,6 +62,11 @@ export async function listMicrosoftVoices(): Promise<SpeechVoiceOption[]> {
name: voice.FriendlyName?.trim() || voice.ShortName?.trim() || undefined,
category: voice.VoiceTag?.ContentCategories?.find((value) => value.trim().length > 0),
description: formatMicrosoftVoiceDescription(voice),
locale: voice.Locale?.trim() || undefined,
gender: voice.Gender?.trim() || undefined,
personalities: voice.VoiceTag?.VoicePersonalities?.filter(
(value): value is string => value.trim().length > 0,
),
}))
.filter((voice) => voice.id.length > 0)
: [];