Files
2026-03-21 18:57:10 +08:00

264 lines
8.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* global require, module, clearTimeout, setTimeout */
const { ensureSyncAuthToken, resolveSyncBaseUrl, requestJson } = require("./syncAuth");
const TTS_REQUEST_TIMEOUT_MS = 15000;
const TTS_GENERATION_TIMEOUT_MS = 6 * 60 * 1000;
const TTS_STATUS_POLL_INTERVAL_MS = 700;
const TTS_LOCAL_CACHE_PREFIX = "tts-cache-";
const KNOWN_TTS_MESSAGES = new Set([
"当前没有可播报内容",
"当前内容不适合播报",
"播报文本过长",
"语音生成繁忙,请稍后重试",
"语音生成超时,请稍后重试",
"语音生成失败",
"TTS 服务未配置"
]);
const KNOWN_TTS_MESSAGE_PREFIXES = ["TTS 上游鉴权或权限失败,请检查密钥、地域和账号权限"];
function isKnownTtsMessage(message) {
const normalized = String(message || "").trim();
if (!normalized) return false;
if (KNOWN_TTS_MESSAGES.has(normalized)) return true;
return KNOWN_TTS_MESSAGE_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function withTimeout(task, timeoutMs, timeoutMessage) {
const waitMs = Math.max(1000, Math.round(Number(timeoutMs) || TTS_REQUEST_TIMEOUT_MS));
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(timeoutMessage || "语音生成超时,请稍后重试"));
}, waitMs);
Promise.resolve(task)
.then((value) => {
clearTimeout(timer);
resolve(value);
})
.catch((error) => {
clearTimeout(timer);
reject(error);
});
});
}
function sleep(ms) {
const waitMs = Math.max(0, Math.round(Number(ms) || 0));
return new Promise((resolve) => {
setTimeout(resolve, waitMs);
});
}
function getMiniProgramFs() {
if (typeof wx === "undefined" || typeof wx.getFileSystemManager !== "function") {
return null;
}
try {
return wx.getFileSystemManager();
} catch {
return null;
}
}
function sanitizeTtsCacheKey(cacheKey) {
return String(cacheKey || "")
.trim()
.replace(/[^a-zA-Z0-9_-]/g, "");
}
/**
* 使用小程序用户目录做确定性文件缓存:
* 1. 以服务端 `cacheKey` 命名,天然与内容一一对应;
* 2. 命中后可直接播放本地文件,不再重复下载;
* 3. 不依赖 `saveFile` 的全局列表,避免额外维护 saved file 配额。
*/
function resolveTtsLocalCachePath(cacheKey) {
const normalizedKey = sanitizeTtsCacheKey(cacheKey);
const userDataPath =
typeof wx !== "undefined" && wx && wx.env && typeof wx.env.USER_DATA_PATH === "string"
? wx.env.USER_DATA_PATH
: "";
if (!normalizedKey || !userDataPath) {
return "";
}
return `${userDataPath}/${TTS_LOCAL_CACHE_PREFIX}${normalizedKey}.mp3`;
}
function accessLocalFile(filePath) {
const targetPath = String(filePath || "").trim();
const fs = getMiniProgramFs();
if (!targetPath || !fs || typeof fs.access !== "function") {
return Promise.resolve(false);
}
return new Promise((resolve) => {
fs.access({
path: targetPath,
success: () => resolve(true),
fail: () => resolve(false)
});
});
}
function downloadTtsAudioToLocal(remoteAudioUrl, cacheKey, timeoutMs) {
const targetUrl = String(remoteAudioUrl || "").trim();
const filePath = resolveTtsLocalCachePath(cacheKey);
if (!targetUrl || !filePath || typeof wx === "undefined" || typeof wx.downloadFile !== "function") {
return Promise.resolve("");
}
return new Promise((resolve) => {
wx.downloadFile({
url: targetUrl,
filePath,
timeout: Math.max(1000, Math.round(Number(timeoutMs) || TTS_REQUEST_TIMEOUT_MS)),
success(res) {
const statusCode = Number(res && res.statusCode);
if (statusCode >= 200 && statusCode < 300) {
resolve(filePath);
return;
}
resolve("");
},
fail() {
resolve("");
}
});
});
}
async function resolvePlayableAudioUrl(remoteAudioUrl, cacheKey, timeoutMs) {
const localPath = resolveTtsLocalCachePath(cacheKey);
if (!localPath) {
return remoteAudioUrl;
}
if (await accessLocalFile(localPath)) {
return localPath;
}
const downloadedPath = await downloadTtsAudioToLocal(remoteAudioUrl, cacheKey, timeoutMs);
return downloadedPath || remoteAudioUrl;
}
/**
* gateway 生成的 TTS 音频/状态地址本质上应与小程序当前同步基地址同源:
* 1. 若反向代理未透传 `x-forwarded-proto/host`,后端可能回出 `http://内网主机/...`
* 2. 小程序 `request` 能成功,不代表 `InnerAudioContext` 也能播放这个错误 origin
* 3. 因此仅对 `/api/miniprogram/tts/` 路径做同源归一化,强制回到当前 `gatewayUrl`。
*/
function normalizeGatewayTtsUrl(rawUrl, baseUrl) {
const candidate = String(rawUrl || "").trim();
const base = String(baseUrl || "").trim();
if (!candidate || !base) {
return candidate;
}
try {
const baseParsed = new URL(base);
const resolved = new URL(candidate, `${baseParsed.origin}/`);
if (!resolved.pathname.startsWith("/api/miniprogram/tts/")) {
return resolved.toString();
}
return `${baseParsed.origin}${resolved.pathname}${resolved.search}${resolved.hash}`;
} catch {
return candidate;
}
}
async function waitForSynthesisReady(statusUrl, timeoutMs) {
const deadline = Date.now() + Math.max(TTS_REQUEST_TIMEOUT_MS, Math.round(Number(timeoutMs) || TTS_GENERATION_TIMEOUT_MS));
while (Date.now() < deadline) {
const payload = await requestJson(statusUrl, {
method: "GET",
timeoutMs: TTS_REQUEST_TIMEOUT_MS
});
const status = payload && payload.status ? String(payload.status) : "";
if (payload && payload.ok === true && status === "ready") {
return;
}
const rawMessage = payload && payload.message ? String(payload.message) : "";
if (status === "error") {
throw new Error(isKnownTtsMessage(rawMessage) ? rawMessage : "语音生成失败");
}
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) {
break;
}
await sleep(Math.min(TTS_STATUS_POLL_INTERVAL_MS, remainingMs));
}
throw new Error("语音生成超时,请稍后重试");
}
/**
* 小程序 TTS 请求只做两件事:
* 1. 复用同步登录 Bearer token
* 2. 把短文本换成短时可播放音频 URL。
*/
async function synthesizeTerminalSpeech(options) {
const input = options && typeof options === "object" ? options : {};
const text = String(input.text || "").trim();
if (!text) {
throw new Error("当前没有可播报内容");
}
const baseUrl = resolveSyncBaseUrl();
if (!baseUrl) {
throw new Error("语音生成失败");
}
const token = await ensureSyncAuthToken();
let response = null;
try {
response = await withTimeout(
requestJson(`${baseUrl}/api/miniprogram/tts/synthesize`, {
method: "POST",
data: {
text,
scene: "codex_terminal",
...(input.voice ? { voice: String(input.voice) } : {}),
...(Number.isFinite(Number(input.speed)) ? { speed: Number(input.speed) } : {})
},
header: {
"content-type": "application/json",
authorization: `Bearer ${token}`
},
timeoutMs: TTS_REQUEST_TIMEOUT_MS
}),
TTS_REQUEST_TIMEOUT_MS,
"语音生成超时,请稍后重试"
);
if (response && response.status === "pending") {
const statusUrl = normalizeGatewayTtsUrl(response.statusUrl, baseUrl);
if (!statusUrl) {
throw new Error("语音生成失败");
}
await waitForSynthesisReady(statusUrl, input.timeoutMs);
}
} catch (error) {
const rawMessage = error instanceof Error && error.message ? error.message : "";
if (/timeout/i.test(rawMessage)) {
throw new Error("语音生成超时,请稍后重试");
}
if (isKnownTtsMessage(rawMessage)) {
throw new Error(rawMessage);
}
throw new Error("语音生成失败");
}
if (!response || response.ok !== true || !response.audioUrl) {
const rawMessage = response && response.message ? String(response.message) : "";
throw new Error(isKnownTtsMessage(rawMessage) ? rawMessage : "语音生成失败");
}
const remoteAudioUrl = normalizeGatewayTtsUrl(response.audioUrl, baseUrl);
const audioUrl = await resolvePlayableAudioUrl(
remoteAudioUrl,
response.cacheKey,
TTS_REQUEST_TIMEOUT_MS
);
return {
cacheKey: String(response.cacheKey || ""),
cached: response.cached === true,
audioUrl,
remoteAudioUrl,
expiresAt: String(response.expiresAt || "")
};
}
module.exports = {
TTS_REQUEST_TIMEOUT_MS,
synthesizeTerminalSpeech
};