/** * 云函数:voiceManager(音色档案 + speaker_id 池) * 替代: * POST /api/voice/auto-speaker-id → action=autoSpeakerId * POST /api/voice-profiles/sync → action=syncProfile * 新增: * action=listProfiles 列出所有音色档案 * action=initPool 初始化 speaker_id 池(首次部署用) * * 注意:speaker_id 池原本在 docs/音色创建/speaker_id.md 文件里。 * 迁移到云函数后改为存储在 "VoiceSpeakerPool" 表。 * 首次部署后请用 action=initPool 把现有池一次性灌入。 */ const VOICE_TOKEN = readEnv('VOICE_TOKEN') || 'Bearer r:f0333969e312a40e4703e8fe4ed1c600'; const VOICE_TTS_BASE_URL = readEnv('VOICE_TTS_BASE_URL') || 'https://server.fmode.cn/api/volcengine/tts'; async function handler(request, response) { try { await Psql.query(` CREATE TABLE IF NOT EXISTS "VoiceProfile" ( "objectId" VARCHAR(50) PRIMARY KEY, "timbreId" VARCHAR(255), "speakerId" VARCHAR(255), "name" VARCHAR(255) DEFAULT '', "data" JSONB NOT NULL DEFAULT '{}', "userId" VARCHAR(255) DEFAULT '', "occupied" BOOLEAN DEFAULT TRUE, "createdAt" TIMESTAMPTZ DEFAULT NOW(), "updatedAt" TIMESTAMPTZ DEFAULT NOW() ) `); await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_voice_timbre ON "VoiceProfile" ("timbreId") WHERE "timbreId" IS NOT NULL AND "timbreId" <> ''`); await Psql.query(`CREATE INDEX IF NOT EXISTS idx_voice_speaker ON "VoiceProfile" ("speakerId")`); await Psql.query(` CREATE TABLE IF NOT EXISTS "VoiceSpeakerPool" ( "speakerId" VARCHAR(255) PRIMARY KEY, "trained" BOOLEAN DEFAULT FALSE, "createdAt" TIMESTAMPTZ DEFAULT NOW() ) `); const action = pickParam(request, 'action') || 'listProfiles'; const userId = pickParam(request, 'userId') || ''; if (action === 'autoSpeakerId') { const used = await Psql.query(` SELECT s."speakerId" FROM "VoiceSpeakerPool" s LEFT JOIN "VoiceProfile" v ON v."speakerId" = s."speakerId" WHERE v."speakerId" IS NULL AND s."trained" = FALSE ORDER BY s."createdAt" ASC LIMIT 1 `); if (!used.length) { return response.json({ code: 500, success: false, error: 'speaker_id 池已耗尽,请先 initPool 添加' }); } response.json({ code: 200, success: true, data: { speakerId: used[0].speakerId } }); return; } if (action === 'syncProfile') { const body = pickParam(request, 'profile', 'data') || request.body || {}; const timbreId = body.timbre_id || body.timbreId || ''; const speakerId = body.speaker_id || body.speakerId || ''; if (!timbreId && !speakerId) { return response.json({ code: 400, success: false, error: '缺少 timbre_id 或 speaker_id' }); } const now = new Date().toISOString(); const merged = { ...body, timbre_id: timbreId, speaker_id: speakerId, updated_at: now }; let existing = []; if (timbreId) { existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "timbreId" = $1 LIMIT 1`, [timbreId]); } if (!existing.length && speakerId) { existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "speakerId" = $1 LIMIT 1`, [speakerId]); } if (existing.length) { const old = typeof existing[0].data === 'string' ? JSON.parse(existing[0].data) : (existing[0].data || {}); const merged2 = { ...old, ...merged }; await Psql.query( `UPDATE "VoiceProfile" SET "timbreId"=$1, "speakerId"=$2, "name"=$3, "data"=$4, "occupied"=$5, "updatedAt"=NOW() WHERE "objectId"=$6`, [timbreId, speakerId, body.name || merged2.name || '', JSON.stringify(merged2), body.occupied !== false, existing[0].objectId] ); response.json({ code: 200, success: true, data: { ...merged2, objectId: existing[0].objectId } }); } else { merged.created_at = now; const objectId = generateId(); await Psql.query( `INSERT INTO "VoiceProfile" ("objectId","timbreId","speakerId","name","data","userId","occupied") VALUES ($1,$2,$3,$4,$5,$6,$7)`, [objectId, timbreId, speakerId, body.name || '', JSON.stringify(merged), userId, body.occupied !== false] ); response.json({ code: 200, success: true, data: { ...merged, objectId } }); } return; } if (action === 'listProfiles') { const conds = [], params = []; if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); } const where = conds.length ? `WHERE ${conds.join(' AND ')}` : ''; const rows = await Psql.query( `SELECT * FROM "VoiceProfile" ${where} ORDER BY "createdAt" DESC LIMIT 1000`, params ); response.json({ code: 200, success: true, data: rows.map(rowToObj) }); return; } if (action === 'initPool') { const ids = pickParam(request, 'speakerIds') || []; const list = Array.isArray(ids) ? ids : String(ids || '').split(/[\s,]+/).filter(Boolean); if (!list.length) { return response.json({ code: 400, success: false, error: '缺少 speakerIds(数组或逗号分隔字符串)' }); } let inserted = 0; for (const sid of list) { const r = await Psql.query( `INSERT INTO "VoiceSpeakerPool" ("speakerId") VALUES ($1) ON CONFLICT DO NOTHING RETURNING "speakerId"`, [String(sid).trim()] ); if (r.length) inserted++; } response.json({ code: 200, success: true, data: { total: list.length, inserted } }); return; } if (action === 'synthesize') { const payload = pickParam(request, 'payload', 'data') || {}; if (!payload.text && !payload.ssml) { return response.json({ code: 400, success: false, error: '缺少合成文本' }); } const upstreamPayload = stripEmpty({ ...payload, token: VOICE_TOKEN, isStream: payload.isStream === true, }); const res = await postJson(`${VOICE_TTS_BASE_URL}/unidirectional`, upstreamPayload); response.json(res); return; } if (action === 'cloneVoice') { const payload = pickParam(request, 'payload', 'data') || {}; if (!payload.name || !payload.speaker_id || !payload.audioData) { return response.json({ code: 400, success: false, error: '缺少声音训练参数' }); } const upstreamPayload = stripEmpty({ ...payload, token: VOICE_TOKEN, }); const res = await postJson(`${VOICE_TTS_BASE_URL}/voice_clone`, upstreamPayload); response.json(res); return; } if (action === 'deleteProfile') { const objectId = pickParam(request, 'objectId'); const timbreId = pickParam(request, 'timbreId'); if (!objectId && !timbreId) { return response.json({ code: 400, success: false, error: '缺少 objectId 或 timbreId' }); } const r = objectId ? await Psql.query(`DELETE FROM "VoiceProfile" WHERE "objectId" = $1 RETURNING "objectId"`, [objectId]) : await Psql.query(`DELETE FROM "VoiceProfile" WHERE "timbreId" = $1 RETURNING "objectId"`, [timbreId]); response.json({ code: 200, success: true, data: { deleted: r.length > 0 } }); return; } response.json({ code: 400, success: false, error: `未知 action: ${action}` }); } catch (error) { console.error('❌ voiceManager 失败:', error.message); response.json({ code: 500, success: false, error: error.message }); } } function rowToObj(row) { const data = typeof row.data === 'string' ? JSON.parse(row.data) : (row.data || {}); return { ...data, objectId: row.objectId, occupied: row.occupied, createdAt: row.createdAt, updatedAt: row.updatedAt }; } function pickParam(request, ...names) { const sources = [request.params, request.body, request]; for (const src of sources) { if (!src || typeof src !== 'object') continue; for (const n of names) { const v = src[n]; if (v !== undefined && v !== null && v !== '') return v; } } return null; } function generateId() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; let s = ''; for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length)); return s; } function stripEmpty(value) { if (!value || typeof value !== 'object') return value; const out = Array.isArray(value) ? [] : {}; for (const [key, val] of Object.entries(value)) { if (val === undefined || val === null || val === '') continue; if (val && typeof val === 'object' && !Array.isArray(val)) { const nested = stripEmpty(val); if (Object.keys(nested).length) out[key] = nested; } else { out[key] = val; } } return out; } async function postJson(url, body) { const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const text = await r.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch {} if (!r.ok) { return { code: r.status, success: false, error: readError(data, text) || `HTTP ${r.status}` }; } return data || { code: 500, success: false, error: '服务返回异常' }; } function readError(data, fallback) { return data?.error?.message || data?.error || data?.message || data?.msg || fallback || ''; } function readEnv(name) { if (typeof process !== 'undefined' && process.env && process.env[name]) { return process.env[name]; } return ''; }