| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400 |
- /**
- * 云函数: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';
- const VOICE_MAX_ATTEMPTS = Math.max(1, Number(readEnv('VOICE_MAX_ATTEMPTS') || 3));
- 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 = normalizeSynthesisPayload(payload);
- 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 normalizeSynthesisPayload(input) {
- const payload = { ...(input || {}) };
- const officialVoiceType = String(payload.volcengine_voice_type || payload.voiceType || payload.voice_type || '').trim();
- if (officialVoiceType && !payload.speaker_id && !payload.speakerId && !payload.timbreId && !payload.timbre_id) {
- payload.speaker_id = officialVoiceType;
- }
- if (payload.timbre_id && !payload.timbreId) {
- payload.timbreId = payload.timbre_id;
- }
- return stripEmpty({
- ...payload,
- token: VOICE_TOKEN,
- isStream: payload.isStream === true,
- });
- }
- 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) {
- let lastError = null;
- for (let attempt = 1; attempt <= VOICE_MAX_ATTEMPTS; attempt += 1) {
- try {
- const { status, ok, rawText, data, transport } = await requestJson('POST', url, {
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
- body: JSON.stringify(body),
- });
- const code = Number(data && data.code ? data.code : status || 0);
- const retryable = !ok || code === 408 || code === 429 || code >= 500;
- if (!retryable || attempt >= VOICE_MAX_ATTEMPTS) {
- if (!ok) {
- return {
- code: status || code || 500,
- success: false,
- error: readError(data, rawText) || `HTTP ${status}`,
- upstream: { transport, attempt, maxAttempts: VOICE_MAX_ATTEMPTS },
- };
- }
- return data || { code: 500, success: false, error: '服务返回异常' };
- }
- } catch (error) {
- lastError = error;
- if (attempt >= VOICE_MAX_ATTEMPTS) break;
- }
- await sleep(Math.min(12000, 1200 * attempt * attempt));
- }
- return {
- code: 500,
- success: false,
- error: `TTS upstream request failed: ${formatFetchError(lastError)}`,
- };
- }
- async function requestJson(method, url, init) {
- let lastError = null;
- if (typeof fetch === 'function') {
- try {
- const response = await fetch(url, { method, ...(init || {}) });
- return readResponse(response, 'fetch');
- } catch (error) {
- lastError = error;
- }
- }
- if (typeof require === 'function') {
- try {
- return await requestJsonWithNodeHttps(method, url, init);
- } catch (error) {
- lastError = error;
- }
- }
- if (typeof XMLHttpRequest !== 'undefined') {
- try {
- return await requestJsonWithXhr(method, url, init);
- } catch (error) {
- lastError = error;
- }
- }
- throw lastError || new Error('No HTTP client is available in this cloud runtime');
- }
- async function readResponse(response, transport) {
- const rawText = await response.text();
- let data = null;
- try { data = rawText ? JSON.parse(rawText) : null; } catch {}
- return {
- status: response.status,
- ok: !!response.ok,
- rawText,
- data,
- transport,
- };
- }
- function requestJsonWithNodeHttps(method, url, init) {
- return new Promise((resolve, reject) => {
- try {
- const parsed = new URL(url);
- const lib = parsed.protocol === 'http:' ? require('http') : require('https');
- const headers = init && init.headers ? init.headers : {};
- const body = init && init.body ? init.body : '';
- const req = lib.request({
- method,
- protocol: parsed.protocol,
- hostname: parsed.hostname,
- port: parsed.port,
- path: `${parsed.pathname}${parsed.search}`,
- headers: body ? { ...headers, 'Content-Length': Buffer.byteLength(body) } : headers,
- }, (res) => {
- const chunks = [];
- res.on('data', (chunk) => chunks.push(chunk));
- res.on('end', () => {
- const rawText = Buffer.concat(chunks).toString('utf8');
- let data = null;
- try { data = rawText ? JSON.parse(rawText) : null; } catch {}
- resolve({
- status: res.statusCode || 0,
- ok: res.statusCode >= 200 && res.statusCode < 300,
- rawText,
- data,
- transport: 'node-https',
- });
- });
- });
- req.on('error', reject);
- if (body) req.write(body);
- req.end();
- } catch (error) {
- reject(error);
- }
- });
- }
- function requestJsonWithXhr(method, url, init) {
- return new Promise((resolve, reject) => {
- const xhr = new XMLHttpRequest();
- xhr.open(method, url, true);
- const headers = init && init.headers ? init.headers : {};
- for (const [key, value] of Object.entries(headers)) {
- xhr.setRequestHeader(key, value);
- }
- xhr.onreadystatechange = function onReadyStateChange() {
- if (xhr.readyState !== 4) return;
- let data = null;
- try { data = xhr.responseText ? JSON.parse(xhr.responseText) : null; } catch {}
- resolve({
- status: xhr.status,
- ok: xhr.status >= 200 && xhr.status < 300,
- rawText: xhr.responseText || '',
- data,
- transport: 'xhr',
- });
- };
- xhr.onerror = function onXhrError() {
- reject(new Error('XMLHttpRequest failed'));
- };
- xhr.send(init && init.body ? init.body : null);
- });
- }
- function readError(data, fallback) {
- return data?.error?.message || data?.error || data?.message || data?.msg || fallback || '';
- }
- function formatFetchError(error) {
- if (!error) return 'unknown error';
- const message = error.message || String(error);
- const cause = error.cause ? `; cause=${error.cause.code || error.cause.message || error.cause}` : '';
- return `${message}${cause}`;
- }
- function sleep(ms) {
- return new Promise((resolve) => setTimeout(resolve, ms));
- }
- function readEnv(name) {
- if (typeof process !== 'undefined' && process.env && process.env[name]) {
- return process.env[name];
- }
- return '';
- }
|