diff --git a/scripts/nodejs/pipeline_native.js b/scripts/nodejs/pipeline_native.js index 3dc6546..12195e4 100644 --- a/scripts/nodejs/pipeline_native.js +++ b/scripts/nodejs/pipeline_native.js @@ -274,6 +274,80 @@ async function fetchJson(url, options = {}) { 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"; +} + +/** + * 通义千问 TTS 合成(对齐 Electron synthesizeOneSegment) + * @param {{ apiKey: string, text: string, voice: string, model?: string, instruction?: 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(); + + 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" }; + + try { + const data = await fetchJson(QWEN_TTS_URL, { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + input: { + text, + voice, + ...(instruction + ? { instructions: instruction, optimize_instructions: true } + : {}), + }, + }), + }); + + 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); + log("info", "qwen tts saved", { outputPath, voice, model }); + 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"; @@ -454,4 +528,6 @@ module.exports = { aliyunAsrFiletrans, localAsr, openaiChat, + resolveTtsModel, + qwenTtsSynthesize, }; diff --git a/scripts/nodejs/voice_preview.js b/scripts/nodejs/voice_preview.js new file mode 100644 index 0000000..71e8abb --- /dev/null +++ b/scripts/nodejs/voice_preview.js @@ -0,0 +1,74 @@ +"use strict"; + +/** + * 音色试听(对齐 Electron aliyun:cosyvoice:synthesize + getPreCachedVoicePath) + */ + +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const pipelineNative = require("./pipeline_native.js"); +const desktopConfig = require("./desktop_config.js"); + +const CACHE_DIR = path.join(os.tmpdir(), "aiclient-voice-preview-cache"); + +function buildPreviewText(title, customText) { + if (customText && String(customText).trim()) { + return String(customText).trim(); + } + const shortName = String(title || "") + .split("(")[0] + .trim(); + return `你好,我是${shortName || "我"},很高兴为您服务。`; +} + +globalThis.__nodejsMain = async function (params) { + const p = params || {}; + const apiKey = + desktopConfig.get("BAILIAN_API_KEY") || + desktopConfig.get("DASHSCOPE_API_KEY"); + if (!apiKey) { + return { + success: false, + error: "缺少 BAILIAN_API_KEY(或 DASHSCOPE_API_KEY),请在配置中设置", + }; + } + + const voice = String(p.voice || "").trim(); + if (!voice) { + return { success: false, error: "缺少 voice 参数" }; + } + + const cacheKey = p.cacheKey ? String(p.cacheKey).trim() : ""; + fs.mkdirSync(CACHE_DIR, { recursive: true }); + + let outputPath; + if (cacheKey) { + outputPath = path.join(CACHE_DIR, `${cacheKey.replace(/[^\w.-]/g, "_")}.wav`); + if (fs.existsSync(outputPath)) { + return { success: true, audioPath: outputPath, cached: true }; + } + } else { + outputPath = path.join( + CACHE_DIR, + `preview-${Date.now()}-${Math.random().toString(36).slice(2)}.wav`, + ); + } + + const text = buildPreviewText(p.title, p.text); + const result = await pipelineNative.qwenTtsSynthesize({ + apiKey, + text, + voice, + model: p.model, + instruction: + p.instruction || + "请用自然、清晰、适合短视频配音的风格朗读。", + outputPath, + }); + + if (!result.success) { + return result; + } + return { ...result, cached: false }; +};