www_site/services/mock-tts/server.mjs

196 lines
9.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 本地演示用 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;
}
// 以字符与音色做种子生成 160320Hz 的基频,让不同文本/音色听感有区分
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}`);
});