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