From d9c4e04d662d4fc13156efa9a5e28144def4abb0 Mon Sep 17 00:00:00 2001 From: "949036910@qq.com" <> Date: Fri, 29 May 2026 23:16:52 +0800 Subject: [PATCH] 11 --- scripts/nodejs/pipeline_native.js | 131 ++++++++++++++++ scripts/nodejs/subtitle_bgm_generate.js | 194 ++++++++++++++++++++++-- scripts/nodejs/voice_clone_enroll.js | 32 ++++ 3 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 scripts/nodejs/voice_clone_enroll.js diff --git a/scripts/nodejs/pipeline_native.js b/scripts/nodejs/pipeline_native.js index 1d500d5..df0b734 100644 --- a/scripts/nodejs/pipeline_native.js +++ b/scripts/nodejs/pipeline_native.js @@ -653,6 +653,136 @@ async function localAsr(audioPath, info) { }); } +const MIN_ENROLLMENT_SAMPLE_RATE = 16000; +const VOICE_ENROLL_URL = + "https://dashscope.aliyuncs.com/api/v1/services/audio/tts/customization"; + +async function ensureEnrollmentAudio(filePath) { + const ffmpeg = requireFfmpeg(); + const normalized = (p) => (process.platform === "win32" ? p.replace(/\\/g, "/") : p); + let sampleRate = 0; + let channels = 0; + try { + const ffprobe = locateFfmpeg()?.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1"); + if (ffprobe && fs.existsSync(ffprobe)) { + const stdout = execFileSync( + ffprobe, + [ + "-v", + "error", + "-select_streams", + "a:0", + "-show_entries", + "stream=sample_rate,channels", + "-of", + "json", + filePath, + ], + { encoding: "utf8", windowsHide: true }, + ); + const probe = JSON.parse(stdout || "{}"); + const stream = (probe.streams && probe.streams[0]) || {}; + sampleRate = parseInt(stream.sample_rate || "0", 10) || 0; + channels = parseInt(stream.channels || "0", 10) || 0; + } + } catch { + /* 转码兜底 */ + } + const ext = path.extname(filePath).toLowerCase(); + const needsConvert = + !sampleRate || + sampleRate < MIN_ENROLLMENT_SAMPLE_RATE || + channels !== 1 || + ext !== ".wav"; + if (!needsConvert) return filePath; + + const parsed = path.parse(filePath); + const outputPath = path.join(parsed.dir, `${parsed.name}_enroll_16k.wav`); + await runCommand(ffmpeg, [ + "-y", + "-i", + normalized(filePath), + "-vn", + "-acodec", + "pcm_s16le", + "-ar", + String(MIN_ENROLLMENT_SAMPLE_RATE), + "-ac", + "1", + normalized(outputPath), + ]); + return outputPath; +} + +function audioFileToDataUri(filePath) { + const ext = path.extname(filePath).toLowerCase(); + const mimeTypes = { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".m4a": "audio/m4a", + ".flac": "audio/flac", + ".ogg": "audio/ogg", + ".aac": "audio/aac", + ".webm": "audio/webm", + }; + const mime = mimeTypes[ext] || "audio/wav"; + const buf = fs.readFileSync(filePath); + return `data:${mime};base64,${buf.toString("base64")}`; +} + +/** + * 阿里云声音复刻(对齐 Electron aliyun:cosyvoice:enroll) + * @param {{ apiKey: string, audioPath: string, name: string }} opts + */ +async function qwenVoiceEnroll(opts) { + const apiKey = String(opts.apiKey || "").trim(); + const audioPath = String(opts.audioPath || "").trim(); + const name = String(opts.name || "").trim(); + if (!apiKey) return { success: false, error: "缺少 DashScope apiKey" }; + if (!audioPath || !fs.existsSync(audioPath)) { + return { success: false, error: "参考音频文件不存在" }; + } + if (!name) return { success: false, error: "请先输入音色名称" }; + + try { + const prepared = await ensureEnrollmentAudio(audioPath); + const dataUri = audioFileToDataUri(prepared); + let prefix = name.replace(/[^a-zA-Z0-9_]/g, ""); + if (!prefix) prefix = "custom"; + else if (prefix.length > 10) prefix = prefix.substring(0, 10); + + const payload = { + model: "qwen-voice-enrollment", + input: { + action: "create", + target_model: "qwen3-tts-vc-2026-01-22", + preferred_name: prefix, + audio: { data: dataUri }, + }, + }; + + const data = await fetchJson(VOICE_ENROLL_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + const voiceId = data?.output?.voice || data?.output?.voice_id; + if (!voiceId) { + return { + success: false, + error: data?.message || data?.code || "复刻响应格式错误", + }; + } + return { success: true, voiceId: String(voiceId) }; + } catch (e) { + return { success: false, error: e.message || String(e) }; + } +} + async function openaiChat(req) { let apiUrl = (req.apiUrl || "").replace(/\/$/, ""); if (!apiUrl.includes("/chat/completions")) { @@ -698,5 +828,6 @@ module.exports = { resolveTtsModel, splitTextForTts, qwenTtsSynthesize, + qwenVoiceEnroll, locateFfmpeg, }; diff --git a/scripts/nodejs/subtitle_bgm_generate.js b/scripts/nodejs/subtitle_bgm_generate.js index f0f5043..108dda2 100644 --- a/scripts/nodejs/subtitle_bgm_generate.js +++ b/scripts/nodejs/subtitle_bgm_generate.js @@ -33,9 +33,18 @@ function execFfmpeg(args) { } } +function locateFfprobe() { + const fromEnv = process.env.AICLIENT_FFPROBE_PATH; + if (fromEnv && fs.existsSync(fromEnv)) return fromEnv; + const ffmpeg = pipelineNative.locateFfmpeg(); + if (!ffmpeg) return null; + const sibling = ffmpeg.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1"); + return fs.existsSync(sibling) ? sibling : null; +} + function probeVideoDuration(videoPath) { - const ffprobe = pipelineNative.locateFfmpeg()?.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1"); - if (ffprobe && fs.existsSync(ffprobe)) { + const ffprobe = locateFfprobe(); + if (ffprobe) { const r = spawnSync( ffprobe, [ @@ -64,6 +73,42 @@ function probeVideoDuration(videoPath) { return 0; } +/** @returns {{ width: number, height: number }} */ +function probeVideoSize(videoPath) { + const ffprobe = locateFfprobe(); + if (ffprobe) { + const r = spawnSync( + ffprobe, + [ + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=p=0:s=x", + videoPath, + ], + { encoding: "utf8", windowsHide: true }, + ); + if (r.status === 0) { + const parts = String(r.stdout || "").trim().split("x"); + const width = parseInt(parts[0], 10); + const height = parseInt(parts[1], 10); + if (width > 0 && height > 0) return { width, height }; + } + } + const ffmpeg = pipelineNative.locateFfmpeg(); + const r2 = spawnSync(ffmpeg, ["-i", videoPath], { encoding: "utf8", windowsHide: true }); + const stderr = r2.stderr || ""; + const m = stderr.match(/,\s*(\d{2,5})x(\d{2,5})/); + if (m) { + return { width: Number(m[1]), height: Number(m[2]) }; + } + return { width: 1920, height: 1080 }; +} + function splitScriptLines(text) { return String(text || "") .split(/(?<=[。!?.!?])\s*|\n+/) @@ -110,12 +155,13 @@ function formatSrtTime(ms) { return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(msPart).padStart(3, "0")}`; } -function buildSrtContent(records) { +function buildSrtContent(records, maxCharsPerLine = 20) { return records .map((r, i) => { const start = formatSrtTime(r.start); const end = formatSrtTime(Math.max(r.end, r.start + 200)); - return `${i + 1}\n${start} --> ${end}\n${r.text}\n`; + const text = wrapSubtitleText(r.text, maxCharsPerLine); + return `${i + 1}\n${start} --> ${end}\n${text}\n`; }) .join("\n"); } @@ -124,11 +170,124 @@ function escapeSubPath(filePath) { return filePath.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "\\'"); } -function addSubtitleToVideo(inputVideo, srtPath, forceStyle) { +/** 对齐 Electron maxCharsPerLine=20,避免单行过长铺满画面 */ +function wrapSubtitleText(text, maxCharsPerLine = 20) { + const raw = String(text || "").trim(); + if (!raw) return ""; + const max = Math.max(8, Math.min(30, Number(maxCharsPerLine) || 20)); + const lines = []; + for (let i = 0; i < raw.length; i += max) { + lines.push(raw.slice(i, i + max)); + } + return lines.join("\n"); +} + +function hexToAssColor(hex) { + const h = String(hex || "#FFFFFF").replace("#", ""); + const base = h.length >= 6 ? h.slice(0, 6) : "FFFFFF"; + const r = base.substring(0, 2); + const g = base.substring(2, 4); + const b = base.substring(4, 6); + return `&H00${b}${g}${r}`.toUpperCase(); +} + +function assEscapeText(text) { + return String(text || "") + .replace(/\r/g, "") + .replace(/\n/g, "\\N") + .replace(/{/g, "(") + .replace(/}/g, ")"); +} + +function normalizeBurnStyle(style) { + const s = style && typeof style === "object" ? style : {}; + const outlineWidth = Number(s.outlineWidth); + const hasBg = + s.backgroundColor && + s.backgroundColor !== "transparent" && + (Number(s.backgroundOpacity) ?? 0) > 0; + return { + fontName: String(s.fontName || "Microsoft YaHei"), + fontSize: Number(s.fontSize) || 35, + fontColor: String(s.fontColor || "#FFFFFF"), + outlineColor: String(s.outlineColor || "#000000"), + outlineWidth: Number.isFinite(outlineWidth) ? outlineWidth : 2, + backgroundColor: String(s.backgroundColor || "transparent"), + backgroundOpacity: Number(s.backgroundOpacity) ?? 0, + position: String(s.position || "bottom"), + fontSizeScale: Number(s.fontSizeScale) || 100, + maxCharsPerLine: Number(s.maxCharsPerLine) || 20, + hasBg, + }; +} + +function resolveAssLayout(position, videoHeight) { + const h = Math.max(360, Number(videoHeight) || 1080); + const marginV = Math.round(48 * (h / 1080)); + if (position === "top") { + return { alignment: 8, marginV }; + } + if (position === "center") { + return { alignment: 5, marginV: 0 }; + } + return { alignment: 2, marginV }; +} + +function buildAssContent(records, styleInput, videoWidth, videoHeight) { + const style = normalizeBurnStyle(styleInput); + const width = Math.max(320, Number(videoWidth) || 1920); + const height = Math.max(320, Number(videoHeight) || 1080); + const scale = Math.max(0.2, Math.min(2, style.fontSizeScale / 100)); + const fontSize = Math.max(14, Math.round(style.fontSize * scale)); + const outline = Math.max(0, Math.round(style.outlineWidth)); + const { alignment, marginV } = resolveAssLayout(style.position, height); + const hasBg = style.hasBg; + const borderStyle = hasBg ? 4 : 1; + const backColour = hasBg + ? hexToAssColor(style.backgroundColor) + : "&H00000000"; + const primary = hexToAssColor(style.fontColor); + const outlineColour = hexToAssColor(style.outlineColor); + + const header = [ + "[Script Info]", + "Title: aiclient", + "ScriptType: v4.00+", + "WrapStyle: 0", + "ScaledBorderAndShadow: yes", + `PlayResX: ${width}`, + `PlayResY: ${height}`, + "", + "[V4+ Styles]", + "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding", + `Style: Default,${style.fontName},${fontSize},${primary},${primary},${outlineColour},${backColour},0,0,0,0,100,100,0,0,${borderStyle},${outline},0,${alignment},20,20,${marginV},1`, + "", + "[Events]", + "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text", + ]; + + const events = records.map((r) => { + const start = formatAssTime(r.start); + const end = formatAssTime(Math.max(r.end, r.start + 200)); + const text = assEscapeText(wrapSubtitleText(r.text, style.maxCharsPerLine)); + return `Dialogue: 0,${start},${end},Default,,0,0,0,,${text}`; + }); + + return `${header.join("\n")}\n${events.join("\n")}\n`; +} + +function formatAssTime(ms) { + const h = Math.floor(ms / 3600000); + const m = Math.floor((ms % 3600000) / 60000); + const s = Math.floor((ms % 60000) / 1000); + const cs = Math.floor((ms % 1000) / 10); + return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}.${String(cs).padStart(2, "0")}`; +} + +function addSubtitleToVideo(inputVideo, subtitlePath, styleInput) { const outputVideo = makeOutputPath("with_subtitle"); - const style = forceStyle || "FontName=Microsoft YaHei,FontSize=24,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=2,Alignment=2,MarginV=40"; - const sub = escapeSubPath(srtPath); - const vf = `subtitles='${sub}':force_style='${style}'`; + const sub = escapeSubPath(subtitlePath); + const vf = `subtitles='${sub}'`; execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]); return outputVideo; } @@ -254,12 +413,25 @@ globalThis.__nodejsMain = async function main(params) { return { success: false, error: "未识别到语音内容,无法生成字幕" }; } + const burnStyle = + p.subtitleStyle && typeof p.subtitleStyle === "object" + ? p.subtitleStyle + : null; + const maxChars = burnStyle?.maxCharsPerLine || 20; + srtPath = makeOutputPath("subtitle", "srt"); - fs.writeFileSync(srtPath, buildSrtContent(records), "utf8"); + fs.writeFileSync(srtPath, buildSrtContent(records, maxChars), "utf8"); + + const { width, height } = probeVideoSize(current); + const assPath = makeOutputPath("subtitle", "ass"); + fs.writeFileSync( + assPath, + buildAssContent(records, burnStyle, width, height), + "utf8", + ); globalThis.__native?.emitProgress?.("正在烧录字幕到视频…"); - const forceStyle = String(p.subtitleForceStyle || "").trim(); - current = addSubtitleToVideo(current, srtPath, forceStyle || undefined); + current = addSubtitleToVideo(current, assPath, burnStyle); pipelineNative.deleteFile(audioPath); } diff --git a/scripts/nodejs/voice_clone_enroll.js b/scripts/nodejs/voice_clone_enroll.js new file mode 100644 index 0000000..87f5831 --- /dev/null +++ b/scripts/nodejs/voice_clone_enroll.js @@ -0,0 +1,32 @@ +"use strict"; + +/** + * 音色一键复刻(对齐 Electron ipc aliyun:cosyvoice:enroll) + */ +const fs = require("fs"); +const pipelineNative = require("./pipeline_native.js"); +const desktopConfig = require("./desktop_config.js"); + +globalThis.__nodejsMain = async function main(params) { + const p = params || {}; + const audioPath = String(p.audioPath || "").trim(); + const name = String(p.name || "").trim(); + if (!audioPath || !fs.existsSync(audioPath)) { + return { success: false, error: "请先录制或上传参考声音" }; + } + if (!name) { + return { success: false, error: "请先输入音色名称" }; + } + + const apiKey = + desktopConfig.get("BAILIAN_API_KEY") || + desktopConfig.get("DASHSCOPE_API_KEY"); + if (!apiKey) { + return { + success: false, + error: "缺少 BAILIAN_API_KEY(或 DASHSCOPE_API_KEY),请在配置中设置", + }; + } + + return pipelineNative.qwenVoiceEnroll({ apiKey, audioPath, name }); +};