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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user