196 lines
9.3 KiB
JavaScript
196 lines
9.3 KiB
JavaScript
|
|
// 本地演示用 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 合成:按字符生成短音,<pause:ms> 生成静音。仅作演示,非真实语音。
|
|||
|
|
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(/(<pause:\d+>)/)) {
|
|||
|
|
const pause = part.match(/^<pause:(\d+)>$/);
|
|||
|
|
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}`);
|
|||
|
|
});
|