06-voiceManager.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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. async function handler(request, response) {
  17. try {
  18. await Psql.query(`
  19. CREATE TABLE IF NOT EXISTS "VoiceProfile" (
  20. "objectId" VARCHAR(50) PRIMARY KEY,
  21. "timbreId" VARCHAR(255),
  22. "speakerId" VARCHAR(255),
  23. "name" VARCHAR(255) DEFAULT '',
  24. "data" JSONB NOT NULL DEFAULT '{}',
  25. "userId" VARCHAR(255) DEFAULT '',
  26. "occupied" BOOLEAN DEFAULT TRUE,
  27. "createdAt" TIMESTAMPTZ DEFAULT NOW(),
  28. "updatedAt" TIMESTAMPTZ DEFAULT NOW()
  29. )
  30. `);
  31. await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_voice_timbre ON "VoiceProfile" ("timbreId") WHERE "timbreId" IS NOT NULL AND "timbreId" <> ''`);
  32. await Psql.query(`CREATE INDEX IF NOT EXISTS idx_voice_speaker ON "VoiceProfile" ("speakerId")`);
  33. await Psql.query(`
  34. CREATE TABLE IF NOT EXISTS "VoiceSpeakerPool" (
  35. "speakerId" VARCHAR(255) PRIMARY KEY,
  36. "trained" BOOLEAN DEFAULT FALSE,
  37. "createdAt" TIMESTAMPTZ DEFAULT NOW()
  38. )
  39. `);
  40. const action = pickParam(request, 'action') || 'listProfiles';
  41. const userId = pickParam(request, 'userId') || '';
  42. if (action === 'autoSpeakerId') {
  43. const used = await Psql.query(`
  44. SELECT s."speakerId" FROM "VoiceSpeakerPool" s
  45. LEFT JOIN "VoiceProfile" v ON v."speakerId" = s."speakerId"
  46. WHERE v."speakerId" IS NULL AND s."trained" = FALSE
  47. ORDER BY s."createdAt" ASC
  48. LIMIT 1
  49. `);
  50. if (!used.length) {
  51. return response.json({ code: 500, success: false, error: 'speaker_id 池已耗尽,请先 initPool 添加' });
  52. }
  53. response.json({ code: 200, success: true, data: { speakerId: used[0].speakerId } });
  54. return;
  55. }
  56. if (action === 'syncProfile') {
  57. const body = pickParam(request, 'profile', 'data') || request.body || {};
  58. const timbreId = body.timbre_id || body.timbreId || '';
  59. const speakerId = body.speaker_id || body.speakerId || '';
  60. if (!timbreId && !speakerId) {
  61. return response.json({ code: 400, success: false, error: '缺少 timbre_id 或 speaker_id' });
  62. }
  63. const now = new Date().toISOString();
  64. const merged = { ...body, timbre_id: timbreId, speaker_id: speakerId, updated_at: now };
  65. let existing = [];
  66. if (timbreId) {
  67. existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "timbreId" = $1 LIMIT 1`, [timbreId]);
  68. }
  69. if (!existing.length && speakerId) {
  70. existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "speakerId" = $1 LIMIT 1`, [speakerId]);
  71. }
  72. if (existing.length) {
  73. const old = typeof existing[0].data === 'string' ? JSON.parse(existing[0].data) : (existing[0].data || {});
  74. const merged2 = { ...old, ...merged };
  75. await Psql.query(
  76. `UPDATE "VoiceProfile" SET "timbreId"=$1, "speakerId"=$2, "name"=$3, "data"=$4, "occupied"=$5, "updatedAt"=NOW() WHERE "objectId"=$6`,
  77. [timbreId, speakerId, body.name || merged2.name || '', JSON.stringify(merged2), body.occupied !== false, existing[0].objectId]
  78. );
  79. response.json({ code: 200, success: true, data: { ...merged2, objectId: existing[0].objectId } });
  80. } else {
  81. merged.created_at = now;
  82. const objectId = generateId();
  83. await Psql.query(
  84. `INSERT INTO "VoiceProfile" ("objectId","timbreId","speakerId","name","data","userId","occupied")
  85. VALUES ($1,$2,$3,$4,$5,$6,$7)`,
  86. [objectId, timbreId, speakerId, body.name || '', JSON.stringify(merged), userId, body.occupied !== false]
  87. );
  88. response.json({ code: 200, success: true, data: { ...merged, objectId } });
  89. }
  90. return;
  91. }
  92. if (action === 'listProfiles') {
  93. const conds = [], params = [];
  94. if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
  95. const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
  96. const rows = await Psql.query(
  97. `SELECT * FROM "VoiceProfile" ${where} ORDER BY "createdAt" DESC LIMIT 1000`,
  98. params
  99. );
  100. response.json({ code: 200, success: true, data: rows.map(rowToObj) });
  101. return;
  102. }
  103. if (action === 'initPool') {
  104. const ids = pickParam(request, 'speakerIds') || [];
  105. const list = Array.isArray(ids) ? ids : String(ids || '').split(/[\s,]+/).filter(Boolean);
  106. if (!list.length) {
  107. return response.json({ code: 400, success: false, error: '缺少 speakerIds(数组或逗号分隔字符串)' });
  108. }
  109. let inserted = 0;
  110. for (const sid of list) {
  111. const r = await Psql.query(
  112. `INSERT INTO "VoiceSpeakerPool" ("speakerId") VALUES ($1) ON CONFLICT DO NOTHING RETURNING "speakerId"`,
  113. [String(sid).trim()]
  114. );
  115. if (r.length) inserted++;
  116. }
  117. response.json({ code: 200, success: true, data: { total: list.length, inserted } });
  118. return;
  119. }
  120. if (action === 'synthesize') {
  121. const payload = pickParam(request, 'payload', 'data') || {};
  122. if (!payload.text && !payload.ssml) {
  123. return response.json({ code: 400, success: false, error: '缺少合成文本' });
  124. }
  125. const upstreamPayload = stripEmpty({
  126. ...payload,
  127. token: VOICE_TOKEN,
  128. isStream: payload.isStream === true,
  129. });
  130. const res = await postJson(`${VOICE_TTS_BASE_URL}/unidirectional`, upstreamPayload);
  131. response.json(res);
  132. return;
  133. }
  134. if (action === 'cloneVoice') {
  135. const payload = pickParam(request, 'payload', 'data') || {};
  136. if (!payload.name || !payload.speaker_id || !payload.audioData) {
  137. return response.json({ code: 400, success: false, error: '缺少声音训练参数' });
  138. }
  139. const upstreamPayload = stripEmpty({
  140. ...payload,
  141. token: VOICE_TOKEN,
  142. });
  143. const res = await postJson(`${VOICE_TTS_BASE_URL}/voice_clone`, upstreamPayload);
  144. response.json(res);
  145. return;
  146. }
  147. if (action === 'deleteProfile') {
  148. const objectId = pickParam(request, 'objectId');
  149. const timbreId = pickParam(request, 'timbreId');
  150. if (!objectId && !timbreId) {
  151. return response.json({ code: 400, success: false, error: '缺少 objectId 或 timbreId' });
  152. }
  153. const r = objectId
  154. ? await Psql.query(`DELETE FROM "VoiceProfile" WHERE "objectId" = $1 RETURNING "objectId"`, [objectId])
  155. : await Psql.query(`DELETE FROM "VoiceProfile" WHERE "timbreId" = $1 RETURNING "objectId"`, [timbreId]);
  156. response.json({ code: 200, success: true, data: { deleted: r.length > 0 } });
  157. return;
  158. }
  159. response.json({ code: 400, success: false, error: `未知 action: ${action}` });
  160. } catch (error) {
  161. console.error('❌ voiceManager 失败:', error.message);
  162. response.json({ code: 500, success: false, error: error.message });
  163. }
  164. }
  165. function rowToObj(row) {
  166. const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
  167. return { ...data, objectId: row.objectId, occupied: row.occupied, createdAt: row.createdAt, updatedAt: row.updatedAt };
  168. }
  169. function pickParam(request, ...names) {
  170. const sources = [request.params, request.body, request];
  171. for (const src of sources) {
  172. if (!src || typeof src !== 'object') continue;
  173. for (const n of names) {
  174. const v = src[n];
  175. if (v !== undefined && v !== null && v !== '') return v;
  176. }
  177. }
  178. return null;
  179. }
  180. function generateId() {
  181. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  182. let s = '';
  183. for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
  184. return s;
  185. }
  186. function stripEmpty(value) {
  187. if (!value || typeof value !== 'object') return value;
  188. const out = Array.isArray(value) ? [] : {};
  189. for (const [key, val] of Object.entries(value)) {
  190. if (val === undefined || val === null || val === '') continue;
  191. if (val && typeof val === 'object' && !Array.isArray(val)) {
  192. const nested = stripEmpty(val);
  193. if (Object.keys(nested).length) out[key] = nested;
  194. } else {
  195. out[key] = val;
  196. }
  197. }
  198. return out;
  199. }
  200. async function postJson(url, body) {
  201. const r = await fetch(url, {
  202. method: 'POST',
  203. headers: { 'Content-Type': 'application/json' },
  204. body: JSON.stringify(body),
  205. });
  206. const text = await r.text();
  207. let data = null;
  208. try { data = text ? JSON.parse(text) : null; } catch {}
  209. if (!r.ok) {
  210. return { code: r.status, success: false, error: readError(data, text) || `HTTP ${r.status}` };
  211. }
  212. return data || { code: 500, success: false, error: '服务返回异常' };
  213. }
  214. function readError(data, fallback) {
  215. return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
  216. }
  217. function readEnv(name) {
  218. if (typeof process !== 'undefined' && process.env && process.env[name]) {
  219. return process.env[name];
  220. }
  221. return '';
  222. }