11
This commit is contained in:
@@ -1,17 +1,53 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
import VoiceManageDialog from "./VoiceManageDialog.vue";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(workflow);
|
||||
const {
|
||||
voiceEmotion,
|
||||
speechRate,
|
||||
voiceLanguage,
|
||||
avatarSelect,
|
||||
speechGenerating,
|
||||
generatedAudioSrc,
|
||||
currentAudioId,
|
||||
audioHistory,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const voiceButtonLabel = computed(() => workflow.selectedVoiceLabel);
|
||||
|
||||
const audioHistoryOptions = computed(() =>
|
||||
audioHistory.value.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.id,
|
||||
})),
|
||||
);
|
||||
|
||||
const hasAudio = computed(() => Boolean(generatedAudioSrc.value));
|
||||
|
||||
function onOpenVoiceManage() {
|
||||
workflow.openVoiceManageDialog();
|
||||
}
|
||||
|
||||
async function onGenerateSpeech() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await workflow.generateSpeech();
|
||||
if (result?.message) {
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function onPickAudioHistory() {
|
||||
if (!currentAudioId.value) return;
|
||||
workflow.pickAudioHistory(currentAudioId.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -60,14 +96,44 @@ function onOpenVoiceManage() {
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button label="生成语音" size="small" class="shrink-0" />
|
||||
<Button
|
||||
label="生成语音"
|
||||
size="small"
|
||||
class="shrink-0"
|
||||
:loading="speechGenerating"
|
||||
:disabled="speechGenerating"
|
||||
@click="onGenerateSpeech"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
<div class="dashboard-preview-box mt-3">
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<span class="text-xs text-slate-400">音频预览</span>
|
||||
<Select placeholder="暂无历史记录" disabled size="small" class="w-40" />
|
||||
<Select
|
||||
v-if="audioHistoryOptions.length"
|
||||
v-model="currentAudioId"
|
||||
:options="audioHistoryOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="历史记录"
|
||||
size="small"
|
||||
class="w-48"
|
||||
@update:model-value="onPickAudioHistory"
|
||||
/>
|
||||
<Select v-else placeholder="暂无历史记录" disabled size="small" class="w-40" />
|
||||
</div>
|
||||
<p class="py-4 text-center text-xs text-gray-500">暂无音频,请生成或选择历史记录</p>
|
||||
<div v-if="hasAudio" class="px-1 py-2">
|
||||
<audio :src="generatedAudioSrc" controls class="h-9 w-full" />
|
||||
</div>
|
||||
<p v-else class="py-4 text-center text-xs text-gray-500">
|
||||
暂无音频,请填写文案后点击「生成语音」
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div class="mb-2 text-xs text-slate-400">选择形象</div>
|
||||
@@ -81,7 +147,7 @@ function onOpenVoiceManage() {
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button label="生成口播视频" size="small" class="mt-3 w-full" />
|
||||
<Button label="生成口播视频" size="small" class="mt-3 w-full" :disabled="!hasAudio" />
|
||||
<div class="mt-2">
|
||||
<div class="mb-1 text-xs text-slate-200">视频预览</div>
|
||||
<div class="dashboard-aspect-video">
|
||||
|
||||
20
src/services/localAudio.js
Normal file
20
src/services/localAudio.js
Normal file
@@ -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}`;
|
||||
}
|
||||
99
src/services/voiceGenerate.js
Normal file
99
src/services/voiceGenerate.js
Normal file
@@ -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<typeof import('../stores/workflow.js').useWorkflowStore>} 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<typeof import('../stores/workflow.js').useWorkflowStore>} 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;
|
||||
}
|
||||
@@ -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<string, string>} 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
|
||||
|
||||
@@ -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();
|
||||
|
||||
71
src/utils/ttsOptions.js
Normal file
71
src/utils/ttsOptions.js
Normal file
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user