This commit is contained in:
fengchuanhn@gmail.com
2026-05-18 14:12:57 +08:00
parent 6cc305f5e8
commit 4771397301
7 changed files with 292 additions and 27 deletions

View File

@@ -15,6 +15,7 @@ fn is_allowed_local_media(path: &Path) -> bool {
let lower = path.to_string_lossy().to_lowercase();
if lower.contains("aiclient-voice-preview-cache")
|| lower.contains("aiclient-node-pipeline")
|| lower.contains("aiclient-generated-speech")
{
return true;
}

View File

@@ -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">

View 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}`;
}

View 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;
}

View File

@@ -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

View File

@@ -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
View 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];
}