Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
12
extensions/microsoft/index.ts
Normal file
12
extensions/microsoft/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Microsoft plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildMicrosoftSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "microsoft",
|
||||
name: "Microsoft Speech",
|
||||
description: "Bundled Microsoft speech provider",
|
||||
register(api) {
|
||||
api.registerSpeechProvider(buildMicrosoftSpeechProvider());
|
||||
},
|
||||
});
|
||||
15
extensions/microsoft/microsoft.live.test.ts
Normal file
15
extensions/microsoft/microsoft.live.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Microsoft tests cover microsoft plugin behavior.
|
||||
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listMicrosoftVoices } from "./speech-provider.js";
|
||||
|
||||
const describeLive = isLiveTestEnabled() ? describe : describe.skip;
|
||||
|
||||
describeLive("microsoft plugin live", () => {
|
||||
it("lists Edge speech voices", async () => {
|
||||
const voices = await listMicrosoftVoices();
|
||||
|
||||
expect(voices.length).toBeGreaterThan(100);
|
||||
expect(voices.map((voice) => voice.id)).toContain("en-US-MichelleNeural");
|
||||
}, 60_000);
|
||||
});
|
||||
15
extensions/microsoft/openclaw.plugin.json
Normal file
15
extensions/microsoft/openclaw.plugin.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"id": "microsoft",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"contracts": {
|
||||
"speechProviders": ["microsoft"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
18
extensions/microsoft/package.json
Normal file
18
extensions/microsoft/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@openclaw/microsoft-speech",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Microsoft speech plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"node-edge-tts": "1.2.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
287
extensions/microsoft/speech-provider.test.ts
Normal file
287
extensions/microsoft/speech-provider.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
// Microsoft tests cover speech provider plugin behavior.
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
finalizeDebugProxyCapture,
|
||||
getDebugProxyCaptureStore,
|
||||
initializeDebugProxyCapture,
|
||||
} from "openclaw/plugin-sdk/proxy-capture";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { installDebugProxyTestResetHooks } from "../test-support/debug-proxy-env-test-helpers.js";
|
||||
|
||||
vi.mock("node-edge-tts", () => ({
|
||||
EdgeTTS: class {
|
||||
async ttsPromise(): Promise<void> {}
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
buildMicrosoftSpeechProvider,
|
||||
isCjkDominant,
|
||||
listMicrosoftVoices,
|
||||
} from "./speech-provider.js";
|
||||
import * as ttsModule from "./tts.js";
|
||||
|
||||
const TEST_CFG = {} as OpenClawConfig;
|
||||
|
||||
function requireFirstEdgeTtsCall(edgeSpy: ReturnType<typeof vi.spyOn>): {
|
||||
config?: unknown;
|
||||
outputPath: string;
|
||||
text?: string;
|
||||
timeoutMs?: number;
|
||||
} {
|
||||
const [call] = edgeSpy.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected Microsoft Edge TTS call");
|
||||
}
|
||||
const [edgeCall] = call;
|
||||
if (!edgeCall || typeof edgeCall !== "object" || Array.isArray(edgeCall)) {
|
||||
throw new Error("expected Microsoft Edge TTS call");
|
||||
}
|
||||
return edgeCall as {
|
||||
config?: unknown;
|
||||
outputPath: string;
|
||||
text?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
}
|
||||
|
||||
describe("listMicrosoftVoices", () => {
|
||||
const proxyReset = installDebugProxyTestResetHooks();
|
||||
|
||||
it("maps Microsoft voice metadata into speech voice options", async () => {
|
||||
globalThis.fetch = 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 },
|
||||
),
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
|
||||
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"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws on Microsoft voice list failures", async () => {
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response("nope", { status: 503 }),
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
|
||||
await expect(listMicrosoftVoices()).rejects.toThrow("Microsoft voices API error (503)");
|
||||
});
|
||||
|
||||
it("records voice discovery exchanges in debug proxy capture mode", async () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "microsoft-voices-capture-"));
|
||||
proxyReset.captureProxyEnv();
|
||||
process.env.OPENCLAW_DEBUG_PROXY_ENABLED = "1";
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID = "ms-voices-session";
|
||||
|
||||
globalThis.fetch = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
new Response(JSON.stringify([{ ShortName: "en-US-AvaNeural" }]), { status: 200 }),
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const store = getDebugProxyCaptureStore();
|
||||
store.upsertSession({
|
||||
id: "ms-voices-session",
|
||||
startedAt: Date.now(),
|
||||
mode: "test",
|
||||
sourceScope: "openclaw",
|
||||
sourceProcess: "openclaw",
|
||||
});
|
||||
|
||||
await listMicrosoftVoices();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const events = store.getSessionEvents("ms-voices-session", 10);
|
||||
expect(
|
||||
events.some(
|
||||
(event) => event.kind === "request" && event.host === "speech.platform.bing.com",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
events.some(
|
||||
(event) => event.kind === "response" && event.host === "speech.platform.bing.com",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not double-capture voice discovery when the global fetch patch is installed", async () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "microsoft-voices-global-"));
|
||||
proxyReset.captureProxyEnv();
|
||||
process.env.OPENCLAW_DEBUG_PROXY_ENABLED = "1";
|
||||
process.env.OPENCLAW_STATE_DIR = tempDir;
|
||||
process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID = "ms-voices-global-session";
|
||||
|
||||
globalThis.fetch = vi.fn(
|
||||
async () => new Response(JSON.stringify([{ ShortName: "en-US-AvaNeural" }]), { status: 200 }),
|
||||
) as unknown as typeof globalThis.fetch;
|
||||
|
||||
const store = getDebugProxyCaptureStore();
|
||||
store.upsertSession({
|
||||
id: "ms-voices-global-session",
|
||||
startedAt: Date.now(),
|
||||
mode: "test",
|
||||
sourceScope: "openclaw",
|
||||
sourceProcess: "openclaw",
|
||||
});
|
||||
initializeDebugProxyCapture("test");
|
||||
|
||||
try {
|
||||
await listMicrosoftVoices();
|
||||
|
||||
let events: Array<Record<string, unknown>> = [];
|
||||
await vi.waitFor(() => {
|
||||
events = store
|
||||
.getSessionEvents("ms-voices-global-session", 10)
|
||||
.filter((event) => event.host === "speech.platform.bing.com");
|
||||
expect(events).toHaveLength(2);
|
||||
});
|
||||
const kinds = events.map((event) => String(event.kind)).toSorted();
|
||||
expect(kinds).toEqual(["request", "response"]);
|
||||
} finally {
|
||||
globalThis.fetch = proxyReset.originalFetch;
|
||||
finalizeDebugProxyCapture();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isCjkDominant", () => {
|
||||
it("returns true for Chinese text", () => {
|
||||
expect(isCjkDominant("你好世界")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for mixed text with majority CJK", () => {
|
||||
expect(isCjkDominant("你好,这是一个测试 hello")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for English text", () => {
|
||||
expect(isCjkDominant("Hello, this is a test")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for empty string", () => {
|
||||
expect(isCjkDominant("")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for mostly English with a few CJK chars", () => {
|
||||
expect(isCjkDominant("This is a long English sentence with one 字")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMicrosoftSpeechProvider", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("switches to a Chinese voice for CJK text when no explicit voice override is set", async () => {
|
||||
const provider = buildMicrosoftSpeechProvider();
|
||||
const edgeSpy = vi.spyOn(ttsModule, "edgeTTS").mockImplementation(async ({ outputPath }) => {
|
||||
writeFileSync(outputPath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
|
||||
});
|
||||
|
||||
await provider.synthesize({
|
||||
text: "你好,这是一个测试 hello",
|
||||
cfg: TEST_CFG,
|
||||
providerConfig: {
|
||||
enabled: true,
|
||||
voice: "en-US-MichelleNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
outputFormatConfigured: true,
|
||||
saveSubtitles: false,
|
||||
},
|
||||
providerOverrides: {},
|
||||
timeoutMs: 1000,
|
||||
target: "audio-file",
|
||||
});
|
||||
|
||||
expect(edgeSpy).toHaveBeenCalledOnce();
|
||||
const edgeCall = requireFirstEdgeTtsCall(edgeSpy);
|
||||
expect(edgeCall.text).toBe("你好,这是一个测试 hello");
|
||||
expect(path.basename(edgeCall.outputPath)).toBe("speech.mp3");
|
||||
expect(edgeCall.timeoutMs).toBe(1000);
|
||||
expect(edgeCall.config).toEqual({
|
||||
enabled: true,
|
||||
voice: "zh-CN-XiaoxiaoNeural",
|
||||
lang: "zh-CN",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
outputFormatConfigured: true,
|
||||
pitch: undefined,
|
||||
rate: undefined,
|
||||
volume: undefined,
|
||||
saveSubtitles: false,
|
||||
proxy: undefined,
|
||||
timeoutMs: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an explicitly configured English voice for CJK text", async () => {
|
||||
const provider = buildMicrosoftSpeechProvider();
|
||||
const edgeSpy = vi.spyOn(ttsModule, "edgeTTS").mockImplementation(async ({ outputPath }) => {
|
||||
writeFileSync(outputPath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
|
||||
});
|
||||
|
||||
await provider.synthesize({
|
||||
text: "你好,这是一个测试 hello",
|
||||
cfg: TEST_CFG,
|
||||
providerConfig: {
|
||||
enabled: true,
|
||||
voice: "en-US-AvaNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
outputFormatConfigured: true,
|
||||
saveSubtitles: false,
|
||||
},
|
||||
providerOverrides: {},
|
||||
timeoutMs: 1000,
|
||||
target: "audio-file",
|
||||
});
|
||||
|
||||
expect(edgeSpy).toHaveBeenCalledOnce();
|
||||
const edgeCall = requireFirstEdgeTtsCall(edgeSpy);
|
||||
expect(edgeCall.text).toBe("你好,这是一个测试 hello");
|
||||
expect(path.basename(edgeCall.outputPath)).toBe("speech.mp3");
|
||||
expect(edgeCall.timeoutMs).toBe(1000);
|
||||
expect(edgeCall.config).toEqual({
|
||||
enabled: true,
|
||||
voice: "en-US-AvaNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
outputFormatConfigured: true,
|
||||
pitch: undefined,
|
||||
rate: undefined,
|
||||
volume: undefined,
|
||||
saveSubtitles: false,
|
||||
proxy: undefined,
|
||||
timeoutMs: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
302
extensions/microsoft/speech-provider.ts
Normal file
302
extensions/microsoft/speech-provider.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
// Microsoft provider module implements model/runtime integration.
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
CHROMIUM_FULL_VERSION,
|
||||
TRUSTED_CLIENT_TOKEN,
|
||||
generateSecMsGecToken,
|
||||
} from "node-edge-tts/dist/drm.js";
|
||||
import { isVoiceCompatibleAudio } from "openclaw/plugin-sdk/media-runtime";
|
||||
import {
|
||||
assertOkOrThrowProviderError,
|
||||
readProviderJsonResponse,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import {
|
||||
captureHttpExchange,
|
||||
isDebugProxyGlobalFetchPatchInstalled,
|
||||
} from "openclaw/plugin-sdk/proxy-capture";
|
||||
import type {
|
||||
SpeechProviderConfig,
|
||||
SpeechProviderPlugin,
|
||||
SpeechVoiceOption,
|
||||
} from "openclaw/plugin-sdk/speech";
|
||||
import { asBoolean, asFiniteNumber, asObject, trimToUndefined } from "openclaw/plugin-sdk/speech";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromHttpBaseUrlAllowedHostname,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { edgeTTS, inferEdgeExtension } from "./tts.js";
|
||||
|
||||
const DEFAULT_EDGE_VOICE = "en-US-MichelleNeural";
|
||||
const DEFAULT_EDGE_LANG = "en-US";
|
||||
const DEFAULT_EDGE_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3";
|
||||
|
||||
type MicrosoftProviderConfig = {
|
||||
enabled: boolean;
|
||||
voice: string;
|
||||
lang: string;
|
||||
outputFormat: string;
|
||||
outputFormatConfigured: boolean;
|
||||
pitch?: string;
|
||||
rate?: string;
|
||||
volume?: string;
|
||||
saveSubtitles: boolean;
|
||||
proxy?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type MicrosoftVoiceListEntry = {
|
||||
ShortName?: string;
|
||||
FriendlyName?: string;
|
||||
Locale?: string;
|
||||
Gender?: string;
|
||||
VoiceTag?: {
|
||||
ContentCategories?: string[];
|
||||
VoicePersonalities?: string[];
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeMicrosoftProviderConfig(
|
||||
rawConfig: Record<string, unknown>,
|
||||
): MicrosoftProviderConfig {
|
||||
const providers = asObject(rawConfig.providers);
|
||||
const rawEdge = asObject(rawConfig.edge);
|
||||
const rawMicrosoft = asObject(rawConfig.microsoft);
|
||||
const rawProviderMicrosoft = asObject(providers?.microsoft);
|
||||
const raw = { ...rawEdge, ...rawMicrosoft, ...rawProviderMicrosoft };
|
||||
const outputFormat = trimToUndefined(raw.outputFormat);
|
||||
return {
|
||||
enabled: asBoolean(raw.enabled) ?? true,
|
||||
voice: trimToUndefined(raw.voice) ?? DEFAULT_EDGE_VOICE,
|
||||
lang: trimToUndefined(raw.lang) ?? DEFAULT_EDGE_LANG,
|
||||
outputFormat: outputFormat ?? DEFAULT_EDGE_OUTPUT_FORMAT,
|
||||
outputFormatConfigured: Boolean(outputFormat),
|
||||
pitch: trimToUndefined(raw.pitch),
|
||||
rate: trimToUndefined(raw.rate),
|
||||
volume: trimToUndefined(raw.volume),
|
||||
saveSubtitles: asBoolean(raw.saveSubtitles) ?? false,
|
||||
proxy: trimToUndefined(raw.proxy),
|
||||
timeoutMs: asFiniteNumber(raw.timeoutMs),
|
||||
};
|
||||
}
|
||||
|
||||
function readMicrosoftProviderConfig(config: SpeechProviderConfig): MicrosoftProviderConfig {
|
||||
const defaults = normalizeMicrosoftProviderConfig({});
|
||||
return {
|
||||
enabled: asBoolean(config.enabled) ?? defaults.enabled,
|
||||
voice: trimToUndefined(config.voice) ?? defaults.voice,
|
||||
lang: trimToUndefined(config.lang) ?? defaults.lang,
|
||||
outputFormat: trimToUndefined(config.outputFormat) ?? defaults.outputFormat,
|
||||
outputFormatConfigured:
|
||||
asBoolean(config.outputFormatConfigured) ?? defaults.outputFormatConfigured,
|
||||
pitch: trimToUndefined(config.pitch) ?? defaults.pitch,
|
||||
rate: trimToUndefined(config.rate) ?? defaults.rate,
|
||||
volume: trimToUndefined(config.volume) ?? defaults.volume,
|
||||
saveSubtitles: asBoolean(config.saveSubtitles) ?? defaults.saveSubtitles,
|
||||
proxy: trimToUndefined(config.proxy) ?? defaults.proxy,
|
||||
timeoutMs: asFiniteNumber(config.timeoutMs) ?? defaults.timeoutMs,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMicrosoftVoiceHeaders(): Record<string, string> {
|
||||
const major = CHROMIUM_FULL_VERSION.split(".")[0] || "0";
|
||||
return {
|
||||
Authority: "speech.platform.bing.com",
|
||||
Origin: "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
|
||||
Accept: "*/*",
|
||||
"User-Agent":
|
||||
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ` +
|
||||
`(KHTML, like Gecko) Chrome/${major}.0.0.0 Safari/537.36 Edg/${major}.0.0.0`,
|
||||
"Sec-MS-GEC": generateSecMsGecToken(),
|
||||
"Sec-MS-GEC-Version": `1-${CHROMIUM_FULL_VERSION}`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMicrosoftVoiceDescription(entry: MicrosoftVoiceListEntry): string | undefined {
|
||||
const personalities = entry.VoiceTag?.VoicePersonalities?.filter(Boolean) ?? [];
|
||||
return personalities.length > 0 ? personalities.join(", ") : undefined;
|
||||
}
|
||||
|
||||
export function isCjkDominant(text: string): boolean {
|
||||
const stripped = text.replace(/\s+/g, "");
|
||||
if (stripped.length === 0) {
|
||||
return false;
|
||||
}
|
||||
let cjkCount = 0;
|
||||
for (const ch of stripped) {
|
||||
const code = ch.codePointAt(0) ?? 0;
|
||||
if (
|
||||
(code >= 0x4e00 && code <= 0x9fff) ||
|
||||
(code >= 0x3400 && code <= 0x4dbf) ||
|
||||
(code >= 0x3000 && code <= 0x303f) ||
|
||||
(code >= 0xff00 && code <= 0xffef)
|
||||
) {
|
||||
cjkCount += 1;
|
||||
}
|
||||
}
|
||||
return cjkCount / stripped.length > 0.3;
|
||||
}
|
||||
|
||||
const DEFAULT_CHINESE_EDGE_VOICE = "zh-CN-XiaoxiaoNeural";
|
||||
const DEFAULT_CHINESE_EDGE_LANG = "zh-CN";
|
||||
|
||||
export async function listMicrosoftVoices(): Promise<SpeechVoiceOption[]> {
|
||||
const url =
|
||||
"https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list" +
|
||||
`?trustedclienttoken=${TRUSTED_CLIENT_TOKEN}`;
|
||||
const headers = buildMicrosoftVoiceHeaders();
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
headers,
|
||||
},
|
||||
policy: ssrfPolicyFromHttpBaseUrlAllowedHostname("https://speech.platform.bing.com"),
|
||||
auditContext: "microsoft.speech.voices",
|
||||
});
|
||||
try {
|
||||
if (!isDebugProxyGlobalFetchPatchInstalled()) {
|
||||
captureHttpExchange({
|
||||
url,
|
||||
method: "GET",
|
||||
requestHeaders: headers,
|
||||
response,
|
||||
transport: "http",
|
||||
meta: {
|
||||
provider: "microsoft",
|
||||
capability: "speech-voices",
|
||||
},
|
||||
});
|
||||
}
|
||||
await assertOkOrThrowProviderError(response, "Microsoft voices API error");
|
||||
const voices = await readProviderJsonResponse<MicrosoftVoiceListEntry[]>(
|
||||
response,
|
||||
"microsoft.speech-voices",
|
||||
);
|
||||
return Array.isArray(voices)
|
||||
? voices
|
||||
.map((voice) => ({
|
||||
id: voice.ShortName?.trim() ?? "",
|
||||
name: trimToUndefined(voice.FriendlyName) ?? trimToUndefined(voice.ShortName),
|
||||
category: voice.VoiceTag?.ContentCategories?.find((value) => value.trim().length > 0),
|
||||
description: formatMicrosoftVoiceDescription(voice),
|
||||
locale: trimToUndefined(voice.Locale),
|
||||
gender: trimToUndefined(voice.Gender),
|
||||
personalities: voice.VoiceTag?.VoicePersonalities?.filter(
|
||||
(value): value is string => value.trim().length > 0,
|
||||
),
|
||||
}))
|
||||
.filter((voice) => voice.id.length > 0)
|
||||
: [];
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMicrosoftSpeechProvider(): SpeechProviderPlugin {
|
||||
return {
|
||||
id: "microsoft",
|
||||
label: "Microsoft",
|
||||
aliases: ["edge"],
|
||||
autoSelectOrder: 30,
|
||||
resolveConfig: ({ rawConfig }) => normalizeMicrosoftProviderConfig(rawConfig),
|
||||
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
|
||||
const base = normalizeMicrosoftProviderConfig(baseTtsConfig);
|
||||
return {
|
||||
...base,
|
||||
enabled: true,
|
||||
...(trimToUndefined(talkProviderConfig.voiceId) == null
|
||||
? {}
|
||||
: { voice: trimToUndefined(talkProviderConfig.voiceId) }),
|
||||
...(trimToUndefined(talkProviderConfig.languageCode) == null
|
||||
? {}
|
||||
: { lang: trimToUndefined(talkProviderConfig.languageCode) }),
|
||||
...(trimToUndefined(talkProviderConfig.outputFormat) == null
|
||||
? {}
|
||||
: { outputFormat: trimToUndefined(talkProviderConfig.outputFormat) }),
|
||||
...(trimToUndefined(talkProviderConfig.pitch) == null
|
||||
? {}
|
||||
: { pitch: trimToUndefined(talkProviderConfig.pitch) }),
|
||||
...(trimToUndefined(talkProviderConfig.rate) == null
|
||||
? {}
|
||||
: { rate: trimToUndefined(talkProviderConfig.rate) }),
|
||||
...(trimToUndefined(talkProviderConfig.volume) == null
|
||||
? {}
|
||||
: { volume: trimToUndefined(talkProviderConfig.volume) }),
|
||||
...(trimToUndefined(talkProviderConfig.proxy) == null
|
||||
? {}
|
||||
: { proxy: trimToUndefined(talkProviderConfig.proxy) }),
|
||||
...(asFiniteNumber(talkProviderConfig.timeoutMs) == null
|
||||
? {}
|
||||
: { timeoutMs: asFiniteNumber(talkProviderConfig.timeoutMs) }),
|
||||
};
|
||||
},
|
||||
resolveTalkOverrides: ({ params }) => ({
|
||||
...(trimToUndefined(params.voiceId) == null
|
||||
? {}
|
||||
: { voice: trimToUndefined(params.voiceId) }),
|
||||
...(trimToUndefined(params.outputFormat) == null
|
||||
? {}
|
||||
: { outputFormat: trimToUndefined(params.outputFormat) }),
|
||||
}),
|
||||
listVoices: async () => await listMicrosoftVoices(),
|
||||
isConfigured: ({ providerConfig }) => readMicrosoftProviderConfig(providerConfig).enabled,
|
||||
synthesize: async (req) => {
|
||||
const config = readMicrosoftProviderConfig(req.providerConfig);
|
||||
const temp = await tempWorkspace({
|
||||
rootDir: resolvePreferredOpenClawTmpDir(),
|
||||
prefix: "tts-microsoft-",
|
||||
});
|
||||
const tempDir = temp.dir;
|
||||
const overrideVoice = trimToUndefined(req.providerOverrides?.voice);
|
||||
let voice = overrideVoice ?? config.voice;
|
||||
let lang = config.lang;
|
||||
let outputFormat =
|
||||
trimToUndefined(req.providerOverrides?.outputFormat) ?? config.outputFormat;
|
||||
const fallbackOutputFormat =
|
||||
outputFormat !== DEFAULT_EDGE_OUTPUT_FORMAT ? DEFAULT_EDGE_OUTPUT_FORMAT : undefined;
|
||||
|
||||
if (!overrideVoice && voice === DEFAULT_EDGE_VOICE && isCjkDominant(req.text)) {
|
||||
voice = DEFAULT_CHINESE_EDGE_VOICE;
|
||||
lang = DEFAULT_CHINESE_EDGE_LANG;
|
||||
}
|
||||
|
||||
try {
|
||||
const runEdge = async (format: string) => {
|
||||
const fileExtension = inferEdgeExtension(format);
|
||||
const outputPath = path.join(tempDir, `speech${fileExtension}`);
|
||||
await edgeTTS({
|
||||
text: req.text,
|
||||
outputPath,
|
||||
config: {
|
||||
...config,
|
||||
voice,
|
||||
lang,
|
||||
outputFormat: format,
|
||||
},
|
||||
timeoutMs: req.timeoutMs,
|
||||
});
|
||||
const audioBuffer = readFileSync(outputPath);
|
||||
return {
|
||||
audioBuffer,
|
||||
outputFormat: format,
|
||||
fileExtension,
|
||||
voiceCompatible: isVoiceCompatibleAudio({ fileName: outputPath }),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
return await runEdge(outputFormat);
|
||||
} catch (error) {
|
||||
if (!fallbackOutputFormat || fallbackOutputFormat === outputFormat) {
|
||||
throw error;
|
||||
}
|
||||
outputFormat = fallbackOutputFormat;
|
||||
return await runEdge(outputFormat);
|
||||
}
|
||||
} finally {
|
||||
await temp.cleanup();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
2
extensions/microsoft/test-api.ts
Normal file
2
extensions/microsoft/test-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Microsoft API module exposes the plugin public contract.
|
||||
export { buildMicrosoftSpeechProvider } from "./speech-provider.js";
|
||||
16
extensions/microsoft/tsconfig.json
Normal file
16
extensions/microsoft/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
194
extensions/microsoft/tts.test.ts
Normal file
194
extensions/microsoft/tts.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
// Microsoft tests cover tts plugin behavior.
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let edgeTTS: typeof import("./tts.js").edgeTTS;
|
||||
|
||||
function createEdgeTTSDeps(
|
||||
ttsPromise: (text: string, filePath: string) => Promise<void>,
|
||||
onConstruct?: () => void,
|
||||
) {
|
||||
return {
|
||||
EdgeTTS: class {
|
||||
constructor() {
|
||||
onConstruct?.();
|
||||
}
|
||||
|
||||
ttsPromise(text: string, filePath: string) {
|
||||
return ttsPromise(text, filePath);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const baseEdgeConfig = {
|
||||
voice: "en-US-MichelleNeural",
|
||||
lang: "en-US",
|
||||
outputFormat: "audio-24khz-48kbitrate-mono-mp3",
|
||||
saveSubtitles: false,
|
||||
};
|
||||
|
||||
describe("edgeTTS empty audio validation", () => {
|
||||
let tempDir: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ edgeTTS } = await import("./tts.js"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects blank text before constructing Edge TTS", async () => {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
|
||||
const outputPath = path.join(tempDir, "voice.mp3");
|
||||
const onConstruct = vi.fn();
|
||||
const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => {
|
||||
writeFileSync(filePath, Buffer.from([0xff]));
|
||||
}, onConstruct);
|
||||
|
||||
await expect(
|
||||
edgeTTS(
|
||||
{
|
||||
text: " \n\t ",
|
||||
outputPath,
|
||||
config: baseEdgeConfig,
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).rejects.toThrow("Microsoft TTS text cannot be empty");
|
||||
expect(onConstruct).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws after one retry when the output file stays empty", async () => {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
|
||||
const outputPath = path.join(tempDir, "voice.mp3");
|
||||
const calls: string[] = [];
|
||||
|
||||
const deps = createEdgeTTSDeps(async (text: string, filePath: string) => {
|
||||
calls.push(text);
|
||||
writeFileSync(filePath, "");
|
||||
});
|
||||
|
||||
await expect(
|
||||
edgeTTS(
|
||||
{
|
||||
text: "Hello",
|
||||
outputPath,
|
||||
config: baseEdgeConfig,
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).rejects.toThrow("Edge TTS produced empty audio file after retry");
|
||||
expect(calls).toEqual(["Hello", "Hello"]);
|
||||
});
|
||||
|
||||
it("succeeds when the output file has content", async () => {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
|
||||
const outputPath = path.join(tempDir, "voice.mp3");
|
||||
let stagedPath = "";
|
||||
|
||||
const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => {
|
||||
stagedPath = filePath;
|
||||
writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
|
||||
});
|
||||
|
||||
await expect(
|
||||
edgeTTS(
|
||||
{
|
||||
text: "Hello",
|
||||
outputPath,
|
||||
config: baseEdgeConfig,
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(stagedPath).not.toBe(outputPath);
|
||||
expect(path.basename(stagedPath)).toContain(path.basename(outputPath));
|
||||
expect(path.basename(stagedPath)).toMatch(/\.part$/);
|
||||
expect(readFileSync(outputPath)).toEqual(Buffer.from([0xff, 0xfb, 0x90, 0x00]));
|
||||
expect(existsSync(stagedPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("retries once when the first output file is empty", async () => {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
|
||||
const outputPath = path.join(tempDir, "voice.mp3");
|
||||
const calls: string[] = [];
|
||||
|
||||
const deps = createEdgeTTSDeps(async (text: string, filePath: string) => {
|
||||
calls.push(text);
|
||||
writeFileSync(filePath, calls.length === 1 ? "" : Buffer.from([0xff, 0xfb, 0x90, 0x00]));
|
||||
});
|
||||
|
||||
await expect(
|
||||
edgeTTS(
|
||||
{
|
||||
text: "Hello",
|
||||
outputPath,
|
||||
config: baseEdgeConfig,
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(calls).toEqual(["Hello", "Hello"]);
|
||||
});
|
||||
|
||||
it("retries once when Edge TTS resolves without creating an output file", async () => {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
|
||||
const outputPath = path.join(tempDir, "voice.mp3");
|
||||
const calls: string[] = [];
|
||||
|
||||
const deps = createEdgeTTSDeps(async (text: string, filePath: string) => {
|
||||
calls.push(text);
|
||||
if (calls.length === 2) {
|
||||
writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
edgeTTS(
|
||||
{
|
||||
text: "Hello",
|
||||
outputPath,
|
||||
config: baseEdgeConfig,
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(calls).toEqual(["Hello", "Hello"]);
|
||||
});
|
||||
|
||||
it("does not retry provider errors", async () => {
|
||||
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
|
||||
const outputPath = path.join(tempDir, "voice.mp3");
|
||||
const calls: string[] = [];
|
||||
|
||||
const deps = createEdgeTTSDeps(async (text: string) => {
|
||||
calls.push(text);
|
||||
throw new Error("upstream timeout");
|
||||
});
|
||||
|
||||
await expect(
|
||||
edgeTTS(
|
||||
{
|
||||
text: "Hello",
|
||||
outputPath,
|
||||
config: baseEdgeConfig,
|
||||
timeoutMs: 10000,
|
||||
},
|
||||
deps,
|
||||
),
|
||||
).rejects.toThrow("upstream timeout");
|
||||
expect(calls).toEqual(["Hello"]);
|
||||
});
|
||||
});
|
||||
138
extensions/microsoft/tts.ts
Normal file
138
extensions/microsoft/tts.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// Microsoft plugin module implements tts behavior.
|
||||
import { statSync, writeFileSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
type EdgeTTSRuntimeConfig = {
|
||||
voice?: string;
|
||||
lang?: string;
|
||||
outputFormat?: string;
|
||||
saveSubtitles?: boolean;
|
||||
proxy?: string;
|
||||
rate?: string;
|
||||
pitch?: string;
|
||||
volume?: string;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
type EdgeTTSDeps = {
|
||||
EdgeTTS: new (config: EdgeTTSRuntimeConfig) => {
|
||||
ttsPromise: (text: string, outputPath: string) => Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
async function loadDefaultEdgeTTSDeps(): Promise<EdgeTTSDeps> {
|
||||
const { EdgeTTS } = await import("node-edge-tts");
|
||||
return { EdgeTTS };
|
||||
}
|
||||
|
||||
function isMissingOutputFileError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
function readOutputSize(outputPath: string): number {
|
||||
try {
|
||||
return statSync(outputPath).size;
|
||||
} catch (error) {
|
||||
if (isMissingOutputFileError(error)) {
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function inferEdgeExtension(outputFormat: string): string {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(outputFormat);
|
||||
if (normalized.includes("webm")) {
|
||||
return ".webm";
|
||||
}
|
||||
if (normalized.includes("ogg")) {
|
||||
return ".ogg";
|
||||
}
|
||||
if (normalized.includes("opus")) {
|
||||
return ".opus";
|
||||
}
|
||||
if (normalized.includes("wav") || normalized.includes("riff") || normalized.includes("pcm")) {
|
||||
return ".wav";
|
||||
}
|
||||
return ".mp3";
|
||||
}
|
||||
|
||||
export async function edgeTTS(
|
||||
params: {
|
||||
text: string;
|
||||
outputPath: string;
|
||||
config: {
|
||||
voice: string;
|
||||
lang: string;
|
||||
outputFormat: string;
|
||||
saveSubtitles: boolean;
|
||||
proxy?: string;
|
||||
rate?: string;
|
||||
pitch?: string;
|
||||
volume?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
timeoutMs: number;
|
||||
},
|
||||
deps?: EdgeTTSDeps,
|
||||
): Promise<void> {
|
||||
const { text, outputPath, config, timeoutMs } = params;
|
||||
if (text.trim().length === 0) {
|
||||
throw new Error("Microsoft TTS text cannot be empty");
|
||||
}
|
||||
|
||||
const resolvedDeps = deps ?? (await loadDefaultEdgeTTSDeps());
|
||||
const tts = new resolvedDeps.EdgeTTS({
|
||||
voice: config.voice,
|
||||
lang: config.lang,
|
||||
outputFormat: config.outputFormat,
|
||||
saveSubtitles: config.saveSubtitles,
|
||||
proxy: config.proxy,
|
||||
rate: config.rate,
|
||||
pitch: config.pitch,
|
||||
volume: config.volume,
|
||||
timeout: config.timeoutMs ?? timeoutMs,
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const outputSize = await writeEdgeTtsOutput({
|
||||
outputPath,
|
||||
ttsPromise: async (tempPath) => {
|
||||
await tts.ttsPromise(text, tempPath);
|
||||
},
|
||||
});
|
||||
if (outputSize > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new Error("Edge TTS produced empty audio file after retry");
|
||||
}
|
||||
|
||||
async function writeEdgeTtsOutput(params: {
|
||||
outputPath: string;
|
||||
ttsPromise: (tempPath: string) => Promise<void>;
|
||||
}): Promise<number> {
|
||||
const rootDir = path.dirname(params.outputPath);
|
||||
await mkdir(rootDir, { recursive: true });
|
||||
let outputSize = 0;
|
||||
await writeExternalFileWithinRoot({
|
||||
rootDir,
|
||||
path: path.basename(params.outputPath),
|
||||
write: async (tempPath) => {
|
||||
await params.ttsPromise(tempPath);
|
||||
outputSize = readOutputSize(tempPath);
|
||||
if (outputSize === 0) {
|
||||
writeFileSync(tempPath, "");
|
||||
}
|
||||
},
|
||||
});
|
||||
return outputSize;
|
||||
}
|
||||
Reference in New Issue
Block a user