This commit is contained in:
949036910@qq.com
2026-05-29 23:16:52 +08:00
parent 7af1a17a5b
commit d9c4e04d66
3 changed files with 346 additions and 11 deletions

View File

@@ -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,
};

View File

@@ -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);
}

View File

@@ -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 });
};