视频预览
diff --git a/src/services/localAudio.js b/src/services/localAudio.js
new file mode 100644
index 0000000..e0ac6af
--- /dev/null
+++ b/src/services/localAudio.js
@@ -0,0 +1,20 @@
+import { invoke, isTauri } from "@tauri-apps/api/core";
+
+export function mimeFromAudioPath(filePath) {
+ const lower = String(filePath || "").toLowerCase();
+ if (lower.endsWith(".mp3")) return "audio/mpeg";
+ if (lower.endsWith(".ogg")) return "audio/ogg";
+ return "audio/wav";
+}
+
+/**
+ * @param {string} filePath
+ */
+export async function localAudioToPlayableUrl(filePath) {
+ if (!isTauri()) {
+ throw new Error("请在 Tauri 桌面端使用音频播放功能");
+ }
+ const base64 = await invoke("read_local_file_base64", { path: filePath });
+ const mime = mimeFromAudioPath(filePath);
+ return `data:${mime};base64,${base64}`;
+}
diff --git a/src/services/voiceGenerate.js b/src/services/voiceGenerate.js
new file mode 100644
index 0000000..0cb2cf3
--- /dev/null
+++ b/src/services/voiceGenerate.js
@@ -0,0 +1,99 @@
+import { invoke } from "@tauri-apps/api/core";
+import { ensureAppConfigReady } from "../config/videoPipeline.js";
+import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
+import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
+import { buildTtsInstruction, buildTtsLanguageType } from "../utils/ttsOptions.js";
+import { localAudioToPlayableUrl } from "./localAudio.js";
+
+const VOICE_GENERATE_SCRIPT = "voice_generate.js";
+
+/**
+ * 生成语音(对齐 Electron generateAudio CosyVoice)
+ * @param {ReturnType} store
+ */
+export async function executeGenerateSpeech(store) {
+ const text = String(store.scriptContent || "").trim();
+ if (!text) {
+ return { ok: false, message: "请先在「视频文案编辑」中填写文案内容" };
+ }
+
+ if (!store.selectedVoiceId) {
+ return { ok: false, message: "请先选择音色" };
+ }
+
+ const cfg = await ensureAppConfigReady();
+ if (!cfg.ok) {
+ return { ok: false, message: cfg.message };
+ }
+
+ const voice = resolveAliyunVoiceId(store.selectedVoiceId);
+ const instruction = buildTtsInstruction(
+ store.voiceLanguage,
+ store.selectedVoiceId,
+ store.voiceEmotion,
+ store.speechRate,
+ );
+ const languageType = buildTtsLanguageType(store.voiceLanguage);
+
+ store.speechGenerating = true;
+
+ try {
+ const result = await invoke("run_nodejs_script", {
+ scriptName: VOICE_GENERATE_SCRIPT,
+ params: {
+ text,
+ voice,
+ instruction,
+ languageType: languageType || null,
+ format: "mp3",
+ },
+ });
+
+ if (!result?.success || !result?.audioPath) {
+ return {
+ ok: false,
+ message: String(result?.error || "语音生成失败,请重试"),
+ };
+ }
+
+ const audioSrc = await localAudioToPlayableUrl(result.audioPath);
+ const label = `${getVoiceDisplayLabel(store.selectedVoiceId)} · ${formatHistoryTime()}`;
+ const entry = {
+ id: `audio_${Date.now()}`,
+ label,
+ audioPath: result.audioPath,
+ audioSrc,
+ createdAt: Date.now(),
+ };
+
+ store.generatedAudioPath = result.audioPath;
+ store.generatedAudioSrc = audioSrc;
+ store.currentAudioId = entry.id;
+ store.audioHistory = [entry, ...store.audioHistory].slice(0, 50);
+
+ return { ok: true, message: "语音生成完成" };
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err || "未知错误");
+ return { ok: false, message: `语音生成失败: ${msg}` };
+ } finally {
+ store.speechGenerating = false;
+ }
+}
+
+function formatHistoryTime() {
+ const d = new Date();
+ const p = (n) => String(n).padStart(2, "0");
+ return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+
+/**
+ * @param {ReturnType} store
+ * @param {string} audioId
+ */
+export function selectAudioHistory(store, audioId) {
+ const item = store.audioHistory.find((h) => h.id === audioId);
+ if (!item) return;
+ store.currentAudioId = item.id;
+ store.generatedAudioPath = item.audioPath;
+ store.generatedAudioSrc = item.audioSrc;
+}
diff --git a/src/services/voicePreview.js b/src/services/voicePreview.js
index e6661a4..c222106 100644
--- a/src/services/voicePreview.js
+++ b/src/services/voicePreview.js
@@ -1,32 +1,13 @@
-import { invoke, isTauri } from "@tauri-apps/api/core";
+import { invoke } from "@tauri-apps/api/core";
import { ensureAppConfigReady } from "../config/videoPipeline.js";
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
+import { localAudioToPlayableUrl } from "./localAudio.js";
const VOICE_PREVIEW_SCRIPT = "voice_preview.js";
/** @type {Map} cacheKey → data:audio URL */
const previewUrlCache = new Map();
-function mimeFromPath(filePath) {
- const lower = String(filePath || "").toLowerCase();
- if (lower.endsWith(".mp3")) return "audio/mpeg";
- if (lower.endsWith(".ogg")) return "audio/ogg";
- return "audio/wav";
-}
-
-/**
- * 将 Node 写入的本地音频转为可在 WebView 播放的 URL(避免 asset.localhost 不可用)
- * @param {string} filePath
- */
-async function localAudioToPlayableUrl(filePath) {
- if (!isTauri()) {
- throw new Error("请在 Tauri 桌面端使用试听功能");
- }
- const base64 = await invoke("read_local_file_base64", { path: filePath });
- const mime = mimeFromPath(filePath);
- return `data:${mime};base64,${base64}`;
-}
-
/**
* 音色试听
* @param {{ listKey: string, title: string, aliyunVoiceId?: string, selectedVoiceId?: string }} opts
diff --git a/src/stores/workflow.js b/src/stores/workflow.js
index 73373e8..1235e28 100644
--- a/src/stores/workflow.js
+++ b/src/stores/workflow.js
@@ -17,6 +17,10 @@ import {
} from "../config/videoPipeline.js";
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
+import {
+ executeGenerateSpeech,
+ selectAudioHistory,
+} from "../services/voiceGenerate.js";
@@ -153,6 +157,17 @@ export const useWorkflowStore = defineStore("workflow", {
avatarSelect: null,
+ speechGenerating: false,
+
+ generatedAudioPath: "",
+
+ generatedAudioSrc: "",
+
+ currentAudioId: null,
+
+ /** @type {Array<{ id: string, label: string, audioPath: string, audioSrc: string, createdAt: number }>} */
+ audioHistory: [],
+
// 03 视频编辑
@@ -430,6 +445,18 @@ export const useWorkflowStore = defineStore("workflow", {
},
+ async generateSpeech() {
+
+ return executeGenerateSpeech(this);
+
+ },
+
+ pickAudioHistory(audioId) {
+
+ selectAudioHistory(this, audioId);
+
+ },
+
resetAll() {
this.$reset();
diff --git a/src/utils/ttsOptions.js b/src/utils/ttsOptions.js
new file mode 100644
index 0000000..36a2eaa
--- /dev/null
+++ b/src/utils/ttsOptions.js
@@ -0,0 +1,71 @@
+/** TTS 参数构建(对齐 Electron generateAudio CosyVoice 分支) */
+
+const DIALECT_LANGUAGES = new Set([
+ "上海话",
+ "北京话",
+ "四川话",
+ "南京话",
+ "陕西话",
+ "闽南语",
+ "天津话",
+ "粤语",
+]);
+
+const LANGUAGE_TYPE_MAP = {
+ 英语: "en",
+ 法语: "fr",
+ 德语: "de",
+ 日语: "ja",
+ 韩语: "ko",
+ 俄语: "ru",
+ 意大利语: "it",
+ 西班牙语: "es",
+ 葡萄牙语: "pt",
+ 上海话: "Chinese",
+ 北京话: "Chinese",
+ 四川话: "Chinese",
+ 南京话: "Chinese",
+ 陕西话: "Chinese",
+ 闽南语: "Chinese",
+ 天津话: "Chinese",
+ 粤语: "Chinese",
+};
+
+/**
+ * @param {string} voiceLanguage
+ * @param {string} selectedVoiceId
+ */
+export function buildTtsInstruction(voiceLanguage, selectedVoiceId, voiceEmotion, speechRate) {
+ const lang = String(voiceLanguage || "").trim();
+ const isSystem = String(selectedVoiceId || "").startsWith("sys_");
+
+ let instruction = "请用自然、清晰、适合短视频配音的风格朗读。";
+
+ const emotion = String(voiceEmotion || "").trim();
+ if (emotion && emotion !== "自然") {
+ instruction = `请用${emotion}的情绪,自然、清晰、适合短视频口播的风格朗读。`;
+ }
+
+ if (!isSystem && DIALECT_LANGUAGES.has(lang)) {
+ return `请用${lang}表达。`;
+ }
+ if (lang && lang !== "中文" && lang !== "中文(普通话)") {
+ return `请用${lang}表达。`;
+ }
+
+ const rate = Number(speechRate);
+ if (rate && rate !== 1 && Number.isFinite(rate)) {
+ instruction += `语速约为正常的 ${rate} 倍。`;
+ }
+
+ return instruction;
+}
+
+/** @param {string} voiceLanguage */
+export function buildTtsLanguageType(voiceLanguage) {
+ const lang = String(voiceLanguage || "").trim();
+ if (!lang || lang === "中文" || lang === "中文(普通话)") {
+ return undefined;
+ }
+ return LANGUAGE_TYPE_MAP[lang];
+}