mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-05 08:40:21 +00:00
refactor(tts): move speech providers into plugins
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user