11
This commit is contained in:
@@ -15,6 +15,7 @@ fn is_allowed_local_media(path: &Path) -> bool {
|
|||||||
let lower = path.to_string_lossy().to_lowercase();
|
let lower = path.to_string_lossy().to_lowercase();
|
||||||
if lower.contains("aiclient-voice-preview-cache")
|
if lower.contains("aiclient-voice-preview-cache")
|
||||||
|| lower.contains("aiclient-node-pipeline")
|
|| lower.contains("aiclient-node-pipeline")
|
||||||
|
|| lower.contains("aiclient-generated-speech")
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,53 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||||
import VoiceManageDialog from "./VoiceManageDialog.vue";
|
import VoiceManageDialog from "./VoiceManageDialog.vue";
|
||||||
|
|
||||||
const workflow = useWorkflowStore();
|
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 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() {
|
function onOpenVoiceManage() {
|
||||||
workflow.openVoiceManageDialog();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -60,14 +96,44 @@ function onOpenVoiceManage() {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button label="生成语音" size="small" class="shrink-0" />
|
<Button
|
||||||
|
label="生成语音"
|
||||||
|
size="small"
|
||||||
|
class="shrink-0"
|
||||||
|
:loading="speechGenerating"
|
||||||
|
:disabled="speechGenerating"
|
||||||
|
@click="onGenerateSpeech"
|
||||||
|
/>
|
||||||
</div>
|
</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="dashboard-preview-box mt-3">
|
||||||
<div class="mb-2 flex items-center justify-between gap-2">
|
<div class="mb-2 flex items-center justify-between gap-2">
|
||||||
<span class="text-xs text-slate-400">音频预览</span>
|
<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>
|
</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>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="mb-2 text-xs text-slate-400">选择形象</div>
|
<div class="mb-2 text-xs text-slate-400">选择形象</div>
|
||||||
@@ -81,7 +147,7 @@ function onOpenVoiceManage() {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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="mt-2">
|
||||||
<div class="mb-1 text-xs text-slate-200">视频预览</div>
|
<div class="mb-1 text-xs text-slate-200">视频预览</div>
|
||||||
<div class="dashboard-aspect-video">
|
<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 { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||||
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
|
import { resolveAliyunVoiceId } from "./soundPromptStorage.js";
|
||||||
|
import { localAudioToPlayableUrl } from "./localAudio.js";
|
||||||
|
|
||||||
const VOICE_PREVIEW_SCRIPT = "voice_preview.js";
|
const VOICE_PREVIEW_SCRIPT = "voice_preview.js";
|
||||||
|
|
||||||
/** @type {Map<string, string>} cacheKey → data:audio URL */
|
/** @type {Map<string, string>} cacheKey → data:audio URL */
|
||||||
const previewUrlCache = new Map();
|
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
|
* @param {{ listKey: string, title: string, aliyunVoiceId?: string, selectedVoiceId?: string }} opts
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import {
|
|||||||
} from "../config/videoPipeline.js";
|
} from "../config/videoPipeline.js";
|
||||||
|
|
||||||
import { getVoiceDisplayLabel } from "../utils/voiceLabel.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,
|
avatarSelect: null,
|
||||||
|
|
||||||
|
speechGenerating: false,
|
||||||
|
|
||||||
|
generatedAudioPath: "",
|
||||||
|
|
||||||
|
generatedAudioSrc: "",
|
||||||
|
|
||||||
|
currentAudioId: null,
|
||||||
|
|
||||||
|
/** @type {Array<{ id: string, label: string, audioPath: string, audioSrc: string, createdAt: number }>} */
|
||||||
|
audioHistory: [],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 03 视频编辑
|
// 03 视频编辑
|
||||||
@@ -430,6 +445,18 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async generateSpeech() {
|
||||||
|
|
||||||
|
return executeGenerateSpeech(this);
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
pickAudioHistory(audioId) {
|
||||||
|
|
||||||
|
selectAudioHistory(this, audioId);
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
resetAll() {
|
resetAll() {
|
||||||
|
|
||||||
this.$reset();
|
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