From 09f37e32060ad1bc914657cd16180201814b1fce Mon Sep 17 00:00:00 2001 From: "fengchuanhn@gmail.com" Date: Mon, 18 May 2026 14:12:59 +0800 Subject: [PATCH] 11 --- scripts/nodejs/pipeline_native.js | 190 +++++++++++++++++++++++++----- scripts/nodejs/voice_generate.js | 52 ++++++++ 2 files changed, 212 insertions(+), 30 deletions(-) create mode 100644 scripts/nodejs/voice_generate.js diff --git a/scripts/nodejs/pipeline_native.js b/scripts/nodejs/pipeline_native.js index 12195e4..80c722f 100644 --- a/scripts/nodejs/pipeline_native.js +++ b/scripts/nodejs/pipeline_native.js @@ -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, }; diff --git a/scripts/nodejs/voice_generate.js b/scripts/nodejs/voice_generate.js new file mode 100644 index 0000000..66217dc --- /dev/null +++ b/scripts/nodejs/voice_generate.js @@ -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, + }); +};