06-voiceManager.js 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  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. async function handler(request, response) {
  15. try {
  16. await Psql.query(`
  17. CREATE TABLE IF NOT EXISTS "VoiceProfile" (
  18. "objectId" VARCHAR(50) PRIMARY KEY,
  19. "timbreId" VARCHAR(255),
  20. "speakerId" VARCHAR(255),
  21. "name" VARCHAR(255) DEFAULT '',
  22. "data" JSONB NOT NULL DEFAULT '{}',
  23. "userId" VARCHAR(255) DEFAULT '',
  24. "occupied" BOOLEAN DEFAULT TRUE,
  25. "createdAt" TIMESTAMPTZ DEFAULT NOW(),
  26. "updatedAt" TIMESTAMPTZ DEFAULT NOW()
  27. )
  28. `);
  29. await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_voice_timbre ON "VoiceProfile" ("timbreId") WHERE "timbreId" IS NOT NULL AND "timbreId" <> ''`);
  30. await Psql.query(`CREATE INDEX IF NOT EXISTS idx_voice_speaker ON "VoiceProfile" ("speakerId")`);
  31. await Psql.query(`
  32. CREATE TABLE IF NOT EXISTS "VoiceSpeakerPool" (
  33. "speakerId" VARCHAR(255) PRIMARY KEY,
  34. "trained" BOOLEAN DEFAULT FALSE,
  35. "createdAt" TIMESTAMPTZ DEFAULT NOW()
  36. )
  37. `);
  38. const action = pickParam(request, 'action') || 'listProfiles';
  39. const userId = pickParam(request, 'userId') || '';
  40. if (action === 'autoSpeakerId') {
  41. const used = await Psql.query(`
  42. SELECT s."speakerId" FROM "VoiceSpeakerPool" s
  43. LEFT JOIN "VoiceProfile" v ON v."speakerId" = s."speakerId"
  44. WHERE v."speakerId" IS NULL AND s."trained" = FALSE
  45. ORDER BY s."createdAt" ASC
  46. LIMIT 1
  47. `);
  48. if (!used.length) {
  49. return response.json({ code: 500, success: false, error: 'speaker_id 池已耗尽,请先 initPool 添加' });
  50. }
  51. response.json({ code: 200, success: true, data: { speakerId: used[0].speakerId } });
  52. return;
  53. }
  54. if (action === 'syncProfile') {
  55. const body = pickParam(request, 'profile', 'data') || request.body || {};
  56. const timbreId = body.timbre_id || body.timbreId || '';
  57. const speakerId = body.speaker_id || body.speakerId || '';
  58. if (!timbreId && !speakerId) {
  59. return response.json({ code: 400, success: false, error: '缺少 timbre_id 或 speaker_id' });
  60. }
  61. const now = new Date().toISOString();
  62. const merged = { ...body, timbre_id: timbreId, speaker_id: speakerId, updated_at: now };
  63. let existing = [];
  64. if (timbreId) {
  65. existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "timbreId" = $1 LIMIT 1`, [timbreId]);
  66. }
  67. if (!existing.length && speakerId) {
  68. existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "speakerId" = $1 LIMIT 1`, [speakerId]);
  69. }
  70. if (existing.length) {
  71. const old = typeof existing[0].data === 'string' ? JSON.parse(existing[0].data) : (existing[0].data || {});
  72. const merged2 = { ...old, ...merged };
  73. await Psql.query(
  74. `UPDATE "VoiceProfile" SET "timbreId"=$1, "speakerId"=$2, "name"=$3, "data"=$4, "occupied"=$5, "updatedAt"=NOW() WHERE "objectId"=$6`,
  75. [timbreId, speakerId, body.name || merged2.name || '', JSON.stringify(merged2), body.occupied !== false, existing[0].objectId]
  76. );
  77. response.json({ code: 200, success: true, data: { ...merged2, objectId: existing[0].objectId } });
  78. } else {
  79. merged.created_at = now;
  80. const objectId = generateId();
  81. await Psql.query(
  82. `INSERT INTO "VoiceProfile" ("objectId","timbreId","speakerId","name","data","userId","occupied")
  83. VALUES ($1,$2,$3,$4,$5,$6,$7)`,
  84. [objectId, timbreId, speakerId, body.name || '', JSON.stringify(merged), userId, body.occupied !== false]
  85. );
  86. response.json({ code: 200, success: true, data: { ...merged, objectId } });
  87. }
  88. return;
  89. }
  90. if (action === 'listProfiles') {
  91. const conds = [], params = [];
  92. if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
  93. const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
  94. const rows = await Psql.query(
  95. `SELECT * FROM "VoiceProfile" ${where} ORDER BY "createdAt" DESC LIMIT 1000`,
  96. params
  97. );
  98. response.json({ code: 200, success: true, data: rows.map(rowToObj) });
  99. return;
  100. }
  101. if (action === 'initPool') {
  102. const ids = pickParam(request, 'speakerIds') || [];
  103. const list = Array.isArray(ids) ? ids : String(ids || '').split(/[\s,]+/).filter(Boolean);
  104. if (!list.length) {
  105. return response.json({ code: 400, success: false, error: '缺少 speakerIds(数组或逗号分隔字符串)' });
  106. }
  107. let inserted = 0;
  108. for (const sid of list) {
  109. const r = await Psql.query(
  110. `INSERT INTO "VoiceSpeakerPool" ("speakerId") VALUES ($1) ON CONFLICT DO NOTHING RETURNING "speakerId"`,
  111. [String(sid).trim()]
  112. );
  113. if (r.length) inserted++;
  114. }
  115. response.json({ code: 200, success: true, data: { total: list.length, inserted } });
  116. return;
  117. }
  118. if (action === 'deleteProfile') {
  119. const objectId = pickParam(request, 'objectId');
  120. const timbreId = pickParam(request, 'timbreId');
  121. if (!objectId && !timbreId) {
  122. return response.json({ code: 400, success: false, error: '缺少 objectId 或 timbreId' });
  123. }
  124. const r = objectId
  125. ? await Psql.query(`DELETE FROM "VoiceProfile" WHERE "objectId" = $1 RETURNING "objectId"`, [objectId])
  126. : await Psql.query(`DELETE FROM "VoiceProfile" WHERE "timbreId" = $1 RETURNING "objectId"`, [timbreId]);
  127. response.json({ code: 200, success: true, data: { deleted: r.length > 0 } });
  128. return;
  129. }
  130. response.json({ code: 400, success: false, error: `未知 action: ${action}` });
  131. } catch (error) {
  132. console.error('❌ voiceManager 失败:', error.message);
  133. response.json({ code: 500, success: false, error: error.message });
  134. }
  135. }
  136. function rowToObj(row) {
  137. const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {});
  138. return { ...data, objectId: row.objectId, occupied: row.occupied, createdAt: row.createdAt, updatedAt: row.updatedAt };
  139. }
  140. function pickParam(request, ...names) {
  141. const sources = [request.params, request.body, request];
  142. for (const src of sources) {
  143. if (!src || typeof src !== 'object') continue;
  144. for (const n of names) {
  145. const v = src[n];
  146. if (v !== undefined && v !== null && v !== '') return v;
  147. }
  148. }
  149. return null;
  150. }
  151. function generateId() {
  152. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  153. let s = '';
  154. for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
  155. return s;
  156. }