11
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
32
scripts/nodejs/voice_clone_enroll.js
Normal file
32
scripts/nodejs/voice_clone_enroll.js
Normal 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 });
|
||||
};
|
||||
Reference in New Issue
Block a user