06-voiceManager.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. /**
  2. * 云函数:voiceManager(音色档案 + speaker_id 池)
  3. * 替代:
  4. * POST /api/voice/auto-speaker-id → action=autoSpeakerId
  5. * POST /api/voice-profiles/sync → action=syncProfile
  6. * 新增:
  7. * action=listProfiles 列出所有音色档案
  8. * action=initPool 初始化 speaker_id 池(首次部署用)
  9. *
  10. * 注意:speaker_id 池原本在 docs/音色创建/speaker_id.md 文件里。
  11. * 迁移到云函数后改为存储在 "VoiceSpeakerPool" 表。
  12. * 首次部署后请用 action=initPool 把现有池一次性灌入。
  13. */
  14. const VOICE_TOKEN = readEnv('VOICE_TOKEN') || 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
  15. const VOICE_TTS_BASE_URL = readEnv('VOICE_TTS_BASE_URL') || 'https://server.fmode.cn/api/volcengine/tts';
  16. const VOICE_MAX_ATTEMPTS = Math.max(1, Number(readEnv('VOICE_MAX_ATTEMPTS') || 3));
  17. const {
  18. requireSession,
  19. requireAdmin,
  20. assertRequestedUserMatchesSession,
  21. } = require('./_session');
  22. async function handler(request, response) {
  23. try {
  24. await Psql.query(`
  25. CREATE TABLE IF NOT EXISTS "VoiceProfile" (
  26. "objectId" VARCHAR(50) PRIMARY KEY,
  27. "timbreId" VARCHAR(255),
  28. "speakerId" VARCHAR(255),
  29. "name" VARCHAR(255) DEFAULT '',
  30. "data" JSONB NOT NULL DEFAULT '{}',
  31. "userId" VARCHAR(255) DEFAULT '',
  32. "occupied" BOOLEAN DEFAULT TRUE,
  33. "createdAt" TIMESTAMPTZ DEFAULT NOW(),
  34. "updatedAt" TIMESTAMPTZ DEFAULT NOW()
  35. )
  36. `);
  37. await Psql.query(`DROP INDEX IF EXISTS idx_voice_timbre`);
  38. await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_voice_user_timbre ON "VoiceProfile" ("userId", "timbreId") WHERE "timbreId" IS NOT NULL AND "timbreId" <> ''`);
  39. await Psql.query(`CREATE INDEX IF NOT EXISTS idx_voice_speaker ON "VoiceProfile" ("speakerId")`);
  40. await Psql.query(`
  41. CREATE TABLE IF NOT EXISTS "VoiceSpeakerPool" (
  42. "speakerId" VARCHAR(255) PRIMARY KEY,
  43. "trained" BOOLEAN DEFAULT FALSE,
  44. "createdAt" TIMESTAMPTZ DEFAULT NOW()
  45. )
  46. `);
  47. const action = pickParam(request, 'action') || 'listProfiles';
  48. const session = action === 'initPool'
  49. ? await requireAdmin(request, Psql)
  50. : await requireSession(request, Psql);
  51. assertRequestedUserMatchesSession(request, session);
  52. const userId = session.userId;
  53. if (action === 'autoSpeakerId') {
  54. const used = await Psql.query(`
  55. SELECT s."speakerId" FROM "VoiceSpeakerPool" s
  56. LEFT JOIN "VoiceProfile" v ON v."speakerId" = s."speakerId"
  57. WHERE v."speakerId" IS NULL AND s."trained" = FALSE
  58. ORDER BY s."createdAt" ASC
  59. LIMIT 1
  60. `);
  61. if (!used.length) {
  62. return response.json({ code: 500, success: false, error: 'speaker_id 池已耗尽,请先 initPool 添加' });
  63. }
  64. response.json({ code: 200, success: true, data: { speakerId: used[0].speakerId } });
  65. return;
  66. }
  67. if (action === 'syncProfile') {
  68. const body = pickParam(request, 'profile', 'data') || request.body || {};
  69. const timbreId = body.timbre_id || body.timbreId || '';
  70. const speakerId = body.speaker_id || body.speakerId || '';
  71. if (!timbreId && !speakerId) {
  72. return response.json({ code: 400, success: false, error: '缺少 timbre_id 或 speaker_id' });
  73. }
  74. const now = new Date().toISOString();
  75. const merged = { ...body, timbre_id: timbreId, speaker_id: speakerId, updated_at: now };
  76. let existing = [];
  77. if (timbreId) {
  78. existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "timbreId" = $1 AND "userId" = $2 LIMIT 1`, [timbreId, userId]);
  79. }
  80. if (!existing.length && speakerId) {
  81. existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "speakerId" = $1 AND "userId" = $2 LIMIT 1`, [speakerId, userId]);
  82. }
  83. if (existing.length) {
  84. const old = typeof existing[0].data === 'string' ? JSON.parse(existing[0].data) : (existing[0].data || {});
  85. const merged2 = { ...old, ...merged };
  86. await Psql.query(
  87. `UPDATE "VoiceProfile" SET "timbreId"=$1, "speakerId"=$2, "name"=$3, "data"=$4, "occupied"=$5, "updatedAt"=NOW() WHERE "objectId"=$6`,
  88. [timbreId, speakerId, body.name || merged2.name || '', JSON.stringify(merged2), body.occupied !== false, existing[0].objectId]
  89. );
  90. response.json({ code: 200, success: true, data: { ...merged2, objectId: existing[0].objectId } });
  91. } else {
  92. merged.created_at = now;
  93. const objectId = generateId();
  94. await Psql.query(
  95. `INSERT INTO "VoiceProfile" ("objectId","timbreId","speakerId","name","data","userId","occupied")
  96. VALUES ($1,$2,$3,$4,$5,$6,$7)`,
  97. [objectId, timbreId, speakerId, body.name || '', JSON.stringify(merged), userId, body.occupied !== false]
  98. );
  99. response.json({ code: 200, success: true, data: { ...merged, objectId } });
  100. }
  101. return;
  102. }
  103. if (action === 'listProfiles') {
  104. const params = [userId];
  105. const rows = await Psql.query(
  106. `SELECT * FROM "VoiceProfile" WHERE "userId" = $1 ORDER BY "createdAt" DESC LIMIT 1000`,
  107. params
  108. );
  109. response.json({ code: 200, success: true, data: rows.map(rowToObj) });
  110. return;
  111. }
  112. if (action === 'initPool') {
  113. const ids = pickParam(request, 'speakerIds') || [];
  114. const list = Array.isArray(ids) ? ids : String(ids || '').split(/[\s,]+/).filter(Boolean);
  115. if (!list.length) {
  116. return response.json({ code: 400, success: false, error: '缺少 speakerIds(数组或逗号分隔字符串)' });
  117. }
  118. let inserted = 0;
  119. for (const sid of list) {
  120. const r = await Psql.query(
  121. `INSERT INTO "VoiceSpeakerPool" ("speakerId") VALUES ($1) ON CONFLICT DO NOTHING RETURNING "speakerId"`,
  122. [String(sid).trim()]
  123. );
  124. if (r.length) inserted++;
  125. }
  126. response.json({ code: 200, success: true, data: { total: list.length, inserted } });
  127. return;
  128. }
  129. if (action === 'synthesize') {
  130. const payload = pickParam(request, 'payload', 'data') || {};
  131. if (!payload.text && !payload.ssml) {
  132. return response.json({ code: 400, success: false, error: '缺少合成文本' });
  133. }
  134. const upstreamPayload = normalizeSynthesisPayload(payload);
  135. const res = await postJson(`${VOICE_TTS_BASE_URL}/unidirectional`, upstreamPayload);
  136. response.json(res);
  137. return;
  138. }
  139. if (action === 'cloneVoice') {
  140. const payload = pickParam(request, 'payload', 'data') || {};
  141. if (!payload.name || !payload.speaker_id || !payload.audioData) {
  142. return response.json({ code: 400, success: false, error: '缺少声音训练参数' });
  143. }
  144. const upstreamPayload = stripEmpty({
  145. ...payload,
  146. token: VOICE_TOKEN,
  147. });
  148. const res = await postJson(`${VOICE_TTS_BASE_URL}/voice_clone`, upstreamPayload);
  149. response.json(res);
  150. return;
  151. }
  152. if (action === 'deleteProfile') {
  153. const objectId = pickParam(request, 'objectId');
  154. const timbreId = pickParam(request, 'timbreId');
  155. if (!objectId && !timbreId) {
  156. return response.json({ code: 400, success: false, error: '缺少 objectId 或 timbreId' });
  157. }
  158. const r = objectId
  159. ? await Psql.query(`DELETE FROM "VoiceProfile" WHERE "objectId" = $1 AND "userId" = $2 RETURNING "objectId"`, [objectId, userId])
  160. : await Psql.query(`DELETE FROM "VoiceProfile" WHERE "timbreId" = $1 AND "userId" = $2 RETURNING "objectId"`, [timbreId, userId]);
  161. response.json({ code: 200, success: true, data: { deleted: r.length > 0 } });
  162. return;
  163. }
  164. response.json({ code: 400, success: false, error: `未知 action: ${action}` });
  165. } catch (error) {
  166. console.error('❌ voiceManager 失败:', error.message);
  167. response.json({ code: error.status || 500, success: false, error: error.message });
  168. }
  169. }
  170. function normalizeSynthesisPayload(input) {
  171. const payload = { ...(input || {}) };
  172. const officialVoiceType = String(payload.volcengine_voice_type || payload.voiceType || payload.voice_type || '').trim();
  173. if (officialVoiceType && !payload.speaker_id && !payload.speakerId && !payload.timbreId && !payload.timbre_id) {
  174. payload.speaker_id = officialVoiceType;
  175. }
  176. if (payload.timbre_id && !payload.timbreId) {
  177. payload.timbreId = payload.timbre_id;
  178. }
  179. return stripEmpty({
  180. ...payload,
  181. token: VOICE_TOKEN,
  182. isStream: payload.isStream === true,
  183. });
  184. }
  185. function rowToObj(row) {
  186. const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
  187. return { ...data, objectId: row.objectId, occupied: row.occupied, createdAt: row.createdAt, updatedAt: row.updatedAt };
  188. }
  189. function pickParam(request, ...names) {
  190. const sources = [request.params, request.body, request];
  191. for (const src of sources) {
  192. if (!src || typeof src !== 'object') continue;
  193. for (const n of names) {
  194. const v = src[n];
  195. if (v !== undefined && v !== null && v !== '') return v;
  196. }
  197. }
  198. return null;
  199. }
  200. function generateId() {
  201. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  202. let s = '';
  203. for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
  204. return s;
  205. }
  206. function stripEmpty(value) {
  207. if (!value || typeof value !== 'object') return value;
  208. const out = Array.isArray(value) ? [] : {};
  209. for (const [key, val] of Object.entries(value)) {
  210. if (val === undefined || val === null || val === '') continue;
  211. if (val && typeof val === 'object' && !Array.isArray(val)) {
  212. const nested = stripEmpty(val);
  213. if (Object.keys(nested).length) out[key] = nested;
  214. } else {
  215. out[key] = val;
  216. }
  217. }
  218. return out;
  219. }
  220. async function postJson(url, body) {
  221. let lastError = null;
  222. for (let attempt = 1; attempt <= VOICE_MAX_ATTEMPTS; attempt += 1) {
  223. try {
  224. const { status, ok, rawText, data, transport } = await requestJson('POST', url, {
  225. headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
  226. body: JSON.stringify(body),
  227. });
  228. const code = Number(data && data.code ? data.code : status || 0);
  229. const retryable = !ok || code === 408 || code === 429 || code >= 500;
  230. if (!retryable || attempt >= VOICE_MAX_ATTEMPTS) {
  231. if (!ok) {
  232. return {
  233. code: status || code || 500,
  234. success: false,
  235. error: readError(data, rawText) || `HTTP ${status}`,
  236. upstream: { transport, attempt, maxAttempts: VOICE_MAX_ATTEMPTS },
  237. };
  238. }
  239. return data || { code: 500, success: false, error: '服务返回异常' };
  240. }
  241. } catch (error) {
  242. lastError = error;
  243. if (attempt >= VOICE_MAX_ATTEMPTS) break;
  244. }
  245. await sleep(Math.min(12000, 1200 * attempt * attempt));
  246. }
  247. return {
  248. code: 500,
  249. success: false,
  250. error: `TTS upstream request failed: ${formatFetchError(lastError)}`,
  251. };
  252. }
  253. async function requestJson(method, url, init) {
  254. let lastError = null;
  255. if (typeof fetch === 'function') {
  256. try {
  257. const response = await fetch(url, { method, ...(init || {}) });
  258. return readResponse(response, 'fetch');
  259. } catch (error) {
  260. lastError = error;
  261. }
  262. }
  263. if (typeof require === 'function') {
  264. try {
  265. return await requestJsonWithNodeHttps(method, url, init);
  266. } catch (error) {
  267. lastError = error;
  268. }
  269. }
  270. if (typeof XMLHttpRequest !== 'undefined') {
  271. try {
  272. return await requestJsonWithXhr(method, url, init);
  273. } catch (error) {
  274. lastError = error;
  275. }
  276. }
  277. throw lastError || new Error('No HTTP client is available in this cloud runtime');
  278. }
  279. async function readResponse(response, transport) {
  280. const rawText = await response.text();
  281. let data = null;
  282. try { data = rawText ? JSON.parse(rawText) : null; } catch {}
  283. return {
  284. status: response.status,
  285. ok: !!response.ok,
  286. rawText,
  287. data,
  288. transport,
  289. };
  290. }
  291. function requestJsonWithNodeHttps(method, url, init) {
  292. return new Promise((resolve, reject) => {
  293. try {
  294. const parsed = new URL(url);
  295. const lib = parsed.protocol === 'http:' ? require('http') : require('https');
  296. const headers = init && init.headers ? init.headers : {};
  297. const body = init && init.body ? init.body : '';
  298. const req = lib.request({
  299. method,
  300. protocol: parsed.protocol,
  301. hostname: parsed.hostname,
  302. port: parsed.port,
  303. path: `${parsed.pathname}${parsed.search}`,
  304. headers: body ? { ...headers, 'Content-Length': Buffer.byteLength(body) } : headers,
  305. }, (res) => {
  306. const chunks = [];
  307. res.on('data', (chunk) => chunks.push(chunk));
  308. res.on('end', () => {
  309. const rawText = Buffer.concat(chunks).toString('utf8');
  310. let data = null;
  311. try { data = rawText ? JSON.parse(rawText) : null; } catch {}
  312. resolve({
  313. status: res.statusCode || 0,
  314. ok: res.statusCode >= 200 && res.statusCode < 300,
  315. rawText,
  316. data,
  317. transport: 'node-https',
  318. });
  319. });
  320. });
  321. req.on('error', reject);
  322. if (body) req.write(body);
  323. req.end();
  324. } catch (error) {
  325. reject(error);
  326. }
  327. });
  328. }
  329. function requestJsonWithXhr(method, url, init) {
  330. return new Promise((resolve, reject) => {
  331. const xhr = new XMLHttpRequest();
  332. xhr.open(method, url, true);
  333. const headers = init && init.headers ? init.headers : {};
  334. for (const [key, value] of Object.entries(headers)) {
  335. xhr.setRequestHeader(key, value);
  336. }
  337. xhr.onreadystatechange = function onReadyStateChange() {
  338. if (xhr.readyState !== 4) return;
  339. let data = null;
  340. try { data = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch {}
  341. resolve({
  342. status: xhr.status,
  343. ok: xhr.status >= 200 && xhr.status < 300,
  344. rawText: xhr.responseText || '',
  345. data,
  346. transport: 'xhr',
  347. });
  348. };
  349. xhr.onerror = function onXhrError() {
  350. reject(new Error('XMLHttpRequest failed'));
  351. };
  352. xhr.send(init && init.body ? init.body : null);
  353. });
  354. }
  355. function readError(data, fallback) {
  356. return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
  357. }
  358. function formatFetchError(error) {
  359. if (!error) return 'unknown error';
  360. const message = error.message || String(error);
  361. const cause = error.cause ? `; cause=${error.cause.code || error.cause.message || error.cause}` : '';
  362. return `${message}${cause}`;
  363. }
  364. function sleep(ms) {
  365. return new Promise((resolve) => setTimeout(resolve, ms));
  366. }
  367. function readEnv(name) {
  368. if (typeof process !== 'undefined' && process.env && process.env[name]) {
  369. return process.env[name];
  370. }
  371. return '';
  372. }