refactor(speech-core): share TTS request pre-resolution across discord and voice-call (#109640)

This commit is contained in:
Peter Steinberger
2026-07-16 21:47:06 -07:00
committed by GitHub
parent 9b80f9a8b1
commit a77c0770ff
13 changed files with 335 additions and 397 deletions
@@ -22,6 +22,7 @@ const {
agentCommandMock,
resolveRealtimeBootstrapContextInstructionsMock,
transcribeAudioFileMock,
prepareTtsRequestMock,
textToSpeechStreamMock,
textToSpeechMock,
logVerboseMock,
@@ -149,6 +150,15 @@ const {
(...args: unknown[]) => Promise<string | undefined>
>(async () => undefined),
transcribeAudioFileMock: vi.fn(async () => ({ text: "hello from voice" })),
prepareTtsRequestMock: vi.fn(async ({ cfg, text }: { cfg: unknown; text: string }) => ({
cfg,
directives: {
cleanedText: text,
hasDirective: false,
overrides: {},
warnings: [],
},
})),
textToSpeechStreamMock: vi.fn(
async (): Promise<unknown> => ({ success: false, error: "stream unavailable" }),
),
@@ -219,13 +229,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async () => {
return {
...actual,
agentCommandFromIngress: agentCommandMock,
getTtsProvider: vi.fn(() => "openai"),
resolveAgentDir: vi.fn(() => "/tmp/openclaw-agent"),
resolveTtsConfig: vi.fn(() => ({
modelOverrides: {},
providerConfigs: {},
})),
resolveTtsPrefsPath: vi.fn(() => "/tmp/openclaw-tts.json"),
};
});
@@ -286,6 +290,7 @@ vi.mock("../runtime.js", () => ({
transcribeAudioFile: transcribeAudioFileMock,
},
tts: {
prepareTtsRequest: prepareTtsRequestMock,
textToSpeechStream: textToSpeechStreamMock,
textToSpeech: textToSpeechMock,
},
@@ -371,6 +376,18 @@ describe("DiscordVoiceManager", () => {
resolveRealtimeBootstrapContextInstructionsMock.mockResolvedValue(undefined);
transcribeAudioFileMock.mockReset();
transcribeAudioFileMock.mockResolvedValue({ text: "hello from voice" });
prepareTtsRequestMock.mockReset();
prepareTtsRequestMock.mockImplementation(
async ({ cfg, text }: { cfg: unknown; text: string }) => ({
cfg,
directives: {
cleanedText: text,
hasDirective: false,
overrides: {},
warnings: [],
},
}),
);
textToSpeechStreamMock.mockReset();
textToSpeechStreamMock.mockResolvedValue({ success: false, error: "stream unavailable" });
textToSpeechMock.mockReset();
@@ -6612,6 +6629,9 @@ describe("DiscordVoiceManager", () => {
expect(commandArgs?.messageProvider).toBe("discord-voice");
expect(commandArgs?.message).toContain("Do not call the tts tool");
expect(commandArgs?.message).toContain("repair obvious transcription artifacts");
expect(prepareTtsRequestMock).toHaveBeenCalledWith(
expect.objectContaining({ text: "hello back" }),
);
expect(lastTtsArgs().channel).toBe("discord");
expect(lastTtsArgs().text).toBe("hello back");
});
+8 -71
View File
@@ -1,14 +1,7 @@
// Discord plugin module implements tts behavior.
import {
getTtsProvider,
resolveAgentDir,
resolveTtsConfig,
resolveTtsPrefsPath,
type ResolvedTtsConfig,
} from "openclaw/plugin-sdk/agent-runtime";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import type { OpenClawConfig, TtsConfig } from "openclaw/plugin-sdk/config-contracts";
import { parseTtsDirectives } from "openclaw/plugin-sdk/speech";
import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getDiscordRuntime } from "../runtime.js";
import { sanitizeVoiceReplyTextForSpeech } from "./sanitize.js";
@@ -34,58 +27,6 @@ type VoiceReplyAudioResult =
error?: string;
};
function mergeTtsConfig(base: TtsConfig, override?: TtsConfig): TtsConfig {
if (!override) {
return base;
}
const baseProviders = base.providers ?? {};
const overrideProviders = override.providers ?? {};
const mergedProviders = Object.fromEntries(
uniqueStrings([...Object.keys(baseProviders), ...Object.keys(overrideProviders)]).map(
(providerId) => {
const baseProvider = baseProviders[providerId] ?? {};
const overrideProvider = overrideProviders[providerId] ?? {};
return [
providerId,
{
...baseProvider,
...overrideProvider,
},
];
},
),
);
return {
...base,
...override,
modelOverrides: {
...base.modelOverrides,
...override.modelOverrides,
},
...(Object.keys(mergedProviders).length === 0 ? {} : { providers: mergedProviders }),
};
}
function resolveVoiceTtsConfig(params: { cfg: OpenClawConfig; override?: TtsConfig }): {
cfg: OpenClawConfig;
resolved: ResolvedTtsConfig;
} {
if (!params.override) {
return { cfg: params.cfg, resolved: resolveTtsConfig(params.cfg) };
}
const base = params.cfg.messages?.tts ?? {};
const merged = mergeTtsConfig(base, params.override);
const messages = params.cfg.messages ?? {};
const cfg = {
...params.cfg,
messages: {
...messages,
tts: merged,
},
};
return { cfg, resolved: resolveTtsConfig(cfg) };
}
export async function transcribeVoiceAudio(params: {
cfg: OpenClawConfig;
agentId: string;
@@ -106,25 +47,21 @@ export async function synthesizeVoiceReplyAudio(params: {
replyText: string;
speakerLabel: string;
}): Promise<VoiceReplyAudioResult> {
const { cfg: ttsCfg, resolved: ttsConfig } = resolveVoiceTtsConfig({
const runtime = getDiscordRuntime();
const prepared = await runtime.tts.prepareTtsRequest({
cfg: params.cfg,
override: params.override,
text: params.replyText,
});
const directive = parseTtsDirectives(params.replyText, ttsConfig.modelOverrides, {
cfg: ttsCfg,
providerConfigs: ttsConfig.providerConfigs,
preferredProviderId: getTtsProvider(ttsConfig, resolveTtsPrefsPath(ttsConfig)),
});
const directive = prepared.directives;
const rawSpeakText = directive.overrides.ttsText ?? directive.cleanedText.trim();
const speakText = sanitizeVoiceReplyTextForSpeech(rawSpeakText, params.speakerLabel);
if (!speakText) {
return { status: "empty" };
}
const runtime = getDiscordRuntime();
const streamResult = await runtime.tts.textToSpeechStream?.({
text: speakText,
cfg: ttsCfg,
cfg: prepared.cfg,
channel: "discord",
overrides: directive.overrides,
disableFallback: true,
@@ -141,7 +78,7 @@ export async function synthesizeVoiceReplyAudio(params: {
const result = await runtime.tts.textToSpeech({
text: speakText,
cfg: ttsCfg,
cfg: prepared.cfg,
channel: "discord",
overrides: directive.overrides,
});
+2 -2
View File
@@ -521,8 +521,8 @@ export async function createVoiceCallRuntime(params: {
const twilioProvider = provider as TwilioProvider;
if (ttsRuntime?.textToSpeechTelephony) {
try {
const ttsProvider = createTelephonyTtsProvider({
coreConfig,
const ttsProvider = await createTelephonyTtsProvider({
coreConfig: cfg,
ttsOverride: config.tts,
runtime: ttsRuntime,
logger: log,
+131 -142
View File
@@ -1,11 +1,11 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
// Voice Call tests cover telephony tts plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { VoiceCallTtsConfig } from "./config.js";
import type { CoreConfig } from "./core-bridge.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
import { createTelephonyTtsProvider, type TelephonyTtsRuntime } from "./telephony-tts.js";
function createCoreConfig(): CoreConfig {
function createCoreConfig(): OpenClawConfig {
const tts: VoiceCallTtsConfig = {
provider: "openai",
providers: {
@@ -18,98 +18,82 @@ function createCoreConfig(): CoreConfig {
return { messages: { tts } };
}
function requireMergedTtsConfig(mergedConfig: CoreConfig | undefined) {
const tts = mergedConfig?.messages?.tts;
if (!tts) {
throw new Error("telephony TTS runtime did not receive merged TTS config");
}
return tts as Record<string, unknown>;
}
function requireOpenAIProviderConfig(tts: Record<string, unknown>): Record<string, unknown> {
const providers =
tts.providers && typeof tts.providers === "object"
? (tts.providers as Record<string, unknown>)
: null;
const openai = providers?.openai;
if (!openai || typeof openai !== "object") {
throw new Error("merged TTS config did not preserve providers.openai");
}
return openai as Record<string, unknown>;
}
async function mergeOverride(override: unknown): Promise<Record<string, unknown>> {
let mergedConfig: CoreConfig | undefined;
const provider = createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
ttsOverride: override as VoiceCallTtsConfig,
runtime: {
textToSpeechTelephony: async ({ cfg }) => {
mergedConfig = cfg;
return {
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
};
},
},
});
await provider.synthesizeForTelephony("hello");
return requireMergedTtsConfig(mergedConfig);
}
afterEach(() => {
delete (Object.prototype as Record<string, unknown>).polluted;
const passthroughPreparation: TelephonyTtsRuntime["prepareTtsRequest"] = async ({ cfg, text }) => ({
cfg,
directives: {
cleanedText: text,
hasDirective: false,
overrides: {},
warnings: [],
},
});
describe("createTelephonyTtsProvider deepMerge hardening", () => {
it("merges safe nested overrides", async () => {
const tts = await mergeOverride({
providers: { openai: { voice: "coral" } },
function createRuntime(
textToSpeechTelephony: TelephonyTtsRuntime["textToSpeechTelephony"],
prepareTtsRequest: TelephonyTtsRuntime["prepareTtsRequest"] = passthroughPreparation,
): TelephonyTtsRuntime {
return { prepareTtsRequest, textToSpeechTelephony };
}
describe("createTelephonyTtsProvider", () => {
it("uses shared preparation for the surface override and request text", async () => {
const effectiveConfig: OpenClawConfig = {
messages: { tts: { provider: "openai", timeoutMs: 15_000 } },
};
const prepareTtsRequest = vi.fn<TelephonyTtsRuntime["prepareTtsRequest"]>(
async ({ cfg, override, text }) => ({
cfg: override ? effectiveConfig : cfg,
directives: {
cleanedText: text,
hasDirective: false,
overrides: {},
warnings: [],
},
}),
);
const textToSpeechTelephony = vi.fn(async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
}));
const override: VoiceCallTtsConfig = { timeoutMs: 15_000 };
const provider = await createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
ttsOverride: override,
runtime: createRuntime(textToSpeechTelephony, prepareTtsRequest),
});
const openai = requireOpenAIProviderConfig(tts);
expect(openai.voice).toBe("coral");
expect(openai.model).toBe("gpt-4o-mini-tts");
});
await provider.synthesizeForTelephony("hello");
it("blocks top-level __proto__ keys", async () => {
const tts = await mergeOverride(
JSON.parse('{"__proto__":{"polluted":"top"},"providers":{"openai":{"voice":"coral"}}}'),
);
const openai = requireOpenAIProviderConfig(tts);
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
expect(tts.polluted).toBeUndefined();
expect(openai.voice).toBe("coral");
});
it("blocks nested __proto__ keys", async () => {
const tts = await mergeOverride(
JSON.parse('{"providers":{"openai":{"model":"safe","__proto__":{"polluted":"nested"}}}}'),
);
const openai = requireOpenAIProviderConfig(tts);
expect((Object.prototype as Record<string, unknown>).polluted).toBeUndefined();
expect(openai.polluted).toBeUndefined();
expect(openai.model).toBe("safe");
expect(provider.synthesisTimeoutMs).toBe(15_000);
expect(prepareTtsRequest).toHaveBeenNthCalledWith(1, {
cfg: createCoreConfig(),
override,
text: "",
});
expect(prepareTtsRequest).toHaveBeenNthCalledWith(2, {
cfg: effectiveConfig,
text: "hello",
});
expect(textToSpeechTelephony).toHaveBeenCalledWith({
text: "hello",
cfg: effectiveConfig,
overrides: {},
});
});
it("logs fallback metadata when telephony TTS uses a fallback provider", async () => {
const warn = vi.fn();
const provider = createTelephonyTtsProvider({
const provider = await createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
runtime: {
textToSpeechTelephony: async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
provider: "microsoft",
fallbackFrom: "elevenlabs",
attemptedProviders: ["elevenlabs", "microsoft"],
}),
},
runtime: createRuntime(async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
provider: "microsoft",
fallbackFrom: "elevenlabs",
attemptedProviders: ["elevenlabs", "microsoft"],
})),
logger: { warn },
});
@@ -119,95 +103,100 @@ describe("createTelephonyTtsProvider deepMerge hardening", () => {
);
});
it("strips telephony TTS directive tags before synthesis", async () => {
let requestText: string | undefined;
const provider = createTelephonyTtsProvider({
it("uses prepared directive-stripped text for synthesis", async () => {
const textToSpeechTelephony = vi.fn(async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
}));
const provider = await createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
runtime: {
textToSpeechTelephony: async ({ text }) => {
requestText = text;
return {
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
};
runtime: createRuntime(textToSpeechTelephony, async ({ cfg, text }) => ({
cfg,
directives: {
cleanedText: text ? "Hello caller" : "",
hasDirective: text.length > 0,
overrides: {},
warnings: [],
},
},
})),
});
await provider.synthesizeForTelephony("[[tts]]Hello caller[[/tts]]");
expect(requestText).toBe("Hello caller");
expect(textToSpeechTelephony).toHaveBeenCalledWith(
expect.objectContaining({ text: "Hello caller" }),
);
});
it("uses hidden telephony TTS directive text for synthesis", async () => {
let requestText: string | undefined;
let requestOverrides: unknown;
const provider = createTelephonyTtsProvider({
it("uses prepared hidden directive text and overrides for synthesis", async () => {
const textToSpeechTelephony = vi.fn(async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
}));
const provider = await createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
runtime: {
textToSpeechTelephony: async ({ text, overrides }) => {
requestText = text;
requestOverrides = overrides;
return {
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
};
runtime: createRuntime(textToSpeechTelephony, async ({ cfg, text }) => ({
cfg,
directives: {
cleanedText: text ? "Visible text " : "",
ttsText: text ? "Speak this instead" : undefined,
hasDirective: text.length > 0,
overrides: text ? { ttsText: "Speak this instead" } : {},
warnings: [],
},
},
})),
});
await provider.synthesizeForTelephony(
"Visible text [[tts:text]]Speak this instead[[/tts:text]]",
);
expect(requestText).toBe("Speak this instead");
expect(requestOverrides).toStrictEqual({ ttsText: "Speak this instead" });
expect(textToSpeechTelephony).toHaveBeenCalledWith(
expect.objectContaining({
text: "Speak this instead",
overrides: { ttsText: "Speak this instead" },
}),
);
});
it("exposes configured timeoutMs as synthesisTimeoutMs", () => {
const provider = createTelephonyTtsProvider({
it("exposes configured timeoutMs as synthesisTimeoutMs", async () => {
const provider = await createTelephonyTtsProvider({
coreConfig: { messages: { tts: { provider: "openai", timeoutMs: 15000 } } },
runtime: {
textToSpeechTelephony: async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
}),
},
runtime: createRuntime(async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
})),
});
expect(provider.synthesisTimeoutMs).toBe(15000);
});
it("clamps oversized configured timeoutMs", () => {
const provider = createTelephonyTtsProvider({
it("clamps oversized configured timeoutMs", async () => {
const provider = await createTelephonyTtsProvider({
coreConfig: {
messages: { tts: { provider: "openai", timeoutMs: Number.MAX_SAFE_INTEGER } },
},
runtime: {
textToSpeechTelephony: async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
}),
},
runtime: createRuntime(async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
})),
});
expect(provider.synthesisTimeoutMs).toBe(MAX_TIMER_TIMEOUT_MS);
});
it("keeps the telephony timeout default when timeoutMs is not configured", () => {
const provider = createTelephonyTtsProvider({
it("keeps the telephony timeout default when timeoutMs is not configured", async () => {
const provider = await createTelephonyTtsProvider({
coreConfig: createCoreConfig(),
runtime: {
textToSpeechTelephony: async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
}),
},
runtime: createRuntime(async () => ({
success: true,
audioBuffer: Buffer.alloc(2),
sampleRate: 8000,
})),
});
expect(provider.synthesisTimeoutMs).toBe(8000);
+25 -174
View File
@@ -1,23 +1,25 @@
// Voice Call plugin module implements telephony tts behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
parseTtsDirectives,
type SpeechModelOverridePolicy,
type SpeechProviderConfig,
type TtsDirectiveOverrides,
} from "openclaw/plugin-sdk/speech";
import type { TtsDirectiveOverrides, TtsDirectiveParseResult } from "openclaw/plugin-sdk/speech";
import type { VoiceCallTtsConfig } from "./config.js";
import type { CoreConfig } from "./core-bridge.js";
import { convertPcmToMulaw8k } from "./telephony-audio.js";
// Telephony TTS adapter that applies voice-call overrides and emits 8kHz mulaw audio.
/** Core runtime TTS API used by the telephony adapter. */
export type TelephonyTtsRuntime = {
prepareTtsRequest: (params: {
cfg: OpenClawConfig;
override?: VoiceCallTtsConfig;
text: string;
}) => Promise<{
cfg: OpenClawConfig;
directives: TtsDirectiveParseResult;
}>;
textToSpeechTelephony: (params: {
text: string;
cfg: CoreConfig;
cfg: OpenClawConfig;
prefsPath?: string;
overrides?: TtsDirectiveOverrides;
}) => Promise<{
@@ -40,48 +42,34 @@ export type TelephonyTtsProvider = {
/** Default timeout for one telephony synthesis request. */
export const TELEPHONY_DEFAULT_TTS_TIMEOUT_MS = 8000;
/** Voice-call override policy for inline TTS model directives. */
type TelephonyModelOverrideConfig = {
enabled?: boolean;
allowText?: boolean;
allowProvider?: boolean;
allowVoice?: boolean;
allowModelId?: boolean;
allowVoiceSettings?: boolean;
allowNormalization?: boolean;
allowSeed?: boolean;
};
/** Create a TTS provider that honors voice-call overrides and converts PCM to mulaw. */
export function createTelephonyTtsProvider(params: {
coreConfig: CoreConfig;
export async function createTelephonyTtsProvider(params: {
coreConfig: OpenClawConfig;
ttsOverride?: VoiceCallTtsConfig;
runtime: TelephonyTtsRuntime;
logger?: {
warn?: (message: string) => void;
};
}): TelephonyTtsProvider {
}): Promise<TelephonyTtsProvider> {
const { coreConfig, ttsOverride, runtime, logger } = params;
const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
const ttsConfig = mergedConfig.messages?.tts;
const modelOverrides = resolveTelephonyModelOverridePolicy(
readTelephonyModelOverrides(ttsConfig),
);
const providerConfigs = collectTelephonyProviderConfigs(ttsConfig);
const activeProvider = normalizeProviderId(ttsConfig?.provider);
const preparedConfig = await runtime.prepareTtsRequest({
cfg: coreConfig,
override: ttsOverride,
text: "",
});
const synthesisTimeoutMs = resolveTimerTimeoutMs(
mergedConfig.messages?.tts?.timeoutMs,
preparedConfig.cfg.messages?.tts?.timeoutMs,
TELEPHONY_DEFAULT_TTS_TIMEOUT_MS,
);
return {
synthesisTimeoutMs,
synthesizeForTelephony: async (text: string) => {
const directives = parseTtsDirectives(text, modelOverrides, {
cfg: mergedConfig,
providerConfigs,
preferredProviderId: activeProvider,
const prepared = await runtime.prepareTtsRequest({
cfg: preparedConfig.cfg,
text,
});
const directives = prepared.directives;
if (directives.warnings.length > 0) {
logger?.warn?.(
`[voice-call] Ignored telephony TTS directive overrides (${directives.warnings.join("; ")})`,
@@ -92,7 +80,7 @@ export function createTelephonyTtsProvider(params: {
: text;
const result = await runtime.textToSpeechTelephony({
text: cleanText,
cfg: mergedConfig,
cfg: prepared.cfg,
overrides: directives.overrides,
});
@@ -114,140 +102,3 @@ export function createTelephonyTtsProvider(params: {
},
};
}
/** Apply voice-call TTS overrides to core config without mutating the original object. */
function applyTtsOverride(coreConfig: CoreConfig, override?: VoiceCallTtsConfig): CoreConfig {
if (!override) {
return coreConfig;
}
const base = coreConfig.messages?.tts;
const merged = mergeTtsConfig(base, override);
if (!merged) {
return coreConfig;
}
return {
...coreConfig,
messages: {
...coreConfig.messages,
tts: merged,
},
};
}
/** Merge core and voice-call TTS config, keeping undefined override fields out. */
function mergeTtsConfig(
base?: VoiceCallTtsConfig,
override?: VoiceCallTtsConfig,
): VoiceCallTtsConfig | undefined {
if (!base && !override) {
return undefined;
}
if (!override) {
return base;
}
if (!base) {
return override;
}
return mergeDeep(base, override) as VoiceCallTtsConfig;
}
/** Resolve directive override policy for telephony synthesis. */
function resolveTelephonyModelOverridePolicy(
overrides: TelephonyModelOverrideConfig | undefined,
): SpeechModelOverridePolicy {
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),
};
}
/** Read model override policy from TTS config when present. */
function readTelephonyModelOverrides(
ttsConfig: VoiceCallTtsConfig | undefined,
): TelephonyModelOverrideConfig | undefined {
const value = (ttsConfig as Record<string, unknown> | undefined)?.modelOverrides;
return value && typeof value === "object" && !Array.isArray(value)
? (value as TelephonyModelOverrideConfig)
: undefined;
}
/** Normalize provider ids for config lookup. */
function normalizeProviderId(value: unknown): string | undefined {
return typeof value === "string" ? value.trim().toLowerCase() || undefined : undefined;
}
/** Coerce provider config objects while rejecting arrays and primitives. */
function asProviderConfig(value: unknown): SpeechProviderConfig {
return value && typeof value === "object" && !Array.isArray(value)
? (value as SpeechProviderConfig)
: {};
}
/** Collect named provider configs from canonical and legacy TTS config shapes. */
function collectTelephonyProviderConfigs(
ttsConfig: VoiceCallTtsConfig | undefined,
): Record<string, SpeechProviderConfig> {
if (!ttsConfig) {
return {};
}
const entries: Record<string, SpeechProviderConfig> = {};
const rawProviders =
ttsConfig.providers &&
typeof ttsConfig.providers === "object" &&
!Array.isArray(ttsConfig.providers)
? (ttsConfig.providers as Record<string, unknown>)
: {};
for (const [providerId, value] of Object.entries(rawProviders)) {
const normalized = normalizeProviderId(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(ttsConfig as Record<string, unknown>)) {
if (
reservedKeys.has(key) ||
typeof value !== "object" ||
value === null ||
Array.isArray(value)
) {
continue;
}
const normalized = normalizeProviderId(key) ?? key;
entries[normalized] ??= asProviderConfig(value);
}
return entries;
}
+2
View File
@@ -29,6 +29,7 @@ export {
isTtsProviderConfigured,
listSpeechVoices,
maybeApplyTtsToPayload,
prepareTtsRequest,
resolveExplicitTtsOverrides,
resolveTtsProviderOrder,
setLastTtsAttempt,
@@ -41,6 +42,7 @@ export {
testApi,
type TtsDirectiveOverrides,
type TtsDirectiveParseResult,
type PreparedTtsRequest,
type TtsResult,
type TtsSynthesisResult,
type TtsSynthesisStreamResult,
+81 -1
View File
@@ -2,7 +2,7 @@
import { realpathSync, rmSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { OpenClawConfig, TtsConfig } from "openclaw/plugin-sdk/config-contracts";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
import {
@@ -117,6 +117,7 @@ const {
getTtsProvider,
listSpeechVoices,
maybeApplyTtsToPayload,
prepareTtsRequest,
resolveTtsConfig,
setSummarizationEnabled,
setTtsMaxLength,
@@ -240,6 +241,7 @@ async function expectTtsPayloadResult(params: {
describe("speech-core native voice-note routing", () => {
afterEach(() => {
clearRuntimeConfigSnapshot();
delete (Object.prototype as Record<string, unknown>).polluted;
synthesizeMock.mockClear();
prepareSynthesisMock.mockClear();
transcodeAudioBufferMock.mockClear();
@@ -267,6 +269,84 @@ describe("speech-core native voice-note routing", () => {
);
});
it("prepares deep-merged surface config and directive inputs", () => {
const cfg: OpenClawConfig = {
messages: {
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.messages?.tts?.providers?.mock).toEqual({
model: "base-model",
voice: "surface-voice",
voiceSettings: { stability: 0.4, speed: 1.1 },
});
expect(prepared.cfg.messages?.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.messages?.tts?.providers?.mock).toEqual({
model: "base-model",
voiceSettings: { stability: 0.4 },
});
});
it("sanitizes blocked override keys while preparing TTS config", () => {
const prepared = prepareTtsRequest({
cfg: {
messages: {
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.messages?.tts).not.toHaveProperty("polluted");
expect(prepared.cfg.messages?.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",
+30
View File
@@ -10,6 +10,7 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import { transcodeAudioBuffer } from "openclaw/plugin-sdk/media-runtime";
import { clampTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { mergeDeep } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
markReplyPayloadAsTtsSupplement,
resolveSendableOutboundReplyParts,
@@ -467,6 +468,35 @@ export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): Tt
return config.provider;
}
export type PreparedTtsRequest = {
cfg: OpenClawConfig;
directives: TtsDirectiveParseResult;
};
/** Merge a surface TTS override and resolve its inline synthesis directives. */
export function prepareTtsRequest(params: {
cfg: OpenClawConfig;
override?: TtsConfig;
text: string;
}): PreparedTtsRequest {
const cfg = params.override
? {
...params.cfg,
messages: {
...params.cfg.messages,
tts: mergeDeep(params.cfg.messages?.tts ?? {}, params.override) as TtsConfig,
},
}
: params.cfg;
const config = resolveTtsConfig(cfg);
const directives = parseTtsDirectives(params.text, config.modelOverrides, {
cfg,
providerConfigs: config.providerConfigs,
preferredProviderId: getTtsProvider(config, resolveTtsPrefsPath(config)),
});
return { cfg, directives };
}
export function resolveExplicitTtsOverrides(params: {
cfg: OpenClawConfig;
prefsPath?: string;
@@ -534,6 +534,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
resizeToJpeg: vi.fn() as unknown as PluginRuntime["media"]["resizeToJpeg"],
},
tts: {
prepareTtsRequest: vi.fn() as unknown as PluginRuntime["tts"]["prepareTtsRequest"],
textToSpeech: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeech"],
textToSpeechStream: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechStream"],
textToSpeechTelephony: vi.fn() as unknown as PluginRuntime["tts"]["textToSpeechTelephony"],
+22 -1
View File
@@ -1,6 +1,11 @@
// TTS runtime types define plugin-facing text-to-speech synthesis hooks and results.
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ResolvedTtsPersona, TtsAutoMode, TtsProvider } from "../config/types.tts.js";
import type {
ResolvedTtsPersona,
TtsAutoMode,
TtsConfig,
TtsProvider,
} from "../config/types.tts.js";
import type {
SpeechProviderConfig,
SpeechVoiceOption,
@@ -94,6 +99,22 @@ export type TtsRequestParams = {
accountId?: string;
};
/** Inputs for surface-specific config merge and directive pre-resolution. */
export type PrepareTtsRequestParams = {
cfg: OpenClawConfig;
override?: TtsConfig;
text: string;
};
/** Effective synthesis inputs returned before choosing file, stream, or telephony output. */
export type PreparedTtsRequest = {
cfg: OpenClawConfig;
directives: TtsDirectiveParseResult;
};
/** Shared surface-specific TTS request preparation contract. */
export type PrepareTtsRequest = (params: PrepareTtsRequestParams) => Promise<PreparedTtsRequest>;
/** Telephony-specific synthesis request where output format is constrained by the caller. */
export type TtsTelephonyRequestParams = {
text: string;
+3
View File
@@ -37,6 +37,7 @@ import { createRuntimeTasks } from "./runtime-tasks.js";
import type { CreatePluginRuntimeOptions, PluginRuntime } from "./types.js";
const loadTtsRuntime = createLazyRuntimeModule(() => import("../../plugin-sdk/tts-runtime.js"));
const loadTtsRequestRuntime = createLazyRuntimeModule(() => import("./runtime-tts-request.js"));
const loadMediaUnderstandingRuntime = createLazyRuntimeModule(
() => import("../../media-understanding/runtime.js"),
);
@@ -62,7 +63,9 @@ function createRuntimeGateway(): PluginRuntime["gateway"] {
function createRuntimeTts(): PluginRuntime["tts"] {
const bindTtsRuntime = createLazyRuntimeMethodBinder(loadTtsRuntime);
const bindTtsRequestRuntime = createLazyRuntimeMethodBinder(loadTtsRequestRuntime);
return {
prepareTtsRequest: bindTtsRequestRuntime((runtime) => runtime.prepareTtsRequest),
textToSpeech: bindTtsRuntime((runtime) => runtime.textToSpeech),
textToSpeechStream: bindTtsRuntime((runtime) => runtime.textToSpeechStream),
textToSpeechTelephony: bindTtsRuntime((runtime) => runtime.textToSpeechTelephony),
@@ -0,0 +1,2 @@
// Lazy runtime bridge for speech-core request pre-resolution.
export { prepareTtsRequest } from "../../../packages/speech-core/runtime-api.js";
+2
View File
@@ -8,6 +8,7 @@ import type { LogLevel } from "../../logging/levels.js";
import type { MediaUnderstandingRuntime } from "../../media-understanding/runtime-types.js";
import type {
ListSpeechVoices,
PrepareTtsRequest,
TextToSpeech,
TextToSpeechStream,
TextToSpeechTelephony,
@@ -361,6 +362,7 @@ export type PluginRuntimeCore = {
resizeToJpeg: typeof import("../../media/media-services.js").resizeToJpeg;
};
tts: {
prepareTtsRequest: PrepareTtsRequest;
textToSpeech: TextToSpeech;
textToSpeechStream: TextToSpeechStream;
textToSpeechTelephony: TextToSpeechTelephony;