openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
338 lines (337 loc) • 11 kB
JavaScript
import { r as writeExternalFileWithinRoot } from "./fs-safe-aqmM_n6V.js";
import { n as resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir-DOKojISm.js";
import { t as createSubsystemLogger } from "./subsystem-BzXSmsuh.js";
import { t as tempWorkspace } from "./private-temp-workspace-MCwLg_M9.js";
import { c as runFfmpeg } from "./media-services-DE3J1qM-.js";
import "./media-runtime-fgpmX6eL.js";
import "./temp-path-CeQpRzl5.js";
import "./runtime-env-D_2q8-VK.js";
import "./security-runtime-CQm7DD1u.js";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
//#region extensions/tts-local-cli/speech-provider.ts
const log = createSubsystemLogger("tts-local-cli");
const VALID_OUTPUT_FORMATS = [
"mp3",
"opus",
"wav"
];
const AUDIO_EXTENSIONS = new Set([
".wav",
".mp3",
".opus",
".ogg",
".m4a"
]);
const DEFAULT_TIMEOUT_MS = 12e4;
function asObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
}
function asStringArray(value) {
return Array.isArray(value) && value.every((v) => typeof v === "string") ? value : void 0;
}
function asRecord(value) {
const obj = asObject(value);
if (!obj) return;
const result = {};
for (const [k, v] of Object.entries(obj)) if (typeof v === "string") result[k] = v;
return Object.keys(result).length > 0 ? result : void 0;
}
function normalizeOutputFormat(value) {
if (typeof value !== "string") return "mp3";
const lower = value.toLowerCase().trim();
if (VALID_OUTPUT_FORMATS.includes(lower)) return lower;
return "mp3";
}
function resolveCliProviderConfig(rawConfig) {
const providers = asObject(rawConfig.providers);
return asObject(providers?.["tts-local-cli"]) ?? asObject(providers?.cli) ?? {};
}
function getConfig(cfg) {
const command = typeof cfg.command === "string" ? cfg.command.trim() : "";
if (!command) return null;
return {
command,
args: asStringArray(cfg.args),
outputFormat: normalizeOutputFormat(cfg.outputFormat),
timeoutMs: typeof cfg.timeoutMs === "number" ? cfg.timeoutMs : DEFAULT_TIMEOUT_MS,
cwd: typeof cfg.cwd === "string" ? cfg.cwd : void 0,
env: asRecord(cfg.env)
};
}
function stripEmojis(text) {
return text.replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, " ").replace(/\s+/g, " ").trim();
}
function applyTemplate(str, ctx) {
return str.replace(/{{\s*(\w+)\s*}}/gi, (_, key) => {
return ctx[key.charAt(0).toUpperCase() + key.slice(1).toLowerCase()] ?? ctx[key] ?? "";
});
}
function parseCommand(cmdStr) {
const parts = [];
let current = "";
let inQuote = false;
let quoteChar = "";
for (const char of cmdStr.trim()) if (inQuote) if (char === quoteChar) inQuote = false;
else current += char;
else if (char === "\"" || char === "'") {
inQuote = true;
quoteChar = char;
} else if (char === " " || char === " ") {
if (current) {
parts.push(current);
current = "";
}
} else current += char;
if (current) parts.push(current);
return {
cmd: parts[0] || "",
initialArgs: parts.slice(1)
};
}
function findAudioFile(dir, baseName) {
const files = readdirSync(dir);
for (const file of files) {
const ext = path.extname(file).toLowerCase();
if (AUDIO_EXTENSIONS.has(ext) && (file.startsWith(baseName) || file.includes(baseName))) return path.join(dir, file);
}
for (const file of files) {
const ext = path.extname(file).toLowerCase();
if (AUDIO_EXTENSIONS.has(ext)) return path.join(dir, file);
}
return null;
}
function detectFormat(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".opus" || ext === ".ogg") return "opus";
if (ext === ".wav") return "wav";
if (ext === ".mp3" || ext === ".m4a") return "mp3";
return null;
}
function getFileExt(format) {
if (format === "opus") return ".opus";
if (format === "wav") return ".wav";
return ".mp3";
}
async function runCli(params) {
const cleanText = stripEmojis(params.text);
if (!cleanText) throw new Error("CLI TTS: text is empty after removing emojis");
const outputExt = getFileExt(params.outputFormat ?? "wav");
const ctx = {
Text: cleanText,
OutputPath: path.join(params.outputDir, `${params.filePrefix}${outputExt}`),
OutputDir: params.outputDir,
OutputBase: params.filePrefix
};
const { cmd, initialArgs } = parseCommand(params.command);
if (!cmd) throw new Error("CLI TTS: invalid command");
const baseArgs = [...initialArgs, ...params.args];
const args = baseArgs.map((a) => applyTemplate(a, ctx));
return new Promise((resolve, reject) => {
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
proc.kill();
setTimeout(() => proc.kill("SIGKILL"), 5e3).unref();
}, params.timeoutMs);
const env = params.env ? {
...process.env,
...params.env
} : process.env;
const proc = spawn(cmd, args, {
cwd: params.cwd,
env,
stdio: [
"pipe",
"pipe",
"pipe"
]
});
const stdoutChunks = [];
const stderrChunks = [];
proc.stdout.on("data", (c) => stdoutChunks.push(c));
proc.stderr.on("data", (c) => stderrChunks.push(c));
proc.on("error", (e) => {
clearTimeout(timer);
reject(/* @__PURE__ */ new Error(`CLI TTS failed: ${e.message}`));
});
proc.on("close", (code) => {
clearTimeout(timer);
if (timedOut) return reject(/* @__PURE__ */ new Error(`CLI TTS timed out after ${params.timeoutMs}ms`));
if (code !== 0) {
const stderr = Buffer.concat(stderrChunks).toString("utf8");
return reject(/* @__PURE__ */ new Error(`CLI TTS exit ${code}: ${stderr}`));
}
const audioFile = findAudioFile(params.outputDir, params.filePrefix);
if (audioFile) {
if (!existsSync(audioFile)) return reject(/* @__PURE__ */ new Error(`CLI TTS: output file not found at ${audioFile}`));
const format = detectFormat(audioFile);
if (!format) return reject(/* @__PURE__ */ new Error(`CLI TTS: unknown format for ${audioFile}`));
return resolve({
buffer: readFileSync(audioFile),
actualFormat: format,
audioPath: audioFile
});
}
const stdout = Buffer.concat(stdoutChunks);
if (stdout.length > 0) return resolve({
buffer: stdout,
actualFormat: "wav"
});
reject(/* @__PURE__ */ new Error("CLI TTS produced no output"));
});
proc.stdin?.on("error", () => {});
if (!baseArgs.some((a) => /{{\s*text\s*}}/i.test(a))) proc.stdin?.write(cleanText);
proc.stdin?.end();
});
}
async function convertAudio(inputPath, outputDir, target) {
const outputFileName = `converted${getFileExt(target)}`;
const outputPath = path.join(outputDir, outputFileName);
const args = [
"-y",
"-i",
inputPath
];
if (target === "opus") args.push("-c:a", "libopus", "-b:a", "64k", "-f", "opus");
else if (target === "wav") args.push("-c:a", "pcm_s16le", "-f", "wav");
else args.push("-c:a", "libmp3lame", "-b:a", "128k", "-f", "mp3");
await writeExternalFileWithinRoot({
rootDir: outputDir,
path: outputFileName,
write: async (tempPath) => {
await runFfmpeg([...args, tempPath]);
}
});
return readFileSync(outputPath);
}
async function convertToRawPcm(inputPath, outputDir) {
const outputFileName = "telephony.pcm";
const outputPath = path.join(outputDir, outputFileName);
await writeExternalFileWithinRoot({
rootDir: outputDir,
path: outputFileName,
write: async (tempPath) => {
await runFfmpeg([
"-y",
"-i",
inputPath,
"-c:a",
"pcm_s16le",
"-ar",
"16000",
"-ac",
"1",
"-f",
"s16le",
tempPath
]);
}
});
return readFileSync(outputPath);
}
function buildCliSpeechProvider() {
return {
id: "tts-local-cli",
aliases: ["cli"],
label: "Local CLI",
autoSelectOrder: 1e3,
resolveConfig(ctx) {
return resolveCliProviderConfig(ctx.rawConfig);
},
isConfigured(ctx) {
return getConfig(ctx.providerConfig) !== null;
},
async synthesize(req) {
const config = getConfig(req.providerConfig);
if (!config) throw new Error("CLI TTS not configured");
log.debug(`synthesize: text=${req.text.slice(0, 50)}...`);
const temp = await tempWorkspace({
rootDir: resolvePreferredOpenClawTmpDir(),
prefix: "openclaw-cli-tts-"
});
const tempDir = temp.dir;
try {
const result = await runCli({
command: config.command,
args: config.args ?? [],
cwd: config.cwd,
env: config.env,
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
text: req.text,
outputDir: tempDir,
filePrefix: "speech",
outputFormat: config.outputFormat
});
log.debug(`synthesize: format=${result.actualFormat}, size=${result.buffer.length}`);
let buffer;
let format;
if (req.target === "voice-note") if (result.actualFormat !== "opus") {
const inputFile = result.audioPath ?? path.join(tempDir, `input${getFileExt(result.actualFormat)}`);
if (!result.audioPath) await temp.write(`input${getFileExt(result.actualFormat)}`, result.buffer);
buffer = await convertAudio(inputFile, tempDir, "opus");
format = "opus";
} else {
buffer = result.buffer;
format = "opus";
}
else {
const desired = config.outputFormat ?? "mp3";
if (result.actualFormat !== desired) {
const inputFile = result.audioPath ?? path.join(tempDir, `input${getFileExt(result.actualFormat)}`);
if (!result.audioPath) await temp.write(`input${getFileExt(result.actualFormat)}`, result.buffer);
buffer = await convertAudio(inputFile, tempDir, desired);
format = desired;
} else {
buffer = result.buffer;
format = result.actualFormat;
}
}
const fileExtension = format === "opus" ? ".ogg" : `.${format}`;
return {
audioBuffer: buffer,
outputFormat: format,
fileExtension,
voiceCompatible: req.target === "voice-note" && format === "opus"
};
} finally {
await temp.cleanup();
}
},
async synthesizeTelephony(req) {
const config = getConfig(req.providerConfig);
if (!config) throw new Error("CLI TTS not configured");
log.debug(`synthesizeTelephony: text=${req.text.slice(0, 50)}...`);
const temp = await tempWorkspace({
rootDir: resolvePreferredOpenClawTmpDir(),
prefix: "openclaw-cli-tts-"
});
const tempDir = temp.dir;
try {
const result = await runCli({
command: config.command,
args: config.args ?? [],
cwd: config.cwd,
env: config.env,
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS,
text: req.text,
outputDir: tempDir,
filePrefix: "telephony",
outputFormat: config.outputFormat
});
const inputFile = result.audioPath ?? path.join(tempDir, `input${getFileExt(result.actualFormat)}`);
if (!result.audioPath) await temp.write(`input${getFileExt(result.actualFormat)}`, result.buffer);
return {
audioBuffer: await convertToRawPcm(inputFile, tempDir),
outputFormat: "pcm",
sampleRate: 16e3
};
} finally {
await temp.cleanup();
}
}
};
}
//#endregion
export { buildCliSpeechProvider as t };