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