Files
yaoyaoai/scripts/nodejs/pipeline_native.js
fengchuanhn@gmail.com 7f57dfa9ed 11
2026-05-21 00:19:11 +08:00

697 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
/**
* Node 流水线宿主能力(对齐 QuickJS __native / Rust js_runtime/native.rs
*/
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const { spawn, execFileSync } = require("child_process");
const https = require("https");
const http = require("http");
const TEMP_DIR = path.join(os.tmpdir(), "aiclient-node-pipeline");
fs.mkdirSync(TEMP_DIR, { recursive: true });
/** Tauri 子进程优先 __native.log独立 node 调试时回退 console */
function log(level, msg, fields) {
const payload =
fields == null
? null
: typeof fields === "object" && !Array.isArray(fields)
? fields
: { value: fields };
if (globalThis.__native?.log) {
globalThis.__native.log(level, msg, payload);
return;
}
const tag = `[pipeline_native.${level}] ${msg}`;
if (payload != null) {
if (level === "error") console.error(tag, payload);
else if (level === "warn") console.warn(tag, payload);
else console.log(tag, payload);
} else if (level === "error") console.error(tag);
else if (level === "warn") console.warn(tag);
else console.log(tag);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function tempPath(prefix, ext) {
return path.join(TEMP_DIR, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext || "tmp"}`);
}
function deleteFile(p) {
try {
if (p && fs.existsSync(p)) fs.unlinkSync(p);
} catch (_) {
/* ignore */
}
}
function runCommand(cmd, args, timeoutMs = 300000) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr.on("data", (d) => {
stderr += d.toString();
});
const timer = setTimeout(() => {
child.kill();
reject(new Error(`命令超时: ${cmd}`));
}, timeoutMs);
child.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0) resolve();
else reject(new Error(`${cmd} 退出码 ${code}: ${stderr.slice(0, 500)}`));
});
});
}
function exeName() {
return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
}
function whichSync(cmd) {
try {
if (process.platform === "win32") {
const out = execFileSync("where", [cmd], { encoding: "utf8", windowsHide: true });
const line = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
return line && fs.existsSync(line) ? line : null;
}
const out = execFileSync("which", [cmd], { encoding: "utf8" });
const line = out.trim().split(/\r?\n/)[0];
return line && fs.existsSync(line) ? line : null;
} catch {
return null;
}
}
/** 与 Rust `ffmpeg::locate_ffmpeg` 顺序对齐 */
function locateFfmpeg() {
const name = exeName();
const envPath = process.env.AICLIENT_FFMPEG_PATH;
if (envPath && fs.existsSync(envPath)) {
return envPath;
}
const candidates = [];
const nodeDir = path.dirname(process.execPath);
candidates.push(path.join(nodeDir, "binaries", name));
candidates.push(path.join(nodeDir, name));
candidates.push(path.join(nodeDir, "..", "binaries", name));
candidates.push(path.join(nodeDir, "..", "..", "binaries", name));
candidates.push(path.join(nodeDir, "..", "..", "..", "binaries", name));
candidates.push(path.join(process.cwd(), "src-tauri", "binaries", name));
candidates.push(path.join(process.cwd(), "binaries", name));
for (const p of candidates) {
try {
if (fs.existsSync(p)) return p;
} catch {
/* ignore */
}
}
return whichSync(name);
}
async function ffmpegExtractAudio(videoPath) {
const dest = tempPath("audio", "wav");
const ffmpeg = locateFfmpeg();
if (!ffmpeg) {
throw new Error(
"未找到 ffmpeg。请安装并加入 PATH或将可执行文件放到 src-tauri/binaries/ffmpeg.exe" +
"或在环境变量中设置 AICLIENT_FFMPEG_PATH桌面端会在启动 Node 时自动注入已解析路径)",
);
}
await runCommand(ffmpeg, [
"-y",
"-i",
videoPath,
"-vn",
"-acodec",
"pcm_s16le",
"-ar",
"16000",
"-ac",
"1",
dest,
]);
return dest;
}
function rfc1123Date() {
return new Date().toUTCString();
}
function signOss(secret, str) {
return crypto.createHmac("sha1", secret).update(str).digest("base64");
}
/** 私有桶ASR 需可公网 GET 的签名 URL对齐 Electron generateSignedUrl */
function ossSignedGetUrl(cfg, objectKey, expiresSec = 3600) {
const expireTime = Math.floor(Date.now() / 1000) + expiresSec;
const resource = `/${cfg.bucket}/${objectKey}`;
const stringToSign = `GET\n\n\n${expireTime}\n${resource}`;
const signature = encodeURIComponent(signOss(cfg.accessKeySecret, stringToSign));
const host = `${cfg.bucket}.${cfg.region}.aliyuncs.com`;
return `https://${host}/${objectKey}?OSSAccessKeyId=${encodeURIComponent(cfg.accessKeyId)}&Expires=${expireTime}&Signature=${signature}`;
}
async function aliyunOssUpload(localPath, cfg) {
const ak = cfg.accessKeyId;
const sk = cfg.accessKeySecret;
const bucket = cfg.bucket;
const region = cfg.region;
if (!ak || !sk || !bucket || !region) {
return { success: false, error: "OSS 配置不完整" };
}
const objectKey = `audio/${Date.now()}-${Math.random().toString(36).slice(2)}.wav`;
const host = `${bucket}.${region}.aliyuncs.com`;
const endpoint = (cfg.endpoint || `https://${host}`).replace(/\/$/, "");
const url = `${endpoint}/${objectKey}`;
const body = fs.readFileSync(localPath);
const date = rfc1123Date();
const contentType = "audio/wav";
const canonicalizedResource = `/${bucket}/${objectKey}`;
const strToSign = `PUT\n\n${contentType}\n${date}\n${canonicalizedResource}`;
const signature = signOss(sk, strToSign);
const authorization = `OSS ${ak}:${signature}`;
return new Promise((resolve) => {
const u = new URL(url);
const opts = {
hostname: u.hostname,
port: u.port || 443,
path: u.pathname,
method: "PUT",
headers: {
Date: date,
"Content-Type": contentType,
Authorization: authorization,
"Content-Length": body.length,
},
};
const req = https.request(opts, (res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
const signedUrl = ossSignedGetUrl(
{ accessKeyId: ak, accessKeySecret: sk, bucket, region },
objectKey,
3600,
);
resolve({ success: true, url: signedUrl });
} else {
resolve({
success: false,
error: `OSS HTTP ${res.statusCode}: ${Buffer.concat(chunks).toString().slice(0, 200)}`,
});
}
});
});
req.on("error", (e) => resolve({ success: false, error: e.message }));
req.write(body);
req.end();
});
}
/** filetrans: output.result批量模型: output.results[0] */
function extractTranscriptionUrl(output) {
if (!output || typeof output !== "object") return null;
if (output.result?.transcription_url) return output.result.transcription_url;
const results = output.results;
if (Array.isArray(results) && results[0]?.transcription_url) {
return results[0].transcription_url;
}
return null;
}
function parseTranscriptionText(trans) {
let fullText = "";
if (trans?.transcripts?.length) {
for (const t of trans.transcripts) {
if (t.sentences?.length) {
for (const s of t.sentences) {
if (s.text) {
if (fullText) fullText += "\n";
fullText += s.text;
}
}
} else if (t.text) {
if (fullText) fullText += "\n";
fullText += t.text;
}
}
} else if (trans?.text) {
fullText = trans.text;
}
return fullText;
}
/** @returns {Array<{ text: string, start: number, end: number }>} start/end 毫秒 */
function parseTranscriptionSentences(trans) {
const records = [];
if (trans?.transcripts?.length) {
for (const t of trans.transcripts) {
if (t.sentences?.length) {
for (const s of t.sentences) {
const text = String(s.text || "").trim();
if (!text) continue;
const start = Number(s.begin_time ?? s.start_time ?? 0);
const end = Number(s.end_time ?? s.end_time ?? start + 2000);
records.push({ text, start, end });
}
}
}
}
return records;
}
async function fetchJson(url, options = {}) {
const res = await fetch(url, options);
const text = await res.text();
let data;
try {
data = JSON.parse(text);
} catch {
data = { _raw: text };
}
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
}
return data;
}
async function fetchArrayBuffer(url, options = {}) {
const res = await fetch(url, options);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
}
return Buffer.from(await res.arrayBuffer());
}
const QWEN_TTS_URL =
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation";
/** 对齐 Electron Ka根据 voiceId 选择 TTS 模型 */
function resolveTtsModel(voiceId) {
const v = String(voiceId || "").trim();
if (!v || v === "Cherry") return "qwen3-tts-flash";
if (v.startsWith("qwen-tts-vc-")) return "qwen3-tts-vc-2026-01-22";
return "qwen3-tts-flash";
}
function splitTextForTts(text, maxLen = 300) {
const t = String(text || "").trim();
if (t.length <= maxLen) return [t];
const segments = [];
let remaining = t;
while (remaining.length > 0) {
if (remaining.length <= maxLen) {
segments.push(remaining.trim());
break;
}
let splitIdx = -1;
for (const sep of ["。", "", "", ".", "!", "?"]) {
const idx = remaining.lastIndexOf(sep, maxLen);
if (idx > maxLen * 0.3) {
splitIdx = idx + 1;
break;
}
}
if (splitIdx === -1) {
for (const sep of ["", ",", "、", "", ":"]) {
const idx = remaining.lastIndexOf(sep, maxLen);
if (idx > maxLen * 0.3) {
splitIdx = idx + 1;
break;
}
}
}
if (splitIdx === -1) splitIdx = maxLen;
segments.push(remaining.slice(0, splitIdx).trim());
remaining = remaining.slice(splitIdx).trim();
}
return segments.filter((s) => s.length > 0);
}
function concatWavFiles(inputPaths, outputPath) {
if (inputPaths.length === 0) throw new Error("无输入文件");
if (inputPaths.length === 1) {
fs.copyFileSync(inputPaths[0], outputPath);
return;
}
const firstFile = fs.readFileSync(inputPaths[0]);
const headerSize = 44;
const header = Buffer.from(firstFile.buffer, 0, headerSize);
const pcmBuffers = [];
let totalPcmSize = 0;
for (const p of inputPaths) {
const buf = fs.readFileSync(p);
const pcm = buf.slice(headerSize);
pcmBuffers.push(pcm);
totalPcmSize += pcm.length;
}
const newHeader = Buffer.from(header);
newHeader.writeUInt32LE(totalPcmSize + headerSize - 8, 4);
newHeader.writeUInt32LE(totalPcmSize, 40);
const outputBuf = Buffer.concat([newHeader, ...pcmBuffers]);
fs.writeFileSync(outputPath, outputBuf);
}
/**
* 通义千问 TTS 单段合成(对齐 Electron synthesizeOneSegment
*/
async function qwenTtsSynthesizeOneSegment(text, config, outputPath) {
const apiKey = config.apiKey;
const voice = String(config.voice || "").trim();
const model = config.model || resolveTtsModel(voice);
const instruction = String(config.instruction || "").trim();
const languageType = config.languageType;
const input = {
text: String(text || "").trim(),
voice,
...(languageType ? { language_type: languageType } : {}),
...(instruction
? { instructions: instruction, optimize_instructions: true }
: {}),
};
const data = await fetchJson(QWEN_TTS_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ model, input }),
});
const audioUrl = data?.output?.audio?.url;
if (!audioUrl) {
return {
success: false,
error: data?.message || data?.code || "未获取到音频 URL",
};
}
const buf = await fetchArrayBuffer(audioUrl);
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, buf);
return { success: true, audioPath: outputPath };
}
/**
* 通义千问 TTS 合成(支持长文本分段,对齐 Electron synthesizeSpeech
* @param {{ apiKey: string, text: string, voice: string, model?: string, instruction?: string, languageType?: string, outputPath: string }} opts
*/
async function qwenTtsSynthesize(opts) {
const apiKey = opts.apiKey;
const text = String(opts.text || "").trim();
const voice = String(opts.voice || "").trim();
const outputPath = opts.outputPath;
const model = opts.model || resolveTtsModel(voice);
const instruction = String(opts.instruction || "").trim();
const languageType = opts.languageType;
if (!apiKey) return { success: false, error: "缺少 DashScope apiKey" };
if (!text) return { success: false, error: "缺少合成文本" };
if (!voice) return { success: false, error: "缺少 voice" };
if (!outputPath) return { success: false, error: "缺少 outputPath" };
const instrLen = instruction.length;
const isVcModel = model.includes("-vc") || model.includes("_vc");
const baseMaxLen = isVcModel ? 200 : 300;
const effectiveMaxLen = Math.max(100, baseMaxLen - instrLen);
const segments = splitTextForTts(text, effectiveMaxLen);
const config = { apiKey, voice, model, instruction, languageType };
try {
if (segments.length === 1) {
const r = await qwenTtsSynthesizeOneSegment(segments[0], config, outputPath);
if (!r.success) return r;
log("info", "qwen tts saved", { outputPath, voice, model });
return { success: true, audioPath: outputPath };
}
const parsed = path.parse(outputPath);
const ext = parsed.ext || ".wav";
const segmentPaths = [];
for (let i = 0; i < segments.length; i++) {
const segPath = path.join(
parsed.dir,
`${parsed.name}_seg${i}${ext}`,
);
log("info", "qwen tts segment", { i: i + 1, total: segments.length, len: segments[i].length });
const segResult = await qwenTtsSynthesizeOneSegment(segments[i], config, segPath);
if (!segResult.success) return segResult;
segmentPaths.push(segPath);
}
const outExt = ext.toLowerCase();
if (outExt === ".wav") {
concatWavFiles(segmentPaths, outputPath);
} else {
const wavOut = path.join(parsed.dir, `${parsed.name}_merged.wav`);
concatWavFiles(segmentPaths, wavOut);
try {
await runCommand(exeName(), [
"-y",
"-i",
wavOut,
"-c:a",
"libmp3lame",
"-b:a",
"128k",
outputPath,
]);
deleteFile(wavOut);
} catch (e) {
fs.copyFileSync(wavOut, outputPath.replace(/\.mp3$/i, ".wav"));
return {
success: false,
error: `分段合成成功但转 mp3 失败: ${e.message}(已保存 wav`,
};
}
}
for (const p of segmentPaths) deleteFile(p);
log("info", "qwen tts merged", { outputPath, segments: segments.length });
return { success: true, audioPath: outputPath };
} catch (e) {
return { success: false, error: e.message || String(e) };
}
}
async function aliyunAsrFiletrans(audioUrl, opts) {
const apiKey = opts.apiKey || opts.api_key;
const model = opts.model || "qwen3-asr-flash-filetrans";
log("info", "apiKey",apiKey);
log("info", "model",model);
log("info", "audioUrl",audioUrl);
if (!apiKey) {
return { success: false, error: "缺少 DashScope apiKey" };
}
const fileUrl = String(audioUrl || "").trim();
if (!/^https?:\/\//i.test(fileUrl)) {
return { success: false, error: "ASR 需要可访问的 http(s) 音频 URL请检查 OSS 上传与签名)" };
}
const input =
model === "qwen3-asr-flash-filetrans"
? { file_url: fileUrl }
: { file_urls: [fileUrl] };
const submit = await fetchJson(
"https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription",
{
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
},
body: JSON.stringify({
model,
input,
parameters: {
channel_id: [0],
enable_itn: false,
},
}),
}
);
const taskId = submit?.output?.task_id;
if (!taskId) {
return { success: false, error: `ASR 提交失败: ${JSON.stringify(submit).slice(0, 200)}` };
}
log("info", "voice recognition taskId",taskId);
for (let i = 0; i < 60; i++) {
await sleep(5000);
const poll = await fetchJson(
`https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
const status = poll?.output?.task_status;
log("info", "voice recognition status",status);
if (status === "SUCCEEDED") {
const transUrl = extractTranscriptionUrl(poll?.output);
if (!transUrl) {
log("error", "voice recognition output", poll?.output);
return {
success: false,
error: "ASR 成功但无 transcription_url期望 output.result 或 output.results[0]",
};
}
const trans = await fetchJson(transUrl);
const fullText = parseTranscriptionText(trans);
const sentences = parseTranscriptionSentences(trans);
return { success: true, text: fullText, sentences };
}
if (status === "FAILED" || status === "UNKNOWN") {
const detail =
poll?.output?.message ||
poll?.output?.code ||
poll?.message ||
(poll?.output ? JSON.stringify(poll.output).slice(0, 300) : null);
return {
success: false,
error: detail || "ASR 任务失败",
};
}
}
return { success: false, error: "ASR 轮询超时" };
}
async function localAsr(audioPath, info) {
const baseUrl = (info.baseUrl || info.localPath || "").replace(/\/$/, "");
if (!baseUrl) {
return { success: false, error: "未配置本地 ASR 服务地址" };
}
const asrPath = info.asrPath || "/asr";
const url = `${baseUrl}${asrPath.startsWith("/") ? asrPath : `/${asrPath}`}`;
const boundary = `----aiclient${Date.now()}`;
const fileBuf = fs.readFileSync(audioPath);
const head = Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\n`,
"utf8"
);
const tail = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8");
const body = Buffer.concat([head, fileBuf, tail]);
const u = new URL(url);
const client = u.protocol === "https:" ? https : http;
return new Promise((resolve) => {
const req = client.request(
{
hostname: u.hostname,
port: u.port || (u.protocol === "https:" ? 443 : 80),
path: u.pathname + u.search,
method: "POST",
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
"Content-Length": body.length,
},
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf8");
const data = JSON.parse(raw);
if (data.code === 0 || data.success) {
const records =
data.data?.data?.records ||
data.data?.records ||
data.records ||
[];
const sentences = Array.isArray(records)
? records
.map((r) => {
const text = String(r.text || "").trim();
if (!text) return null;
const start = Number(r.start ?? r.begin_time ?? 0);
const end = Number(r.end ?? r.end_time ?? start + 2000);
return { text, start, end };
})
.filter(Boolean)
: [];
const text = sentences.length
? sentences.map((r) => r.text).join("")
: data.text || data.data?.text || "";
resolve({ success: true, text: String(text), sentences });
} else {
resolve({ success: false, error: data.msg || data.error || raw.slice(0, 200) });
}
} catch (e) {
resolve({ success: false, error: e.message });
}
});
}
);
req.on("error", (e) => resolve({ success: false, error: e.message }));
req.write(body);
req.end();
});
}
async function openaiChat(req) {
let apiUrl = (req.apiUrl || "").replace(/\/$/, "");
if (!apiUrl.includes("/chat/completions")) {
apiUrl = apiUrl.endsWith("/v1")
? `${apiUrl}/chat/completions`
: `${apiUrl}/v1/chat/completions`;
}
const body = {
model: req.model,
messages: req.messages,
stream: false,
temperature: typeof req.temperature === "number" ? req.temperature : 0.8,
max_tokens: req.max_tokens || req.maxTokens || 2000,
};
try {
const data = await fetchJson(apiUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${req.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const content = data?.choices?.[0]?.message?.content;
if (!content) {
return { success: false, error: "模型返回为空" };
}
return { success: true, content: String(content).trim() };
} catch (e) {
return { success: false, error: e.message };
}
}
module.exports = {
tempPath,
deleteFile,
ffmpegExtractAudio,
aliyunOssUpload,
aliyunAsrFiletrans,
localAsr,
parseTranscriptionSentences,
openaiChat,
resolveTtsModel,
splitTextForTts,
qwenTtsSynthesize,
locateFfmpeg,
};