264 lines
8.4 KiB
JavaScript
264 lines
8.4 KiB
JavaScript
/* 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
|
||
};
|