// 本地演示用 mock TTS 服务:实现 AI Lab 页面依赖的 /v1/audio/* 接口。 // 启动:node services/mock-tts/server.mjs(配合 .env.local 里 TTS_MOCK_UPSTREAM=http://127.0.0.1:8100) // 真实上游为外部 TTS 引擎,需要服务端凭据;此服务仅用于无引擎环境下演示与联调。 import { createServer } from "node:http"; const PORT = Number(process.env.MOCK_TTS_PORT ?? 8100); const SAMPLE_RATE = 22050; const voices = [ { id: "xiaoman", name: "小满", desc: "温暖亲和的女声,适合品牌宣传与产品介绍。", category: "通用女声", gender: "女声", genre: "温和", accent: "普通话", style: "亲切", styles: ["亲切", "自然"], preview: "/v1/previews/xiaoman.wav", range: [240, 400], }, { id: "qingluo", name: "青落", desc: "清亮利落的女声,适合新闻播报与知识讲解。", category: "通用女声", gender: "女声", genre: "专业", accent: "普通话", style: "专业", styles: ["专业", "清晰"], preview: "/v1/previews/qingluo.wav", range: [260, 430], }, { id: "tianxin", name: "甜芯", desc: "甜美活泼的女声,适合促销活动与社媒内容。", category: "通用女声", gender: "女声", genre: "甜美", accent: "台湾腔", style: "活泼", styles: ["活泼", "甜美"], preview: "/v1/previews/tianxin.wav", range: [280, 460], }, { id: "mufan", name: "沐帆", desc: "低沉沉稳的男声,适合纪录片旁白与高端品牌。", category: "通用男声", gender: "男声", genre: "磁性", accent: "普通话", style: "沉稳", styles: ["沉稳", "磁性"], preview: "/v1/previews/mufan.wav", range: [110, 200], }, { id: "haoran", name: "浩然", desc: "爽朗有力的男声,适合广告口号与活动主持。", category: "通用男声", gender: "男声", genre: "活力", accent: "普通话", style: "有力", styles: ["有力", "活力"], preview: "/v1/previews/haoran.wav", range: [130, 230], }, { id: "awan", name: "阿万", desc: "带粤语口音的男声,适合大湾区本地化内容。", category: "通用男声", gender: "男声", genre: "温和", accent: "粤语", style: "自然", styles: ["自然"], preview: "/v1/previews/awan.wav", range: [120, 210], }, { id: "doudou", name: "豆豆", desc: "元气满满的童声,适合儿歌故事与亲子内容。", category: "童声", gender: "童声", genre: "活泼", accent: "普通话", style: "可爱", styles: ["可爱", "活泼"], preview: "/v1/previews/doudou.wav", range: [300, 480], }, { id: "dongbei-ge", name: "东北哥", desc: "幽默豪爽的东北男声,适合短视频与段子配音。", category: "情景演绎", gender: "男声", genre: "活力", accent: "东北腔", style: "幽默", styles: ["幽默", "豪爽"], preview: "/v1/previews/dongbei-ge.wav", range: [115, 205], }, ]; const facets = { gender: [...new Set(voices.map((v) => v.gender))], genre: [...new Set(voices.map((v) => v.genre))], accent: [...new Set(voices.map((v) => v.accent))], style: [...new Set(voices.map((v) => v.style))], }; function json(res, status, body) { res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Access-Control-Allow-Origin": "*" }); res.end(JSON.stringify(body)); } // 极简 WAV 合成:按字符生成短音, 生成静音。仅作演示,非真实语音。 function synthesizeWav(text, voiceId, speed) { const voice = voices.find((v) => v.id === voiceId) ?? voices[0]; const rate = Math.min(4, Math.max(0.25, Number(speed) || 1)); const segments = []; for (const part of String(text ?? "").split(/()/)) { const pause = part.match(/^$/); if (pause) segments.push({ pauseMs: Math.min(10000, Math.max(50, Number(pause[1]))) }); else if (part) segments.push({ chars: [...part] }); } const samples = []; for (const segment of segments) { if (segment.pauseMs) { const silence = Math.floor((SAMPLE_RATE * segment.pauseMs) / 1000 / rate); samples.push(...new Array(silence).fill(0)); continue; } for (const [index, ch] of segment.chars.entries()) { if (/\s/.test(ch)) { samples.push(...new Array(Math.floor(SAMPLE_RATE * 0.06 / rate)).fill(0)); continue; } // 以字符与音色做种子生成 160–320Hz 的基频,让不同文本/音色听感有区分 const seed = (ch.codePointAt(0) * 31 + voice.id.charCodeAt(0) * 7) % 1000 / 1000; const freq = voice.range[0] + seed * (voice.range[1] - voice.range[0]); const duration = 0.13 + ((index % 3) * 0.02); const count = Math.floor((SAMPLE_RATE * duration) / rate); for (let i = 0; i < count; i += 1) { const t = i / SAMPLE_RATE; const envelope = Math.min(1, i / (count * 0.15), (count - i) / (count * 0.25)); const value = Math.sin(2 * Math.PI * freq * t) * 0.55 + Math.sin(2 * Math.PI * freq * 2 * t) * 0.18; samples.push(Math.round(Math.max(-1, Math.min(1, value * envelope)) * 32000)); } samples.push(...new Array(Math.floor(SAMPLE_RATE * 0.025 / rate)).fill(0)); } } const capped = samples.slice(0, SAMPLE_RATE * 30); const buffer = Buffer.alloc(44 + capped.length * 2); buffer.write("RIFF", 0); buffer.writeUInt32LE(36 + capped.length * 2, 4); buffer.write("WAVE", 8); buffer.write("fmt ", 12); buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(1, 20); // PCM buffer.writeUInt16LE(1, 22); // mono buffer.writeUInt32LE(SAMPLE_RATE, 24); buffer.writeUInt32LE(SAMPLE_RATE * 2, 28); buffer.writeUInt16LE(2, 32); buffer.writeUInt16LE(16, 34); buffer.write("data", 36); buffer.writeUInt32LE(capped.length * 2, 40); capped.forEach((value, i) => buffer.writeInt16LE(value, 44 + i * 2)); return buffer; } function voiceById(id) { return voices.find((v) => v.id === id); } const server = createServer((req, res) => { const url = new URL(req.url, `http://127.0.0.1:${PORT}`); if (req.method === "OPTIONS") { res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }); return res.end(); } if (req.method === "GET" && url.pathname === "/v1/audio/config") { return json(res, 200, { model: "qwen3-tts-mock", formats: ["wav"], default_format: "wav", speed: { min: 0.25, max: 4, default: 1 }, pause: { supported: true, min_ms: 50, max_ms: 10000, presets: [{ ms: 500, label: "0.5s" }, { ms: 1000, label: "1s" }, { ms: 2000, label: "2s" }], }, }); } if (req.method === "GET" && url.pathname === "/v1/audio/voices") { return json(res, 200, { categories: [...new Set(voices.map((v) => v.category))], facets, voices: voices.map(({ range, ...voice }) => voice), }); } const preview = req.method === "GET" && url.pathname.match(/^\/v1\/previews\/([\w-]+)\.wav$/); if (preview) { const voice = voiceById(preview[1]); if (!voice) return json(res, 404, { error: { message: "音色不存在" } }); const audio = synthesizeWav(`你好,我是${voice.name}。`, voice.id, 1); res.writeHead(200, { "Content-Type": "audio/wav", "Content-Length": audio.length, "Access-Control-Allow-Origin": "*" }); return res.end(audio); } if (req.method === "POST" && url.pathname === "/v1/audio/speech") { let raw = ""; req.on("data", (chunk) => { raw += chunk; if (raw.length > 1_000_000) req.destroy(); }); req.on("end", () => { let body = {}; try { body = JSON.parse(raw || "{}"); } catch { return json(res, 400, { error: { message: "请求体不是合法 JSON" } }); } if (!voiceById(body.voice)) return json(res, 400, { error: { message: "音色不存在或未提供" } }); if (!String(body.input ?? "").trim()) return json(res, 400, { error: { message: "文本不能为空" } }); const audio = synthesizeWav(body.input, body.voice, body.speed); res.writeHead(200, { "Content-Type": "audio/wav", "Content-Length": audio.length, "Access-Control-Allow-Origin": "*" }); res.end(audio); }); return; } if (req.method === "POST" && url.pathname === "/v1/audio/clone-speech") { // 真人声音配音 mock:校验文本与声音样本后返回演示音频(非真实克隆)。 let raw = ""; req.on("data", (chunk) => { raw += chunk; if (raw.length > 16_000_000) req.destroy(); }); req.on("end", () => { let body = {}; try { body = JSON.parse(raw || "{}"); } catch { return json(res, 400, { error: { message: "请求体不是合法 JSON" } }); } if (!String(body.input ?? "").trim()) return json(res, 400, { error: { message: "文本不能为空" } }); if (!String(body.reference_audio ?? "").startsWith("data:audio/")) return json(res, 400, { error: { message: "未提供声音样本 reference_audio" } }); const audio = synthesizeWav(body.input, "xiaoman", body.speed); res.writeHead(200, { "Content-Type": "audio/wav", "Content-Length": audio.length, "Access-Control-Allow-Origin": "*" }); res.end(audio); }); return; } if (req.method === "GET" && url.pathname === "/healthz") return json(res, 200, { status: "ok" }); json(res, 404, { error: { message: `mock-tts 未实现 ${req.method} ${url.pathname}` } }); }); server.listen(PORT, "127.0.0.1", () => { console.log(`[mock-tts] listening on http://127.0.0.1:${PORT}`); });