11
This commit is contained in:
@@ -294,9 +294,109 @@ function resolveTtsModel(voiceId) {
|
|||||||
return "qwen3-tts-flash";
|
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)
|
* 通义千问 TTS 单段合成(对齐 Electron synthesizeOneSegment)
|
||||||
* @param {{ apiKey: string, text: string, voice: string, model?: string, instruction?: string, outputPath: string }} opts
|
*/
|
||||||
|
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) {
|
async function qwenTtsSynthesize(opts) {
|
||||||
const apiKey = opts.apiKey;
|
const apiKey = opts.apiKey;
|
||||||
@@ -305,43 +405,72 @@ async function qwenTtsSynthesize(opts) {
|
|||||||
const outputPath = opts.outputPath;
|
const outputPath = opts.outputPath;
|
||||||
const model = opts.model || resolveTtsModel(voice);
|
const model = opts.model || resolveTtsModel(voice);
|
||||||
const instruction = String(opts.instruction || "").trim();
|
const instruction = String(opts.instruction || "").trim();
|
||||||
|
const languageType = opts.languageType;
|
||||||
|
|
||||||
if (!apiKey) return { success: false, error: "缺少 DashScope apiKey" };
|
if (!apiKey) return { success: false, error: "缺少 DashScope apiKey" };
|
||||||
if (!text) return { success: false, error: "缺少合成文本" };
|
if (!text) return { success: false, error: "缺少合成文本" };
|
||||||
if (!voice) return { success: false, error: "缺少 voice" };
|
if (!voice) return { success: false, error: "缺少 voice" };
|
||||||
if (!outputPath) return { success: false, error: "缺少 outputPath" };
|
if (!outputPath) return { success: false, error: "缺少 outputPath" };
|
||||||
|
|
||||||
try {
|
const instrLen = instruction.length;
|
||||||
const data = await fetchJson(QWEN_TTS_URL, {
|
const isVcModel = model.includes("-vc") || model.includes("_vc");
|
||||||
method: "POST",
|
const baseMaxLen = isVcModel ? 200 : 300;
|
||||||
headers: {
|
const effectiveMaxLen = Math.max(100, baseMaxLen - instrLen);
|
||||||
Authorization: `Bearer ${apiKey}`,
|
const segments = splitTextForTts(text, effectiveMaxLen);
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model,
|
|
||||||
input: {
|
|
||||||
text,
|
|
||||||
voice,
|
|
||||||
...(instruction
|
|
||||||
? { instructions: instruction, optimize_instructions: true }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const audioUrl = data?.output?.audio?.url;
|
const config = { apiKey, voice, model, instruction, languageType };
|
||||||
if (!audioUrl) {
|
|
||||||
return {
|
try {
|
||||||
success: false,
|
if (segments.length === 1) {
|
||||||
error: data?.message || data?.code || "未获取到音频 URL",
|
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);
|
const parsed = path.parse(outputPath);
|
||||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
const ext = parsed.ext || ".wav";
|
||||||
fs.writeFileSync(outputPath, buf);
|
const segmentPaths = [];
|
||||||
log("info", "qwen tts saved", { outputPath, voice, model });
|
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 };
|
return { success: true, audioPath: outputPath };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { success: false, error: e.message || String(e) };
|
return { success: false, error: e.message || String(e) };
|
||||||
@@ -529,5 +658,6 @@ module.exports = {
|
|||||||
localAsr,
|
localAsr,
|
||||||
openaiChat,
|
openaiChat,
|
||||||
resolveTtsModel,
|
resolveTtsModel,
|
||||||
|
splitTextForTts,
|
||||||
qwenTtsSynthesize,
|
qwenTtsSynthesize,
|
||||||
};
|
};
|
||||||
|
|||||||
52
scripts/nodejs/voice_generate.js
Normal file
52
scripts/nodejs/voice_generate.js
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user