58 语音合成系统
模块目标
理解 OpenClaw 的 TTS 系统:多 provider 优先级选择、自动 fallback 循环、auto-mode 判断、通道格式适配。
核心文件
| 文件 | 职责 |
|---|---|
src/tts/tts.ts | 主编排模块(provider 选择、主循环、auto-mode) |
src/tts/tts-core.ts | Provider 底层实现(Edge / OpenAI / ElevenLabs) |
src/tts/prepare-text.ts | 文本预处理(Markdown 剥离、LLM 摘要) |
一、getTtsProvider(provider 优先级,精确源码)
ts
// src/tts/tts.ts
export function getTtsProvider(config: ResolvedTtsConfig, prefsPath: string): TtsProvider {
// 优先级 1:用户偏好文件(tts.json)
const prefs = readPrefs(prefsPath);
if (prefs.tts?.provider) {
return prefs.tts.provider;
}
// 优先级 2:配置文件显式指定
if (config.providerSource === "config") {
return config.provider;
}
// 优先级 3:自动推断(按 API Key 可用性)
if (resolveTtsApiKey(config, "openai")) {
return "openai";
}
if (resolveTtsApiKey(config, "elevenlabs")) {
return "elevenlabs";
}
// 优先级 4:默认(Edge TTS,无需 API Key)
return "edge";
}优先级总结:
用户偏好文件 > 配置文件显式指定 > OpenAI(有key) > ElevenLabs(有key) > Edge(默认)二、textToSpeech(主函数,provider 循环 + fallback)
ts
// src/tts/tts.ts
export async function textToSpeech(params: {
text: string;
cfg: OpenClawConfig;
prefsPath?: string;
channel?: string;
overrides?: TtsDirectiveOverrides;
}): Promise<TtsResult>执行流程:
ts
// 1. 解析配置和通道输出格式
const config = resolveTtsConfig(params.cfg);
const output = resolveOutputFormat(channelId);
// 2. 文本长度检查
if (params.text.length > config.maxTextLength) {
return { success: false, error: `Text too long (${params.text.length} chars, max ${config.maxTextLength})` };
}
// 3. provider 循环(自动 fallback)
for (const provider of providers) {
const providerStart = Date.now();
try {
if (provider === "edge") { /* Edge TTS 逻辑 */ }
if (provider === "openai") { /* OpenAI TTS 逻辑 */ }
if (provider === "elevenlabs") { /* ElevenLabs TTS 逻辑 */ }
} catch (err) {
lastError = String(err);
continue; // 失败 → 尝试下一个 provider
}
}
// 4. 全部失败
return { success: false, error: lastError ?? "all providers failed" };三、三大 Provider 详情
Edge TTS(免费,默认)
ts
if (provider === "edge") {
if (!config.edge.enabled) {
lastError = "edge: disabled";
continue;
}
const tempDir = mkdtempSync(path.join(tmpdir(), "tts-"));
let edgeOutputFormat = resolveEdgeOutputFormat(config);
const fallbackEdgeOutputFormat =
edgeOutputFormat !== DEFAULT_EDGE_OUTPUT_FORMAT ? DEFAULT_EDGE_OUTPUT_FORMAT : undefined;
const attemptEdgeTts = async (outputFormat: string) => {
const extension = inferEdgeExtension(outputFormat);
const audioPath = path.join(tempDir, `voice-${Date.now()}${extension}`);
await edgeTTS({
text: params.text,
outputPath: audioPath,
config: { ...config.edge, outputFormat },
timeoutMs: config.timeoutMs,
});
return { audioPath, outputFormat };
};
// 主格式失败时回退到 DEFAULT_EDGE_OUTPUT_FORMAT
}ElevenLabs TTS
ts
if (provider === "elevenlabs") {
const output = TELEPHONY_OUTPUT.elevenlabs;
const audioBuffer = await elevenLabsTTS({
text: params.text,
apiKey,
baseUrl: config.elevenlabs.baseUrl,
voiceId: config.elevenlabs.voiceId,
modelId: config.elevenlabs.modelId,
outputFormat: output.format,
seed: config.elevenlabs.seed,
applyTextNormalization: config.elevenlabs.applyTextNormalization,
languageCode: config.elevenlabs.languageCode,
voiceSettings: config.elevenlabs.voiceSettings,
timeoutMs: config.timeoutMs,
});
return {
success: true,
audioBuffer,
latencyMs: Date.now() - providerStart,
provider,
outputFormat: output.format,
sampleRate: output.sampleRate,
};
}四、通道格式适配
| 通道 | 输出格式 | 采样率 |
|---|---|---|
| Telegram | Opus @ 64kbps | 48kHz(语音消息兼容) |
| 电话(OpenAI) | PCM | 24kHz |
| 电话(ElevenLabs) | PCM | 22.05kHz |
| 默认 | MP3 @ 128kbps | 44.1kHz |
五、resolveTtsAutoMode(auto-mode 判断,三级优先级)
ts
// src/tts/tts.ts
export function resolveTtsAutoMode(params: {
config: ResolvedTtsConfig;
prefsPath: string;
sessionAuto?: string; // 来自当前会话参数
}): TtsAutoMode {
// 优先级 1:session 参数(最高优先级)
const sessionAuto = normalizeTtsAutoMode(params.sessionAuto);
if (sessionAuto) return sessionAuto;
// 优先级 2:用户偏好文件
const prefsAuto = resolveTtsAutoModeFromPrefs(readPrefs(params.prefsPath));
if (prefsAuto) return prefsAuto;
// 优先级 3:配置文件默认值(最低优先级)
return params.config.auto;
}auto-mode 取值:
| 模式 | 行为 |
|---|---|
off | 不自动转语音(默认) |
always | 所有回复都转语音 |
inbound | 仅当用户发送语音时才回复语音 |
tagged | 仅当 Agent 使用 [[tts]] 指令时才转语音 |
六、maybeApplyTtsToPayload(自动 TTS 附加)
ts
// src/tts/tts.ts
export async function maybeApplyTtsToPayload(params: {
payload: ReplyPayload;
cfg: OpenClawConfig;
channel?: string;
kind?: "tool" | "block" | "final"; // 消息类型
inboundAudio?: boolean; // 用户是否发送了语音
ttsAuto?: string; // session 级 auto 设置
}): Promise<ReplyPayload> {
const config = resolveTtsConfig(params.cfg);
const prefsPath = resolveTtsPrefsPath(config);
const autoMode = resolveTtsAutoMode({
config, prefsPath, sessionAuto: params.ttsAuto,
});
// 判断是否应该应用 TTS
const shouldApply =
autoMode === "always" ||
(autoMode === "inbound" && params.inboundAudio) ||
(autoMode === "tagged" && payloadHasTtsTag(params.payload));
if (!shouldApply) return params.payload;
// 执行文本预处理 + TTS 转换
const text = prepareTextForTts(params.payload.text ?? "");
const result = await textToSpeech({ text, cfg: params.cfg, channel: params.channel });
if (!result.success) return params.payload; // TTS 失败降级为文本
return { ...params.payload, audio: result.audioBuffer, audioFormat: result.outputFormat };
}七、文本预处理链
原始 payload.text
│
▼
Markdown 剥离 → 纯文本
│
▼
[[tts:...]] 指令解析(provider/voice 覆盖)
│
▼
[可选] LLM 摘要(text.length > config.summarizeThreshold)
│
▼
传入 textToSpeech(text)支持行内指令覆盖:[[tts:provider=openai,voice=alloy]]
八、用户偏好持久化
偏好文件路径:~/.openclaw/settings/tts.json
json
{
"tts": {
"enabled": true,
"auto": "inbound",
"provider": "openai",
"maxTextLength": 4000,
"summarize": true
}
}tts.enable / tts.disable 方法修改此文件,tts.setProvider 设置首选 provider。
九、自检清单
- provider 优先级:用户偏好 > 配置显式 > OpenAI(有key) > ElevenLabs(有key) > Edge。
- auto-mode 优先级:session 参数 > 用户偏好文件 > 配置默认值。
- TTS 失败时
maybeApplyTtsToPayload降级为纯文本,不报错给用户。 - Edge TTS 有输出格式回退:主格式失败时尝试
DEFAULT_EDGE_OUTPUT_FORMAT。 - ElevenLabs 返回
audioBuffer(内存),Edge TTS 先写临时文件再读(文件路径)。 kind参数区分 tool/block/final,某些 auto-mode 可能只对 final 消息应用 TTS。
十、开发避坑
- Edge TTS 依赖外部进程:底层调用系统 TTS 工具,在无头环境(CI/容器)中可能不可用。
- ElevenLabs 的
seed参数:相同 seed + 相同文本 + 相同 voiceId 理论上生成相同音频(可缓存)。 - 长文本自动摘要:超过
config.summarizeThreshold的文本会通过 LLM 摘要后再转语音,摘要消耗额外 token。 - 通道格式硬编码:Telegram 必须是 Opus,电话必须是 PCM,不要在 config 中覆盖这些。