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) {
|
async function openaiChat(req) {
|
||||||
let apiUrl = (req.apiUrl || "").replace(/\/$/, "");
|
let apiUrl = (req.apiUrl || "").replace(/\/$/, "");
|
||||||
if (!apiUrl.includes("/chat/completions")) {
|
if (!apiUrl.includes("/chat/completions")) {
|
||||||
@@ -698,5 +828,6 @@ module.exports = {
|
|||||||
resolveTtsModel,
|
resolveTtsModel,
|
||||||
splitTextForTts,
|
splitTextForTts,
|
||||||
qwenTtsSynthesize,
|
qwenTtsSynthesize,
|
||||||
|
qwenVoiceEnroll,
|
||||||
locateFfmpeg,
|
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) {
|
function probeVideoDuration(videoPath) {
|
||||||
const ffprobe = pipelineNative.locateFfmpeg()?.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
|
const ffprobe = locateFfprobe();
|
||||||
if (ffprobe && fs.existsSync(ffprobe)) {
|
if (ffprobe) {
|
||||||
const r = spawnSync(
|
const r = spawnSync(
|
||||||
ffprobe,
|
ffprobe,
|
||||||
[
|
[
|
||||||
@@ -64,6 +73,42 @@ function probeVideoDuration(videoPath) {
|
|||||||
return 0;
|
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) {
|
function splitScriptLines(text) {
|
||||||
return String(text || "")
|
return String(text || "")
|
||||||
.split(/(?<=[。!?.!?])\s*|\n+/)
|
.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")}`;
|
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
|
return records
|
||||||
.map((r, i) => {
|
.map((r, i) => {
|
||||||
const start = formatSrtTime(r.start);
|
const start = formatSrtTime(r.start);
|
||||||
const end = formatSrtTime(Math.max(r.end, r.start + 200));
|
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");
|
.join("\n");
|
||||||
}
|
}
|
||||||
@@ -124,11 +170,124 @@ function escapeSubPath(filePath) {
|
|||||||
return filePath.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "\\'");
|
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 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(subtitlePath);
|
||||||
const sub = escapeSubPath(srtPath);
|
const vf = `subtitles='${sub}'`;
|
||||||
const vf = `subtitles='${sub}':force_style='${style}'`;
|
|
||||||
execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]);
|
execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]);
|
||||||
return outputVideo;
|
return outputVideo;
|
||||||
}
|
}
|
||||||
@@ -254,12 +413,25 @@ globalThis.__nodejsMain = async function main(params) {
|
|||||||
return { success: false, error: "未识别到语音内容,无法生成字幕" };
|
return { success: false, error: "未识别到语音内容,无法生成字幕" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const burnStyle =
|
||||||
|
p.subtitleStyle && typeof p.subtitleStyle === "object"
|
||||||
|
? p.subtitleStyle
|
||||||
|
: null;
|
||||||
|
const maxChars = burnStyle?.maxCharsPerLine || 20;
|
||||||
|
|
||||||
srtPath = makeOutputPath("subtitle", "srt");
|
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?.("正在烧录字幕到视频…");
|
globalThis.__native?.emitProgress?.("正在烧录字幕到视频…");
|
||||||
const forceStyle = String(p.subtitleForceStyle || "").trim();
|
current = addSubtitleToVideo(current, assPath, burnStyle);
|
||||||
current = addSubtitleToVideo(current, srtPath, forceStyle || undefined);
|
|
||||||
pipelineNative.deleteFile(audioPath);
|
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