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/inworld/README.md
Normal file
12
extensions/inworld/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# OpenClaw Inworld Plugin
|
||||
|
||||
Official OpenClaw plugin for Inworld.
|
||||
|
||||
Install from OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw plugins install @openclaw/inworld-speech
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
See <https://docs.openclaw.ai/providers/inworld> for setup and configuration.
|
||||
12
extensions/inworld/index.ts
Normal file
12
extensions/inworld/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Inworld plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildInworldSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "inworld",
|
||||
name: "Inworld Speech",
|
||||
description: "Bundled Inworld speech provider",
|
||||
register(api) {
|
||||
api.registerSpeechProvider(buildInworldSpeechProvider());
|
||||
},
|
||||
});
|
||||
85
extensions/inworld/inworld.live.test.ts
Normal file
85
extensions/inworld/inworld.live.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Inworld tests cover inworld plugin behavior.
|
||||
import {
|
||||
registerProviderPlugin,
|
||||
requireRegisteredProvider,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { isLiveTestEnabled } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
|
||||
const INWORLD_API_KEY = process.env.INWORLD_API_KEY?.trim() ?? "";
|
||||
const LIVE = isLiveTestEnabled() && INWORLD_API_KEY.length > 0;
|
||||
const describeLive = LIVE ? describe : describe.skip;
|
||||
|
||||
const registerInworldPlugin = () =>
|
||||
registerProviderPlugin({
|
||||
plugin,
|
||||
id: "inworld",
|
||||
name: "Inworld",
|
||||
});
|
||||
|
||||
describeLive("inworld plugin live", () => {
|
||||
it("lists voices through the registered speech provider", async () => {
|
||||
const { speechProviders } = await registerInworldPlugin();
|
||||
const provider = requireRegisteredProvider(speechProviders, "inworld");
|
||||
|
||||
const voices = await provider.listVoices?.({
|
||||
apiKey: INWORLD_API_KEY,
|
||||
});
|
||||
|
||||
expect(voices?.length).toBeGreaterThan(0);
|
||||
expect(voices?.some((voice) => voice.id === "Sarah")).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it("synthesizes MP3, native voice-note Ogg/Opus, and telephony PCM", async () => {
|
||||
const { speechProviders } = await registerInworldPlugin();
|
||||
const provider = requireRegisteredProvider(speechProviders, "inworld");
|
||||
const providerConfig = {
|
||||
apiKey: INWORLD_API_KEY,
|
||||
voiceId: "Sarah",
|
||||
modelId: "inworld-tts-1.5-max",
|
||||
};
|
||||
|
||||
const audioFile = await provider.synthesize({
|
||||
text: "OpenClaw Inworld text to speech integration test OK.",
|
||||
cfg: { plugins: { enabled: true } } as never,
|
||||
providerConfig,
|
||||
target: "audio-file",
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
|
||||
expect(audioFile.outputFormat).toBe("mp3");
|
||||
expect(audioFile.fileExtension).toBe(".mp3");
|
||||
expect(audioFile.voiceCompatible).toBe(false);
|
||||
expect(audioFile.audioBuffer.byteLength).toBeGreaterThan(512);
|
||||
expect(audioFile.audioBuffer.subarray(0, 4).toString("ascii")).not.toBe("RIFF");
|
||||
|
||||
const voiceNote = await provider.synthesize({
|
||||
text: "OpenClaw Inworld voice note integration test OK.",
|
||||
cfg: { plugins: { enabled: true } } as never,
|
||||
providerConfig,
|
||||
target: "voice-note",
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
|
||||
expect(voiceNote.outputFormat).toBe("ogg_opus");
|
||||
expect(voiceNote.fileExtension).toBe(".ogg");
|
||||
expect(voiceNote.voiceCompatible).toBe(true);
|
||||
expect(voiceNote.audioBuffer.byteLength).toBeGreaterThan(128);
|
||||
expect(voiceNote.audioBuffer.subarray(0, 4).toString("ascii")).toBe("OggS");
|
||||
|
||||
const telephony = await provider.synthesizeTelephony?.({
|
||||
text: "OpenClaw Inworld telephony check OK.",
|
||||
cfg: { plugins: { enabled: true } } as never,
|
||||
providerConfig,
|
||||
timeoutMs: 90_000,
|
||||
});
|
||||
if (!telephony) {
|
||||
throw new Error("Inworld telephony synthesis did not return audio");
|
||||
}
|
||||
expect(telephony.outputFormat).toBe("pcm");
|
||||
expect(telephony.sampleRate).toBe(22_050);
|
||||
expect(telephony.audioBuffer.byteLength).toBeGreaterThan(512);
|
||||
expect(telephony.audioBuffer.subarray(0, 4).toString("ascii")).not.toBe("RIFF");
|
||||
}, 180_000);
|
||||
});
|
||||
12
extensions/inworld/npm-shrinkwrap.json
generated
Normal file
12
extensions/inworld/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@openclaw/inworld-speech",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/inworld-speech",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
48
extensions/inworld/openclaw.plugin.json
Normal file
48
extensions/inworld/openclaw.plugin.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"id": "inworld",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"name": "Inworld",
|
||||
"description": "Inworld streaming text-to-speech (MP3, OGG_OPUS, PCM telephony).",
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "inworld",
|
||||
"envVars": ["INWORLD_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"contracts": {
|
||||
"speechProviders": ["inworld"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"description": "Inworld API key. Must be the Base64 credential string from the Inworld dashboard (used as Authorization: Basic <apiKey>). Falls back to INWORLD_API_KEY env var."
|
||||
},
|
||||
"baseUrl": {
|
||||
"type": "string",
|
||||
"description": "Override Inworld API base URL (default https://api.inworld.ai)."
|
||||
},
|
||||
"voiceId": {
|
||||
"type": "string",
|
||||
"description": "Voice identifier (default Sarah)."
|
||||
},
|
||||
"modelId": {
|
||||
"type": "string",
|
||||
"description": "TTS model id (default inworld-tts-1.5-max)."
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 2,
|
||||
"description": "Sampling temperature 0..2."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
extensions/inworld/package.json
Normal file
35
extensions/inworld/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@openclaw/inworld-speech",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw Inworld speech plugin.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
},
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/inworld-speech",
|
||||
"npmSpec": "@openclaw/inworld-speech",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.8"
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
}
|
||||
}
|
||||
270
extensions/inworld/speech-provider.test.ts
Normal file
270
extensions/inworld/speech-provider.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
// Inworld tests cover speech provider plugin behavior.
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { inworldTTSMock, listInworldVoicesMock } = vi.hoisted(() => ({
|
||||
inworldTTSMock: vi.fn(),
|
||||
listInworldVoicesMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./tts.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./tts.js")>();
|
||||
return {
|
||||
...actual,
|
||||
inworldTTS: inworldTTSMock,
|
||||
listInworldVoices: listInworldVoicesMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { buildInworldSpeechProvider } from "./speech-provider.js";
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./tts.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("buildInworldSpeechProvider", () => {
|
||||
afterEach(() => {
|
||||
inworldTTSMock.mockReset();
|
||||
listInworldVoicesMock.mockReset();
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports configured when INWORLD_API_KEY env var is set", () => {
|
||||
vi.stubEnv("INWORLD_API_KEY", "test-key");
|
||||
const provider = buildInworldSpeechProvider();
|
||||
expect(
|
||||
provider.isConfigured({
|
||||
providerConfig: {},
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports configured when providerConfig apiKey is set", () => {
|
||||
vi.stubEnv("INWORLD_API_KEY", "");
|
||||
const provider = buildInworldSpeechProvider();
|
||||
expect(
|
||||
provider.isConfigured({
|
||||
providerConfig: { apiKey: "config-key" },
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("reports not configured when no key is available", () => {
|
||||
vi.stubEnv("INWORLD_API_KEY", "");
|
||||
const provider = buildInworldSpeechProvider();
|
||||
expect(
|
||||
provider.isConfigured({
|
||||
providerConfig: {},
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("has correct provider metadata", () => {
|
||||
const provider = buildInworldSpeechProvider();
|
||||
expect(provider.id).toBe("inworld");
|
||||
expect(provider.label).toBe("Inworld");
|
||||
expect(provider.autoSelectOrder).toBe(30);
|
||||
expect(provider.models).toContain("inworld-tts-1.5-max");
|
||||
expect(provider.models).toContain("inworld-tts-1.5-mini");
|
||||
});
|
||||
|
||||
it("normalizes provider-owned speech config from raw provider config", () => {
|
||||
const provider = buildInworldSpeechProvider();
|
||||
const resolved = provider.resolveConfig?.({
|
||||
cfg: {} as never,
|
||||
timeoutMs: 30_000,
|
||||
rawConfig: {
|
||||
providers: {
|
||||
inworld: {
|
||||
apiKey: "basic-key",
|
||||
baseUrl: "https://custom.inworld.example.com/",
|
||||
voiceId: "Ashley",
|
||||
modelId: "inworld-tts-1.5-mini",
|
||||
temperature: 0.8,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({
|
||||
apiKey: "basic-key",
|
||||
baseUrl: "https://custom.inworld.example.com",
|
||||
voiceId: "Ashley",
|
||||
modelId: "inworld-tts-1.5-mini",
|
||||
temperature: 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses Inworld TTS directive overrides", () => {
|
||||
const provider = buildInworldSpeechProvider();
|
||||
const policy = {
|
||||
enabled: true,
|
||||
allowText: true,
|
||||
allowProvider: true,
|
||||
allowVoice: true,
|
||||
allowModelId: true,
|
||||
allowVoiceSettings: true,
|
||||
allowNormalization: true,
|
||||
allowSeed: true,
|
||||
};
|
||||
|
||||
const parseDirectiveToken = provider.parseDirectiveToken;
|
||||
expect(parseDirectiveToken).toBeTypeOf("function");
|
||||
if (!parseDirectiveToken) {
|
||||
throw new Error("expected Inworld directive parser");
|
||||
}
|
||||
|
||||
expect(parseDirectiveToken({ key: "voice", value: "Ashley", policy })).toEqual({
|
||||
handled: true,
|
||||
overrides: { voiceId: "Ashley" },
|
||||
});
|
||||
expect(
|
||||
parseDirectiveToken({
|
||||
key: "model",
|
||||
value: "inworld-tts-1.5-mini",
|
||||
policy,
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
overrides: { modelId: "inworld-tts-1.5-mini" },
|
||||
});
|
||||
expect(parseDirectiveToken({ key: "temperature", value: "0.7", policy })).toEqual({
|
||||
handled: true,
|
||||
overrides: { temperature: 0.7 },
|
||||
});
|
||||
});
|
||||
|
||||
it("warns on invalid directive temperature", () => {
|
||||
const provider = buildInworldSpeechProvider();
|
||||
expect(
|
||||
provider.parseDirectiveToken?.({
|
||||
key: "temperature",
|
||||
value: "3",
|
||||
policy: {
|
||||
enabled: true,
|
||||
allowText: true,
|
||||
allowProvider: true,
|
||||
allowVoice: true,
|
||||
allowModelId: true,
|
||||
allowVoiceSettings: true,
|
||||
allowNormalization: true,
|
||||
allowSeed: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
warnings: ['invalid Inworld temperature "3"'],
|
||||
});
|
||||
});
|
||||
|
||||
it("warns on non-decimal directive temperature", () => {
|
||||
const provider = buildInworldSpeechProvider();
|
||||
expect(
|
||||
provider.parseDirectiveToken?.({
|
||||
key: "temperature",
|
||||
value: "0x1",
|
||||
policy: {
|
||||
enabled: true,
|
||||
allowText: true,
|
||||
allowProvider: true,
|
||||
allowVoice: true,
|
||||
allowModelId: true,
|
||||
allowVoiceSettings: true,
|
||||
allowNormalization: true,
|
||||
allowSeed: true,
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
handled: true,
|
||||
warnings: ['invalid Inworld temperature "0x1"'],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops malformed temperature values before synthesis", async () => {
|
||||
inworldTTSMock.mockResolvedValueOnce(Buffer.from("audio"));
|
||||
const provider = buildInworldSpeechProvider();
|
||||
|
||||
await provider.synthesize?.({
|
||||
text: "Hello",
|
||||
cfg: {} as never,
|
||||
providerConfig: {
|
||||
apiKey: "key",
|
||||
voiceId: "Sarah",
|
||||
modelId: "inworld-tts-1.5-max",
|
||||
temperature: 0,
|
||||
},
|
||||
providerOverrides: { temperature: 3 },
|
||||
target: "audio-file",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(inworldTTSMock).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ temperature: expect.any(Number) }),
|
||||
);
|
||||
});
|
||||
|
||||
it("synthesizes voice-note targets with native OGG_OPUS output", async () => {
|
||||
inworldTTSMock.mockResolvedValueOnce(Buffer.from("opus"));
|
||||
const provider = buildInworldSpeechProvider();
|
||||
|
||||
const result = await provider.synthesize?.({
|
||||
text: "Hello",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "key", voiceId: "Sarah", modelId: "inworld-tts-1.5-max" },
|
||||
providerOverrides: { voice: "Ashley", model: "inworld-tts-1.5-mini", temperature: 0.6 },
|
||||
target: "voice-note",
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(inworldTTSMock).toHaveBeenCalledWith({
|
||||
text: "Hello",
|
||||
apiKey: "key",
|
||||
baseUrl: "https://api.inworld.ai",
|
||||
voiceId: "Ashley",
|
||||
modelId: "inworld-tts-1.5-mini",
|
||||
audioEncoding: "OGG_OPUS",
|
||||
temperature: 0.6,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
audioBuffer: Buffer.from("opus"),
|
||||
outputFormat: "ogg_opus",
|
||||
fileExtension: ".ogg",
|
||||
voiceCompatible: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("synthesizes telephony PCM at 22050 Hz", async () => {
|
||||
inworldTTSMock.mockResolvedValueOnce(Buffer.from("pcm"));
|
||||
const provider = buildInworldSpeechProvider();
|
||||
|
||||
const result = await provider.synthesizeTelephony?.({
|
||||
text: "Hello",
|
||||
cfg: {} as never,
|
||||
providerConfig: { apiKey: "key", voiceId: "Sarah", modelId: "inworld-tts-1.5-max" },
|
||||
providerOverrides: { voice: "Ashley", model: "inworld-tts-1.5-mini", temperature: 0.6 },
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
expect(inworldTTSMock).toHaveBeenCalledWith({
|
||||
text: "Hello",
|
||||
apiKey: "key",
|
||||
baseUrl: "https://api.inworld.ai",
|
||||
voiceId: "Ashley",
|
||||
modelId: "inworld-tts-1.5-mini",
|
||||
audioEncoding: "PCM",
|
||||
sampleRateHertz: 22_050,
|
||||
temperature: 0.6,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
expect(result).toEqual({
|
||||
audioBuffer: Buffer.from("pcm"),
|
||||
outputFormat: "pcm",
|
||||
sampleRate: 22_050,
|
||||
});
|
||||
});
|
||||
});
|
||||
231
extensions/inworld/speech-provider.ts
Normal file
231
extensions/inworld/speech-provider.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// Inworld provider module implements model/runtime integration.
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import type {
|
||||
SpeechDirectiveTokenParseContext,
|
||||
SpeechProviderConfig,
|
||||
SpeechProviderOverrides,
|
||||
SpeechProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/speech-core";
|
||||
import {
|
||||
asObject,
|
||||
parseSpeechDirectiveNumberOverride,
|
||||
trimToUndefined,
|
||||
} from "openclaw/plugin-sdk/speech-core";
|
||||
import { asFiniteNumberInRange } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
DEFAULT_INWORLD_MODEL_ID,
|
||||
DEFAULT_INWORLD_VOICE_ID,
|
||||
type InworldAudioEncoding,
|
||||
INWORLD_TTS_MODELS,
|
||||
inworldTTS,
|
||||
listInworldVoices,
|
||||
normalizeInworldBaseUrl,
|
||||
} from "./tts.js";
|
||||
|
||||
type InworldProviderConfig = {
|
||||
apiKey?: string;
|
||||
baseUrl: string;
|
||||
voiceId: string;
|
||||
modelId: string;
|
||||
temperature?: number;
|
||||
};
|
||||
|
||||
type InworldProviderOverrides = {
|
||||
voiceId?: string;
|
||||
modelId?: string;
|
||||
temperature?: number;
|
||||
};
|
||||
|
||||
function normalizeInworldTemperature(value: unknown): number | undefined {
|
||||
return asFiniteNumberInRange(value, { min: 0, minExclusive: true, max: 2 });
|
||||
}
|
||||
|
||||
function normalizeInworldProviderConfig(rawConfig: Record<string, unknown>): InworldProviderConfig {
|
||||
const providers = asObject(rawConfig.providers);
|
||||
const raw = asObject(providers?.inworld) ?? asObject(rawConfig.inworld);
|
||||
return {
|
||||
apiKey: normalizeResolvedSecretInputString({
|
||||
value: raw?.apiKey,
|
||||
path: "messages.tts.providers.inworld.apiKey",
|
||||
}),
|
||||
baseUrl: normalizeInworldBaseUrl(trimToUndefined(raw?.baseUrl)),
|
||||
voiceId: trimToUndefined(raw?.voiceId) ?? DEFAULT_INWORLD_VOICE_ID,
|
||||
modelId: trimToUndefined(raw?.modelId) ?? DEFAULT_INWORLD_MODEL_ID,
|
||||
temperature: normalizeInworldTemperature(raw?.temperature),
|
||||
};
|
||||
}
|
||||
|
||||
function readInworldProviderConfig(config: SpeechProviderConfig): InworldProviderConfig {
|
||||
const defaults = normalizeInworldProviderConfig({});
|
||||
return {
|
||||
apiKey: trimToUndefined(config.apiKey) ?? defaults.apiKey,
|
||||
baseUrl: normalizeInworldBaseUrl(trimToUndefined(config.baseUrl) ?? defaults.baseUrl),
|
||||
voiceId: trimToUndefined(config.voiceId) ?? defaults.voiceId,
|
||||
modelId: trimToUndefined(config.modelId) ?? defaults.modelId,
|
||||
temperature: normalizeInworldTemperature(config.temperature) ?? defaults.temperature,
|
||||
};
|
||||
}
|
||||
|
||||
function readInworldOverrides(
|
||||
overrides: SpeechProviderOverrides | undefined,
|
||||
): InworldProviderOverrides {
|
||||
if (!overrides) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
voiceId: trimToUndefined(overrides.voiceId ?? overrides.voice),
|
||||
modelId: trimToUndefined(overrides.modelId ?? overrides.model),
|
||||
temperature: normalizeInworldTemperature(overrides.temperature),
|
||||
};
|
||||
}
|
||||
|
||||
function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
|
||||
handled: boolean;
|
||||
overrides?: SpeechProviderOverrides;
|
||||
warnings?: string[];
|
||||
} {
|
||||
switch (ctx.key) {
|
||||
case "voice":
|
||||
case "voiceid":
|
||||
case "voice_id":
|
||||
case "inworld_voice":
|
||||
case "inworldvoice":
|
||||
if (!ctx.policy.allowVoice) {
|
||||
return { handled: true };
|
||||
}
|
||||
return { handled: true, overrides: { voiceId: ctx.value } };
|
||||
case "model":
|
||||
case "modelid":
|
||||
case "model_id":
|
||||
case "inworld_model":
|
||||
case "inworldmodel":
|
||||
if (!ctx.policy.allowModelId) {
|
||||
return { handled: true };
|
||||
}
|
||||
return { handled: true, overrides: { modelId: ctx.value } };
|
||||
case "temperature": {
|
||||
return parseSpeechDirectiveNumberOverride({
|
||||
ctx,
|
||||
overrideKey: "temperature",
|
||||
range: { min: 0, minExclusive: true, max: 2 },
|
||||
warning: (value) => `invalid Inworld temperature "${value}"`,
|
||||
});
|
||||
}
|
||||
default:
|
||||
return { handled: false };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInworldSpeechProvider(): SpeechProviderPlugin {
|
||||
return {
|
||||
id: "inworld",
|
||||
label: "Inworld",
|
||||
autoSelectOrder: 30,
|
||||
defaultModel: DEFAULT_INWORLD_MODEL_ID,
|
||||
models: INWORLD_TTS_MODELS,
|
||||
resolveConfig: ({ rawConfig }) => normalizeInworldProviderConfig(rawConfig),
|
||||
parseDirectiveToken,
|
||||
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
|
||||
const base = normalizeInworldProviderConfig(baseTtsConfig);
|
||||
const resolvedApiKey =
|
||||
talkProviderConfig.apiKey === undefined
|
||||
? undefined
|
||||
: normalizeResolvedSecretInputString({
|
||||
value: talkProviderConfig.apiKey,
|
||||
path: "talk.providers.inworld.apiKey",
|
||||
});
|
||||
return {
|
||||
...base,
|
||||
...(resolvedApiKey === undefined ? {} : { apiKey: resolvedApiKey }),
|
||||
...(trimToUndefined(talkProviderConfig.baseUrl) == null
|
||||
? {}
|
||||
: { baseUrl: normalizeInworldBaseUrl(trimToUndefined(talkProviderConfig.baseUrl)) }),
|
||||
...(trimToUndefined(talkProviderConfig.voiceId) == null
|
||||
? {}
|
||||
: { voiceId: trimToUndefined(talkProviderConfig.voiceId) }),
|
||||
...(trimToUndefined(talkProviderConfig.modelId) == null
|
||||
? {}
|
||||
: { modelId: trimToUndefined(talkProviderConfig.modelId) }),
|
||||
...(normalizeInworldTemperature(talkProviderConfig.temperature) == null
|
||||
? {}
|
||||
: { temperature: normalizeInworldTemperature(talkProviderConfig.temperature) }),
|
||||
};
|
||||
},
|
||||
resolveTalkOverrides: ({ params }) => ({
|
||||
...(trimToUndefined(params.voiceId) == null
|
||||
? {}
|
||||
: { voiceId: trimToUndefined(params.voiceId) }),
|
||||
...(trimToUndefined(params.modelId) == null
|
||||
? {}
|
||||
: { modelId: trimToUndefined(params.modelId) }),
|
||||
...(normalizeInworldTemperature(params.temperature) == null
|
||||
? {}
|
||||
: { temperature: normalizeInworldTemperature(params.temperature) }),
|
||||
}),
|
||||
listVoices: async (req) => {
|
||||
const config = req.providerConfig ? readInworldProviderConfig(req.providerConfig) : undefined;
|
||||
const apiKey = req.apiKey || config?.apiKey || process.env.INWORLD_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Inworld API key missing");
|
||||
}
|
||||
return listInworldVoices({
|
||||
apiKey,
|
||||
baseUrl: req.baseUrl ?? config?.baseUrl,
|
||||
});
|
||||
},
|
||||
isConfigured: ({ providerConfig }) =>
|
||||
Boolean(readInworldProviderConfig(providerConfig).apiKey || process.env.INWORLD_API_KEY),
|
||||
synthesize: async (req) => {
|
||||
const config = readInworldProviderConfig(req.providerConfig);
|
||||
const overrides = readInworldOverrides(req.providerOverrides);
|
||||
const apiKey = config.apiKey || process.env.INWORLD_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Inworld API key missing");
|
||||
}
|
||||
|
||||
const useOpus = req.target === "voice-note";
|
||||
const audioEncoding: InworldAudioEncoding = useOpus ? "OGG_OPUS" : "MP3";
|
||||
|
||||
const audioBuffer = await inworldTTS({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
voiceId: overrides.voiceId ?? config.voiceId,
|
||||
modelId: overrides.modelId ?? config.modelId,
|
||||
audioEncoding,
|
||||
temperature: overrides.temperature ?? config.temperature,
|
||||
timeoutMs: req.timeoutMs,
|
||||
});
|
||||
|
||||
return {
|
||||
audioBuffer,
|
||||
outputFormat: audioEncoding.toLowerCase(),
|
||||
fileExtension: useOpus ? ".ogg" : ".mp3",
|
||||
voiceCompatible: useOpus,
|
||||
};
|
||||
},
|
||||
synthesizeTelephony: async (req) => {
|
||||
const config = readInworldProviderConfig(req.providerConfig);
|
||||
const overrides = readInworldOverrides(req.providerOverrides);
|
||||
const apiKey = config.apiKey || process.env.INWORLD_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Inworld API key missing");
|
||||
}
|
||||
|
||||
const sampleRate = 22_050;
|
||||
const audioBuffer = await inworldTTS({
|
||||
text: req.text,
|
||||
apiKey,
|
||||
baseUrl: config.baseUrl,
|
||||
voiceId: overrides.voiceId ?? config.voiceId,
|
||||
modelId: overrides.modelId ?? config.modelId,
|
||||
audioEncoding: "PCM",
|
||||
sampleRateHertz: sampleRate,
|
||||
temperature: overrides.temperature ?? config.temperature,
|
||||
timeoutMs: req.timeoutMs,
|
||||
});
|
||||
|
||||
return { audioBuffer, outputFormat: "pcm", sampleRate };
|
||||
},
|
||||
};
|
||||
}
|
||||
16
extensions/inworld/tsconfig.json
Normal file
16
extensions/inworld/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"
|
||||
]
|
||||
}
|
||||
484
extensions/inworld/tts.test.ts
Normal file
484
extensions/inworld/tts.test.ts
Normal file
@@ -0,0 +1,484 @@
|
||||
// Inworld tests cover tts plugin behavior.
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
};
|
||||
});
|
||||
|
||||
import { inworldTTS, listInworldVoices } from "./tts.js";
|
||||
|
||||
type GuardRequest = {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
auditContext?: string;
|
||||
policy?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
function queueGuardedResponse(response: Response): { release: ReturnType<typeof vi.fn> } {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release });
|
||||
return { release };
|
||||
}
|
||||
|
||||
function lastGuardRequest(): GuardRequest {
|
||||
const calls = fetchWithSsrFGuardMock.mock.calls;
|
||||
const call = calls[calls.length - 1];
|
||||
if (!call) {
|
||||
throw new Error("fetchWithSsrFGuard was not called");
|
||||
}
|
||||
return call[0] as GuardRequest;
|
||||
}
|
||||
|
||||
function readRequestBody(request: GuardRequest): string {
|
||||
const body = request.init?.body;
|
||||
if (typeof body !== "string") {
|
||||
throw new Error("expected request body to be a string");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const guardedSuccessReleaseCases = [
|
||||
{
|
||||
name: "listInworldVoices",
|
||||
run: async () => {
|
||||
const { release } = queueGuardedResponse(
|
||||
new Response(JSON.stringify({ voices: [] }), { status: 200 }),
|
||||
);
|
||||
|
||||
await listInworldVoices({ apiKey: "test-key" });
|
||||
return release;
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "inworldTTS",
|
||||
run: async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
const { release } = queueGuardedResponse(
|
||||
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
return release;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("Inworld guarded dispatcher lifecycle", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each(guardedSuccessReleaseCases)(
|
||||
"$name releases the guarded dispatcher after success",
|
||||
async ({ run }) => {
|
||||
const release = await run();
|
||||
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("listInworldVoices", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("maps Inworld voice metadata into speech voice options", async () => {
|
||||
queueGuardedResponse(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
voices: [
|
||||
{
|
||||
voiceId: "Dennis",
|
||||
displayName: "Dennis",
|
||||
description: "Middle-aged man with a smooth, calm and friendly voice",
|
||||
langCode: "EN_US",
|
||||
tags: ["male", "middle-aged", "smooth", "calm", "friendly"],
|
||||
source: "SYSTEM",
|
||||
},
|
||||
{
|
||||
voiceId: "Ashley",
|
||||
displayName: "Ashley",
|
||||
description: "A warm, natural female voice",
|
||||
langCode: "EN_US",
|
||||
tags: ["female", "warm", "natural"],
|
||||
source: "SYSTEM",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const voices = await listInworldVoices({ apiKey: "test-key" });
|
||||
|
||||
expect(voices).toEqual([
|
||||
{
|
||||
id: "Dennis",
|
||||
name: "Dennis",
|
||||
description: "Middle-aged man with a smooth, calm and friendly voice",
|
||||
locale: "EN_US",
|
||||
gender: "male",
|
||||
},
|
||||
{
|
||||
id: "Ashley",
|
||||
name: "Ashley",
|
||||
description: "A warm, natural female voice",
|
||||
locale: "EN_US",
|
||||
gender: "female",
|
||||
},
|
||||
]);
|
||||
const request = lastGuardRequest();
|
||||
expect(request.url).toBe("https://api.inworld.ai/voices/v1/voices");
|
||||
expect(request.auditContext).toBe("inworld-voices");
|
||||
expect(request.policy).toEqual({ hostnameAllowlist: ["api.inworld.ai"] });
|
||||
const headers = new Headers(request.init?.headers);
|
||||
expect(headers.get("authorization")).toBe("Basic test-key");
|
||||
});
|
||||
|
||||
it("throws on API errors with response body", async () => {
|
||||
queueGuardedResponse(new Response("service unavailable", { status: 503 }));
|
||||
|
||||
await expect(listInworldVoices({ apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld voices API error (503): service unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("filters out voices with empty voiceId", async () => {
|
||||
queueGuardedResponse(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
voices: [
|
||||
{ voiceId: "", displayName: "Empty" },
|
||||
{ voiceId: "Dennis", displayName: "Dennis" },
|
||||
],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const voices = await listInworldVoices({ apiKey: "test-key" });
|
||||
expect(voices).toHaveLength(1);
|
||||
expect(voices[0].id).toBe("Dennis");
|
||||
});
|
||||
|
||||
it("returns empty array when no voices present", async () => {
|
||||
queueGuardedResponse(new Response(JSON.stringify({}), { status: 200 }));
|
||||
|
||||
const voices = await listInworldVoices({ apiKey: "test-key" });
|
||||
expect(voices).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("passes language filter as query parameter", async () => {
|
||||
queueGuardedResponse(new Response(JSON.stringify({ voices: [] }), { status: 200 }));
|
||||
|
||||
await listInworldVoices({ apiKey: "test-key", language: "EN_US" });
|
||||
|
||||
expect(lastGuardRequest().url).toBe("https://api.inworld.ai/voices/v1/voices?languages=EN_US");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inworldTTS", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("concatenates base64 audio chunks from streaming response", async () => {
|
||||
const chunk1 = Buffer.from("audio-chunk-1").toString("base64");
|
||||
const chunk2 = Buffer.from("audio-chunk-2").toString("base64");
|
||||
const body = [
|
||||
JSON.stringify({ result: { audioContent: chunk1 } }),
|
||||
JSON.stringify({ result: { audioContent: chunk2 } }),
|
||||
].join("\n");
|
||||
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
const buffer = await inworldTTS({
|
||||
text: "Hello world",
|
||||
apiKey: "test-key",
|
||||
});
|
||||
|
||||
expect(buffer).toEqual(
|
||||
Buffer.concat([Buffer.from("audio-chunk-1"), Buffer.from("audio-chunk-2")]),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on HTTP errors with response body", async () => {
|
||||
queueGuardedResponse(new Response("bad request body", { status: 400 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS API error (400): bad request body",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on in-stream errors", async () => {
|
||||
const body = JSON.stringify({
|
||||
error: { code: 3, message: "Invalid voice ID" },
|
||||
});
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS stream error (3): Invalid voice ID",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on empty audio response", async () => {
|
||||
const body = JSON.stringify({ result: { audioContent: "" } });
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS returned no audio data",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws descriptive error on non-JSON line in stream", async () => {
|
||||
queueGuardedResponse(new Response("<html>Rate limited</html>", { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS stream parse error: unexpected non-JSON line:",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends correct request body with defaults", async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
queueGuardedResponse(
|
||||
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await inworldTTS({ text: "Hello", apiKey: "test-key" });
|
||||
|
||||
const request = lastGuardRequest();
|
||||
expect(request.url).toBe("https://api.inworld.ai/tts/v1/voice:stream");
|
||||
expect(request.auditContext).toBe("inworld-tts");
|
||||
expect(request.policy).toEqual({ hostnameAllowlist: ["api.inworld.ai"] });
|
||||
if (!request.init) {
|
||||
throw new Error("expected Inworld TTS request init");
|
||||
}
|
||||
expect(request.init.method).toBe("POST");
|
||||
const headers = new Headers(request.init.headers);
|
||||
expect(headers.get("authorization")).toBe("Basic test-key");
|
||||
expect(headers.get("content-type")).toBe("application/json");
|
||||
expect(JSON.parse(readRequestBody(request))).toEqual({
|
||||
text: "Hello",
|
||||
voiceId: "Sarah",
|
||||
modelId: "inworld-tts-1.5-max",
|
||||
audioConfig: { audioEncoding: "MP3" },
|
||||
});
|
||||
});
|
||||
|
||||
it("includes temperature and sampleRateHertz when provided", async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
queueGuardedResponse(
|
||||
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await inworldTTS({
|
||||
text: "Hello",
|
||||
apiKey: "test-key",
|
||||
voiceId: "Ashley",
|
||||
modelId: "inworld-tts-1.5-mini",
|
||||
audioEncoding: "PCM",
|
||||
sampleRateHertz: 22_050,
|
||||
temperature: 0.8,
|
||||
});
|
||||
|
||||
const callBody = JSON.parse(readRequestBody(lastGuardRequest()));
|
||||
expect(callBody.voiceId).toBe("Ashley");
|
||||
expect(callBody.modelId).toBe("inworld-tts-1.5-mini");
|
||||
expect(callBody.audioConfig.audioEncoding).toBe("PCM");
|
||||
expect(callBody.audioConfig.sampleRateHertz).toBe(22_050);
|
||||
expect(callBody.temperature).toBe(0.8);
|
||||
});
|
||||
|
||||
it("uses custom base URL", async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
queueGuardedResponse(
|
||||
new Response(JSON.stringify({ result: { audioContent: chunk } }), { status: 200 }),
|
||||
);
|
||||
|
||||
await inworldTTS({
|
||||
text: "Hello",
|
||||
apiKey: "test-key",
|
||||
baseUrl: "https://custom.inworld.example.com/",
|
||||
});
|
||||
|
||||
expect(lastGuardRequest().url).toBe("https://custom.inworld.example.com/tts/v1/voice:stream");
|
||||
expect(lastGuardRequest().policy).toEqual({
|
||||
hostnameAllowlist: ["custom.inworld.example.com"],
|
||||
});
|
||||
});
|
||||
|
||||
it("skips empty lines in streaming response", async () => {
|
||||
const chunk = Buffer.from("audio").toString("base64");
|
||||
const body = `\n${JSON.stringify({ result: { audioContent: chunk } })}\n\n`;
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
const buffer = await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
expect(buffer).toEqual(Buffer.from("audio"));
|
||||
});
|
||||
|
||||
it("releases the guarded dispatcher after failure", async () => {
|
||||
const { release } = queueGuardedResponse(new Response("fail", { status: 500 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS API error (500): fail",
|
||||
);
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Inworld response read bounding", () => {
|
||||
const MiB = 1024 * 1024;
|
||||
|
||||
// A never-ending stream that enqueues one fixed-size chunk per pull. An
|
||||
// unbounded reader (the previous `await response.text()` / `response.json()`)
|
||||
// would buffer this forever and OOM; the bounded reader must stop at the cap
|
||||
// and cancel the stream.
|
||||
function infiniteByteStream(chunkBytes: number): {
|
||||
stream: ReadableStream<Uint8Array>;
|
||||
state: { enqueued: number; cancelled: boolean };
|
||||
} {
|
||||
const state = { enqueued: 0, cancelled: false };
|
||||
const chunk = new Uint8Array(chunkBytes).fill(0x61); // "a"
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
state.enqueued += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
state.cancelled = true;
|
||||
},
|
||||
});
|
||||
return { stream, state };
|
||||
}
|
||||
|
||||
it("fail-closed: rejects and cancels an oversized TTS audio stream instead of buffering it (32 MiB cap)", async () => {
|
||||
const { stream, state } = infiniteByteStream(8 * MiB);
|
||||
queueGuardedResponse(new Response(stream, { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld TTS audio stream too large: \d+ bytes \(limit: 33554432 bytes\)/,
|
||||
);
|
||||
// Enforced after a bounded number of 8 MiB chunks, never the full unbounded
|
||||
// stream, and the stream is cancelled so the socket/buffers are released.
|
||||
expect(state.enqueued).toBeLessThanOrEqual(8);
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("happy-path: a normal multi-line NDJSON audio payload still decodes unchanged", async () => {
|
||||
const part1 = Buffer.from("hello-").toString("base64");
|
||||
const part2 = Buffer.from("world").toString("base64");
|
||||
const body = [
|
||||
JSON.stringify({ result: { audioContent: part1 } }),
|
||||
JSON.stringify({ result: { audioContent: part2 } }),
|
||||
].join("\n");
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
const audio = await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
expect(audio.toString("utf8")).toBe("hello-world");
|
||||
});
|
||||
|
||||
it("edge: an under-cap ~1 MiB audio payload is read intact, not truncated", async () => {
|
||||
const payload = "x".repeat(MiB);
|
||||
const encoded = Buffer.from(payload).toString("base64");
|
||||
const body = JSON.stringify({ result: { audioContent: encoded } });
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
const audio = await inworldTTS({ text: "test", apiKey: "test-key" });
|
||||
expect(audio.length).toBe(payload.length);
|
||||
expect(audio.toString("utf8")).toBe(payload);
|
||||
});
|
||||
|
||||
it("fail-closed: rejects decoded audio that exceeds the shared audio cap", async () => {
|
||||
const decodedPayload = Buffer.alloc(16 * MiB + 1, 0x61);
|
||||
const body = JSON.stringify({
|
||||
result: { audioContent: decodedPayload.toString("base64") },
|
||||
});
|
||||
queueGuardedResponse(new Response(body, { status: 200 }));
|
||||
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld TTS decoded audio too large: 16777217 bytes \(limit: 16777216 bytes\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it("regression: a malformed NDJSON line under the cap still throws a bounded parse error", async () => {
|
||||
queueGuardedResponse(new Response("this-is-not-json", { status: 200 }));
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld TTS stream parse error/,
|
||||
);
|
||||
});
|
||||
|
||||
it("fail-closed: truncates an oversized HTTP error body to a bounded marker", async () => {
|
||||
queueGuardedResponse(new Response("E".repeat(64 * 1024), { status: 500 }));
|
||||
|
||||
let captured: unknown;
|
||||
await inworldTTS({ text: "test", apiKey: "test-key" }).catch((error: unknown) => {
|
||||
captured = error;
|
||||
});
|
||||
|
||||
expect(captured).toBeInstanceOf(Error);
|
||||
const message = (captured as Error).message;
|
||||
expect(message.startsWith("Inworld TTS API error (500): ")).toBe(true);
|
||||
// Never the full 64 KiB hostile body: it collapses to a fixed marker.
|
||||
expect(message).toContain("(error body exceeded diagnostic limit; truncated)");
|
||||
expect(message.length).toBeLessThan(512);
|
||||
});
|
||||
|
||||
it("edge: a small error body is preserved verbatim in the thrown message", async () => {
|
||||
queueGuardedResponse(new Response("invalid api key", { status: 401 }));
|
||||
await expect(inworldTTS({ text: "test", apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld TTS API error (401): invalid api key",
|
||||
);
|
||||
});
|
||||
|
||||
it("fail-closed: rejects and cancels an oversized voices JSON stream (16 MiB cap)", async () => {
|
||||
const { stream, state } = infiniteByteStream(8 * MiB);
|
||||
queueGuardedResponse(new Response(stream, { status: 200 }));
|
||||
|
||||
await expect(listInworldVoices({ apiKey: "test-key" })).rejects.toThrow(
|
||||
/Inworld voices response too large: \d+ bytes \(limit: 16777216 bytes\)/,
|
||||
);
|
||||
expect(state.enqueued).toBeLessThanOrEqual(4);
|
||||
expect(state.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it("happy-path: a normal voices JSON list still parses unchanged", async () => {
|
||||
queueGuardedResponse(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
voices: [{ voiceId: "Sarah", displayName: "Sarah", langCode: "en-US", tags: ["female"] }],
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
|
||||
const voices = await listInworldVoices({ apiKey: "test-key" });
|
||||
expect(voices).toEqual([
|
||||
{ id: "Sarah", name: "Sarah", description: undefined, locale: "en-US", gender: "female" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("regression: malformed voices JSON under the cap throws descriptive error", async () => {
|
||||
queueGuardedResponse(new Response("{not-json", { status: 200 }));
|
||||
await expect(listInworldVoices({ apiKey: "test-key" })).rejects.toThrow(
|
||||
"Inworld voices API returned malformed JSON",
|
||||
);
|
||||
});
|
||||
});
|
||||
278
extensions/inworld/tts.ts
Normal file
278
extensions/inworld/tts.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
// Inworld plugin module implements tts behavior.
|
||||
import { MAX_AUDIO_BYTES } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import type { SpeechVoiceOption } from "openclaw/plugin-sdk/speech-core";
|
||||
import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const DEFAULT_INWORLD_BASE_URL = "https://api.inworld.ai";
|
||||
export const DEFAULT_INWORLD_VOICE_ID = "Sarah";
|
||||
export const DEFAULT_INWORLD_MODEL_ID = "inworld-tts-1.5-max";
|
||||
|
||||
// The streaming TTS endpoint returns newline-delimited JSON whose audio is
|
||||
// base64-encoded, so the wire body is ~4/3 larger than the decoded audio plus a
|
||||
// JSON envelope. Cap the read at double the shared 16 MiB audio limit so a
|
||||
// full-size legitimate clip still fits, while bounding memory against an
|
||||
// unbounded or hijacked SSE stream that would otherwise be buffered whole by the
|
||||
// previous `await response.text()`.
|
||||
const INWORLD_TTS_BODY_MAX_BYTES = MAX_AUDIO_BYTES * 2;
|
||||
// The voices listing is a small JSON catalog, so the shared 16 MiB audio limit
|
||||
// is already generous headroom while still closing the unbounded
|
||||
// `await response.json()` read.
|
||||
const INWORLD_VOICES_BODY_MAX_BYTES = MAX_AUDIO_BYTES;
|
||||
// Abort the read if the upstream stalls mid-body so a hung stream cannot pin the
|
||||
// socket and buffers open indefinitely.
|
||||
const INWORLD_BODY_READ_IDLE_TIMEOUT_MS = 30_000;
|
||||
// Error responses only need a short diagnostic snippet, never the whole body.
|
||||
const INWORLD_ERROR_BODY_MAX_BYTES = 8 * 1024;
|
||||
const INWORLD_ERROR_BODY_MAX_CHARS = 400;
|
||||
const INWORLD_ERROR_BODY_READ_IDLE_TIMEOUT_MS = 10_000;
|
||||
|
||||
// Sentinel so the error-snippet reader can tell a cap overflow apart from an
|
||||
// unrelated read failure without leaking the (possibly hostile) body.
|
||||
class InworldErrorBodyOverflow extends Error {}
|
||||
|
||||
/**
|
||||
* Reads a bounded, whitespace-collapsed diagnostic snippet from a non-OK
|
||||
* response body. A misbehaving or hostile endpoint can stream an arbitrarily
|
||||
* large error body, so this never buffers it whole: it reuses the shared
|
||||
* `readResponseWithLimit` reader (which cancels the underlying stream on
|
||||
* overflow and enforces an idle timeout) with a small cap. On overflow it
|
||||
* returns a fixed marker instead of echoing attacker-controlled bytes into the
|
||||
* thrown error. Kept local to this extension so it depends only on the
|
||||
* already-exported `response-limit-runtime` entry and adds no shared plugin-SDK
|
||||
* surface.
|
||||
*/
|
||||
async function readInworldErrorBodySnippet(response: Response): Promise<string> {
|
||||
let buffer: Buffer;
|
||||
try {
|
||||
buffer = await readResponseWithLimit(response, INWORLD_ERROR_BODY_MAX_BYTES, {
|
||||
chunkTimeoutMs: INWORLD_ERROR_BODY_READ_IDLE_TIMEOUT_MS,
|
||||
onOverflow: () => new InworldErrorBodyOverflow(),
|
||||
});
|
||||
} catch (error) {
|
||||
return error instanceof InworldErrorBodyOverflow
|
||||
? "(error body exceeded diagnostic limit; truncated)"
|
||||
: "";
|
||||
}
|
||||
|
||||
const collapsed = buffer.toString("utf8").replace(/\s+/g, " ").trim();
|
||||
if (collapsed.length > INWORLD_ERROR_BODY_MAX_CHARS) {
|
||||
return `${collapsed.slice(0, INWORLD_ERROR_BODY_MAX_CHARS)}…`;
|
||||
}
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
export const INWORLD_TTS_MODELS = [
|
||||
"inworld-tts-1.5-max",
|
||||
"inworld-tts-1.5-mini",
|
||||
"inworld-tts-1-max",
|
||||
"inworld-tts-1",
|
||||
] as const;
|
||||
|
||||
export type InworldAudioEncoding =
|
||||
| "MP3"
|
||||
| "OGG_OPUS"
|
||||
| "LINEAR16"
|
||||
| "PCM"
|
||||
| "WAV"
|
||||
| "ALAW"
|
||||
| "MULAW"
|
||||
| "FLAC";
|
||||
|
||||
export function normalizeInworldBaseUrl(baseUrl?: string): string {
|
||||
const trimmed = baseUrl?.trim();
|
||||
return trimmed?.replace(/\/+$/, "") || DEFAULT_INWORLD_BASE_URL;
|
||||
}
|
||||
|
||||
function ssrfPolicyFromInworldBaseUrl(baseUrl: string): SsrFPolicy | undefined {
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
return { hostnameAllowlist: [parsed.hostname] };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the Inworld streaming TTS endpoint and concatenates every audio chunk
|
||||
* into a single buffer. The stream returns newline-delimited JSON, each line
|
||||
* carrying base64 audio in `result.audioContent`.
|
||||
*/
|
||||
export async function inworldTTS(params: {
|
||||
text: string;
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
voiceId?: string;
|
||||
modelId?: string;
|
||||
audioEncoding?: InworldAudioEncoding;
|
||||
sampleRateHertz?: number;
|
||||
temperature?: number;
|
||||
timeoutMs?: number;
|
||||
}): Promise<Buffer> {
|
||||
const baseUrl = normalizeInworldBaseUrl(params.baseUrl);
|
||||
const url = `${baseUrl}/tts/v1/voice:stream`;
|
||||
const requestBody = JSON.stringify({
|
||||
text: params.text,
|
||||
voiceId: params.voiceId ?? DEFAULT_INWORLD_VOICE_ID,
|
||||
modelId: params.modelId ?? DEFAULT_INWORLD_MODEL_ID,
|
||||
audioConfig: {
|
||||
audioEncoding: params.audioEncoding ?? "MP3",
|
||||
...(params.sampleRateHertz && { sampleRateHertz: params.sampleRateHertz }),
|
||||
},
|
||||
...(params.temperature != null && { temperature: params.temperature }),
|
||||
});
|
||||
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// apiKey is the Base64-encoded credential string copied from the
|
||||
// Inworld dashboard; it is sent verbatim as the HTTP Basic
|
||||
// credential. Do not Base64-encode it here, and do not normalize
|
||||
// bearer-style tokens.
|
||||
Authorization: `Basic ${params.apiKey}`,
|
||||
},
|
||||
body: requestBody,
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
policy: ssrfPolicyFromInworldBaseUrl(baseUrl),
|
||||
auditContext: "inworld-tts",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const errorBody = await readInworldErrorBodySnippet(response);
|
||||
throw new Error(`Inworld TTS API error (${response.status}): ${errorBody}`);
|
||||
}
|
||||
|
||||
const body = (
|
||||
await readResponseWithLimit(response, INWORLD_TTS_BODY_MAX_BYTES, {
|
||||
chunkTimeoutMs: INWORLD_BODY_READ_IDLE_TIMEOUT_MS,
|
||||
onOverflow: ({ size, maxBytes }) =>
|
||||
new Error(`Inworld TTS audio stream too large: ${size} bytes (limit: ${maxBytes} bytes)`),
|
||||
onIdleTimeout: ({ chunkTimeoutMs }) =>
|
||||
new Error(`Inworld TTS audio stream stalled: no data received for ${chunkTimeoutMs}ms`),
|
||||
})
|
||||
).toString("utf8");
|
||||
const chunks: Buffer[] = [];
|
||||
let decodedAudioBytes = 0;
|
||||
|
||||
for (const line of body.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: {
|
||||
result?: { audioContent?: string };
|
||||
error?: { code?: number; message?: string };
|
||||
};
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as typeof parsed;
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Inworld TTS stream parse error: unexpected non-JSON line: ${trimmed.slice(0, 80)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (parsed.error) {
|
||||
throw new Error(`Inworld TTS stream error (${parsed.error.code}): ${parsed.error.message}`);
|
||||
}
|
||||
|
||||
if (parsed.result?.audioContent) {
|
||||
const chunk = Buffer.from(parsed.result.audioContent, "base64");
|
||||
const nextDecodedAudioBytes = decodedAudioBytes + chunk.length;
|
||||
if (nextDecodedAudioBytes > MAX_AUDIO_BYTES) {
|
||||
throw new Error(
|
||||
`Inworld TTS decoded audio too large: ${nextDecodedAudioBytes} bytes (limit: ${MAX_AUDIO_BYTES} bytes)`,
|
||||
);
|
||||
}
|
||||
decodedAudioBytes = nextDecodedAudioBytes;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
throw new Error("Inworld TTS returned no audio data");
|
||||
}
|
||||
|
||||
return Buffer.concat(chunks);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
export async function listInworldVoices(params: {
|
||||
apiKey: string;
|
||||
baseUrl?: string;
|
||||
language?: string;
|
||||
timeoutMs?: number;
|
||||
}): Promise<SpeechVoiceOption[]> {
|
||||
const baseUrl = normalizeInworldBaseUrl(params.baseUrl);
|
||||
const langParam = params.language ? `?languages=${encodeURIComponent(params.language)}` : "";
|
||||
const url = `${baseUrl}/voices/v1/voices${langParam}`;
|
||||
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Basic ${params.apiKey}`,
|
||||
},
|
||||
},
|
||||
timeoutMs: params.timeoutMs,
|
||||
policy: ssrfPolicyFromInworldBaseUrl(baseUrl),
|
||||
auditContext: "inworld-voices",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const errorBody = await readInworldErrorBodySnippet(response);
|
||||
throw new Error(`Inworld voices API error (${response.status}): ${errorBody}`);
|
||||
}
|
||||
|
||||
const voicesBody = (
|
||||
await readResponseWithLimit(response, INWORLD_VOICES_BODY_MAX_BYTES, {
|
||||
chunkTimeoutMs: INWORLD_BODY_READ_IDLE_TIMEOUT_MS,
|
||||
onOverflow: ({ size, maxBytes }) =>
|
||||
new Error(`Inworld voices response too large: ${size} bytes (limit: ${maxBytes} bytes)`),
|
||||
onIdleTimeout: ({ chunkTimeoutMs }) =>
|
||||
new Error(`Inworld voices response stalled: no data received for ${chunkTimeoutMs}ms`),
|
||||
})
|
||||
).toString("utf8");
|
||||
let json: {
|
||||
voices?: Array<{
|
||||
voiceId?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
langCode?: string;
|
||||
tags?: string[];
|
||||
source?: string;
|
||||
}>;
|
||||
};
|
||||
try {
|
||||
json = JSON.parse(voicesBody) as typeof json;
|
||||
} catch {
|
||||
throw new Error("Inworld voices API returned malformed JSON");
|
||||
}
|
||||
|
||||
return Array.isArray(json.voices)
|
||||
? json.voices
|
||||
.map((voice) => ({
|
||||
id: voice.voiceId?.trim() ?? "",
|
||||
name: voice.displayName?.trim() || undefined,
|
||||
description: voice.description?.trim() || undefined,
|
||||
locale: voice.langCode || undefined,
|
||||
gender: voice.tags?.find((t) => t === "male" || t === "female") || undefined,
|
||||
}))
|
||||
.filter((voice) => voice.id.length > 0)
|
||||
: [];
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user