11
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user