first commit
This commit is contained in:
263
apps/miniprogram/utils/ttsGateway.js
Normal file
263
apps/miniprogram/utils/ttsGateway.js
Normal file
@@ -0,0 +1,263 @@
|
||||
/* 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
|
||||
};
|
||||
Reference in New Issue
Block a user