This commit is contained in:
fengchuanhn@gmail.com
2026-05-18 14:12:59 +08:00
parent 1b800653ef
commit 09f37e3206
2 changed files with 212 additions and 30 deletions

View File

@@ -294,9 +294,109 @@ function resolveTtsModel(voiceId) {
return "qwen3-tts-flash";
}
function splitTextForTts(text, maxLen = 300) {
const t = String(text || "").trim();
if (t.length <= maxLen) return [t];
const segments = [];
let remaining = t;
while (remaining.length > 0) {
if (remaining.length <= maxLen) {
segments.push(remaining.trim());
break;
}
let splitIdx = -1;
for (const sep of ["。", "", "", ".", "!", "?"]) {
const idx = remaining.lastIndexOf(sep, maxLen);
if (idx > maxLen * 0.3) {
splitIdx = idx + 1;
break;
}
}
if (splitIdx === -1) {
for (const sep of ["", ",", "、", "", ":"]) {
const idx = remaining.lastIndexOf(sep, maxLen);
if (idx > maxLen * 0.3) {
splitIdx = idx + 1;
break;
}
}
}
if (splitIdx === -1) splitIdx = maxLen;
segments.push(remaining.slice(0, splitIdx).trim());
remaining = remaining.slice(splitIdx).trim();
}
return segments.filter((s) => s.length > 0);
}
function concatWavFiles(inputPaths, outputPath) {
if (inputPaths.length === 0) throw new Error("无输入文件");
if (inputPaths.length === 1) {
fs.copyFileSync(inputPaths[0], outputPath);
return;
}
const firstFile = fs.readFileSync(inputPaths[0]);
const headerSize = 44;
const header = Buffer.from(firstFile.buffer, 0, headerSize);
const pcmBuffers = [];
let totalPcmSize = 0;
for (const p of inputPaths) {
const buf = fs.readFileSync(p);
const pcm = buf.slice(headerSize);
pcmBuffers.push(pcm);
totalPcmSize += pcm.length;
}
const newHeader = Buffer.from(header);
newHeader.writeUInt32LE(totalPcmSize + headerSize - 8, 4);
newHeader.writeUInt32LE(totalPcmSize, 40);
const outputBuf = Buffer.concat([newHeader, ...pcmBuffers]);
fs.writeFileSync(outputPath, outputBuf);
}
/**
* 通义千问 TTS 合成(对齐 Electron synthesizeOneSegment
* @param {{ apiKey: string, text: string, voice: string, model?: string, instruction?: string, outputPath: string }} opts
* 通义千问 TTS 单段合成(对齐 Electron synthesizeOneSegment
*/
async function qwenTtsSynthesizeOneSegment(text, config, outputPath) {
const apiKey = config.apiKey;
const voice = String(config.voice || "").trim();
const model = config.model || resolveTtsModel(voice);
const instruction = String(config.instruction || "").trim();
const languageType = config.languageType;
const input = {
text: String(text || "").trim(),
voice,
...(languageType ? { language_type: languageType } : {}),
...(instruction
? { instructions: instruction, optimize_instructions: true }
: {}),
};
const data = await fetchJson(QWEN_TTS_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model, input }),
});
const audioUrl = data?.output?.audio?.url;
if (!audioUrl) {
return {
success: false,
error: data?.message || data?.code || "未获取到音频 URL",
};
}
const buf = await fetchArrayBuffer(audioUrl);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, buf);
return { success: true, audioPath: outputPath };
}
/**
* 通义千问 TTS 合成(支持长文本分段,对齐 Electron synthesizeSpeech
* @param {{ apiKey: string, text: string, voice: string, model?: string, instruction?: string, languageType?: string, outputPath: string }} opts
*/
async function qwenTtsSynthesize(opts) {
const apiKey = opts.apiKey;
@@ -305,43 +405,72 @@ async function qwenTtsSynthesize(opts) {
const outputPath = opts.outputPath;
const model = opts.model || resolveTtsModel(voice);
const instruction = String(opts.instruction || "").trim();
const languageType = opts.languageType;
if (!apiKey) return { success: false, error: "缺少 DashScope apiKey" };
if (!text) return { success: false, error: "缺少合成文本" };
if (!voice) return { success: false, error: "缺少 voice" };
if (!outputPath) return { success: false, error: "缺少 outputPath" };
try {
const data = await fetchJson(QWEN_TTS_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
input: {
text,
voice,
...(instruction
? { instructions: instruction, optimize_instructions: true }
: {}),
},
}),
});
const instrLen = instruction.length;
const isVcModel = model.includes("-vc") || model.includes("_vc");
const baseMaxLen = isVcModel ? 200 : 300;
const effectiveMaxLen = Math.max(100, baseMaxLen - instrLen);
const segments = splitTextForTts(text, effectiveMaxLen);
const audioUrl = data?.output?.audio?.url;
if (!audioUrl) {
return {
success: false,
error: data?.message || data?.code || "未获取到音频 URL",
};
const config = { apiKey, voice, model, instruction, languageType };
try {
if (segments.length === 1) {
const r = await qwenTtsSynthesizeOneSegment(segments[0], config, outputPath);
if (!r.success) return r;
log("info", "qwen tts saved", { outputPath, voice, model });
return { success: true, audioPath: outputPath };
}
const buf = await fetchArrayBuffer(audioUrl);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, buf);
log("info", "qwen tts saved", { outputPath, voice, model });
const parsed = path.parse(outputPath);
const ext = parsed.ext || ".wav";
const segmentPaths = [];
for (let i = 0; i < segments.length; i++) {
const segPath = path.join(
parsed.dir,
`${parsed.name}_seg${i}${ext}`,
);
log("info", "qwen tts segment", { i: i + 1, total: segments.length, len: segments[i].length });
const segResult = await qwenTtsSynthesizeOneSegment(segments[i], config, segPath);
if (!segResult.success) return segResult;
segmentPaths.push(segPath);
}
const outExt = ext.toLowerCase();
if (outExt === ".wav") {
concatWavFiles(segmentPaths, outputPath);
} else {
const wavOut = path.join(parsed.dir, `${parsed.name}_merged.wav`);
concatWavFiles(segmentPaths, wavOut);
try {
await runCommand(exeName(), [
"-y",
"-i",
wavOut,
"-c:a",
"libmp3lame",
"-b:a",
"128k",
outputPath,
]);
deleteFile(wavOut);
} catch (e) {
fs.copyFileSync(wavOut, outputPath.replace(/\.mp3$/i, ".wav"));
return {
success: false,
error: `分段合成成功但转 mp3 失败: ${e.message}(已保存 wav`,
};
}
}
for (const p of segmentPaths) deleteFile(p);
log("info", "qwen tts merged", { outputPath, segments: segments.length });
return { success: true, audioPath: outputPath };
} catch (e) {
return { success: false, error: e.message || String(e) };
@@ -529,5 +658,6 @@ module.exports = {
localAsr,
openaiChat,
resolveTtsModel,
splitTextForTts,
qwenTtsSynthesize,
};

View File

@@ -0,0 +1,52 @@
"use strict";
/**
* 文案转语音(对齐 Electron generateAudio / CosyVoice
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const pipelineNative = require("./pipeline_native.js");
const desktopConfig = require("./desktop_config.js");
const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-generated-speech");
globalThis.__nodejsMain = async function (params) {
const p = params || {};
const apiKey =
desktopConfig.get("BAILIAN_API_KEY") ||
desktopConfig.get("DASHSCOPE_API_KEY");
if (!apiKey) {
return {
success: false,
error: "缺少 BAILIAN_API_KEY或 DASHSCOPE_API_KEY请在配置中设置",
};
}
const text = String(p.text || "").trim();
if (!text) {
return { success: false, error: "请先在「视频文案编辑」中填写文案内容" };
}
const voice = String(p.voice || "").trim();
if (!voice) {
return { success: false, error: "请先选择音色" };
}
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const ext = String(p.format || "mp3").toLowerCase() === "wav" ? ".wav" : ".mp3";
const outputPath =
p.outputPath ||
path.join(OUTPUT_DIR, `speech_${Date.now()}${ext}`);
return pipelineNative.qwenTtsSynthesize({
apiKey,
text,
voice,
model: p.model,
instruction: p.instruction,
languageType: p.languageType,
outputPath,
});
};