refactor(tts): move speech providers into plugins

This commit is contained in:
Vincent Koc
2026-03-22 17:46:48 -07:00
parent 1d08ad4bac
commit de6bf58e79
15 changed files with 448 additions and 128 deletions

View File

@@ -1,62 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { loadOpenClawPluginsMock } = vi.hoisted(() => ({
loadOpenClawPluginsMock: vi.fn(() => createEmptyPluginRegistry()),
}));
import type { OpenClawConfig } from "../config/config.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import type { SpeechProviderPlugin } from "../plugins/types.js";
import { getSpeechProvider, listSpeechProviders, normalizeSpeechProviderId } from "./provider-registry.js";
const loadOpenClawPluginsMock = vi.fn();
vi.mock("../plugins/loader.js", () => ({
loadOpenClawPlugins: loadOpenClawPluginsMock,
loadOpenClawPlugins: (...args: Parameters<typeof loadOpenClawPluginsMock>) =>
loadOpenClawPluginsMock(...args),
}));
import { getSpeechProvider, listSpeechProviders } from "./provider-registry.js";
function createSpeechProvider(id: string, aliases?: string[]): SpeechProviderPlugin {
return {
id,
...(aliases ? { aliases } : {}),
isConfigured: () => true,
synthesize: async () => ({
audioBuffer: Buffer.from("audio"),
outputFormat: "mp3",
voiceCompatible: false,
fileExtension: ".mp3",
}),
};
}
describe("speech provider registry", () => {
afterEach(() => {
beforeEach(() => {
resetPluginRuntimeStateForTest();
loadOpenClawPluginsMock.mockReset();
loadOpenClawPluginsMock.mockReturnValue(createEmptyPluginRegistry());
});
afterEach(() => {
resetPluginRuntimeStateForTest();
});
it("does not load plugins for builtin provider lookup", () => {
const provider = getSpeechProvider("openai", {} as OpenClawConfig);
it("uses active plugin speech providers without reloading plugins", () => {
setActivePluginRegistry({
...createEmptyPluginRegistry(),
speechProviders: [
{
pluginId: "test-openai",
provider: createSpeechProvider("openai"),
},
],
});
expect(provider?.id).toBe("openai");
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("does not load plugins when listing without config", () => {
const providers = listSpeechProviders();
expect(providers.map((provider) => provider.id)).toEqual(["openai", "elevenlabs", "microsoft"]);
expect(providers.map((provider) => provider.id)).toEqual(["openai"]);
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
});
it("uses active plugin speech providers without loading from disk", () => {
const registry = createEmptyPluginRegistry();
registry.speechProviders.push({
pluginId: "custom-speech",
pluginName: "Custom Speech",
source: "test",
provider: {
id: "custom-speech",
label: "Custom Speech",
isConfigured: () => true,
synthesize: async () => ({
audioBuffer: Buffer.from("audio"),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: false,
}),
},
it("loads speech providers from plugins when config is provided", () => {
loadOpenClawPluginsMock.mockReturnValue({
...createEmptyPluginRegistry(),
speechProviders: [
{
pluginId: "test-microsoft",
provider: createSpeechProvider("microsoft", ["edge"]),
},
],
});
setActivePluginRegistry(registry);
const provider = getSpeechProvider("custom-speech");
const cfg = {} as OpenClawConfig;
expect(provider?.id).toBe("custom-speech");
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
expect(listSpeechProviders(cfg).map((provider) => provider.id)).toEqual(["microsoft"]);
expect(getSpeechProvider("edge", cfg)?.id).toBe("microsoft");
expect(loadOpenClawPluginsMock).toHaveBeenCalledWith({ config: cfg });
});
it("returns no providers when neither plugins nor active registry provide speech support", () => {
expect(listSpeechProviders()).toEqual([]);
expect(getSpeechProvider("openai")).toBeUndefined();
});
it("normalizes the legacy edge alias to microsoft", () => {
expect(normalizeSpeechProviderId("edge")).toBe("microsoft");
});
});

View File

@@ -3,15 +3,6 @@ import { loadOpenClawPlugins } from "../plugins/loader.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import type { SpeechProviderPlugin } from "../plugins/types.js";
import type { SpeechProviderId } from "./provider-types.js";
import { buildElevenLabsSpeechProvider } from "./providers/elevenlabs.js";
import { buildMicrosoftSpeechProvider } from "./providers/microsoft.js";
import { buildOpenAISpeechProvider } from "./providers/openai.js";
const BUILTIN_SPEECH_PROVIDER_BUILDERS = [
buildOpenAISpeechProvider,
buildElevenLabsSpeechProvider,
buildMicrosoftSpeechProvider,
] as const satisfies readonly (() => SpeechProviderPlugin)[];
function trimToUndefined(value: string | undefined): string | undefined {
const trimmed = value?.trim().toLowerCase();
@@ -66,9 +57,6 @@ function buildProviderMaps(cfg?: OpenClawConfig): {
const aliases = new Map<string, SpeechProviderPlugin>();
const maps = { canonical, aliases };
for (const buildProvider of BUILTIN_SPEECH_PROVIDER_BUILDERS) {
registerSpeechProvider(maps, buildProvider());
}
for (const provider of resolveSpeechProviderPluginEntries(cfg)) {
registerSpeechProvider(maps, provider);
}
@@ -88,10 +76,5 @@ export function getSpeechProvider(
if (!normalized) {
return undefined;
}
const local = buildProviderMaps().aliases.get(normalized);
if (local || !cfg) {
return local;
}
return buildProviderMaps(cfg).aliases.get(normalized);
}

View File

@@ -1,66 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { listMicrosoftVoices } from "./microsoft.js";
describe("listMicrosoftVoices", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
vi.restoreAllMocks();
});
it("maps Microsoft voice metadata into speech voice options", async () => {
globalThis.fetch = withFetchPreconnect(
vi.fn().mockResolvedValue(
new Response(
JSON.stringify([
{
ShortName: "en-US-AvaNeural",
FriendlyName: "Microsoft Ava Online (Natural) - English (United States)",
Locale: "en-US",
Gender: "Female",
VoiceTag: {
ContentCategories: ["General"],
VoicePersonalities: ["Friendly", "Positive"],
},
},
]),
{ status: 200 },
),
),
);
const voices = await listMicrosoftVoices();
expect(voices).toEqual([
{
id: "en-US-AvaNeural",
name: "Microsoft Ava Online (Natural) - English (United States)",
category: "General",
description: "Friendly, Positive",
locale: "en-US",
gender: "Female",
personalities: ["Friendly", "Positive"],
},
]);
expect(globalThis.fetch).toHaveBeenCalledWith(
expect.stringContaining("/voices/list?trustedclienttoken="),
expect.objectContaining({
headers: expect.objectContaining({
Origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
"Sec-MS-GEC": expect.any(String),
"Sec-MS-GEC-Version": expect.stringContaining("1-"),
}),
}),
);
});
it("throws on Microsoft voice list failures", async () => {
globalThis.fetch = withFetchPreconnect(
vi.fn().mockResolvedValue(new Response("nope", { status: 503 })),
);
await expect(listMicrosoftVoices()).rejects.toThrow("Microsoft voices API error (503)");
});
});