refactor(tts): absorb speech core package (#118513)

* refactor(tts): absorb speech core package

* fix(tts): preserve runtime SDK exports

* refactor(tts): remove private package exports

* test(tts): align canonical runtime mocks

* test(tts): complete canonical settings mocks

* chore(plugin-sdk): regenerate API baseline for #118513
This commit is contained in:
Peter Steinberger
2026-08-03 02:25:48 -07:00
committed by GitHub
parent d2046614f9
commit ecc49b5a87
61 changed files with 2693 additions and 2711 deletions
-5
View File
@@ -620,11 +620,6 @@ const config = {
entry: ["src/*.ts!", "src/host/embeddings-worker-child.ts!"],
project: ["src/**/*.ts!"],
},
"packages/speech-core": {
entry: ["runtime-api.ts!", "speaker.ts!", "voice-models.ts!"],
project: ["**/*.ts!"],
ignoreDependencies: ["openclaw"],
},
"packages/*": {
entry: ["index.js!", "scripts/postinstall.js!"],
project: ["index.js!", "scripts/**/*.js!"],
-1
View File
@@ -320,7 +320,6 @@ packages/markdown-core/src/ir.ts
packages/memory-host-sdk/src/host/session-files.ts
packages/sdk/src/client.ts
packages/sdk/src/index.test.ts
packages/speech-core/src/tts.test.ts
packages/tool-call-repair/src/stream-normalizer.test.ts
packages/tool-call-repair/src/stream-normalizer.ts
src/acp/control-plane/manager.test.ts
@@ -6,7 +6,7 @@ e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-c
74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness
95a907e1c33305b9473be64cc8d723e1a12b909eda86b15b94cee91879fb6a89 module/agent-harness-runtime
5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload
fd54eb654443d646d6430d2be99d1f25c32701f1c45aaa870ffe74aefc7d7f00 module/agent-runtime
6ee8bb70cd7b8a5a976ee84cd0c6e632dbfa5e4a047f616cdc00fa5b27879b27 module/agent-runtime
56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from
55cea5390d68839ca7768b4a0cc570b17b65fa0fa3bc4d76130ef0f16cb79ede module/allowlist-config-edit
7ddd81bd5f55de9adf64bf4d92d012f24b37b6da0a72805a3a220d8feff24ca3 module/approval-auth-runtime
@@ -29,7 +29,7 @@ c0f910ebfa3dbf283145fb1e3b9c016d03e853ef13e70402b09f3b9d9c2f4ab0 module/channel
9a5aaf650f9242523bb57bdc2556c323ab64e55aa11e25e2685b73b23ee12534 module/channel-dm-policy
ba41c40956d6b4565605fa38c2d12f4b8471a0f8afe842798716b9032ee4d74a module/channel-entry-contract
982f29a18e07228e3da82cae67d06ff38249592a29c2fd28f01f0d2016ff80d9 module/channel-feedback
d645d24bcb7a5f68cc46c692ad0d1fbd19be0a99996f31e9479ce9cffce301c1 module/channel-inbound
1d13edd07a8ae8e21ece6c542cb0cde22e5b6e11d3bb9b2bfaa5ebd2a5239bf8 module/channel-inbound
76bb7f531f3702c801e8fe7479e9e499f601fb361a4303afdcb45fc0da440e4b module/channel-inbound-debounce
df567ce2f4a4ba8a0937f825c46e83763412a724e36b72ce727dc4203c7dd134 module/channel-ingress-runtime
0e6efb79730fae59bb549ad00d9af2848c139b4bb1762e83b66886284e1c1421 module/channel-lifecycle
@@ -107,7 +107,7 @@ aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/questio
2e09c3181e79e157ed5366b144d116ef8cc06023256ace3fa59b35c43cab513a module/reply-chunking
7994045066b29af1fc6b36ae32068f2a6f277195971af84701cb739cc23d0579 module/reply-dispatch-runtime
ac2b199e95c5c8b1e2a65e62bd41d1b6322e531bca294ef4979a297a12640bce module/reply-history
f394fe4d5a7ed9e4d574063ae44e8d6af85c9a0e7d8b329f750ca16b0664325f module/reply-payload
ad69a4a6970cfac86f9379efb927beac898217946d025c3aea55e6f399da08bb module/reply-payload
1f899eb54013f268d6698ce8e6943289ea0e872747e8e868259106829518db86 module/reply-runtime
b4043b356372f6af64ee3c26e4d6a6d623b817e4d95346358dc0e64a3b61d1e0 module/root-walk
97fc4ed1ac6e62b7af95b4352cee3893252d8b691fef71a000c2602b3128541a module/routing
@@ -20,7 +20,7 @@ afterEach(() => {
});
function makeTtsStyleVoiceFile(): string {
// Mirrors cron auto-TTS: speech-core writes the voice file under the preferred
// Mirrors cron auto-TTS: the TTS runtime writes the voice file under the preferred
// OpenClaw temp root, which is outside the QQ Bot media storage tree.
const tmpRoot = resolvePreferredOpenClawTmpDir();
const ttsDir = makeTrackedDir(tmpRoot, "tts-");
@@ -1,34 +0,0 @@
// Xai type declarations define plugin contracts.
export type ResolvedTtsConfig = unknown;
export type ResolvedTtsModelOverrides = unknown;
export type TtsDirectiveOverrides = unknown;
export type TtsDirectiveParseResult = unknown;
export type TtsResult = unknown;
export type TtsSynthesisResult = unknown;
export type TtsTelephonyResult = unknown;
export const testApi: unknown;
export { testApi as _test };
export const buildTtsSystemPromptHint: (...args: unknown[]) => unknown;
export const getLastTtsAttempt: (...args: unknown[]) => unknown;
export const getResolvedSpeechProviderConfig: (...args: unknown[]) => unknown;
export const getTtsMaxLength: (...args: unknown[]) => unknown;
export const getTtsProvider: (...args: unknown[]) => unknown;
export const isSummarizationEnabled: (...args: unknown[]) => unknown;
export const isTtsEnabled: (...args: unknown[]) => unknown;
export const isTtsProviderConfigured: (...args: unknown[]) => unknown;
export const listSpeechVoices: (...args: unknown[]) => unknown;
export const maybeApplyTtsToPayload: (...args: unknown[]) => unknown;
export const resolveTtsAutoMode: (...args: unknown[]) => unknown;
export const resolveTtsConfig: (...args: unknown[]) => unknown;
export const resolveTtsPrefsPath: (...args: unknown[]) => unknown;
export const resolveTtsProviderOrder: (...args: unknown[]) => unknown;
export const setLastTtsAttempt: (...args: unknown[]) => unknown;
export const setSummarizationEnabled: (...args: unknown[]) => unknown;
export const setTtsAutoMode: (...args: unknown[]) => unknown;
export const setTtsEnabled: (...args: unknown[]) => unknown;
export const setTtsMaxLength: (...args: unknown[]) => unknown;
export const setTtsProvider: (...args: unknown[]) => unknown;
export const synthesizeSpeech: (...args: unknown[]) => unknown;
export const textToSpeech: (...args: unknown[]) => unknown;
export const textToSpeechTelephony: (...args: unknown[]) => unknown;
-3
View File
@@ -848,9 +848,6 @@
],
"@openclaw/ollama/runtime-api.js": [
"./.boundary-stubs/ollama-runtime-api.d.ts"
],
"@openclaw/speech-core/runtime-api.js": [
"./.boundary-stubs/speech-core-runtime-api.d.ts"
]
}
}
-34
View File
@@ -1,34 +0,0 @@
{
"name": "@openclaw/speech-core",
"version": "2026.5.31",
"private": true,
"description": "OpenClaw speech runtime package",
"type": "module",
"main": "./dist/runtime-api.mjs",
"types": "./dist/runtime-api.d.mts",
"exports": {
".": {
"types": "./dist/runtime-api.d.mts",
"import": "./dist/runtime-api.mjs",
"default": "./dist/runtime-api.mjs"
},
"./runtime-api": {
"types": "./dist/runtime-api.d.mts",
"import": "./dist/runtime-api.mjs",
"default": "./dist/runtime-api.mjs"
},
"./speaker": {
"types": "./dist/speaker.d.mts",
"import": "./dist/speaker.mjs",
"default": "./dist/speaker.mjs"
},
"./voice-models": {
"types": "./dist/voice-models.d.mts",
"import": "./dist/voice-models.mjs",
"default": "./dist/voice-models.mjs"
}
},
"dependencies": {
"openclaw": "workspace:*"
}
}
-49
View File
@@ -1,49 +0,0 @@
// Runtime speech API barrel for TTS preferences, synthesis, streaming, and test
// helpers used by speech-capable plugins.
export { setSpeechRuntimeAvailabilityGuard } from "./src/runtime-availability.js";
export {
buildTtsSystemPromptHint,
getTtsMaxLength,
getTtsPersona,
isSummarizationEnabled,
isTtsEnabled,
listTtsPersonas,
resolveTtsAutoMode,
resolveTtsConfig,
resolveTtsPrefsPath,
setTtsMachinePrefsPathResolver,
type ResolvedTtsConfig,
type ResolvedTtsModelOverrides,
} from "./src/tts-settings.js";
export {
setSummarizationEnabled,
setTtsAutoMode,
setTtsEnabled,
setTtsMaxLength,
setTtsPersona,
setTtsProvider,
} from "./src/tts-settings-writes.js";
export {
getLastTtsAttempt,
getResolvedSpeechProviderConfig,
getTtsProvider,
isTtsProviderConfigured,
listSpeechVoices,
prepareTtsRequest,
resolveExplicitTtsOverrides,
resolveTtsProviderOrder,
setLastTtsAttempt,
synthesizeSpeech,
streamSpeech,
textToSpeechStream,
textToSpeechTelephony,
testApi as _test,
testApi,
type TtsDirectiveOverrides,
type TtsDirectiveParseResult,
type PreparedTtsRequest,
type TtsSynthesisResult,
type TtsSynthesisStreamResult,
type TtsStreamResult,
type TtsTelephonyResult,
} from "./src/tts.js";
-401
View File
@@ -1,401 +0,0 @@
// Lightweight TTS settings resolution shared by agent prompts, status, and speech runtime.
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import type {
OpenClawConfig,
ResolvedTtsPersona,
TtsAutoMode,
TtsConfig,
TtsModelOverrideConfig,
TtsProvider,
} from "openclaw/plugin-sdk/config-contracts";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
selectApplicableRuntimeConfig,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import type { SpeechProviderConfig } from "openclaw/plugin-sdk/speech-core";
import {
normalizeSpeechProviderId,
normalizeTtsAutoMode,
resolveEffectiveTtsConfig,
type ResolvedTtsConfig,
type ResolvedTtsModelOverrides,
type TtsConfigResolutionContext,
} from "openclaw/plugin-sdk/speech-settings";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveConfigDir, resolveUserPath } from "openclaw/plugin-sdk/text-utility-runtime";
import { withSpeakerSelectionCompat } from "../speaker.js";
export type { ResolvedTtsConfig, ResolvedTtsModelOverrides };
export const DEFAULT_TTS_TIMEOUT_MS = 30_000;
const DEFAULT_TTS_MAX_LENGTH = 1500;
const DEFAULT_TTS_SUMMARIZE = true;
const DEFAULT_MAX_TEXT_LENGTH = 4096;
let machinePrefsPathResolver: () => string | undefined = () => undefined;
export function setTtsMachinePrefsPathResolver(resolver?: () => string | undefined): void {
machinePrefsPathResolver = resolver ?? (() => undefined);
}
export type TtsUserPrefs = {
tts?: {
auto?: TtsAutoMode;
enabled?: boolean;
provider?: TtsProvider;
persona?: string | null;
maxLength?: number;
summarize?: boolean;
};
};
function resolveConfiguredTtsAutoMode(raw: TtsConfig): TtsAutoMode {
return normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
}
export function normalizeConfiguredSpeechProviderId(
providerId: string | undefined,
): TtsProvider | undefined {
const normalized = normalizeSpeechProviderId(providerId);
if (!normalized) {
return undefined;
}
return normalized === "edge" ? "microsoft" : normalized;
}
export function normalizeTtsPersonaId(personaId: string | null | undefined): string | undefined {
return normalizeOptionalLowercaseString(personaId ?? undefined);
}
function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
// Scoped agent paths must win over the migrated machine-wide default.
if (prefsPath?.trim()) {
return resolveUserPath(prefsPath.trim());
}
const envPath = process.env.OPENCLAW_TTS_PREFS?.trim();
if (envPath) {
return resolveUserPath(envPath);
}
const machinePath = machinePrefsPathResolver()?.trim();
if (machinePath) {
return resolveUserPath(machinePath);
}
return path.join(resolveConfigDir(process.env), "settings", "tts.json");
}
export function resolveModelOverridePolicy(
overrides: TtsModelOverrideConfig | undefined,
): ResolvedTtsModelOverrides {
const enabled = overrides?.enabled ?? true;
if (!enabled) {
return {
enabled: false,
allowText: false,
allowProvider: false,
allowVoice: false,
allowModelId: false,
allowVoiceSettings: false,
allowNormalization: false,
allowSeed: false,
};
}
const allow = (value: boolean | undefined, defaultValue = true) => value ?? defaultValue;
return {
enabled: true,
allowText: allow(overrides?.allowText),
allowProvider: allow(overrides?.allowProvider, false),
allowVoice: allow(overrides?.allowVoice),
allowModelId: allow(overrides?.allowModelId),
allowVoiceSettings: allow(overrides?.allowVoiceSettings),
allowNormalization: allow(overrides?.allowNormalization),
allowSeed: allow(overrides?.allowSeed),
};
}
export function resolveTtsRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig {
return (
selectApplicableRuntimeConfig({
inputConfig: cfg,
runtimeConfig: getRuntimeConfigSnapshot(),
runtimeSourceConfig: getRuntimeConfigSourceSnapshot(),
}) ?? cfg
);
}
export function asProviderConfig(value: unknown): SpeechProviderConfig {
return typeof value === "object" && value !== null && !Array.isArray(value)
? withSpeakerSelectionCompat(value as SpeechProviderConfig)
: {};
}
export function asProviderConfigMap(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function hasOwnProperty(value: object, key: string): boolean {
return Object.hasOwn(value, key);
}
function normalizeProviderConfigMap(
value: unknown,
): Record<string, SpeechProviderConfig> | undefined {
const rawMap = asProviderConfigMap(value);
if (Object.keys(rawMap).length === 0) {
return undefined;
}
const next: Record<string, SpeechProviderConfig> = {};
for (const [providerId, providerConfig] of Object.entries(rawMap)) {
const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId;
next[normalized] = asProviderConfig(providerConfig);
}
return next;
}
function collectTtsPersonas(raw: TtsConfig): Record<string, ResolvedTtsPersona> {
const rawPersonas = asProviderConfigMap(raw.personas);
const personas: Record<string, ResolvedTtsPersona> = {};
for (const [id, value] of Object.entries(rawPersonas)) {
const normalizedId = normalizeTtsPersonaId(id);
if (!normalizedId || typeof value !== "object" || value === null || Array.isArray(value)) {
continue;
}
const persona = value as Omit<ResolvedTtsPersona, "id">;
personas[normalizedId] = {
...persona,
id: normalizedId,
provider: normalizeConfiguredSpeechProviderId(persona.provider) ?? persona.provider,
providers: normalizeProviderConfigMap(persona.providers),
};
}
return personas;
}
function collectDirectProviderConfigEntries(raw: TtsConfig): Record<string, SpeechProviderConfig> {
const entries: Record<string, SpeechProviderConfig> = {};
const rawProviders = asProviderConfigMap(raw.providers);
for (const [providerId, value] of Object.entries(rawProviders)) {
const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId;
entries[normalized] = asProviderConfig(value);
}
const reservedKeys = new Set([
"auto",
"enabled",
"maxTextLength",
"mode",
"modelOverrides",
"persona",
"personas",
"prefsPath",
"provider",
"providers",
"summaryModel",
"timeoutMs",
]);
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (reservedKeys.has(key)) {
continue;
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
continue;
}
const normalized = normalizeConfiguredSpeechProviderId(key) ?? key;
entries[normalized] ??= asProviderConfig(value);
}
return entries;
}
export function resolveTtsConfig(
cfgInput: OpenClawConfig,
contextOrAgentId?: string | TtsConfigResolutionContext,
): ResolvedTtsConfig {
const cfg = resolveTtsRuntimeConfig(cfgInput);
const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, contextOrAgentId);
const providerSource = raw.provider ? "config" : "default";
const timeoutMs = raw.timeoutMs ?? DEFAULT_TTS_TIMEOUT_MS;
const timeoutMsSource = raw.timeoutMs === undefined ? "default" : "config";
return {
auto: resolveConfiguredTtsAutoMode(raw),
mode: raw.mode ?? "final",
provider:
normalizeConfiguredSpeechProviderId(raw.provider) ??
(providerSource === "config" ? (normalizeOptionalLowercaseString(raw.provider) ?? "") : ""),
providerSource,
persona: normalizeTtsPersonaId(raw.persona),
personas: collectTtsPersonas(raw),
summaryModel: normalizeOptionalString(raw.summaryModel),
modelOverrides: resolveModelOverridePolicy(raw.modelOverrides),
providerConfigs: collectDirectProviderConfigEntries(raw),
prefsPath: (raw as TtsConfig & { prefsPath?: string }).prefsPath,
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
timeoutMs,
timeoutMsSource,
rawConfig: raw,
sourceConfig: cfg,
};
}
export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string {
return resolveTtsPrefsPathValue(config.prefsPath);
}
export function readTtsPrefs(prefsPath: string): TtsUserPrefs {
try {
if (!existsSync(prefsPath)) {
return {};
}
const parsed: unknown = JSON.parse(readFileSync(prefsPath, "utf8"));
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as TtsUserPrefs)
: {};
} catch {
return {};
}
}
function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined {
const auto = normalizeTtsAutoMode(prefs.tts?.auto);
if (auto) {
return auto;
}
if (typeof prefs.tts?.enabled === "boolean") {
return prefs.tts.enabled ? "always" : "off";
}
return undefined;
}
export function resolveTtsAutoMode(params: {
config: ResolvedTtsConfig;
prefsPath: string;
sessionAuto?: string;
}): TtsAutoMode {
const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
if (sessionAuto) {
return sessionAuto;
}
return resolveTtsAutoModeFromPrefs(readTtsPrefs(params.prefsPath)) ?? params.config.auto;
}
function resolveTtsPersonaIdFromPrefs(
config: ResolvedTtsConfig,
prefs: TtsUserPrefs,
): string | undefined {
if (prefs.tts && hasOwnProperty(prefs.tts, "persona")) {
return normalizeTtsPersonaId(prefs.tts.persona);
}
return normalizeTtsPersonaId(config.persona);
}
export function resolveTtsPersonaFromPrefs(
config: ResolvedTtsConfig,
prefs: TtsUserPrefs,
): ResolvedTtsPersona | undefined {
const personaId = resolveTtsPersonaIdFromPrefs(config, prefs);
return personaId ? config.personas[personaId] : undefined;
}
type ResolvedTtsSettingsSnapshot = {
autoMode: TtsAutoMode;
config: ResolvedTtsConfig;
maxLength: number;
persona?: ResolvedTtsPersona;
personaId?: string;
preferredProvider?: TtsProvider;
prefsPath: string;
summarize: boolean;
};
export function resolveTtsSettingsSnapshot(params: {
cfg: OpenClawConfig;
sessionAuto?: string;
agentId?: string;
channelId?: string;
accountId?: string;
}): ResolvedTtsSettingsSnapshot {
const config = resolveTtsConfig(params.cfg, {
agentId: params.agentId,
channelId: params.channelId,
accountId: params.accountId,
});
const prefsPath = resolveTtsPrefsPath(config);
const prefs = readTtsPrefs(prefsPath);
const personaId = resolveTtsPersonaIdFromPrefs(config, prefs);
const persona = personaId ? config.personas[personaId] : undefined;
const preferredProvider =
normalizeConfiguredSpeechProviderId(prefs.tts?.provider) ??
normalizeConfiguredSpeechProviderId(persona?.provider) ??
(config.providerSource === "config"
? (normalizeConfiguredSpeechProviderId(config.provider) ?? config.provider)
: undefined);
return {
autoMode:
normalizeTtsAutoMode(params.sessionAuto) ?? resolveTtsAutoModeFromPrefs(prefs) ?? config.auto,
config,
maxLength: prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH,
...(persona ? { persona } : {}),
...(personaId ? { personaId } : {}),
...(preferredProvider ? { preferredProvider } : {}),
prefsPath,
summarize: prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE,
};
}
export function buildTtsSystemPromptHint(
cfg: OpenClawConfig,
agentId?: string,
): string | undefined {
const settings = resolveTtsSettingsSnapshot({ cfg, agentId });
if (settings.autoMode === "off") {
return undefined;
}
const autoHint =
settings.autoMode === "inbound"
? "Only use TTS when the user's last message includes audio/voice."
: settings.autoMode === "tagged"
? "Only use TTS when you include [[tts:key=value]] directives or a [[tts:text]]...[[/tts:text]] block."
: undefined;
return [
"Voice (TTS) is enabled.",
autoHint,
settings.persona
? `Active TTS persona: ${settings.persona.label ?? settings.persona.id}${settings.persona.description ? ` - ${settings.persona.description}` : ""}.`
: undefined,
`Keep spoken text ≤${settings.maxLength} chars to avoid auto-summary (summary ${settings.summarize ? "on" : "off"}).`,
"If workspace context (especially MEMORY.md) tells you not to use [[tts:...]] or to use a local/non-tagged voice workflow, follow that workspace instruction instead.",
"Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
]
.filter(Boolean)
.join("\n");
}
export function isTtsEnabled(
config: ResolvedTtsConfig,
prefsPath: string,
sessionAuto?: string,
): boolean {
return resolveTtsAutoMode({ config, prefsPath, sessionAuto }) !== "off";
}
export function getTtsPersona(
config: ResolvedTtsConfig,
prefsPath: string,
): ResolvedTtsPersona | undefined {
return resolveTtsPersonaFromPrefs(config, readTtsPrefs(prefsPath));
}
export function listTtsPersonas(config: ResolvedTtsConfig): ResolvedTtsPersona[] {
return Object.values(config.personas).toSorted((left, right) => left.id.localeCompare(right.id));
}
export function getTtsMaxLength(prefsPath: string): number {
return readTtsPrefs(prefsPath).tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH;
}
export function isSummarizationEnabled(prefsPath: string): boolean {
return readTtsPrefs(prefsPath).tts?.summarize ?? DEFAULT_TTS_SUMMARIZE;
}
File diff suppressed because it is too large Load Diff
-16
View File
@@ -1,16 +0,0 @@
{
"extends": "../../tsconfig.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"
]
}
-6
View File
@@ -2323,12 +2323,6 @@ importers:
packages/session-url-contract: {}
packages/speech-core:
dependencies:
openclaw:
specifier: workspace:*
version: link:../..
packages/terminal-core:
dependencies:
'@clack/prompts':
+1 -1
View File
@@ -19,7 +19,7 @@ scenario:
- docs/tools/media-overview.md
- docs/concepts/qa-e2e-automation.md
codeRefs:
- packages/speech-core/src/tts.ts
- src/tts/runtime-api.ts
- src/gateway/server-methods/chat-webchat-media.ts
- src/gateway/managed-image-attachments.ts
- src/gateway/server-methods/artifacts.ts
@@ -287,7 +287,6 @@ export const EXTENSION_PACKAGE_BOUNDARY_XAI_PATHS = {
"@openclaw/anthropic-vertex/api.js": ["./.boundary-stubs/anthropic-vertex-api.d.ts"],
"@openclaw/ollama/api.js": ["./.boundary-stubs/ollama-api.d.ts"],
"@openclaw/ollama/runtime-api.js": ["./.boundary-stubs/ollama-runtime-api.d.ts"],
"@openclaw/speech-core/runtime-api.js": ["./.boundary-stubs/speech-core-runtime-api.d.ts"],
} as const;
type ExtensionPackageBoundaryTsConfigJson = {
-1
View File
@@ -13,7 +13,6 @@ const TSDOWN_PACKAGE_NAMES = [
"net-policy",
"normalization-core",
"retry",
"speech-core",
"terminal-core",
"acp-core",
];
@@ -97,6 +97,8 @@ vi.mock("../skills/research/autocapture.js", () => ({
vi.mock("../tts/tts-settings.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
resolveModelOverridePolicy: vi.fn(),
setTtsMachinePrefsPathResolver: vi.fn(),
}));
const mockGetGlobalHookRunner = vi.mocked(getGlobalHookRunner);
@@ -5,6 +5,8 @@ import { buildCliAgentSystemPrompt } from "./helpers.js";
vi.mock("../../tts/tts-settings.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
resolveModelOverridePolicy: vi.fn(),
setTtsMachinePrefsPathResolver: vi.fn(),
}));
describe("buildCliAgentSystemPrompt", () => {
+2
View File
@@ -100,6 +100,8 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({
vi.mock("../../tts/tts-settings.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
resolveModelOverridePolicy: vi.fn(),
setTtsMachinePrefsPathResolver: vi.fn(),
}));
vi.mock("../video-generation-task-status.js", () => ({
@@ -466,6 +466,8 @@ vi.mock("../../../infra/net/undici-global-dispatcher.js", () => ({
vi.mock("../../../tts/tts-settings.js", () => ({
buildTtsSystemPromptHint: () => undefined,
resolveModelOverridePolicy: () => undefined,
setTtsMachinePrefsPathResolver: () => undefined,
}));
vi.mock("../../bootstrap-files.js", async () => {
@@ -10,6 +10,8 @@ import { applySystemPromptToSession, buildEmbeddedSystemPrompt } from "./system-
vi.mock("../../tts/tts-settings.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
resolveModelOverridePolicy: vi.fn(),
setTtsMachinePrefsPathResolver: vi.fn(),
}));
describe("applySystemPromptToSession", () => {
+2
View File
@@ -6,6 +6,8 @@ import { buildConfiguredAgentSystemPrompt } from "./system-prompt-config.js";
vi.mock("../tts/tts-settings.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
resolveModelOverridePolicy: vi.fn(),
setTtsMachinePrefsPathResolver: vi.fn(),
}));
function buildPrompt(config: OpenClawConfig, agentId = "main"): string {
@@ -71,6 +71,8 @@ vi.mock("../../agents/agent-tools.js", () => ({
vi.mock("../../tts/tts-settings.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
resolveModelOverridePolicy: vi.fn(),
setTtsMachinePrefsPathResolver: vi.fn(),
}));
function makeParams(): HandleCommandsParams {
+1 -1
View File
@@ -254,7 +254,7 @@ export type ChannelGroupContext = {
/** TTS voice delivery behavior advertised by a channel plugin. */
/**
* Container tokens (file-extension shape, no leading dot) that the host
* speech-core pipeline knows how to pre-transcode synthesized audio into.
* TTS pipeline knows how to pre-transcode synthesized audio into.
* Channels that benefit from a specific container currently only
* iMessage, which needs Apple's native voice-memo CAF descriptor name
* one here. Adding a new entry requires extending the host transcoder
+6 -6
View File
@@ -7,12 +7,6 @@ import {
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import { ErrorCodes } from "../../../packages/gateway-protocol/src/index.js";
import {
getVoiceProviderConfig,
providerMatchesId,
resolveSupportedVoiceModelRefs,
type VoiceModelProvider,
} from "../../../packages/speech-core/voice-models.js";
import { resolveRealtimeBootstrapContextInstructions } from "../../agents/realtime-bootstrap-context.js";
import type { TalkRealtimeConfig } from "../../config/types.gateway.js";
import type { OpenClawConfig } from "../../config/types.js";
@@ -32,6 +26,12 @@ import type {
RealtimeVoiceProviderConfig,
} from "../../talk/provider-types.js";
import type { TalkBrain, TalkEvent, TalkMode, TalkTransport } from "../../talk/talk-events.js";
import {
getVoiceProviderConfig,
providerMatchesId,
resolveSupportedVoiceModelRefs,
type VoiceModelProvider,
} from "../../tts/voice-models.js";
import { ADMIN_SCOPE } from "../operator-scopes.js";
import type { TalkHandoffTurnResult } from "../talk-handoff.js";
+6 -9
View File
@@ -15,15 +15,6 @@ import {
validateTalkModeParams,
validateTalkSpeakParams,
} from "../../../packages/gateway-protocol/src/index.js";
import {
withSpeakerSelectionCompat,
withSpeakerSelectionFallbackCompat,
} from "../../../packages/speech-core/speaker.js";
import {
CODE_HEAVY_SPOKEN_FALLBACK,
isCodeHeavySpeechText,
} from "../../../packages/speech-core/src/speech-text.js";
import { getVoiceProviderConfig } from "../../../packages/speech-core/voice-models.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import { redactConfigObject } from "../../config/redact-snapshot.js";
import {
@@ -54,12 +45,18 @@ import {
getSpeechProvider,
listSpeechProviders,
} from "../../tts/provider-registry.js";
import {
withSpeakerSelectionCompat,
withSpeakerSelectionFallbackCompat,
} from "../../tts/speaker.js";
import { CODE_HEAVY_SPOKEN_FALLBACK, isCodeHeavySpeechText } from "../../tts/speech-text.js";
import {
getResolvedSpeechProviderConfig,
resolveTtsConfig,
synthesizeSpeech,
type TtsDirectiveOverrides,
} from "../../tts/tts.js";
import { getVoiceProviderConfig } from "../../tts/voice-models.js";
import { ADMIN_SCOPE, READ_SCOPE, TALK_SECRETS_SCOPE } from "../operator-scopes.js";
import { resolveConfiguredSecretInputString } from "../resolve-configured-secret-input-string.js";
import { formatForLog } from "../ws-log.js";
+1 -1
View File
@@ -2,7 +2,7 @@
* Tests gateway talk runtime wiring for speech provider execution.
*/
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { CODE_HEAVY_SPOKEN_FALLBACK } from "../../packages/speech-core/src/speech-text.js";
import { CODE_HEAVY_SPOKEN_FALLBACK } from "../tts/speech-text.js";
import {
invokeTalkSpeakDirect,
type TalkSpeakTestPayload,
+71
View File
@@ -0,0 +1,71 @@
import { normalizeStringEntries } from "../../../packages/normalization-core/src/string-normalization.js";
/** Derived sendability facts for text/media outbound payload delivery. */
export type SendableOutboundReplyParts = {
/** Raw text selected for delivery before trimming. */
text: string;
/** Text after trimming whitespace for sendability checks. */
trimmedText: string;
/** Normalized non-empty media URLs. */
mediaUrls: string[];
/** Number of normalized media URLs. */
mediaCount: number;
/** Whether trimmed text is sendable. */
hasText: boolean;
/** Whether at least one media URL is sendable. */
hasMedia: boolean;
/** Whether the payload has any sendable text or media. */
hasContent: boolean;
};
/** Prefer multi-attachment payloads, then fall back to the legacy single-media field. */
export function resolveOutboundMediaUrls(payload: {
mediaUrls?: string[];
mediaUrl?: string;
}): string[] {
if (payload.mediaUrls?.length) {
return payload.mediaUrls;
}
if (payload.mediaUrl) {
return [payload.mediaUrl];
}
return [];
}
/** Count outbound media items after legacy single-media fallback normalization. */
export function countOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): number {
return resolveOutboundMediaUrls(payload).length;
}
/** Check whether an outbound payload includes any media after normalization. */
export function hasOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): boolean {
return countOutboundMedia(payload) > 0;
}
/** Check whether an outbound payload includes text, optionally trimming whitespace first. */
export function hasOutboundText(payload: { text?: string }, options?: { trim?: boolean }): boolean {
const text = options?.trim ? payload.text?.trim() : payload.text;
return Boolean(text);
}
/** Normalize reply payload text/media into a trimmed, sendable shape for delivery paths. */
export function resolveSendableOutboundReplyParts(
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string },
options?: { text?: string },
): SendableOutboundReplyParts {
const text = options?.text ?? payload.text ?? "";
const trimmedText = text.trim();
const mediaUrls = normalizeStringEntries(resolveOutboundMediaUrls(payload));
const mediaCount = mediaUrls.length;
const hasText = Boolean(trimmedText);
const hasMedia = mediaCount > 0;
return {
text,
trimmedText,
mediaUrls,
mediaCount,
hasText,
hasMedia,
hasContent: hasText || hasMedia,
};
}
+1 -1
View File
@@ -700,7 +700,7 @@ describe("plugin-sdk facade runtime", () => {
}
});
it("does not treat package-backed speech-core as a bundled extension facade", () => {
it("does not treat the core-owned speech runtime as a bundled extension facade", () => {
setRuntimeConfigSnapshot({});
expect(
+14 -68
View File
@@ -1,9 +1,15 @@
// Reply payload helpers normalize plugin reply targets, text, media, and approval metadata.
import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js";
import { normalizeStringEntries } from "../../packages/normalization-core/src/string-normalization.js";
import type { ReplyPayload as InternalReplyPayload } from "../auto-reply/reply-payload.js";
import type { ChannelOutboundAdapter } from "../channels/plugins/outbound.types.js";
import { normalizeOutboundReplyPayload as normalizeCoreOutboundReplyPayload } from "../infra/outbound/reply-payload-normalize.js";
import {
countOutboundMedia,
hasOutboundMedia,
hasOutboundText,
resolveOutboundMediaUrls,
resolveSendableOutboundReplyParts,
} from "../infra/outbound/reply-payload-parts.js";
import { createReplyToFanout } from "../infra/outbound/reply-policy.js";
import { hasReplyPayloadContent } from "../interactive/payload.js";
@@ -68,21 +74,13 @@ export type ReasoningReplyPayload = {
};
/** Derived sendability facts for text/media outbound payload delivery. */
export type SendableOutboundReplyParts = {
/** Raw text selected for delivery before trimming. */
text: string;
/** Text after trimming whitespace for sendability checks. */
trimmedText: string;
/** Normalized non-empty media URLs. */
mediaUrls: string[];
/** Number of normalized media URLs. */
mediaCount: number;
/** Whether trimmed text is sendable. */
hasText: boolean;
/** Whether at least one media URL is sendable. */
hasMedia: boolean;
/** Whether the payload has any sendable text or media. */
hasContent: boolean;
export type { SendableOutboundReplyParts } from "../infra/outbound/reply-payload-parts.js";
export {
countOutboundMedia,
hasOutboundMedia,
hasOutboundText,
resolveOutboundMediaUrls,
resolveSendableOutboundReplyParts,
};
type SendPayloadContext = Parameters<NonNullable<ChannelOutboundAdapter["sendPayload"]>>[0];
@@ -139,41 +137,11 @@ export function createNormalizedOutboundDeliverer(
};
}
/** Prefer multi-attachment payloads, then fall back to the legacy single-media field. */
export function resolveOutboundMediaUrls(payload: {
mediaUrls?: string[];
mediaUrl?: string;
}): string[] {
if (payload.mediaUrls?.length) {
return payload.mediaUrls;
}
if (payload.mediaUrl) {
return [payload.mediaUrl];
}
return [];
}
/** Resolve media URLs from a channel sendPayload context after legacy fallback normalization. */
export function resolvePayloadMediaUrls(payload: SendPayloadContext["payload"]): string[] {
return resolveOutboundMediaUrls(payload);
}
/** Count outbound media items after legacy single-media fallback normalization. */
export function countOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): number {
return resolveOutboundMediaUrls(payload).length;
}
/** Check whether an outbound payload includes any media after normalization. */
export function hasOutboundMedia(payload: { mediaUrls?: string[]; mediaUrl?: string }): boolean {
return countOutboundMedia(payload) > 0;
}
/** Check whether an outbound payload includes text, optionally trimming whitespace first. */
export function hasOutboundText(payload: { text?: string }, options?: { trim?: boolean }): boolean {
const text = options?.trim ? payload.text?.trim() : payload.text;
return Boolean(text);
}
/** Check whether an outbound payload includes any sendable text, media, or rich reply content. */
export function hasOutboundReplyContent(
payload: {
@@ -189,28 +157,6 @@ export function hasOutboundReplyContent(
return hasReplyPayloadContent(payload, { trimText: options?.trimText });
}
/** Normalize reply payload text/media into a trimmed, sendable shape for delivery paths. */
export function resolveSendableOutboundReplyParts(
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string },
options?: { text?: string },
): SendableOutboundReplyParts {
const text = options?.text ?? payload.text ?? "";
const trimmedText = text.trim();
const mediaUrls = normalizeStringEntries(resolveOutboundMediaUrls(payload));
const mediaCount = mediaUrls.length;
const hasText = Boolean(trimmedText);
const hasMedia = mediaCount > 0;
return {
text,
trimmedText,
mediaUrls,
mediaCount,
hasText,
hasMedia,
hasContent: hasText || hasMedia,
};
}
/** Preserve caller-provided chunking, but fall back to the full text when chunkers return nothing. */
export function resolveTextChunksWithFallback(text: string, chunks: readonly string[]): string[] {
if (chunks.length > 0) {
+4 -17
View File
@@ -1,17 +1,6 @@
// TTS runtime exports expose text-to-speech runtime helpers through the plugin SDK.
import { maybeApplyTtsToPayload as maybeApplyTtsToPayloadCore } from "../../packages/speech-core/src/tts-payload.js";
import { textToSpeech as textToSpeechCore } from "../../packages/speech-core/src/tts-synthesis.js";
import { persistTtsAudioToMediaStore } from "../tts/tts-audio-store.js";
export type { TtsResult } from "../../packages/speech-core/src/tts-types.js";
export function textToSpeech(params: Parameters<typeof textToSpeechCore>[0]) {
return textToSpeechCore(params, persistTtsAudioToMediaStore);
}
export function maybeApplyTtsToPayload(params: Parameters<typeof maybeApplyTtsToPayloadCore>[0]) {
return maybeApplyTtsToPayloadCore(params, persistTtsAudioToMediaStore);
}
// TTS runtime exports expose host-owned text-to-speech helpers through the plugin SDK.
export { maybeApplyTtsToPayload, textToSpeech } from "../tts/tts.js";
export type { TtsResult } from "../tts/tts-runtime-types.js";
export {
TtsAutoSchema,
@@ -23,8 +12,6 @@ export {
/** Compatibility no-op retained for callers that prewarm facade runtimes generically. */
export function prewarmTtsRuntimeFacade(): void {}
// Pure synthesis stays in speech-core. File-backed helpers above inject the
// core media-store owner so package code never imports from src.
export {
buildTtsSystemPromptHint,
getLastTtsAttempt,
@@ -63,4 +50,4 @@ export {
type TtsSynthesisStreamResult,
type TtsStreamResult,
type TtsTelephonyResult,
} from "../../packages/speech-core/runtime-api.js";
} from "../tts/runtime-api.js";
+1 -1
View File
@@ -1,6 +1,6 @@
import { sortUniqueStrings } from "@openclaw/normalization-core/string-normalization";
import { resolveVoiceModelRefs } from "../../packages/speech-core/voice-models.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveVoiceModelRefs } from "../tts/voice-models.js";
import {
getLoadedRuntimePluginRegistry,
registryContainsRuntimePluginIds,
+1 -1
View File
@@ -14,7 +14,7 @@ import {
synthesizeVoiceModelCatalogEntries,
type VoiceModelCapabilities,
type VoiceModelProvider,
} from "../../packages/speech-core/voice-models.js";
} from "../tts/voice-models.js";
import type { PluginDiagnostic } from "./manifest-types.js";
import { projectProviderCatalogResultToUnifiedTextRows } from "./provider-catalog-unified-text.js";
import type { PluginRecord, PluginRegistry } from "./registry-types.js";
+2 -2
View File
@@ -1,2 +1,2 @@
// Lazy runtime bridge for speech-core request pre-resolution.
export { prepareTtsRequest } from "../../../packages/speech-core/runtime-api.js";
// Lazy runtime bridge for TTS request pre-resolution.
export { prepareTtsRequest } from "../../tts/runtime-api.js";
@@ -1,44 +1,62 @@
import type { TtsProvider } from "openclaw/plugin-sdk/config-contracts";
import { parseTtsDirectives, summarizeText } from "openclaw/plugin-sdk/speech-core";
// Runtime speech API barrel for TTS preferences, synthesis, streaming, and test
// helpers used by speech-capable plugins.
import type { TtsProvider } from "../config/types.js";
import { parseTtsDirectives } from "./directives.js";
import { summarizeText } from "./tts-core.js";
import { getResolvedSpeechProviderConfig, resolveTtsProvider } from "./tts-provider-resolution.js";
import { resolveModelOverridePolicy, type ResolvedTtsConfig } from "./tts-settings.js";
import { formatTtsProviderError, sanitizeTtsErrorForLog } from "./tts-synthesis-support.js";
import {
resolveTtsSynthesisTarget,
shouldDeliverTtsAsVoice,
supportsNativeVoiceNoteTts,
supportsTranscodedVoiceNoteTts,
resolveTtsSynthesisTarget,
} from "./tts-synthesis.js";
export type {
TtsDirectiveOverrides,
TtsDirectiveParseResult,
} from "openclaw/plugin-sdk/speech-core";
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
return resolveTtsProvider(config, prefsPath);
}
export { setSpeechRuntimeAvailabilityGuard } from "./runtime-availability.js";
export {
buildTtsSystemPromptHint,
getTtsMaxLength,
getTtsPersona,
isSummarizationEnabled,
isTtsEnabled,
listTtsPersonas,
resolveTtsAutoMode,
resolveTtsConfig,
resolveTtsPrefsPath,
setTtsMachinePrefsPathResolver,
type ResolvedTtsConfig,
type ResolvedTtsModelOverrides,
} from "./tts-settings.js";
export {
setSummarizationEnabled,
setTtsAutoMode,
setTtsEnabled,
setTtsMaxLength,
setTtsPersona,
setTtsProvider,
} from "./tts-settings-writes.js";
export { getLastTtsAttempt, listSpeechVoices, setLastTtsAttempt } from "./tts-payload.js";
export {
getResolvedSpeechProviderConfig,
isTtsProviderConfigured,
resolveTtsProviderOrder,
} from "./tts-provider-resolution.js";
export {
prepareTtsRequest,
resolveExplicitTtsOverrides,
type PreparedTtsRequest,
} from "./tts-request.js";
export { prepareTtsRequest, resolveExplicitTtsOverrides } from "./tts-request.js";
export { streamSpeech, textToSpeechStream } from "./tts-streaming.js";
export { synthesizeSpeech } from "./tts-synthesis.js";
export { textToSpeechTelephony } from "./tts-telephony.js";
export type { TtsDirectiveOverrides, TtsDirectiveParseResult } from "./provider-types.js";
export type {
TtsStreamResult,
TtsSynthesisResult,
TtsSynthesisStreamResult,
TtsTelephonyResult,
} from "./tts-types.js";
} from "./tts-runtime-types.js";
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
return resolveTtsProvider(config, prefsPath);
}
export const testApi = {
parseTtsDirectives,
@@ -1,4 +1,4 @@
/** Host-owned availability guard shared by every speech-core entrypoint. */
/** Host-owned availability guard shared by every speech runtime entrypoint. */
let assertRuntimeAvailable: (() => void) | undefined;
@@ -1,6 +1,6 @@
// Speaker-selection compatibility helpers for plugins that renamed voice fields
// over time but still need one normalized config object.
export type SpeakerSelectionConfig = Record<string, unknown>;
type SpeakerSelectionConfig = Record<string, unknown>;
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
@@ -1,5 +1,5 @@
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
import { describe, expect, it } from "vitest";
import { stripMarkdown } from "../shared/text/strip-markdown.js";
import {
CODE_HEAVY_SPOKEN_FALLBACK,
isCodeHeavySpeechText,
@@ -1,4 +1,4 @@
import { stripMarkdown } from "openclaw/plugin-sdk/text-chunking";
import { stripMarkdown } from "../shared/text/strip-markdown.js";
export const CODE_HEAVY_SPOKEN_FALLBACK = "I've put the detailed response on screen.";
+2 -2
View File
@@ -1,8 +1,8 @@
// File-backed TTS output is owned by the canonical media store, not speech-core.
// File-backed TTS output is owned by the canonical media store.
import { mimeTypeFromFilePath } from "@openclaw/media-core/mime";
import type { TtsAudioPersistence } from "../../packages/speech-core/src/tts-synthesis.js";
import { resolveGeneratedMediaMaxBytes } from "../media/configured-max-bytes.js";
import { saveMediaBuffer } from "../media/store.js";
import type { TtsAudioPersistence } from "./tts-synthesis.js";
const TTS_MEDIA_SUBDIR = "tool-speech-synthesis";
@@ -1,25 +1,20 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
markReplyPayloadAsTtsSupplement,
resolveSendableOutboundReplyParts,
type ReplyPayload,
} from "openclaw/plugin-sdk/reply-payload";
import { isVerbose, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
canonicalizeSpeechProviderId,
getSpeechProvider,
parseTtsDirectives,
summarizeText,
type SpeechVoiceOption,
} from "openclaw/plugin-sdk/speech-core";
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { markReplyPayloadAsTtsSupplement, type ReplyPayload } from "../auto-reply/reply-payload.js";
import type { OpenClawConfig } from "../config/types.js";
import { isVerbose, logVerbose } from "../globals.js";
import { resolveSendableOutboundReplyParts } from "../infra/outbound/reply-payload-parts.js";
import { truncateUtf16Safe } from "../utils.js";
import { parseTtsDirectives } from "./directives.js";
import { canonicalizeSpeechProviderId, getSpeechProvider } from "./provider-registry.js";
import type { SpeechVoiceOption } from "./provider-types.js";
import { assertSpeechRuntimeAvailable, isSpeechRuntimeAvailable } from "./runtime-availability.js";
import { isCodeHeavySpeechText, normalizeSpeechText } from "./speech-text.js";
import { summarizeText } from "./tts-core.js";
import {
getResolvedSpeechProviderConfig,
resolveSpeechProviderTimeoutMs,
resolveTtsProvider,
} from "./tts-provider-resolution.js";
import type { TtsStatusEntry } from "./tts-runtime-types.js";
import {
getTtsMaxLength,
isSummarizationEnabled,
@@ -29,7 +24,6 @@ import {
type ResolvedTtsConfig,
} from "./tts-settings.js";
import { textToSpeech, type TtsAudioPersistence } from "./tts-synthesis.js";
import type { TtsStatusEntry } from "./tts-types.js";
let lastTtsAttempt: TtsStatusEntry | undefined;
@@ -1,33 +1,23 @@
import { clampTimerTimeoutMs } from "../../packages/normalization-core/src/number-coercion.js";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../../packages/normalization-core/src/string-coerce.js";
import type {
OpenClawConfig,
ResolvedTtsPersona,
TtsConfig,
TtsProvider,
} from "openclaw/plugin-sdk/config-contracts";
import { clampTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
} from "../config/types.js";
import type { SpeechProviderPlugin } from "../plugins/types.js";
import {
canonicalizeSpeechProviderId,
getSpeechProvider,
listSpeechProviders,
normalizeSpeechProviderId,
type SpeechProviderConfig,
type SpeechProviderPlugin,
} from "openclaw/plugin-sdk/speech-core";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { withSpeakerSelectionCompat } from "../speaker.js";
import {
resolvePrimaryVoiceProviderCandidate,
resolveSupportedVoiceModelRefs,
resolveVoiceModelRefs,
resolveVoiceProviderCandidates,
voiceProviderSupportsModel,
type VoiceModelProvider,
type VoiceModelRef,
type VoiceProviderCandidate,
} from "../voice-models.js";
} from "./provider-registry.js";
import type { SpeechProviderConfig } from "./provider-types.js";
import { withSpeakerSelectionCompat } from "./speaker.js";
import {
DEFAULT_TTS_TIMEOUT_MS,
asProviderConfig,
@@ -39,6 +29,16 @@ import {
resolveTtsRuntimeConfig,
type ResolvedTtsConfig,
} from "./tts-settings.js";
import {
resolvePrimaryVoiceProviderCandidate,
resolveSupportedVoiceModelRefs,
resolveVoiceModelRefs,
resolveVoiceProviderCandidates,
voiceProviderSupportsModel,
type VoiceModelProvider,
type VoiceModelRef,
type VoiceProviderCandidate,
} from "./voice-models.js";
function resolvePositiveTimeoutMs(timeoutMs: number | undefined): number | undefined {
return typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
@@ -1,17 +1,16 @@
import type { OpenClawConfig, TtsConfig } from "openclaw/plugin-sdk/config-contracts";
import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
canonicalizeSpeechProviderId,
getSpeechProvider,
parseTtsDirectives,
type SpeechProviderOverrides,
type TtsDirectiveOverrides,
type TtsDirectiveParseResult,
} from "openclaw/plugin-sdk/speech-core";
import type { OpenClawConfig, TtsConfig } from "../config/types.js";
import { mergeDeep } from "../infra/deep-merge.js";
import { parseTtsDirectives } from "./directives.js";
import { canonicalizeSpeechProviderId, getSpeechProvider } from "./provider-registry.js";
import type {
SpeechProviderOverrides,
TtsDirectiveOverrides,
TtsDirectiveParseResult,
} from "./provider-types.js";
import { resolveTtsProvider } from "./tts-provider-resolution.js";
import { resolveTtsConfig, resolveTtsPrefsPath, resolveTtsRuntimeConfig } from "./tts-settings.js";
export type PreparedTtsRequest = {
type PreparedTtsRequest = {
cfg: OpenClawConfig;
directives: TtsDirectiveParseResult;
};
+462
View File
@@ -0,0 +1,462 @@
import { rmSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
clearRuntimeConfigSnapshot,
createMockSpeechProvider,
createTtsConfig,
expectTtsPayloadResult,
installSpeechProviders,
maybeApplyTtsToPayload,
maybeApplyTtsToPayloadCore,
prefsPathFor,
prepareSynthesisMock,
requireFirstSynthesisRequest,
requireRecord,
setSummarizationEnabled,
setTtsMachinePrefsPathResolver,
setTtsMaxLength,
synthesizeMock,
synthesizeSpeech,
testApi,
transcodeAudioBufferMock,
type OpenClawConfig,
} from "./tts-runtime.test-support.js";
describe("TTS runtime provider fallback and delivery behavior", () => {
afterEach(() => {
setTtsMachinePrefsPathResolver();
clearRuntimeConfigSnapshot();
delete (Object.prototype as Record<string, unknown>).polluted;
synthesizeMock.mockClear();
prepareSynthesisMock.mockClear();
transcodeAudioBufferMock.mockClear();
installSpeechProviders([createMockSpeechProvider()]);
});
it("ignores voiceModel refs that are not speech models", async () => {
installSpeechProviders([
createMockSpeechProvider("openai", {
autoSelectOrder: 10,
defaultModel: "gpt-4o-mini-tts",
models: ["gpt-4o-mini-tts"],
resolveConfig: ({ rawConfig }) => {
const providers = requireRecord(rawConfig.providers, "raw provider configs");
return {
model: "gpt-4o-mini-tts",
modelId: "gpt-4o-mini-tts",
...requireRecord(providers.openai, "raw openai provider config"),
};
},
}),
]);
const result = await synthesizeSpeech({
text: "Use speech provider default for unsupported realtime model.",
cfg: {
agents: {
defaults: {
voiceModel: { primary: "openai/gpt-realtime-2" },
},
},
tts: {
enabled: true,
provider: "openai",
prefsPath: "/tmp/openclaw-speech-core-realtime-voice-model-ignored-test.json",
},
} as OpenClawConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
expect(result.provider).toBe("openai");
expect(result.providerModel).toBe("gpt-4o-mini-tts");
const request = requireFirstSynthesisRequest("speech model fallback request");
expect(request.providerConfig).toMatchObject({
model: "gpt-4o-mini-tts",
modelId: "gpt-4o-mini-tts",
});
});
it("uses the first speech-supported voiceModel fallback as the default provider", async () => {
installSpeechProviders([
createMockSpeechProvider("openai", {
autoSelectOrder: 1,
models: ["gpt-4o-mini-tts"],
}),
createMockSpeechProvider("elevenlabs", {
autoSelectOrder: 99,
models: ["eleven_multilingual_v2"],
}),
]);
const result = await synthesizeSpeech({
text: "Use first speech-supported voice model.",
cfg: {
agents: {
defaults: {
voiceModel: {
primary: "openai/gpt-realtime-2",
fallbacks: ["elevenlabs/eleven_multilingual_v2"],
},
},
},
tts: {
enabled: true,
prefsPath: "/tmp/openclaw-speech-core-supported-voice-model-provider-test.json",
},
} as OpenClawConfig,
});
expect(result.success).toBe(true);
expect(result.provider).toBe("elevenlabs");
expect(result.providerModel).toBe("eleven_multilingual_v2");
expect(result.attemptedProviders).toEqual(["elevenlabs"]);
});
it("maps speakerVoice provider config to provider-compatible voice fields", async () => {
const result = await synthesizeSpeech({
text: "Use the configured speaker.",
cfg: {
tts: {
enabled: true,
provider: "mock",
providers: {
mock: {
speakerVoice: "cedar",
speakerVoiceId: "voice-123",
voice: "legacy-voice",
voiceName: "legacy-name",
voiceId: "legacy-id",
},
},
},
} as OpenClawConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
expect(result.providerVoice).toBe("voice-123");
const request = requireFirstSynthesisRequest("speaker voice synthesis request");
expect(request.providerConfig).toMatchObject({
speakerVoice: "cedar",
voice: "cedar",
voiceName: "cedar",
speakerVoiceId: "voice-123",
voiceId: "voice-123",
});
});
it("preserves alias-keyed provider config when resolving canonical providers", async () => {
installSpeechProviders([
createMockSpeechProvider("xiaomi", {
aliases: ["mimo"],
resolveConfig: ({ rawConfig }) => {
const providers = requireRecord(rawConfig.providers, "raw provider configs");
return requireRecord(providers.xiaomi ?? providers.mimo, "raw xiaomi provider config");
},
}),
]);
const result = await synthesizeSpeech({
text: "Use alias provider config.",
cfg: {
tts: {
enabled: true,
provider: "xiaomi",
providers: {
mimo: { apiKey: "fake" },
},
},
} as OpenClawConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
const request = requireFirstSynthesisRequest("alias provider synthesis request");
expect(request.providerConfig).toMatchObject({ apiKey: "fake" });
});
it("maps speakerVoice persona provider config to provider-compatible voice fields", async () => {
const result = await synthesizeSpeech({
text: "Use the persona speaker.",
cfg: {
tts: {
enabled: true,
provider: "mock",
persona: "narrator",
personas: {
narrator: {
providers: {
mock: {
speakerVoice: "marin",
},
},
},
},
},
} as OpenClawConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
expect(result.providerVoice).toBe("marin");
const request = requireFirstSynthesisRequest("persona speaker voice synthesis request");
expect(request.providerConfig).toMatchObject({
speakerVoice: "marin",
voice: "marin",
voiceName: "marin",
});
});
it.each(["feishu", "whatsapp"] as const)(
"marks %s voice-note TTS for channel-side transcoding when provider returns mp3",
async (channel) => {
expect(testApi.supportsTranscodedVoiceNoteTts(channel)).toBe(true);
await expectTtsPayloadResult({
channel,
prefsName: `openclaw-speech-core-tts-${channel}-mp3-test`,
text: `This ${channel} reply should be transcoded by the channel.`,
target: "voice-note",
audioAsVoice: true,
mediaExtension: "mp3",
providerResult: {
audioBuffer: Buffer.from("mp3"),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: false,
},
});
},
);
it("keeps non-native voice-note channels as regular audio files", async () => {
await expectTtsPayloadResult({
channel: "slack",
prefsName: "openclaw-speech-core-tts-slack-test",
text: "Slack replies should be delivered as regular audio attachments.",
target: "audio-file",
audioAsVoice: undefined,
});
});
it("preserves the text reply when auto-TTS audio persistence fails", async () => {
const payload = { text: "This text must still be delivered when media storage rejects audio." };
const result = await maybeApplyTtsToPayloadCore(
{
payload,
cfg: createTtsConfig("openclaw-speech-core-auto-persistence-failure-test"),
channel: "slack",
kind: "final",
},
async () => {
throw new Error("Media exceeds configured limit");
},
);
expect(result).toBe(payload);
});
it("normalizes voice-note Markdown once before synthesis", async () => {
const text =
'This short explanation keeps the fenced literal below from becoming code-heavy.\n\n```md\nconst literal = "[x](y)";\n```';
let mediaDir: string | undefined;
try {
const result = await maybeApplyTtsToPayload({
payload: { text },
cfg: createTtsConfig("openclaw-speech-core-once-normalized-markdown-test"),
channel: "telegram",
kind: "final",
});
const request = requireFirstSynthesisRequest("once-normalized voice-note synthesis request");
expect(request.text).toBe(
'This short explanation keeps the fenced literal below from becoming code-heavy.\n\nconst literal = "[x](y)";',
);
expect(result.text).toBe(text);
mediaDir = result.mediaUrl ? path.dirname(result.mediaUrl) : undefined;
} finally {
if (mediaDir) {
rmSync(mediaDir, { recursive: true, force: true });
}
}
});
it("skips channel auto-TTS audio for code-heavy replies", async () => {
const text = "```ts\nexport function answer() {\n return 42;\n}\n```";
const result = await maybeApplyTtsToPayload({
payload: { text },
cfg: createTtsConfig("openclaw-speech-core-code-heavy-voice-note-test"),
channel: "telegram",
kind: "final",
});
expect(synthesizeMock).not.toHaveBeenCalled();
expect(result).toEqual({ text });
});
it("synthesizes code-heavy explicitly tagged hidden TTS text", async () => {
const cfg = createTtsConfig("openclaw-speech-core-code-heavy-hidden-tts-test");
let mediaDir: string | undefined;
try {
const result = await maybeApplyTtsToPayload({
payload: {
text: '[[tts:text]]```ts\nconst detailedAnswer = "this code should still be spoken";\n```[[/tts:text]]',
audioAsVoice: true,
},
cfg,
channel: "telegram",
kind: "final",
});
expect(synthesizeMock).toHaveBeenCalled();
const request = requireFirstSynthesisRequest("code-heavy hidden TTS request");
expect(request.text).toBe('const detailedAnswer = "this code should still be spoken";');
expect(result.text).toBeUndefined();
mediaDir = result.mediaUrl ? path.dirname(result.mediaUrl) : undefined;
} finally {
if (mediaDir) {
rmSync(mediaDir, { recursive: true, force: true });
}
}
});
it("synthesizes explicitly tagged short hidden TTS text", async () => {
const cfg = createTtsConfig("openclaw-speech-core-short-hidden-tts-test");
let mediaDir: string | undefined;
try {
const result = await maybeApplyTtsToPayload({
payload: {
text: "[[tts:text]]hello[[/tts:text]]",
audioAsVoice: true,
},
cfg,
channel: "telegram",
kind: "final",
});
expect(synthesizeMock).toHaveBeenCalled();
const request = requireFirstSynthesisRequest("hidden TTS request");
expect(request.text).toBe("hello");
expect(result.mediaUrl).toMatch(/voice---[a-f0-9-]+\.ogg$/);
expect(result.audioAsVoice).toBe(true);
expect(result.text).toBeUndefined();
expect(result.ttsSupplement).toBeUndefined();
mediaDir = result.mediaUrl ? path.dirname(result.mediaUrl) : undefined;
} finally {
if (mediaDir) {
rmSync(mediaDir, { recursive: true, force: true });
}
}
});
it("truncates long TTS text on a UTF-16 boundary", async () => {
const prefsName = "openclaw-speech-core-utf16-truncate-test";
const prefsPath = prefsPathFor(prefsName);
const cfg = createTtsConfig(prefsName);
setTtsMaxLength(prefsPath, 11);
setSummarizationEnabled(prefsPath, false);
let mediaDir: string | undefined;
try {
const result = await maybeApplyTtsToPayload({
payload: { text: `${"a".repeat(7)}😀tail long enough for TTS` },
cfg,
channel: "telegram",
kind: "final",
});
expect(synthesizeMock).toHaveBeenCalled();
const request = requireFirstSynthesisRequest("utf16 truncated TTS request");
const spokenText = String(request.text);
expect(spokenText).toBe(`${"a".repeat(7)}...`);
expect(result.spokenText).toBe(spokenText);
mediaDir = result.mediaUrl ? path.dirname(result.mediaUrl) : undefined;
} finally {
rmSync(prefsPath, { force: true });
if (mediaDir) {
rmSync(mediaDir, { recursive: true, force: true });
}
}
});
it("skips block delivery kind in final mode (accumulated final tail synthesizes instead)", async () => {
synthesizeMock.mockClear();
const cfg = createTtsConfig("openclaw-speech-core-block-kind-tts-test");
const result = await maybeApplyTtsToPayload({
payload: { text: "WebChat block stream chunks defer TTS to the final tail." },
cfg,
channel: "webchat",
kind: "block",
});
expect(synthesizeMock).not.toHaveBeenCalled();
expect((result as { trustedLocalMedia?: boolean }).trustedLocalMedia).toBeUndefined();
expect(result.text).toBe("WebChat block stream chunks defer TTS to the final tail.");
});
it("skips tool delivery kind in final mode", async () => {
synthesizeMock.mockClear();
const cfg = createTtsConfig("openclaw-speech-core-tool-kind-tts-test");
const result = await maybeApplyTtsToPayload({
payload: { text: "Intermediate tool output should not be spoken." },
cfg,
channel: "webchat",
kind: "tool",
});
expect(synthesizeMock).not.toHaveBeenCalled();
expect((result as { trustedLocalMedia?: boolean }).trustedLocalMedia).toBeUndefined();
expect(result.text).toBe("Intermediate tool output should not be spoken.");
});
it("keeps skipping untagged short TTS text", async () => {
const cfg = createTtsConfig("openclaw-speech-core-short-plain-tts-test");
const result = await maybeApplyTtsToPayload({
payload: {
text: "hello",
audioAsVoice: true,
},
cfg,
channel: "telegram",
kind: "final",
});
expect(synthesizeMock).not.toHaveBeenCalled();
expect(result).toEqual({
text: "hello",
audioAsVoice: true,
});
});
it("skips auto TTS for legacy final media directives", async () => {
synthesizeMock.mockClear();
const cfg = createTtsConfig("openclaw-speech-core-media-directive-tts-test");
const result = await maybeApplyTtsToPayload({
payload: { text: "Here is the render.\nMEDIA:/tmp/render.png" },
cfg,
channel: "telegram",
kind: "final",
});
expect(synthesizeMock).not.toHaveBeenCalled();
expect(result).toEqual({ text: "Here is the render.\nMEDIA:/tmp/render.png" });
});
it("keeps skipping explicit tagged TTS text that strips to empty markdown", async () => {
const cfg = createTtsConfig("openclaw-speech-core-empty-hidden-tts-test");
const result = await maybeApplyTtsToPayload({
payload: {
text: "[[tts:text]]***[[/tts:text]]",
audioAsVoice: true,
},
cfg,
channel: "telegram",
kind: "final",
});
expect(synthesizeMock).not.toHaveBeenCalled();
expect(result).toEqual({
audioAsVoice: true,
});
});
});
+341
View File
@@ -0,0 +1,341 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
MAX_TIMER_TIMEOUT_MS,
clearRuntimeConfigSnapshot,
createMockSpeechProvider,
installSpeechProviders,
prepareSynthesisMock,
requireAttempt,
requireFirstSynthesisRequest,
requireRecord,
setTtsMachinePrefsPathResolver,
synthesizeMock,
synthesizeSpeech,
textToSpeechStream,
transcodeAudioBufferMock,
type OpenClawConfig,
type SpeechSynthesisRequest,
} from "./tts-runtime.test-support.js";
describe("TTS runtime voice model and streaming behavior", () => {
afterEach(() => {
setTtsMachinePrefsPathResolver();
clearRuntimeConfigSnapshot();
delete (Object.prototype as Record<string, unknown>).polluted;
synthesizeMock.mockClear();
prepareSynthesisMock.mockClear();
transcodeAudioBufferMock.mockClear();
installSpeechProviders([createMockSpeechProvider()]);
});
it("caps oversized voice model TTS timeouts before synthesis", async () => {
installSpeechProviders([
createMockSpeechProvider("mock", { autoSelectOrder: 1, models: ["mock-tts"] }),
]);
const result = await synthesizeSpeech({
text: "Use capped explicit timeout.",
cfg: {
agents: {
defaults: {
voiceModel: { primary: "mock/mock-tts", timeoutMs: Number.MAX_SAFE_INTEGER },
},
},
tts: {
enabled: true,
provider: "mock",
},
} as OpenClawConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
const request = requireFirstSynthesisRequest("voice model capped timeout request");
expect(request.timeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("uses agents.defaults.voiceModel as the default speech provider and model", async () => {
installSpeechProviders([
createMockSpeechProvider("mock", { autoSelectOrder: 1 }),
createMockSpeechProvider("openai", {
autoSelectOrder: 10,
models: ["gpt-4o-mini-tts"],
resolveConfig: ({ rawConfig }) => {
const providers = requireRecord(rawConfig.providers, "raw provider configs");
return {
model: "provider-default-model",
modelId: "provider-default-model",
...requireRecord(providers.openai, "raw openai provider config"),
};
},
}),
]);
const result = await synthesizeSpeech({
text: "Use configured voice model.",
cfg: {
agents: {
defaults: {
voiceModel: { primary: "openai/gpt-4o-mini-tts", timeoutMs: 12_345 },
},
},
tts: {
enabled: true,
prefsPath: "/tmp/openclaw-speech-core-voice-model-default-test.json",
},
} as OpenClawConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
expect(result.provider).toBe("openai");
expect(result.providerModel).toBe("gpt-4o-mini-tts");
const request = requireFirstSynthesisRequest("voice model synthesis request");
expect(request.providerConfig).toMatchObject({
model: "gpt-4o-mini-tts",
modelId: "gpt-4o-mini-tts",
});
expect(request.timeoutMs).toBe(12_345);
});
it("keeps explicit provider model aliases ahead of voiceModel defaults", async () => {
installSpeechProviders([
createMockSpeechProvider("openrouter", {
models: ["explicit-model", "default-model"],
resolveConfig: ({ rawConfig }) => {
const providers = requireRecord(rawConfig.providers, "raw provider configs");
return requireRecord(providers.openrouter, "raw openrouter provider config");
},
}),
]);
const result = await synthesizeSpeech({
text: "Prefer explicit model alias.",
cfg: {
agents: {
defaults: {
voiceModel: { primary: "openrouter/default-model" },
},
},
tts: {
enabled: true,
provider: "openrouter",
prefsPath: "/tmp/openclaw-speech-core-explicit-model-alias-test.json",
providers: {
openrouter: {
modelId: "explicit-model",
},
},
},
} as OpenClawConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
const request = requireFirstSynthesisRequest("explicit model alias synthesis request");
const providerConfig = requireRecord(request.providerConfig, "provider config");
expect(providerConfig).toMatchObject({
modelId: "explicit-model",
});
expect(providerConfig.model).toBeUndefined();
});
it("tries voiceModel fallbacks before auto-selected speech providers", async () => {
installSpeechProviders([
createMockSpeechProvider("mock", { autoSelectOrder: 1 }),
createMockSpeechProvider("openai", {
autoSelectOrder: 10,
models: ["gpt-4o-mini-tts"],
isConfigured: () => false,
}),
createMockSpeechProvider("elevenlabs", {
autoSelectOrder: 99,
models: ["eleven_multilingual_v2"],
}),
]);
const result = await synthesizeSpeech({
text: "Use configured voice model fallback.",
cfg: {
agents: {
defaults: {
voiceModel: {
primary: "openai/gpt-4o-mini-tts",
fallbacks: ["elevenlabs/eleven_multilingual_v2"],
},
},
},
tts: {
enabled: true,
prefsPath: "/tmp/openclaw-speech-core-voice-model-fallback-test.json",
},
} as OpenClawConfig,
});
expect(result.success).toBe(true);
expect(result.provider).toBe("elevenlabs");
expect(result.fallbackFrom).toBe("openai");
expect(result.providerModel).toBe("eleven_multilingual_v2");
});
it("tries same-provider voiceModel fallbacks as separate model attempts", async () => {
const synthesize = vi.fn(async (request: SpeechSynthesisRequest) => {
if (request.providerConfig.model === "bad-tts") {
throw new Error("unavailable model");
}
return {
audioBuffer: Buffer.from("voice"),
fileExtension: ".ogg",
outputFormat: "ogg",
voiceCompatible: request.target === "voice-note",
};
});
installSpeechProviders([
createMockSpeechProvider("openai", {
autoSelectOrder: 10,
models: ["bad-tts", "good-tts"],
synthesize,
}),
]);
const result = await synthesizeSpeech({
text: "Use same-provider fallback model.",
cfg: {
agents: {
defaults: {
voiceModel: {
primary: "openai/bad-tts",
fallbacks: ["openai/good-tts"],
},
},
},
tts: {
enabled: true,
prefsPath: "/tmp/openclaw-speech-core-same-provider-voice-model-fallback-test.json",
},
} as OpenClawConfig,
});
expect(result.success).toBe(true);
expect(result.provider).toBe("openai");
expect(result.providerModel).toBe("good-tts");
expect(result.attemptedProviders).toEqual(["openai", "openai"]);
expect(synthesize.mock.calls.map(([request]) => request.providerConfig.model)).toEqual([
"bad-tts",
"good-tts",
]);
});
it("skips non-streaming providers before using a streaming fallback", async () => {
const release = vi.fn(async () => {});
const streamSynthesize = vi.fn(async () => ({
audioStream: new ReadableStream<Uint8Array>({
start(controller) {
controller.close();
},
}),
fileExtension: ".pcm",
outputFormat: "pcm",
voiceCompatible: false,
release,
}));
installSpeechProviders([
createMockSpeechProvider("buffered", { autoSelectOrder: 1 }),
createMockSpeechProvider("streaming", {
autoSelectOrder: 2,
streamSynthesize,
}),
]);
const result = await textToSpeechStream({
text: "Use streaming fallback.",
cfg: {
tts: {
enabled: true,
provider: "buffered",
prefsPath: "/tmp/openclaw-speech-core-streaming-fallback-test.json",
},
} as OpenClawConfig,
});
expect(result.success).toBe(true);
expect(result.provider).toBe("streaming");
expect(result.fallbackFrom).toBe("buffered");
expect(result.attemptedProviders).toEqual(["buffered", "streaming"]);
expect(result.outputFormat).toBe("pcm");
expect(result.fileExtension).toBe(".pcm");
expect(result.target).toBe("audio-file");
expect(result.release).toBe(release);
const skippedAttempt = requireAttempt(result.attempts, 0);
expect(skippedAttempt).toMatchObject({
provider: "buffered",
outcome: "skipped",
reasonCode: "unsupported_for_streaming",
personaBinding: "none",
error: "buffered does not support streaming TTS",
});
expect(skippedAttempt).not.toHaveProperty("latencyMs");
expect(requireAttempt(result.attempts, 1)).toMatchObject({
provider: "streaming",
outcome: "success",
reasonCode: "success",
});
expect(streamSynthesize).toHaveBeenCalledOnce();
});
it("classifies streaming timeouts before falling back with raw text", async () => {
const timeoutStreamSynthesize = vi.fn(async () => {
const error = new Error("stalled");
error.name = "AbortError";
throw error;
});
const fallbackStreamSynthesize = vi.fn(async () => ({
audioStream: new ReadableStream<Uint8Array>({
start(controller) {
controller.close();
},
}),
fileExtension: ".pcm",
outputFormat: "pcm",
voiceCompatible: false,
}));
installSpeechProviders([
createMockSpeechProvider("primary", {
autoSelectOrder: 1,
streamSynthesize: timeoutStreamSynthesize,
}),
createMockSpeechProvider("fallback", {
autoSelectOrder: 2,
streamSynthesize: fallbackStreamSynthesize,
}),
]);
const text = "## Keep [streaming Markdown](https://example.com) raw!!!!!";
const result = await textToSpeechStream({
text,
cfg: {
tts: {
enabled: true,
provider: "primary",
prefsPath: "/tmp/openclaw-speech-core-streaming-timeout-test.json",
},
} as OpenClawConfig,
});
expect(result.success).toBe(true);
expect(result.provider).toBe("fallback");
expect(result.fallbackFrom).toBe("primary");
expect(requireAttempt(result.attempts, 0)).toMatchObject({
provider: "primary",
outcome: "failed",
reasonCode: "timeout",
error: "primary: request timed out",
});
expect(requireAttempt(result.attempts, 1)).toMatchObject({
provider: "fallback",
outcome: "success",
reasonCode: "success",
});
expect(fallbackStreamSynthesize).toHaveBeenCalledWith(expect.objectContaining({ text }));
});
});
+496
View File
@@ -0,0 +1,496 @@
import { rmSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearRuntimeConfigSnapshot,
createMockSpeechProvider,
getTtsPersona,
getTtsProvider,
installSpeechProviders,
isTtsProviderConfigured,
maybeApplyTtsToPayload,
prepareSynthesisMock,
requireAttempt,
requireFirstCallParam,
requireFirstSynthesisRequest,
requireRecord,
resolveTtsConfig,
setTtsMachinePrefsPathResolver,
synthesizeMock,
synthesizeSpeech,
textToSpeechTelephony,
transcodeAudioBufferMock,
type OpenClawConfig,
type ReplyPayload,
type SpeechTelephonySynthesisRequest,
} from "./tts-runtime.test-support.js";
describe("TTS runtime persona behavior", () => {
afterEach(() => {
setTtsMachinePrefsPathResolver();
clearRuntimeConfigSnapshot();
delete (Object.prototype as Record<string, unknown>).polluted;
synthesizeMock.mockClear();
prepareSynthesisMock.mockClear();
transcodeAudioBufferMock.mockClear();
installSpeechProviders([createMockSpeechProvider()]);
});
it("selects persona preferred provider before config fallback", () => {
const cfg: OpenClawConfig = {
tts: {
enabled: true,
provider: "other",
persona: "alfred",
personas: {
alfred: {
label: "Alfred",
provider: "mock",
providers: {
mock: {
voice: "Algieba",
},
},
},
},
},
};
const config = resolveTtsConfig(cfg);
const prefsPath = "/tmp/openclaw-speech-core-persona-provider.json";
expect(getTtsPersona(config, prefsPath)?.id).toBe("alfred");
expect(getTtsProvider(config, prefsPath)).toBe("mock");
});
it("treats provider configuration errors as unconfigured", () => {
installSpeechProviders([
createMockSpeechProvider("broken", {
resolveConfig: () => {
throw new Error("invalid provider URL");
},
}),
]);
const prefsPath = "/tmp/openclaw-speech-core-invalid-provider.json";
setTtsMachinePrefsPathResolver(() => prefsPath);
const cfg = {
tts: {
providers: { broken: {} },
},
} as OpenClawConfig;
const config = resolveTtsConfig(cfg);
expect(isTtsProviderConfigured(config, "broken", cfg)).toBe(false);
expect(getTtsProvider(config, prefsPath)).toBe("");
});
it("merges active persona provider binding into synthesis config", async () => {
setTtsMachinePrefsPathResolver(() => "/tmp/openclaw-speech-core-persona-merge.json");
const cfg: OpenClawConfig = {
tts: {
enabled: true,
provider: "mock",
providers: {
mock: {
model: "base-model",
voice: "base-voice",
},
},
persona: "alfred",
personas: {
alfred: {
provider: "mock",
providers: {
mock: {
voice: "persona-voice",
style: "dry",
},
},
},
},
},
};
const payload: ReplyPayload = {
text: "This reply should use persona-specific provider configuration.",
};
let mediaDir: string | undefined;
try {
const result = await maybeApplyTtsToPayload({
payload,
cfg,
channel: "slack",
kind: "final",
});
expect(synthesizeMock).toHaveBeenCalled();
const request = requireFirstSynthesisRequest("persona synthesis request");
const providerConfig = requireRecord(request.providerConfig, "persona provider config");
expect(providerConfig.model).toBe("base-model");
expect(providerConfig.voice).toBe("persona-voice");
expect(providerConfig.style).toBe("dry");
expect(result.mediaUrl).toMatch(/voice---[a-f0-9-]+\.ogg$/);
mediaDir = result.mediaUrl ? path.dirname(result.mediaUrl) : undefined;
} finally {
if (mediaDir) {
rmSync(mediaDir, { recursive: true, force: true });
}
}
});
it("does not mark skipped unregistered providers as missing persona bindings", async () => {
const result = await synthesizeSpeech({
text: "Use fallback provider.",
cfg: {
tts: {
enabled: true,
provider: "missing",
persona: "alfred",
personas: {
alfred: {
providers: {
missing: {
voice: "configured-but-unregistered",
},
},
},
},
},
},
});
expect(result.success).toBe(true);
const attempt = requireAttempt(result.attempts, 0);
expect(attempt.provider).toBe("missing");
expect(attempt.outcome).toBe("skipped");
expect(attempt.reasonCode).toBe("no_provider_registered");
expect(attempt.persona).toBe("alfred");
expect(attempt).not.toHaveProperty("personaBinding");
});
it("does not mark skipped telephony providers as missing persona bindings", async () => {
const result = await textToSpeechTelephony({
text: "Use telephony provider.",
cfg: {
tts: {
enabled: true,
provider: "mock",
persona: "alfred",
personas: {
alfred: {
providers: {
mock: {
voice: "persona-voice",
},
},
},
},
},
},
});
expect(result.success).toBe(false);
const attempt = requireAttempt(result.attempts, 0);
expect(attempt.provider).toBe("mock");
expect(attempt.outcome).toBe("skipped");
expect(attempt.reasonCode).toBe("unsupported_for_telephony");
expect(attempt.persona).toBe("alfred");
expect(attempt).not.toHaveProperty("personaBinding");
});
it("passes directive overrides to telephony synthesis providers", async () => {
const synthesizeTelephonyMock = vi.fn(async (_request: SpeechTelephonySynthesisRequest) => ({
audioBuffer: Buffer.from("voice"),
outputFormat: "pcm",
sampleRate: 24_000,
}));
installSpeechProviders([
createMockSpeechProvider("mock", {
synthesizeTelephony: synthesizeTelephonyMock,
}),
]);
const text = "## Keep [telephony Markdown](https://example.com) raw!!!!!";
const result = await textToSpeechTelephony({
text,
cfg: {
tts: {
enabled: true,
provider: "mock",
providers: {
mock: {
modelId: "telephony-model",
voiceId: "default-voice",
},
},
},
},
overrides: {
providerOverrides: {
mock: {
speakerVoice: "directed-voice",
speed: 1.5,
},
},
},
});
expect(result.success).toBe(true);
expect(result.providerModel).toBe("telephony-model");
expect(result.providerVoice).toBe("directed-voice");
expect(synthesizeTelephonyMock).toHaveBeenCalledOnce();
const telephonyRequest = requireRecord(
requireFirstCallParam(synthesizeTelephonyMock.mock.calls, "telephony synthesis"),
"telephony synthesis request",
);
expect(telephonyRequest.providerOverrides).toEqual({
speakerVoice: "directed-voice",
speed: 1.5,
});
expect(telephonyRequest.text).toBe(text);
expect(telephonyRequest).not.toHaveProperty("target");
});
it("uses provider defaults when fallback policy allows missing persona bindings", async () => {
await synthesizeSpeech({
text: "Use neutral provider defaults.",
cfg: {
tts: {
enabled: true,
provider: "mock",
persona: "alfred",
personas: {
alfred: {
fallbackPolicy: "provider-defaults",
},
},
},
},
});
expect(prepareSynthesisMock).toHaveBeenCalledOnce();
const prepareContext = requireRecord(
requireFirstCallParam(prepareSynthesisMock.mock.calls, "prepare synthesis"),
"prepare synthesis context",
);
expect(prepareContext.persona).toBeUndefined();
expect(prepareContext.personaProviderConfig).toBeUndefined();
});
it("preserves persona metadata by default when provider bindings are missing", async () => {
await synthesizeSpeech({
text: "Use persona prompt.",
cfg: {
tts: {
enabled: true,
provider: "mock",
persona: "alfred",
personas: {
alfred: {
label: "Alfred",
},
},
},
},
});
expect(prepareSynthesisMock).toHaveBeenCalledOnce();
const prepareContext = requireRecord(
requireFirstCallParam(prepareSynthesisMock.mock.calls, "prepare synthesis"),
"prepare synthesis context",
);
const persona = requireRecord(prepareContext.persona, "prepare synthesis persona");
expect(persona.id).toBe("alfred");
expect(prepareContext.personaProviderConfig).toBeUndefined();
});
it("skips unbound providers under fail policy while allowing bound fallbacks", async () => {
installSpeechProviders([
createMockSpeechProvider("mock", { autoSelectOrder: 1 }),
createMockSpeechProvider("fallback", { autoSelectOrder: 2 }),
]);
const result = await synthesizeSpeech({
text: "Use the first persona-bound provider.",
cfg: {
tts: {
enabled: true,
provider: "mock",
persona: "alfred",
personas: {
alfred: {
fallbackPolicy: "fail",
providers: {
fallback: {
voice: "fallback-voice",
},
},
},
},
},
},
});
expect(result.success).toBe(true);
expect(result.provider).toBe("fallback");
expect(result.fallbackFrom).toBe("mock");
const skippedAttempt = requireAttempt(result.attempts, 0);
expect(skippedAttempt.provider).toBe("mock");
expect(skippedAttempt.outcome).toBe("skipped");
expect(skippedAttempt.reasonCode).toBe("not_configured");
expect(skippedAttempt.persona).toBe("alfred");
expect(skippedAttempt.personaBinding).toBe("missing");
expect(skippedAttempt.error).toBe("mock: persona alfred has no provider binding");
const successAttempt = requireAttempt(result.attempts, 1);
expect(successAttempt.provider).toBe("fallback");
expect(successAttempt.outcome).toBe("success");
expect(successAttempt.persona).toBe("alfred");
expect(successAttempt.personaBinding).toBe("applied");
});
});
describe("TTS runtime per-agent config", () => {
it("deep-merges the active agent TTS override over tts", () => {
const cfg = {
tts: {
enabled: true,
provider: "openai",
providers: {
openai: {
apiKey: "example",
voice: "coral",
speed: 1,
},
},
},
agents: {
list: [
{
id: "reader",
tts: {
provider: "openai",
providers: {
openai: {
voice: "nova",
},
},
},
},
],
},
} satisfies OpenClawConfig;
const resolved = resolveTtsConfig(cfg, "reader");
const rawConfig = requireRecord(resolved.rawConfig, "resolved raw TTS config");
expect(rawConfig.enabled).toBe(true);
expect(rawConfig.provider).toBe("openai");
const providers = requireRecord(rawConfig.providers, "resolved raw TTS providers");
const openai = requireRecord(providers.openai, "resolved OpenAI TTS provider config");
expect(openai.apiKey).toBe("example");
expect(openai.voice).toBe("nova");
expect(openai.speed).toBe(1);
});
it("composes per-agent TTS overrides with active persona bindings", async () => {
const cfg = {
tts: {
enabled: true,
provider: "mock",
providers: {
mock: {
model: "base-model",
voice: "base-voice",
},
},
persona: "alfred",
personas: {
alfred: {
provider: "mock",
providers: {
mock: {
voice: "alfred-voice",
},
},
},
jarvis: {
provider: "mock",
providers: {
mock: {
style: "jarvis-style",
},
},
},
},
},
agents: {
list: [
{
id: "reader",
tts: {
persona: "jarvis",
providers: {
mock: {
voice: "agent-voice",
},
},
},
},
],
},
} satisfies OpenClawConfig;
let mediaDir: string | undefined;
try {
const result = await maybeApplyTtsToPayload({
payload: { text: "This agent reply should use the composed persona config." },
cfg,
channel: "slack",
kind: "final",
agentId: "reader",
});
expect(synthesizeMock).toHaveBeenCalled();
const request = requireFirstSynthesisRequest("agent persona synthesis request");
const providerConfig = requireRecord(request.providerConfig, "agent persona provider config");
expect(providerConfig.model).toBe("base-model");
expect(providerConfig.voice).toBe("agent-voice");
expect(providerConfig.style).toBe("jarvis-style");
expect(result.mediaUrl).toMatch(/voice---[a-f0-9-]+\.ogg$/);
mediaDir = result.mediaUrl ? path.dirname(result.mediaUrl) : undefined;
} finally {
if (mediaDir) {
rmSync(mediaDir, { recursive: true, force: true });
}
}
});
it("ignores prototype-pollution keys in agent TTS overrides", () => {
const cfg = {
tts: {
provider: "openai",
providers: {
openai: {
voice: "coral",
},
},
},
agents: {
list: [
{
id: "reader",
tts: JSON.parse(
'{"providers":{"openai":{"voice":"nova","__proto__":{"polluted":true}}}}',
),
},
],
},
} as OpenClawConfig;
const resolved = resolveTtsConfig(cfg, "reader");
expect(resolved.rawConfig?.providers?.openai).toEqual({ voice: "nova" });
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
});
+447
View File
@@ -0,0 +1,447 @@
import { rmSync } from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CODE_HEAVY_SPOKEN_FALLBACK,
MAX_TIMER_TIMEOUT_MS,
buildTtsSystemPromptHint,
clearRuntimeConfigSnapshot,
createMockSpeechProvider,
createTtsConfig,
expectTtsPayloadResult,
installSpeechProviders,
listSpeechVoices,
nativeVoiceNoteChannels,
prefsPathFor,
prepareSynthesisMock,
prepareTtsRequest,
requireFirstCallParam,
requireFirstSynthesisRequest,
requireRecord,
resolveTtsConfig,
resolveTtsPrefsPath,
setRuntimeConfigSnapshot,
setTtsMachinePrefsPathResolver,
synthesizeMock,
synthesizeSpeech,
testApi,
textToSpeech,
textToSpeechCore,
transcodeAudioBufferMock,
type OpenClawConfig,
type SpeechListVoicesRequest,
type TtsConfig,
} from "./tts-runtime.test-support.js";
describe("TTS runtime native voice-note routing", () => {
afterEach(() => {
setTtsMachinePrefsPathResolver();
clearRuntimeConfigSnapshot();
delete (Object.prototype as Record<string, unknown>).polluted;
synthesizeMock.mockClear();
prepareSynthesisMock.mockClear();
transcodeAudioBufferMock.mockClear();
installSpeechProviders([createMockSpeechProvider()]);
});
it("prefers the environment preference path over migrated machine state", () => {
const previousEnvPath = process.env.OPENCLAW_TTS_PREFS;
const envPath = prefsPathFor("env-override");
setTtsMachinePrefsPathResolver(() => prefsPathFor("machine-state"));
process.env.OPENCLAW_TTS_PREFS = envPath;
try {
expect(resolveTtsPrefsPath(resolveTtsConfig({}))).toBe(envPath);
} finally {
if (previousEnvPath === undefined) {
delete process.env.OPENCLAW_TTS_PREFS;
} else {
process.env.OPENCLAW_TTS_PREFS = previousEnvPath;
}
}
});
it("resolves voice delivery support from channel capabilities", () => {
for (const channel of nativeVoiceNoteChannels) {
expect(testApi.supportsNativeVoiceNoteTts(channel)).toBe(true);
expect(testApi.supportsNativeVoiceNoteTts(channel.toUpperCase())).toBe(true);
}
expect(testApi.supportsNativeVoiceNoteTts("slack")).toBe(false);
expect(testApi.supportsNativeVoiceNoteTts(undefined)).toBe(false);
});
it("tells generic TTS guidance to defer to MEMORY voice-delivery instructions", () => {
const hint = buildTtsSystemPromptHint(createTtsConfig("openclaw-speech-core-tts-hint-test"));
expect(hint).toContain("Voice (TTS) is enabled.");
expect(hint).toContain(
"If workspace context (especially MEMORY.md) tells you not to use [[tts:...]] or to use a local/non-tagged voice workflow, follow that workspace instruction instead.",
);
expect(hint).toContain(
"Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
);
});
it("prepares deep-merged surface config and directive inputs", () => {
const cfg: OpenClawConfig = {
tts: {
provider: "mock",
modelOverrides: { allowProvider: false },
providers: {
mock: {
model: "base-model",
voiceSettings: { stability: 0.4 },
},
},
},
};
const prepared = prepareTtsRequest({
cfg,
override: {
modelOverrides: { allowProvider: true },
providers: {
mock: {
voice: "surface-voice",
voiceSettings: { speed: 1.1 },
},
},
},
text: "Hello [[tts:text]]Speak this instead[[/tts:text]] caller",
});
expect(prepared.cfg).not.toBe(cfg);
expect(prepared.cfg.tts?.providers?.mock).toEqual({
model: "base-model",
voice: "surface-voice",
voiceSettings: { stability: 0.4, speed: 1.1 },
});
expect(prepared.cfg.tts?.modelOverrides?.allowProvider).toBe(true);
expect(prepared.directives).toEqual({
cleanedText: "Hello caller",
hasDirective: true,
overrides: {
ttsText: "Speak this instead",
},
ttsText: "Speak this instead",
warnings: [],
});
expect(cfg.tts?.providers?.mock).toEqual({
model: "base-model",
voiceSettings: { stability: 0.4 },
});
});
it("sanitizes blocked override keys while preparing TTS config", () => {
const prepared = prepareTtsRequest({
cfg: {
tts: {
provider: "mock",
providers: { mock: { model: "base-model" } },
},
},
override: JSON.parse(
'{"__proto__":{"polluted":"top"},"providers":{"mock":{"voice":"safe","__proto__":{"polluted":"nested"}}}}',
) as TtsConfig,
text: "[[tts:text]]Speak this instead[[/tts:text]]",
});
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
expect(prepared.cfg.tts).not.toHaveProperty("polluted");
expect(prepared.cfg.tts?.providers?.mock).toEqual({
model: "base-model",
voice: "safe",
});
expect(prepared.directives.cleanedText).toBe("");
expect(prepared.directives.ttsText).toBe("Speak this instead");
});
it("marks Discord auto TTS replies as native voice messages", async () => {
await expectTtsPayloadResult({
channel: "discord",
prefsName: "openclaw-speech-core-tts-test",
text: "This Discord reply should be delivered as a native voice note.",
target: "voice-note",
audioAsVoice: true,
});
});
it("keeps compatible audio-file synthesis deliverable as a voice memo", async () => {
await expectTtsPayloadResult({
channel: "voice-memo-chat",
prefsName: "openclaw-speech-core-tts-voice-memo-mp3-test",
text: "This reply should be delivered as a native voice memo.",
target: "audio-file",
audioAsVoice: true,
mediaExtension: "mp3",
providerResult: {
audioBuffer: Buffer.from("mp3"),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: false,
},
});
});
it("does not mark unsupported audio-file output as a voice memo", async () => {
await expectTtsPayloadResult({
channel: "voice-memo-chat",
prefsName: "openclaw-speech-core-tts-voice-memo-ogg-test",
text: "This reply should stay a regular audio attachment.",
target: "audio-file",
audioAsVoice: undefined,
});
});
it("pre-transcodes synthesized mp3 to opus-in-CAF when the host can satisfy preferAudioFileFormat", async () => {
transcodeAudioBufferMock.mockResolvedValueOnce({
ok: true,
buffer: Buffer.from("transcoded-caf"),
});
await expectTtsPayloadResult({
channel: "voice-memo-chat",
prefsName: "openclaw-speech-core-tts-voice-memo-caf-transcode-test",
text: "This reply should be pre-transcoded to a native voice-memo CAF.",
target: "audio-file",
audioAsVoice: true,
mediaExtension: "caf",
providerResult: {
audioBuffer: Buffer.from("mp3"),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: false,
},
});
expect(transcodeAudioBufferMock).toHaveBeenCalledOnce();
const transcodeRequest = requireRecord(
requireFirstCallParam(transcodeAudioBufferMock.mock.calls as unknown[][], "transcode"),
"transcode request",
);
expect(transcodeRequest.sourceExtension).toBe("mp3");
expect(transcodeRequest.targetExtension).toBe("caf");
});
it("falls back to the original mp3 buffer when the host transcoder fails", async () => {
transcodeAudioBufferMock.mockResolvedValueOnce({
ok: false,
reason: "transcoder-failed",
detail: "exit-1",
});
// Even though the transcode failed, the original mp3 still satisfies the
// channel audioFileFormats list, so the channel still flips audioAsVoice.
// The user gets a voice memo bubble, possibly with bad duration, instead
// of a regression. The failure is logged via the call site in tts.ts.
await expectTtsPayloadResult({
channel: "voice-memo-chat",
prefsName: "openclaw-speech-core-tts-voice-memo-caf-fallback-test",
text: "This reply should fall back to the original mp3.",
target: "audio-file",
audioAsVoice: true,
mediaExtension: "mp3",
providerResult: {
audioBuffer: Buffer.from("mp3"),
outputFormat: "mp3",
fileExtension: ".mp3",
voiceCompatible: false,
},
});
});
it("uses the active runtime snapshot when source config still contains TTS SecretRefs", async () => {
const sourceConfig = {
tts: {
enabled: true,
provider: "mock",
providers: {
mock: {
apiKey: { source: "exec", provider: "mockexec", id: "minimax/tts/apiKey" },
},
},
},
} as unknown as OpenClawConfig;
const runtimeConfig = {
tts: {
enabled: true,
provider: "mock",
providers: {
mock: {
apiKey: "test-key",
},
},
},
} as unknown as OpenClawConfig;
installSpeechProviders([
createMockSpeechProvider("mock", {
isConfigured: ({ providerConfig }) => providerConfig.apiKey === "test-key",
resolveConfig: ({ rawConfig }) => {
const providers = rawConfig.providers as Record<string, { apiKey?: unknown }> | undefined;
return providers?.mock ?? {};
},
}),
]);
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
const result = await synthesizeSpeech({
text: "Runtime snapshot TTS SecretRef",
cfg: sourceConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
expect(synthesizeMock).toHaveBeenCalled();
const request = requireFirstSynthesisRequest("runtime snapshot synthesis request");
expect(request.cfg).toBe(runtimeConfig);
const providerConfig = requireRecord(request.providerConfig, "provider config");
expect(providerConfig.apiKey).toBe("test-key");
});
it("uses provider default TTS timeout when the call and config omit timeoutMs", async () => {
installSpeechProviders([createMockSpeechProvider("mock", { defaultTimeoutMs: 600_000 })]);
const result = await synthesizeSpeech({
text: "Use provider timeout.",
cfg: {
tts: {
enabled: true,
provider: "mock",
},
} as OpenClawConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
const request = requireFirstSynthesisRequest("provider default timeout synthesis request");
expect(request.timeoutMs).toBe(600_000);
});
it("normalizes non-streaming synthesis text before calling the provider", async () => {
const result = await synthesizeSpeech({
text: "## Update\n\nRead the [guide](https://example.com/guide)!!!!!",
cfg: createTtsConfig("openclaw-speech-core-talk-markdown-test"),
disableFallback: true,
});
expect(result.success).toBe(true);
const request = requireFirstSynthesisRequest("normalized talk synthesis request");
expect(request.text).toBe("Update\n\nRead the guide!");
});
it("speaks stripped code through the explicit textToSpeech conversion path", async () => {
let mediaDir: string | undefined;
try {
const result = await textToSpeech({
text: "```ts\nconst answer = 42;\n```",
cfg: createTtsConfig("openclaw-speech-core-code-convert-test"),
});
expect(result.success).toBe(true);
const request = requireFirstSynthesisRequest("explicit code conversion request");
expect(request.text).toBe("const answer = 42;");
expect(request.text).not.toBe(CODE_HEAVY_SPOKEN_FALLBACK);
mediaDir = result.audioPath ? path.dirname(result.audioPath) : undefined;
} finally {
if (mediaDir) {
rmSync(mediaDir, { recursive: true, force: true });
}
}
});
it("returns a normal TTS failure when audio persistence rejects", async () => {
const result = await textToSpeechCore(
{
text: "Store this synthesized reply.",
cfg: createTtsConfig("openclaw-speech-core-persistence-failure-test"),
},
async () => {
throw new Error("Media exceeds configured limit");
},
);
expect(result).toMatchObject({
success: false,
error: "TTS audio persistence failed",
provider: "mock",
});
});
it("resolves the configured timeout for voice listing", async () => {
const listVoicesMock = vi.fn(async (_request: SpeechListVoicesRequest) => []);
installSpeechProviders([
createMockSpeechProvider("mock", {
defaultTimeoutMs: 60_000,
listVoices: listVoicesMock,
}),
]);
await listSpeechVoices({
provider: "mock",
cfg: {
tts: {
enabled: true,
provider: "mock",
timeoutMs: 45_000,
},
} as OpenClawConfig,
});
expect(listVoicesMock).toHaveBeenCalledWith(expect.objectContaining({ timeoutMs: 45_000 }));
});
it("caps oversized provider default TTS timeouts before synthesis", async () => {
installSpeechProviders([
createMockSpeechProvider("mock", { defaultTimeoutMs: Number.MAX_SAFE_INTEGER }),
]);
const result = await synthesizeSpeech({
text: "Use capped provider timeout.",
cfg: {
tts: {
enabled: true,
provider: "mock",
},
} as OpenClawConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
const request = requireFirstSynthesisRequest("provider default capped timeout request");
expect(request.timeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("ignores nonpositive provider default TTS timeouts", async () => {
installSpeechProviders([createMockSpeechProvider("mock", { defaultTimeoutMs: 0 })]);
const result = await synthesizeSpeech({
text: "Use fallback timeout.",
cfg: {
tts: {
enabled: true,
provider: "mock",
},
} as OpenClawConfig,
disableFallback: true,
});
expect(result.success).toBe(true);
const request = requireFirstSynthesisRequest("provider default fallback timeout request");
expect(request.timeoutMs).toBe(30_000);
});
it("keeps explicit TTS config timeout ahead of provider default timeout", async () => {
installSpeechProviders([createMockSpeechProvider("mock", { defaultTimeoutMs: 600_000 })]);
await synthesizeSpeech({
text: "Use configured timeout.",
cfg: {
tts: {
enabled: true,
provider: "mock",
timeoutMs: 45_000,
},
} as OpenClawConfig,
disableFallback: true,
});
const request = requireFirstSynthesisRequest("configured timeout synthesis request");
expect(request.timeoutMs).toBe(45_000);
});
});
+297
View File
@@ -0,0 +1,297 @@
// TTS runtime tests cover speech synthesis behavior.
import crypto from "node:crypto";
import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig, TtsConfig } from "openclaw/plugin-sdk/config-contracts";
import { MAX_TIMER_TIMEOUT_MS as MAX_TIMER_TIMEOUT_MS_CORE } from "openclaw/plugin-sdk/number-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
import {
clearRuntimeConfigSnapshot as clearRuntimeConfigSnapshotCore,
setRuntimeConfigSnapshot as setRuntimeConfigSnapshotCore,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import type {
SpeechListVoicesRequest,
SpeechProviderPlugin,
SpeechProviderPrepareSynthesisContext,
SpeechSynthesisRequest,
SpeechTelephonySynthesisRequest,
} from "openclaw/plugin-sdk/speech-core";
import { expect, vi } from "vitest";
import { CODE_HEAVY_SPOKEN_FALLBACK as CODE_HEAVY_SPOKEN_FALLBACK_CORE } from "./speech-text.js";
import type { TtsAudioPersistence } from "./tts-synthesis.js";
type MockSpeechSynthesisResult = Awaited<ReturnType<SpeechProviderPlugin["synthesize"]>>;
const synthesizeMock = vi.hoisted(() =>
vi.fn(
async (request: SpeechSynthesisRequest): Promise<MockSpeechSynthesisResult> => ({
audioBuffer: Buffer.from("voice"),
fileExtension: ".ogg",
outputFormat: "ogg",
voiceCompatible: request.target === "voice-note",
}),
),
);
const prepareSynthesisMock = vi.hoisted(() =>
vi.fn(async (_ctx: SpeechProviderPrepareSynthesisContext) => undefined),
);
const listSpeechProvidersMock = vi.hoisted(() => vi.fn());
const getSpeechProviderMock = vi.hoisted(() => vi.fn());
const transcodeAudioBufferMock = vi.hoisted(() =>
// Default off: most tests rely on the synthesized buffer reaching the
// channel unchanged. Tests that exercise the pre-transcode branch override
// per-call via `transcodeAudioBufferMock.mockResolvedValueOnce(...)`.
// Typed as the helper's full return shape so per-call overrides aren't
// narrowed to the default's literal.
vi.fn<
() => Promise<
| { ok: true; buffer: Buffer }
| {
ok: false;
reason:
| "platform-unsupported"
| "invalid-extension"
| "noop-same-container"
| "no-recipe"
| "transcoder-failed";
detail?: string;
}
>
>(async () => ({ ok: false, reason: "platform-unsupported" })),
);
vi.mock("../media/media-services.js", () => ({
transcodeAudioBuffer: transcodeAudioBufferMock,
}));
vi.mock("../channels/plugins/tts-capabilities.js", () => ({
normalizeChannelId: (channel: string | undefined) => channel?.trim().toLowerCase() ?? null,
resolveChannelTtsVoiceDelivery: (channel: string | undefined) => {
const normalized = channel?.trim().toLowerCase();
if (normalized === "voice-memo-chat") {
return {
synthesisTarget: "audio-file",
audioFileFormats: ["mp3", "caf", "audio/mpeg", "audio/x-caf"],
preferAudioFileFormat: "caf",
};
}
if (normalized === "feishu" || normalized === "whatsapp") {
return { synthesisTarget: "voice-note", transcodesAudio: true };
}
if (normalized === "discord" || normalized === "matrix" || normalized === "telegram") {
return { synthesisTarget: "voice-note" };
}
return undefined;
},
}));
vi.mock("./provider-registry.js", async () => {
const actual =
await vi.importActual<typeof import("./provider-registry.js")>("./provider-registry.js");
const mockProvider: SpeechProviderPlugin = {
id: "mock",
label: "Mock",
autoSelectOrder: 1,
isConfigured: () => true,
prepareSynthesis: prepareSynthesisMock,
synthesize: synthesizeMock,
};
listSpeechProvidersMock.mockImplementation(() => [mockProvider]);
getSpeechProviderMock.mockImplementation((providerId: string) =>
providerId === "mock" ? mockProvider : null,
);
return {
...actual,
canonicalizeSpeechProviderId: (providerId: string | undefined) =>
providerId?.trim().toLowerCase() || undefined,
normalizeSpeechProviderId: (providerId: string | undefined) =>
providerId?.trim().toLowerCase() || undefined,
getSpeechProvider: getSpeechProviderMock,
listSpeechProviders: listSpeechProvidersMock,
};
});
vi.mock("./tts-core.js", async () => {
const actual = await vi.importActual<typeof import("./tts-core.js")>("./tts-core.js");
return { ...actual, scheduleCleanup: vi.fn() };
});
export const {
testApi,
buildTtsSystemPromptHint,
getTtsPersona,
getTtsProvider,
isTtsProviderConfigured,
listSpeechVoices,
prepareTtsRequest,
resolveTtsConfig,
resolveTtsPrefsPath,
setTtsMachinePrefsPathResolver,
setSummarizationEnabled,
setTtsMaxLength,
synthesizeSpeech,
textToSpeechStream,
textToSpeechTelephony,
} = await import("./runtime-api.js");
export const { maybeApplyTtsToPayload: maybeApplyTtsToPayloadCore } =
await import("./tts-payload.js");
export const { textToSpeech: textToSpeechCore } = await import("./tts-synthesis.js");
export const CODE_HEAVY_SPOKEN_FALLBACK = CODE_HEAVY_SPOKEN_FALLBACK_CORE;
export const MAX_TIMER_TIMEOUT_MS = MAX_TIMER_TIMEOUT_MS_CORE;
export function clearRuntimeConfigSnapshot(): void {
clearRuntimeConfigSnapshotCore();
}
export const setRuntimeConfigSnapshot = (
...args: Parameters<typeof setRuntimeConfigSnapshotCore>
) => setRuntimeConfigSnapshotCore(...args);
export const nativeVoiceNoteChannels = [
"discord",
"feishu",
"matrix",
"telegram",
"whatsapp",
] as const;
export function createMockSpeechProvider(
id = "mock",
options: Partial<SpeechProviderPlugin> = {},
): SpeechProviderPlugin {
return {
id,
label: id,
autoSelectOrder: id === "mock" ? 1 : 2,
isConfigured: () => true,
prepareSynthesis: prepareSynthesisMock,
synthesize: synthesizeMock,
...options,
};
}
export function installSpeechProviders(providers: SpeechProviderPlugin[]): void {
listSpeechProvidersMock.mockImplementation(() => providers);
getSpeechProviderMock.mockImplementation(
(providerId: string) => providers.find((provider) => provider.id === providerId) ?? null,
);
}
// macOS os.tmpdir() is a /var -> /private/var symlink and fs-safe rejects
// symlinked store roots; resolve the canonical dir before writing prefs.
const PREFS_TMP_DIR = realpathSync(os.tmpdir());
async function persistTestTtsAudio({
audioBuffer,
fileExtension,
}: Parameters<TtsAudioPersistence>[0]): Promise<string> {
const dir = path.join(PREFS_TMP_DIR, `openclaw-speech-core-media-${crypto.randomUUID()}`);
mkdirSync(dir, { recursive: true });
const audioPath = path.join(dir, `voice---${crypto.randomUUID()}${fileExtension}`);
writeFileSync(audioPath, audioBuffer);
return audioPath;
}
export function textToSpeech(params: Parameters<typeof textToSpeechCore>[0]) {
return textToSpeechCore(params, persistTestTtsAudio);
}
export function maybeApplyTtsToPayload(params: Parameters<typeof maybeApplyTtsToPayloadCore>[0]) {
return maybeApplyTtsToPayloadCore(params, persistTestTtsAudio);
}
export function prefsPathFor(prefsName: string): string {
return path.join(PREFS_TMP_DIR, `${prefsName}.json`);
}
export function createTtsConfig(prefsName: string): OpenClawConfig {
setTtsMachinePrefsPathResolver(() => prefsPathFor(prefsName));
return {
tts: {
enabled: true,
provider: "mock",
},
};
}
export function requireRecord(value: unknown, label: string): Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`expected ${label} to be a record`);
}
return value as Record<string, unknown>;
}
export function requireFirstCallParam(calls: ReadonlyArray<readonly unknown[]>, label: string) {
const call = calls[0];
if (!call) {
throw new Error(`expected ${label} call`);
}
return call[0];
}
export function requireFirstSynthesisRequest(label: string): Record<string, unknown> {
return requireRecord(requireFirstCallParam(synthesizeMock.mock.calls, label), label);
}
export function requireAttempt(attempts: unknown[] | undefined, index: number) {
if (!attempts) {
throw new Error("expected synthesis attempts");
}
return requireRecord(attempts[index], `synthesis attempt ${index}`);
}
export async function expectTtsPayloadResult(params: {
channel: string;
prefsName: string;
text: string;
target: "voice-note" | "audio-file";
audioAsVoice: true | undefined;
providerResult?: MockSpeechSynthesisResult;
mediaExtension?: string;
kind?: "tool" | "block" | "final";
}) {
if (params.providerResult) {
synthesizeMock.mockResolvedValueOnce(params.providerResult);
}
const cfg = createTtsConfig(params.prefsName);
let mediaDir: string | undefined;
try {
const result = await maybeApplyTtsToPayload({
payload: { text: params.text },
cfg,
channel: params.channel,
kind: params.kind ?? "final",
});
expect(synthesizeMock).toHaveBeenCalled();
const request = requireRecord(
synthesizeMock.mock.calls.at(-1)?.[0],
"latest synthesis request",
);
expect(request.target).toBe(params.target);
expect(result.audioAsVoice).toBe(params.audioAsVoice);
expect(result.mediaUrl).toMatch(
new RegExp(`voice---[a-f0-9-]+\\.${params.mediaExtension ?? "ogg"}$`),
);
expect(result.spokenText).toBe(params.text);
expect(result.ttsSupplement).toEqual({ spokenText: params.text });
expect((result as { trustedLocalMedia?: boolean }).trustedLocalMedia).toBe(true);
mediaDir = result.mediaUrl ? path.dirname(result.mediaUrl) : undefined;
} finally {
if (mediaDir) {
rmSync(mediaDir, { recursive: true, force: true });
}
}
}
export { prepareSynthesisMock, synthesizeMock, transcodeAudioBufferMock };
export type {
OpenClawConfig,
ReplyPayload,
SpeechListVoicesRequest,
SpeechSynthesisRequest,
SpeechTelephonySynthesisRequest,
TtsConfig,
};
@@ -1,8 +1,8 @@
// TTS preference mutations stay off the agent prompt's read-only import path.
import path from "node:path";
import type { TtsAutoMode, TtsProvider } from "openclaw/plugin-sdk/config-contracts";
import { privateFileStoreSync } from "openclaw/plugin-sdk/security-runtime";
import { canonicalizeSpeechProviderId } from "openclaw/plugin-sdk/speech-core";
import type { TtsAutoMode, TtsProvider } from "../config/types.js";
import { privateFileStoreSync } from "../infra/private-file-store.js";
import { canonicalizeSpeechProviderId } from "./provider-registry.js";
import { normalizeTtsPersonaId, readTtsPrefs, type TtsUserPrefs } from "./tts-settings.js";
function updateTtsPrefs(prefsPath: string, update: (prefs: TtsUserPrefs) => void): void {
+397 -5
View File
@@ -1,5 +1,397 @@
// Lightweight core facade for TTS settings used by agent and status hot paths.
export {
buildTtsSystemPromptHint,
resolveTtsSettingsSnapshot,
} from "../../packages/speech-core/src/tts-settings.js";
// Lightweight TTS settings resolution shared by agent prompts, status, and speech runtime.
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import {
normalizeOptionalLowercaseString,
normalizeOptionalString,
} from "../../packages/normalization-core/src/string-coerce.js";
import {
getRuntimeConfigSnapshot,
getRuntimeConfigSourceSnapshot,
selectApplicableRuntimeConfig,
} from "../config/runtime-snapshot.js";
import type {
OpenClawConfig,
ResolvedTtsPersona,
TtsAutoMode,
TtsConfig,
TtsModelOverrideConfig,
TtsProvider,
} from "../config/types.js";
import { resolveConfigDir, resolveUserPath } from "../utils.js";
import { normalizeSpeechProviderId } from "./provider-registry-core.js";
import type { SpeechProviderConfig } from "./provider-types.js";
import { withSpeakerSelectionCompat } from "./speaker.js";
import { normalizeTtsAutoMode } from "./tts-auto-mode.js";
import { resolveEffectiveTtsConfig, type TtsConfigResolutionContext } from "./tts-config.js";
import type { ResolvedTtsConfig, ResolvedTtsModelOverrides } from "./tts-types.js";
export type { ResolvedTtsConfig, ResolvedTtsModelOverrides };
export const DEFAULT_TTS_TIMEOUT_MS = 30_000;
const DEFAULT_TTS_MAX_LENGTH = 1500;
const DEFAULT_TTS_SUMMARIZE = true;
const DEFAULT_MAX_TEXT_LENGTH = 4096;
let machinePrefsPathResolver: () => string | undefined = () => undefined;
export function setTtsMachinePrefsPathResolver(resolver?: () => string | undefined): void {
machinePrefsPathResolver = resolver ?? (() => undefined);
}
export type TtsUserPrefs = {
tts?: {
auto?: TtsAutoMode;
enabled?: boolean;
provider?: TtsProvider;
persona?: string | null;
maxLength?: number;
summarize?: boolean;
};
};
function resolveConfiguredTtsAutoMode(raw: TtsConfig): TtsAutoMode {
return normalizeTtsAutoMode(raw.auto) ?? (raw.enabled ? "always" : "off");
}
export function normalizeConfiguredSpeechProviderId(
providerId: string | undefined,
): TtsProvider | undefined {
const normalized = normalizeSpeechProviderId(providerId);
if (!normalized) {
return undefined;
}
return normalized === "edge" ? "microsoft" : normalized;
}
export function normalizeTtsPersonaId(personaId: string | null | undefined): string | undefined {
return normalizeOptionalLowercaseString(personaId ?? undefined);
}
function resolveTtsPrefsPathValue(prefsPath: string | undefined): string {
// Scoped agent paths must win over the migrated machine-wide default.
if (prefsPath?.trim()) {
return resolveUserPath(prefsPath.trim());
}
const envPath = process.env.OPENCLAW_TTS_PREFS?.trim();
if (envPath) {
return resolveUserPath(envPath);
}
const machinePath = machinePrefsPathResolver()?.trim();
if (machinePath) {
return resolveUserPath(machinePath);
}
return path.join(resolveConfigDir(process.env), "settings", "tts.json");
}
export function resolveModelOverridePolicy(
overrides: TtsModelOverrideConfig | undefined,
): ResolvedTtsModelOverrides {
const enabled = overrides?.enabled ?? true;
if (!enabled) {
return {
enabled: false,
allowText: false,
allowProvider: false,
allowVoice: false,
allowModelId: false,
allowVoiceSettings: false,
allowNormalization: false,
allowSeed: false,
};
}
const allow = (value: boolean | undefined, defaultValue = true) => value ?? defaultValue;
return {
enabled: true,
allowText: allow(overrides?.allowText),
allowProvider: allow(overrides?.allowProvider, false),
allowVoice: allow(overrides?.allowVoice),
allowModelId: allow(overrides?.allowModelId),
allowVoiceSettings: allow(overrides?.allowVoiceSettings),
allowNormalization: allow(overrides?.allowNormalization),
allowSeed: allow(overrides?.allowSeed),
};
}
export function resolveTtsRuntimeConfig(cfg: OpenClawConfig): OpenClawConfig {
return (
selectApplicableRuntimeConfig({
inputConfig: cfg,
runtimeConfig: getRuntimeConfigSnapshot(),
runtimeSourceConfig: getRuntimeConfigSourceSnapshot(),
}) ?? cfg
);
}
export function asProviderConfig(value: unknown): SpeechProviderConfig {
return typeof value === "object" && value !== null && !Array.isArray(value)
? withSpeakerSelectionCompat(value as SpeechProviderConfig)
: {};
}
export function asProviderConfigMap(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
export function hasOwnProperty(value: object, key: string): boolean {
return Object.hasOwn(value, key);
}
function normalizeProviderConfigMap(
value: unknown,
): Record<string, SpeechProviderConfig> | undefined {
const rawMap = asProviderConfigMap(value);
if (Object.keys(rawMap).length === 0) {
return undefined;
}
const next: Record<string, SpeechProviderConfig> = {};
for (const [providerId, providerConfig] of Object.entries(rawMap)) {
const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId;
next[normalized] = asProviderConfig(providerConfig);
}
return next;
}
function collectTtsPersonas(raw: TtsConfig): Record<string, ResolvedTtsPersona> {
const rawPersonas = asProviderConfigMap(raw.personas);
const personas: Record<string, ResolvedTtsPersona> = {};
for (const [id, value] of Object.entries(rawPersonas)) {
const normalizedId = normalizeTtsPersonaId(id);
if (!normalizedId || typeof value !== "object" || value === null || Array.isArray(value)) {
continue;
}
const persona = value as Omit<ResolvedTtsPersona, "id">;
personas[normalizedId] = {
...persona,
id: normalizedId,
provider: normalizeConfiguredSpeechProviderId(persona.provider) ?? persona.provider,
providers: normalizeProviderConfigMap(persona.providers),
};
}
return personas;
}
function collectDirectProviderConfigEntries(raw: TtsConfig): Record<string, SpeechProviderConfig> {
const entries: Record<string, SpeechProviderConfig> = {};
const rawProviders = asProviderConfigMap(raw.providers);
for (const [providerId, value] of Object.entries(rawProviders)) {
const normalized = normalizeConfiguredSpeechProviderId(providerId) ?? providerId;
entries[normalized] = asProviderConfig(value);
}
const reservedKeys = new Set([
"auto",
"enabled",
"maxTextLength",
"mode",
"modelOverrides",
"persona",
"personas",
"prefsPath",
"provider",
"providers",
"summaryModel",
"timeoutMs",
]);
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (reservedKeys.has(key)) {
continue;
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
continue;
}
const normalized = normalizeConfiguredSpeechProviderId(key) ?? key;
entries[normalized] ??= asProviderConfig(value);
}
return entries;
}
export function resolveTtsConfig(
cfgInput: OpenClawConfig,
contextOrAgentId?: string | TtsConfigResolutionContext,
): ResolvedTtsConfig {
const cfg = resolveTtsRuntimeConfig(cfgInput);
const raw: TtsConfig = resolveEffectiveTtsConfig(cfg, contextOrAgentId);
const providerSource = raw.provider ? "config" : "default";
const timeoutMs = raw.timeoutMs ?? DEFAULT_TTS_TIMEOUT_MS;
const timeoutMsSource = raw.timeoutMs === undefined ? "default" : "config";
return {
auto: resolveConfiguredTtsAutoMode(raw),
mode: raw.mode ?? "final",
provider:
normalizeConfiguredSpeechProviderId(raw.provider) ??
(providerSource === "config" ? (normalizeOptionalLowercaseString(raw.provider) ?? "") : ""),
providerSource,
persona: normalizeTtsPersonaId(raw.persona),
personas: collectTtsPersonas(raw),
summaryModel: normalizeOptionalString(raw.summaryModel),
modelOverrides: resolveModelOverridePolicy(raw.modelOverrides),
providerConfigs: collectDirectProviderConfigEntries(raw),
prefsPath: (raw as TtsConfig & { prefsPath?: string }).prefsPath,
maxTextLength: raw.maxTextLength ?? DEFAULT_MAX_TEXT_LENGTH,
timeoutMs,
timeoutMsSource,
rawConfig: raw,
sourceConfig: cfg,
};
}
export function resolveTtsPrefsPath(config: ResolvedTtsConfig): string {
return resolveTtsPrefsPathValue(config.prefsPath);
}
export function readTtsPrefs(prefsPath: string): TtsUserPrefs {
try {
if (!existsSync(prefsPath)) {
return {};
}
const parsed: unknown = JSON.parse(readFileSync(prefsPath, "utf8"));
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as TtsUserPrefs)
: {};
} catch {
return {};
}
}
function resolveTtsAutoModeFromPrefs(prefs: TtsUserPrefs): TtsAutoMode | undefined {
const auto = normalizeTtsAutoMode(prefs.tts?.auto);
if (auto) {
return auto;
}
if (typeof prefs.tts?.enabled === "boolean") {
return prefs.tts.enabled ? "always" : "off";
}
return undefined;
}
export function resolveTtsAutoMode(params: {
config: ResolvedTtsConfig;
prefsPath: string;
sessionAuto?: string;
}): TtsAutoMode {
const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
if (sessionAuto) {
return sessionAuto;
}
return resolveTtsAutoModeFromPrefs(readTtsPrefs(params.prefsPath)) ?? params.config.auto;
}
function resolveTtsPersonaIdFromPrefs(
config: ResolvedTtsConfig,
prefs: TtsUserPrefs,
): string | undefined {
if (prefs.tts && hasOwnProperty(prefs.tts, "persona")) {
return normalizeTtsPersonaId(prefs.tts.persona);
}
return normalizeTtsPersonaId(config.persona);
}
export function resolveTtsPersonaFromPrefs(
config: ResolvedTtsConfig,
prefs: TtsUserPrefs,
): ResolvedTtsPersona | undefined {
const personaId = resolveTtsPersonaIdFromPrefs(config, prefs);
return personaId ? config.personas[personaId] : undefined;
}
type ResolvedTtsSettingsSnapshot = {
autoMode: TtsAutoMode;
config: ResolvedTtsConfig;
maxLength: number;
persona?: ResolvedTtsPersona;
personaId?: string;
preferredProvider?: TtsProvider;
prefsPath: string;
summarize: boolean;
};
export function resolveTtsSettingsSnapshot(params: {
cfg: OpenClawConfig;
sessionAuto?: string;
agentId?: string;
channelId?: string;
accountId?: string;
}): ResolvedTtsSettingsSnapshot {
const config = resolveTtsConfig(params.cfg, {
agentId: params.agentId,
channelId: params.channelId,
accountId: params.accountId,
});
const prefsPath = resolveTtsPrefsPath(config);
const prefs = readTtsPrefs(prefsPath);
const personaId = resolveTtsPersonaIdFromPrefs(config, prefs);
const persona = personaId ? config.personas[personaId] : undefined;
const preferredProvider =
normalizeConfiguredSpeechProviderId(prefs.tts?.provider) ??
normalizeConfiguredSpeechProviderId(persona?.provider) ??
(config.providerSource === "config"
? (normalizeConfiguredSpeechProviderId(config.provider) ?? config.provider)
: undefined);
return {
autoMode:
normalizeTtsAutoMode(params.sessionAuto) ?? resolveTtsAutoModeFromPrefs(prefs) ?? config.auto,
config,
maxLength: prefs.tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH,
...(persona ? { persona } : {}),
...(personaId ? { personaId } : {}),
...(preferredProvider ? { preferredProvider } : {}),
prefsPath,
summarize: prefs.tts?.summarize ?? DEFAULT_TTS_SUMMARIZE,
};
}
export function buildTtsSystemPromptHint(
cfg: OpenClawConfig,
agentId?: string,
): string | undefined {
const settings = resolveTtsSettingsSnapshot({ cfg, agentId });
if (settings.autoMode === "off") {
return undefined;
}
const autoHint =
settings.autoMode === "inbound"
? "Only use TTS when the user's last message includes audio/voice."
: settings.autoMode === "tagged"
? "Only use TTS when you include [[tts:key=value]] directives or a [[tts:text]]...[[/tts:text]] block."
: undefined;
return [
"Voice (TTS) is enabled.",
autoHint,
settings.persona
? `Active TTS persona: ${settings.persona.label ?? settings.persona.id}${settings.persona.description ? ` - ${settings.persona.description}` : ""}.`
: undefined,
`Keep spoken text ≤${settings.maxLength} chars to avoid auto-summary (summary ${settings.summarize ? "on" : "off"}).`,
"If workspace context (especially MEMORY.md) tells you not to use [[tts:...]] or to use a local/non-tagged voice workflow, follow that workspace instruction instead.",
"Use [[tts:...]] and optional [[tts:text]]...[[/tts:text]] to control voice/expressiveness.",
]
.filter(Boolean)
.join("\n");
}
export function isTtsEnabled(
config: ResolvedTtsConfig,
prefsPath: string,
sessionAuto?: string,
): boolean {
return resolveTtsAutoMode({ config, prefsPath, sessionAuto }) !== "off";
}
export function getTtsPersona(
config: ResolvedTtsConfig,
prefsPath: string,
): ResolvedTtsPersona | undefined {
return resolveTtsPersonaFromPrefs(config, readTtsPrefs(prefsPath));
}
export function listTtsPersonas(config: ResolvedTtsConfig): ResolvedTtsPersona[] {
return Object.values(config.personas).toSorted((left, right) => left.id.localeCompare(right.id));
}
export function getTtsMaxLength(prefsPath: string): number {
return readTtsPrefs(prefsPath).tts?.maxLength ?? DEFAULT_TTS_MAX_LENGTH;
}
export function isSummarizationEnabled(prefsPath: string): boolean {
return readTtsPrefs(prefsPath).tts?.summarize ?? DEFAULT_TTS_SUMMARIZE;
}
@@ -1,9 +1,9 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { TtsDirectiveOverrides } from "openclaw/plugin-sdk/speech-core";
import type { OpenClawConfig } from "../config/types.js";
import type { TtsDirectiveOverrides } from "./provider-types.js";
import { assertSpeechRuntimeAvailable } from "./runtime-availability.js";
import type { TtsStreamResult, TtsSynthesisStreamResult } from "./tts-runtime-types.js";
import { executeTtsProviderAttempts, resolveTtsRequestSetup } from "./tts-synthesis-support.js";
import { resolveTtsSynthesisTarget } from "./tts-synthesis.js";
import type { TtsStreamResult, TtsSynthesisStreamResult } from "./tts-types.js";
export async function streamSpeech(params: {
text: string;
@@ -1,18 +1,9 @@
import type {
OpenClawConfig,
ResolvedTtsPersona,
TtsProvider,
} from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
canonicalizeSpeechProviderId,
getSpeechProvider,
type SpeechProviderConfig,
type SpeechProviderOverrides,
} from "openclaw/plugin-sdk/speech-core";
import type { VoiceModelRef, VoiceProviderCandidate } from "../voice-models.js";
import type { OpenClawConfig, ResolvedTtsPersona, TtsProvider } from "../config/types.js";
import { logVerbose } from "../globals.js";
import { formatErrorMessage } from "../infra/errors.js";
import { redactSensitiveText } from "../logging/redact.js";
import { canonicalizeSpeechProviderId, getSpeechProvider } from "./provider-registry.js";
import type { SpeechProviderConfig, SpeechProviderOverrides } from "./provider-types.js";
import {
getResolvedSpeechProviderConfigForVoiceModel,
mergeProviderConfigWithPersona,
@@ -22,6 +13,7 @@ import {
resolveTtsProvider,
resolveTtsProviderCandidates,
} from "./tts-provider-resolution.js";
import type { TtsProviderAttempt } from "./tts-runtime-types.js";
import {
getTtsPersona,
resolveTtsConfig,
@@ -29,7 +21,7 @@ import {
resolveTtsRuntimeConfig,
type ResolvedTtsConfig,
} from "./tts-settings.js";
import type { TtsProviderAttempt } from "./tts-types.js";
import type { VoiceModelRef, VoiceProviderCandidate } from "./voice-models.js";
export function formatTtsProviderError(provider: TtsProvider, err: unknown): string {
const error = err instanceof Error ? err : new Error(String(err));
@@ -1,16 +1,16 @@
import { resolveChannelTtsVoiceDelivery } from "openclaw/plugin-sdk/channel-targets";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { transcodeAudioBuffer } from "openclaw/plugin-sdk/media-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { TtsDirectiveOverrides } from "openclaw/plugin-sdk/speech-core";
import { resolveChannelTtsVoiceDelivery } from "../channels/plugins/tts-capabilities.js";
import type { OpenClawConfig } from "../config/types.js";
import { logVerbose } from "../globals.js";
import { transcodeAudioBuffer } from "../media/media-services.js";
import type { TtsDirectiveOverrides } from "./provider-types.js";
import { assertSpeechRuntimeAvailable } from "./runtime-availability.js";
import { normalizeSpeechText } from "./speech-text.js";
import type { TtsResult, TtsSynthesisResult } from "./tts-runtime-types.js";
import {
executeTtsProviderAttempts,
resolveTtsRequestSetup,
sanitizeTtsErrorForLog,
} from "./tts-synthesis-support.js";
import type { TtsResult, TtsSynthesisResult } from "./tts-types.js";
export type TtsAudioPersistence = (params: {
audioBuffer: Buffer;
@@ -1,8 +1,8 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { TtsDirectiveOverrides } from "openclaw/plugin-sdk/speech-core";
import type { OpenClawConfig } from "../config/types.js";
import type { TtsDirectiveOverrides } from "./provider-types.js";
import { assertSpeechRuntimeAvailable } from "./runtime-availability.js";
import type { TtsTelephonyResult } from "./tts-runtime-types.js";
import { executeTtsProviderAttempts, resolveTtsRequestSetup } from "./tts-synthesis-support.js";
import type { TtsTelephonyResult } from "./tts-types.js";
export async function textToSpeechTelephony(params: {
text: string;
+1 -30
View File
@@ -1,41 +1,12 @@
// TTS integration tests cover text-to-speech command behavior.
import { readFileSync } from "node:fs";
// TTS integration tests cover host runtime availability behavior.
import { afterEach, describe, expect, it } from "vitest";
import { setActiveDegradedSecretOwners } from "../secrets/runtime-degraded-state.js";
function readSource(relativePath: string): string {
return readFileSync(new URL(relativePath, import.meta.url), "utf8");
}
describe("tts runtime facade", () => {
afterEach(() => {
setActiveDegradedSecretOwners([]);
});
it("routes public TTS helpers through the core speech package", () => {
const publicFacadeSource = readSource("./tts.ts");
const runtimeFacadeSource = readSource("../plugin-sdk/tts-runtime.ts");
expect(publicFacadeSource).toContain('} from "../plugin-sdk/tts-runtime.js";');
expect(publicFacadeSource).toContain("setSpeechRuntimeAvailabilityGuard");
expect(runtimeFacadeSource).toContain('from "../../packages/speech-core/runtime-api.js";');
expect(runtimeFacadeSource).not.toContain('dirName: "speech-core"');
});
it("keeps agent prompt TTS settings off the synthesis runtime chain", () => {
const agentConfigSource = readSource("../agents/system-prompt-config.ts");
const settingsFacadeSource = readSource("./tts-settings.ts");
const packageSettingsSource = readSource("../../packages/speech-core/src/tts-settings.ts");
expect(agentConfigSource).toContain('from "../tts/tts-settings.js";');
expect(settingsFacadeSource).toContain(
'from "../../packages/speech-core/src/tts-settings.js";',
);
expect(settingsFacadeSource).not.toContain("tts-runtime");
expect(packageSettingsSource).toContain('from "openclaw/plugin-sdk/speech-settings";');
expect(packageSettingsSource).not.toContain("plugin-sdk/media-runtime");
});
it("blocks explicit synthesis but preserves text delivery when TTS is cold", async () => {
setActiveDegradedSecretOwners([
{
+15 -6
View File
@@ -1,10 +1,13 @@
/** Public TTS runtime barrel exposed to core callers and plugin SDK facades. */
import { assertSecretOwnerAvailable } from "../secrets/runtime-degraded-state.js";
import { readConfigMachineState } from "../state/config-machine-state.js";
import {
setSpeechRuntimeAvailabilityGuard,
setTtsMachinePrefsPathResolver,
} from "../../packages/speech-core/runtime-api.js";
import { assertSecretOwnerAvailable } from "../secrets/runtime-degraded-state.js";
import { readConfigMachineState } from "../state/config-machine-state.js";
} from "./runtime-api.js";
import { persistTtsAudioToMediaStore } from "./tts-audio-store.js";
import { maybeApplyTtsToPayload as maybeApplyTtsToPayloadCore } from "./tts-payload.js";
import { textToSpeech as textToSpeechCore } from "./tts-synthesis.js";
setSpeechRuntimeAvailabilityGuard(() => {
assertSecretOwnerAvailable("capability", "tts");
@@ -12,6 +15,14 @@ setSpeechRuntimeAvailabilityGuard(() => {
setTtsMachinePrefsPathResolver(() => readConfigMachineState<string>("tts.prefsPath"));
export function textToSpeech(params: Parameters<typeof textToSpeechCore>[0]) {
return textToSpeechCore(params, persistTtsAudioToMediaStore);
}
export function maybeApplyTtsToPayload(params: Parameters<typeof maybeApplyTtsToPayloadCore>[0]) {
return maybeApplyTtsToPayloadCore(params, persistTtsAudioToMediaStore);
}
export {
getLastTtsAttempt,
getResolvedSpeechProviderConfig,
@@ -23,7 +34,6 @@ export {
isTtsProviderConfigured,
listSpeechVoices,
listTtsPersonas,
maybeApplyTtsToPayload,
resolveExplicitTtsOverrides,
resolveTtsAutoMode,
resolveTtsConfig,
@@ -36,7 +46,6 @@ export {
setTtsPersona,
setTtsProvider,
synthesizeSpeech,
textToSpeech,
type ResolvedTtsConfig,
type TtsDirectiveOverrides,
} from "../plugin-sdk/tts-runtime.js";
} from "./runtime-api.js";
@@ -1,7 +1,7 @@
// Voice model catalog helpers shared by TTS and realtime voice plugins.
import { parseModelCatalogRef } from "@openclaw/model-catalog-core/model-catalog-refs";
export type VoiceModelCapability = "tts" | "realtime_transcription" | "realtime_voice";
type VoiceModelCapability = "tts" | "realtime_transcription" | "realtime_voice";
/** Capability flags advertised by a voice model catalog entry. */
export type VoiceModelCapabilities = Partial<Record<VoiceModelCapability, true>>;
@@ -23,7 +23,7 @@ export type VoiceModelProvider = {
};
/** Synthesized voice model catalog row exposed to provider/model selection. */
export type VoiceModelCatalogEntry = {
type VoiceModelCatalogEntry = {
kind: "voice";
provider: string;
model: string;
@@ -50,7 +50,7 @@ const SCENARIOS = {
docsRefs: ["docs/tools/tts.md", "docs/tools/media-overview.md"],
codeRefs: [
SOURCE_PATH,
"packages/speech-core/src/tts.ts",
"src/tts/runtime-api.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/server-methods/artifacts.ts",
],
+2 -2
View File
@@ -247,13 +247,13 @@ describe("createScopedVitestConfig", () => {
it("keeps broad package scoped cli directory filters aligned with repo-root include patterns", () => {
const config = createScopedVitestConfig(["packages/**/*.test.ts"], {
argv: ["vitest", "run", "packages/speech-core"],
argv: ["vitest", "run", "packages/normalization-core"],
dir: "packages",
env: {},
passWithNoTests: true,
});
expect(requireTestConfig(config).include).toEqual(["speech-core/**/*.test.*"]);
expect(requireTestConfig(config).include).toEqual(["normalization-core/**/*.test.*"]);
});
it("relativizes scoped include and exclude patterns to the configured dir", () => {
-5
View File
@@ -250,11 +250,6 @@
"@openclaw/net-policy/url-protocol": ["./packages/net-policy/src/url-protocol.ts"],
"@openclaw/net-policy/url-userinfo": ["./packages/net-policy/src/url-userinfo.ts"],
"@openclaw/net-policy/*": ["./packages/net-policy/src/*"],
"@openclaw/speech-core": ["./packages/speech-core/runtime-api.ts"],
"@openclaw/speech-core/runtime-api": ["./packages/speech-core/runtime-api.ts"],
"@openclaw/speech-core/speaker": ["./packages/speech-core/speaker.ts"],
"@openclaw/speech-core/voice-models": ["./packages/speech-core/voice-models.ts"],
"@openclaw/speech-core/*": ["./packages/speech-core/*"],
"@openclaw/sdk": ["./packages/sdk/src/index.ts"],
"@openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"],
"openclaw/plugin-sdk/account-id": ["./src/plugin-sdk/account-id.ts"],
-18
View File
@@ -410,14 +410,6 @@ function buildPackageDistEntriesFromExports(packageDir: string): Record<string,
return Object.fromEntries(Object.entries(entries).toSorted(([a], [b]) => a.localeCompare(b)));
}
function buildSpeechCoreDistEntries(): Record<string, string> {
return {
"runtime-api": "packages/speech-core/runtime-api.ts",
speaker: "packages/speech-core/speaker.ts",
"voice-models": "packages/speech-core/voice-models.ts",
};
}
function buildLlmCoreDistEntries(): Record<string, string> {
return {
index: "packages/llm-core/src/index.ts",
@@ -458,10 +450,6 @@ function shouldExternalizeNetPolicyDependency(id: string): boolean {
return id === "ipaddr.js" || id.startsWith("ipaddr.js/");
}
function shouldExternalizeSpeechCoreDependency(id: string): boolean {
return id === "openclaw" || id.startsWith("openclaw/");
}
function shouldExternalizeLlmCoreDependency(id: string): boolean {
return id === "typebox" || id.startsWith("typebox/");
}
@@ -665,12 +653,6 @@ const configs = [
neverBundle: shouldExternalizeTerminalCoreDependency,
},
}),
nodeWorkspacePackageBuildConfig("speech-core", {
entry: buildSpeechCoreDistEntries(),
deps: {
neverBundle: shouldExternalizeSpeechCoreDependency,
},
}),
nodeWorkspacePackageBuildConfig("llm-core", {
entry: buildLlmCoreDistEntries(),
deps: {