11
This commit is contained in:
@@ -1,9 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed } 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";
|
||||||
|
|
||||||
const workflow = useWorkflowStore();
|
const workflow = useWorkflowStore();
|
||||||
const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(workflow);
|
const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(workflow);
|
||||||
|
|
||||||
|
const voiceButtonLabel = computed(() => workflow.selectedVoiceLabel);
|
||||||
|
|
||||||
|
function onOpenVoiceManage() {
|
||||||
|
workflow.openVoiceManageDialog();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -11,7 +19,15 @@ const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(wo
|
|||||||
<div class="grid grid-cols-3 gap-2">
|
<div class="grid grid-cols-3 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 text-xs text-slate-200">音色</div>
|
<div class="mb-1 text-xs text-slate-200">音色</div>
|
||||||
<Button label="选择音色" size="small" severity="secondary" outlined class="w-full" />
|
<Button
|
||||||
|
:label="voiceButtonLabel"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
class="w-full truncate"
|
||||||
|
:title="voiceButtonLabel"
|
||||||
|
@click="onOpenVoiceManage"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="mb-1 text-xs text-slate-200">情绪</div>
|
<div class="mb-1 text-xs text-slate-200">情绪</div>
|
||||||
@@ -72,5 +88,7 @@ const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(wo
|
|||||||
<span class="text-xs text-gray-400">暂无视频预览</span>
|
<span class="text-xs text-gray-400">暂无视频预览</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VoiceManageDialog />
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
133
src/components/workflow/VoiceCloneEditDialog.vue
Normal file
133
src/components/workflow/VoiceCloneEditDialog.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import {
|
||||||
|
addCloneVoice,
|
||||||
|
updateCloneVoice,
|
||||||
|
cloneVoiceTitleExists,
|
||||||
|
} from "../../services/soundPromptStorage.js";
|
||||||
|
|
||||||
|
const emit = defineEmits(["saved"]);
|
||||||
|
|
||||||
|
const visible = ref(false);
|
||||||
|
const editingId = ref(null);
|
||||||
|
const name = ref("");
|
||||||
|
const promptText = ref("");
|
||||||
|
const aliyunVoiceId = ref("");
|
||||||
|
const saving = ref(false);
|
||||||
|
const errorMessage = ref("");
|
||||||
|
|
||||||
|
const isEdit = computed(() => Boolean(editingId.value));
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingId.value = null;
|
||||||
|
name.value = "";
|
||||||
|
promptText.value = "";
|
||||||
|
aliyunVoiceId.value = "";
|
||||||
|
errorMessage.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdd() {
|
||||||
|
resetForm();
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ id: string, title: string, content?: { promptText?: string, aliyunVoiceId?: string } }} record
|
||||||
|
*/
|
||||||
|
function openEdit(record) {
|
||||||
|
editingId.value = record.id;
|
||||||
|
name.value = record.title || "";
|
||||||
|
promptText.value = record.content?.promptText || "";
|
||||||
|
aliyunVoiceId.value = record.content?.aliyunVoiceId || "";
|
||||||
|
errorMessage.value = "";
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
errorMessage.value = "";
|
||||||
|
const title = name.value.trim();
|
||||||
|
if (!title) {
|
||||||
|
errorMessage.value = "请输入音色名称";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cloneVoiceTitleExists(title, editingId.value)) {
|
||||||
|
errorMessage.value = "名称重复";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
content: {
|
||||||
|
promptText: promptText.value.trim(),
|
||||||
|
aliyunVoiceId: aliyunVoiceId.value.trim(),
|
||||||
|
url: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (editingId.value) {
|
||||||
|
updateCloneVoice(editingId.value, payload);
|
||||||
|
} else {
|
||||||
|
addCloneVoice(payload);
|
||||||
|
}
|
||||||
|
visible.value = false;
|
||||||
|
emit("saved");
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value =
|
||||||
|
err instanceof Error ? err.message : String(err || "保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ openAdd, openEdit });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="visible"
|
||||||
|
modal
|
||||||
|
:header="isEdit ? '编辑音色' : '添加音色'"
|
||||||
|
:style="{ width: '520px' }"
|
||||||
|
:draggable="false"
|
||||||
|
class="voice-clone-edit-dialog"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-field-label mb-1">名称 <span class="text-red-400">*</span></div>
|
||||||
|
<InputText v-model="name" class="w-full" size="small" placeholder="例如:我的克隆音色" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-field-label mb-1">参考文字</div>
|
||||||
|
<InputText
|
||||||
|
v-model="promptText"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
placeholder="可选,部分模型需要参考文字"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">部分模型需要使用,可选填写参考声音的文字内容</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="dashboard-field-label mb-1">音色 ID(可选)</div>
|
||||||
|
<InputText
|
||||||
|
v-model="aliyunVoiceId"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
placeholder="复刻成功后填入,留空则使用默认音色"
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
仅限复刻音色。参考音频录制与一键复刻功能后续版本支持。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="errorMessage" class="text-sm text-red-400">{{ errorMessage }}</p>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="取消" severity="secondary" @click="onCancel" />
|
||||||
|
<Button label="保存" :loading="saving" @click="onSave" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
197
src/components/workflow/VoiceManageDialog.vue
Normal file
197
src/components/workflow/VoiceManageDialog.vue
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||||
|
import {
|
||||||
|
VOICE_CATEGORY_OPTIONS,
|
||||||
|
filterSystemVoices,
|
||||||
|
} from "../../data/systemVoices.js";
|
||||||
|
import {
|
||||||
|
listCloneVoices,
|
||||||
|
deleteCloneVoice,
|
||||||
|
} from "../../services/soundPromptStorage.js";
|
||||||
|
import VoiceCloneEditDialog from "./VoiceCloneEditDialog.vue";
|
||||||
|
|
||||||
|
const workflow = useWorkflowStore();
|
||||||
|
const { voiceManageModalVisible, selectedVoiceId } = storeToRefs(workflow);
|
||||||
|
|
||||||
|
const activeTab = ref("system");
|
||||||
|
const voiceCategory = ref("putonghua");
|
||||||
|
const cloneVoices = ref([]);
|
||||||
|
const cloneLoading = ref(false);
|
||||||
|
const editDialogRef = ref(null);
|
||||||
|
|
||||||
|
const filteredSystemVoices = computed(() =>
|
||||||
|
filterSystemVoices(voiceCategory.value),
|
||||||
|
);
|
||||||
|
|
||||||
|
function loadCloneVoices() {
|
||||||
|
cloneLoading.value = true;
|
||||||
|
try {
|
||||||
|
cloneVoices.value = listCloneVoices();
|
||||||
|
} finally {
|
||||||
|
cloneLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(voiceManageModalVisible, (open) => {
|
||||||
|
if (open) {
|
||||||
|
activeTab.value = "system";
|
||||||
|
voiceCategory.value = "putonghua";
|
||||||
|
loadCloneVoices();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onClose() {
|
||||||
|
workflow.closeVoiceManageDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelectVoice(voiceId) {
|
||||||
|
workflow.selectVoice(voiceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAddClone() {
|
||||||
|
editDialogRef.value?.openAdd();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditClone(record) {
|
||||||
|
editDialogRef.value?.openEdit(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDeleteClone(record) {
|
||||||
|
if (!window.confirm(`确认删除音色「${record.title}」?`)) return;
|
||||||
|
deleteCloneVoice(record.id);
|
||||||
|
if (selectedVoiceId.value === record.id) {
|
||||||
|
workflow.selectVoice("sys_Cherry");
|
||||||
|
}
|
||||||
|
loadCloneVoices();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripVoiceIdPrefix(id) {
|
||||||
|
return String(id || "").replace(/^cosyvoice-v3-flash-/, "");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="voiceManageModalVisible"
|
||||||
|
modal
|
||||||
|
header="音色管理"
|
||||||
|
:style="{ width: '900px' }"
|
||||||
|
:draggable="false"
|
||||||
|
class="voice-manage-dialog"
|
||||||
|
@hide="onClose"
|
||||||
|
>
|
||||||
|
<Tabs v-model:value="activeTab">
|
||||||
|
<TabList>
|
||||||
|
<Tab value="system">系统音色</Tab>
|
||||||
|
<Tab value="clone">克隆音色</Tab>
|
||||||
|
</TabList>
|
||||||
|
<TabPanels>
|
||||||
|
<TabPanel value="system">
|
||||||
|
<SelectButton
|
||||||
|
v-model="voiceCategory"
|
||||||
|
:options="VOICE_CATEGORY_OPTIONS"
|
||||||
|
option-label="label"
|
||||||
|
option-value="key"
|
||||||
|
size="small"
|
||||||
|
class="mb-3"
|
||||||
|
/>
|
||||||
|
<div class="voice-list-scroll max-h-[50vh] space-y-2 overflow-y-auto pr-1">
|
||||||
|
<div
|
||||||
|
v-for="voice in filteredSystemVoices"
|
||||||
|
:key="voice.id"
|
||||||
|
class="rounded-xl border border-white/10 bg-white/5 p-3 transition-colors hover:border-purple-500/40"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div
|
||||||
|
class="inline-flex max-w-full flex-wrap items-center gap-x-2 rounded-full bg-white/5 px-2 py-1"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-slate-100">{{ voice.title }}</span>
|
||||||
|
<span class="text-xs text-slate-500">{{ voice.desc }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-slate-500">音色ID: {{ voice.aliyunVoiceId }}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
label="选择"
|
||||||
|
size="small"
|
||||||
|
icon="pi pi-check"
|
||||||
|
@click="onSelectVoice(voice.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="filteredSystemVoices.length === 0"
|
||||||
|
class="py-8 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
暂无音色
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel value="clone">
|
||||||
|
<div class="mb-3 flex justify-end">
|
||||||
|
<Button
|
||||||
|
label="添加克隆音色"
|
||||||
|
size="small"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
@click="onAddClone"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="voice-list-scroll max-h-[50vh] space-y-2 overflow-y-auto pr-1">
|
||||||
|
<p v-if="cloneLoading" class="py-6 text-center text-sm text-slate-500">加载中...</p>
|
||||||
|
<p
|
||||||
|
v-else-if="cloneVoices.length === 0"
|
||||||
|
class="py-8 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
暂无克隆音色,点击右上角添加
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="item in cloneVoices"
|
||||||
|
:key="item.id"
|
||||||
|
class="rounded-xl border border-white/10 bg-white/5 p-3 transition-colors hover:border-purple-500/40"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="truncate text-sm font-medium text-slate-100">
|
||||||
|
{{ item.title }}
|
||||||
|
</div>
|
||||||
|
<p v-if="item.content?.promptText" class="mt-1 text-xs text-slate-500">
|
||||||
|
{{ item.content.promptText }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="item.content?.aliyunVoiceId"
|
||||||
|
class="mt-1 text-xs text-blue-400"
|
||||||
|
>
|
||||||
|
音色ID: {{ stripVoiceIdPrefix(item.content.aliyunVoiceId) }}
|
||||||
|
</p>
|
||||||
|
<p v-else class="mt-1 text-xs text-amber-500/90">
|
||||||
|
未配置音色ID(将使用默认音色)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 gap-1">
|
||||||
|
<Button label="选择" size="small" @click="onSelectVoice(item.id)" />
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
aria-label="编辑"
|
||||||
|
@click="onEditClone(item)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
aria-label="删除"
|
||||||
|
@click="onDeleteClone(item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
<VoiceCloneEditDialog ref="editDialogRef" @saved="loadCloneVoices" />
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
110
src/data/systemVoices.js
Normal file
110
src/data/systemVoices.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/** 系统音色(对齐 Electron SoundPromptDialog 内置列表) */
|
||||||
|
|
||||||
|
export const VOICE_DIALECT_IDS = new Set([
|
||||||
|
"sys_Jada",
|
||||||
|
"sys_Dylan",
|
||||||
|
"sys_Li",
|
||||||
|
"sys_Marcus",
|
||||||
|
"sys_Roy",
|
||||||
|
"sys_Peter",
|
||||||
|
"sys_Sunny",
|
||||||
|
"sys_Eric",
|
||||||
|
"sys_Rocky",
|
||||||
|
"sys_Kiki",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const VOICE_FOREIGN_IDS = new Set([
|
||||||
|
"sys_Jennifer",
|
||||||
|
"sys_Aiden",
|
||||||
|
"sys_Bodega",
|
||||||
|
"sys_Sonrisa",
|
||||||
|
"sys_Alek",
|
||||||
|
"sys_Dolce",
|
||||||
|
"sys_Sohee",
|
||||||
|
"sys_Ono_Anna",
|
||||||
|
"sys_Lenn",
|
||||||
|
"sys_Emilien",
|
||||||
|
"sys_Katerina",
|
||||||
|
"sys_Andre",
|
||||||
|
"sys_Radio_Gol",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const SYSTEM_VOICES = [
|
||||||
|
{ id: "sys_Cherry", title: "芊悦(普通话女声)", desc: "阳光积极、亲切自然小姐姐|适合:普通话", aliyunVoiceId: "Cherry" },
|
||||||
|
{ id: "sys_Serena", title: "苏瑶(普通话女声)", desc: "温柔小姐姐|适合:普通话", aliyunVoiceId: "Serena" },
|
||||||
|
{ id: "sys_Ethan", title: "晨煦(普通话男声)", desc: "阳光温暖、活力朝气|适合:普通话", aliyunVoiceId: "Ethan" },
|
||||||
|
{ id: "sys_Chelsie", title: "千雪(二次元女声)", desc: "二次元虚拟女友|适合:普通话", aliyunVoiceId: "Chelsie" },
|
||||||
|
{ id: "sys_Momo", title: "茉兔(活泼女声)", desc: "撒娇搞怪,逗你开心|适合:普通话", aliyunVoiceId: "Momo" },
|
||||||
|
{ id: "sys_Vivian", title: "十三(个性女声)", desc: "拽拽的、可爱的小暴躁|适合:普通话", aliyunVoiceId: "Vivian" },
|
||||||
|
{ id: "sys_Moon", title: "月白(率性男声)", desc: "率性帅气的月白|适合:普通话", aliyunVoiceId: "Moon" },
|
||||||
|
{ id: "sys_Maia", title: "四月(知性女声)", desc: "知性与温柔的碰撞|适合:普通话", aliyunVoiceId: "Maia" },
|
||||||
|
{ id: "sys_Kai", title: "凯(沉浸男声)", desc: "耳朵的一场SPA|适合:普通话", aliyunVoiceId: "Kai" },
|
||||||
|
{ id: "sys_Nofish", title: "不吃鱼(特色男声)", desc: "不会翘舌音的设计师|适合:普通话", aliyunVoiceId: "Nofish" },
|
||||||
|
{ id: "sys_Bella", title: "萌宝(萝莉女声)", desc: "喝酒不打醉拳的小萝莉|适合:普通话", aliyunVoiceId: "Bella" },
|
||||||
|
{ id: "sys_Jennifer", title: "詹妮弗(美语女声)", desc: "品牌级、电影质感般美语女声|适合:英语", aliyunVoiceId: "Jennifer" },
|
||||||
|
{ id: "sys_Ryan", title: "甜茶(张力男声)", desc: "节奏拉满,戏感炸裂|适合:普通话", aliyunVoiceId: "Ryan" },
|
||||||
|
{ id: "sys_Katerina", title: "卡捷琳娜(御姐女声)", desc: "御姐音色,韵律回味十足|适合:普通话", aliyunVoiceId: "Katerina" },
|
||||||
|
{ id: "sys_Aiden", title: "艾登(美语男声)", desc: "精通厨艺的美语大男孩|适合:英语", aliyunVoiceId: "Aiden" },
|
||||||
|
{ id: "sys_Arthur", title: "徐大爷(故事男声)", desc: "被岁月浸泡过的质朴嗓音|适合:普通话", aliyunVoiceId: "Arthur" },
|
||||||
|
{ id: "sys_Bellona", title: "燕铮莺(洪亮女声)", desc: "声音洪亮,热血沸腾|适合:普通话", aliyunVoiceId: "Bellona" },
|
||||||
|
{ id: "sys_Bunny", title: "萌小姬(萌系女声)", desc: "萌属性爆棚的小萝莉|适合:普通话", aliyunVoiceId: "Bunny" },
|
||||||
|
{ id: "sys_Mia", title: "乖小妹(温顺女声)", desc: "温顺如春水,乖巧如初雪|适合:普通话", aliyunVoiceId: "Mia" },
|
||||||
|
{ id: "sys_Mochi", title: "沙小弥(早慧童声)", desc: "聪明伶俐的小大人|适合:普通话", aliyunVoiceId: "Mochi" },
|
||||||
|
{ id: "sys_Neil", title: "阿闻(新闻男声)", desc: "字正腔圆的新闻主持人|适合:普通话", aliyunVoiceId: "Neil" },
|
||||||
|
{ id: "sys_Nini", title: "邻家妹妹(甜美女声)", desc: "糯米糍一样又软又黏|适合:普通话", aliyunVoiceId: "Nini" },
|
||||||
|
{ id: "sys_Ebona", title: "诡婆婆(惊悚女声)", desc: "低语般的惊悚氛围|适合:普通话", aliyunVoiceId: "Ebona" },
|
||||||
|
{ id: "sys_Seren", title: "小婉(助眠女声)", desc: "温和舒缓,助你入眠|适合:普通话", aliyunVoiceId: "Seren" },
|
||||||
|
{ id: "sys_Pip", title: "顽屁小孩(淘气童声)", desc: "调皮捣蛋却充满童真|适合:普通话", aliyunVoiceId: "Pip" },
|
||||||
|
{ id: "sys_Stella", title: "少女阿月(元气女声)", desc: "甜系元气少女|适合:普通话", aliyunVoiceId: "Stella" },
|
||||||
|
{ id: "sys_Vincent", title: "田叔(烟嗓男声)", desc: "独特沙哑烟嗓|适合:普通话", aliyunVoiceId: "Vincent" },
|
||||||
|
{ id: "sys_Radio_Gol", title: "拉迪奥·戈尔(足球解说)", desc: "足球诗人解说风格|适合:普通话", aliyunVoiceId: "Radio Gol" },
|
||||||
|
{ id: "sys_Jada", title: "上海-阿珍(上海话女声)", desc: "风风火火的沪上阿姐|适合:上海话", aliyunVoiceId: "Jada" },
|
||||||
|
{ id: "sys_Dylan", title: "北京-晓东(北京话男声)", desc: "北京胡同里长大的少年|适合:北京话", aliyunVoiceId: "Dylan" },
|
||||||
|
{ id: "sys_Li", title: "南京-老李(南京话男声)", desc: "耐心的瑜伽老师|适合:南京话", aliyunVoiceId: "Li" },
|
||||||
|
{ id: "sys_Marcus", title: "陕西-秦川(陕西话男声)", desc: "老陕的味道|适合:陕西话", aliyunVoiceId: "Marcus" },
|
||||||
|
{ id: "sys_Roy", title: "闽南-阿杰(闽南语男声)", desc: "诙谐直爽、市井活泼|适合:闽南语", aliyunVoiceId: "Roy" },
|
||||||
|
{ id: "sys_Peter", title: "天津-李彼得(天津话男声)", desc: "天津相声,专业捧哏|适合:天津话", aliyunVoiceId: "Peter" },
|
||||||
|
{ id: "sys_Sunny", title: "四川-晴儿(四川话女声)", desc: "甜到心里的川妹子|适合:四川话", aliyunVoiceId: "Sunny" },
|
||||||
|
{ id: "sys_Eric", title: "四川-程川(四川话男声)", desc: "跳脱市井的成都男子|适合:四川话", aliyunVoiceId: "Eric" },
|
||||||
|
{ id: "sys_Rocky", title: "粤语-阿强(粤语男声)", desc: "幽默风趣,在线陪聊|适合:粤语", aliyunVoiceId: "Rocky" },
|
||||||
|
{ id: "sys_Kiki", title: "粤语-阿清(粤语女声)", desc: "甜美的港妹闺蜜|适合:粤语", aliyunVoiceId: "Kiki" },
|
||||||
|
{ id: "sys_Bodega", title: "博德加(西语男声)", desc: "热情的西班牙大叔|适合:西班牙语", aliyunVoiceId: "Bodega" },
|
||||||
|
{ id: "sys_Sonrisa", title: "索尼莎(西语女声)", desc: "热情开朗的拉美大姐|适合:西班牙语", aliyunVoiceId: "Sonrisa" },
|
||||||
|
{ id: "sys_Alek", title: "阿列克(俄语男声)", desc: "战斗民族的冷与暖|适合:俄语", aliyunVoiceId: "Alek" },
|
||||||
|
{ id: "sys_Dolce", title: "多尔切(意语男声)", desc: "慵懒的意大利大叔|适合:意大利语", aliyunVoiceId: "Dolce" },
|
||||||
|
{ id: "sys_Sohee", title: "素熙(韩语女声)", desc: "温柔开朗,情绪丰富|适合:韩语", aliyunVoiceId: "Sohee" },
|
||||||
|
{ id: "sys_Ono_Anna", title: "小野杏(日语女声)", desc: "鬼灵精怪的青梅竹马|适合:日语", aliyunVoiceId: "Ono Anna" },
|
||||||
|
{ id: "sys_Lenn", title: "莱恩(德语男声)", desc: "理性底色,叛逆细节|适合:德语", aliyunVoiceId: "Lenn" },
|
||||||
|
{ id: "sys_Emilien", title: "埃米尔安(法语男声)", desc: "浪漫的法国大哥哥|适合:法语", aliyunVoiceId: "Emilien" },
|
||||||
|
{ id: "sys_Andre", title: "安德雷(磁性男声)", desc: "自然舒服、沉稳男声|适合:普通话", aliyunVoiceId: "Andre" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_SYSTEM_VOICE_ID = "sys_Cherry";
|
||||||
|
|
||||||
|
export const VOICE_CATEGORY_OPTIONS = [
|
||||||
|
{ key: "putonghua", label: "普通话" },
|
||||||
|
{ key: "waiwen", label: "外文" },
|
||||||
|
{ key: "fangyan", label: "方言" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {'putonghua'|'waiwen'|'fangyan'} category
|
||||||
|
*/
|
||||||
|
export function filterSystemVoices(category) {
|
||||||
|
if (category === "fangyan") {
|
||||||
|
return SYSTEM_VOICES.filter((v) => VOICE_DIALECT_IDS.has(v.id));
|
||||||
|
}
|
||||||
|
if (category === "waiwen") {
|
||||||
|
return SYSTEM_VOICES.filter((v) => VOICE_FOREIGN_IDS.has(v.id));
|
||||||
|
}
|
||||||
|
return SYSTEM_VOICES.filter(
|
||||||
|
(v) => !VOICE_DIALECT_IDS.has(v.id) && !VOICE_FOREIGN_IDS.has(v.id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} voiceId
|
||||||
|
*/
|
||||||
|
export function findSystemVoice(voiceId) {
|
||||||
|
return SYSTEM_VOICES.find((v) => v.id === voiceId) ?? null;
|
||||||
|
}
|
||||||
103
src/services/soundPromptStorage.js
Normal file
103
src/services/soundPromptStorage.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/** 克隆音色本地存储(对齐 Electron be.list/add/update/delete SoundPrompt) */
|
||||||
|
|
||||||
|
import { findSystemVoice } from "../data/systemVoices.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "aiclient_sound_prompts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {{ id: string, title: string, content: { url?: string, promptText?: string, aliyunVoiceId?: string } }} SoundPromptRecord
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @returns {SoundPromptRecord[]} */
|
||||||
|
export function listCloneVoices() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {SoundPromptRecord[]} list */
|
||||||
|
function saveCloneVoices(list) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(list));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ title: string, content?: SoundPromptRecord['content'] }} payload
|
||||||
|
* @returns {SoundPromptRecord}
|
||||||
|
*/
|
||||||
|
export function addCloneVoice(payload) {
|
||||||
|
const list = listCloneVoices();
|
||||||
|
const record = {
|
||||||
|
id: `clone_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
title: String(payload.title || "").trim(),
|
||||||
|
content: {
|
||||||
|
url: payload.content?.url || "",
|
||||||
|
promptText: payload.content?.promptText || "",
|
||||||
|
aliyunVoiceId: payload.content?.aliyunVoiceId || "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
list.unshift(record);
|
||||||
|
saveCloneVoices(list);
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @param {{ title: string, content?: SoundPromptRecord['content'] }} payload
|
||||||
|
*/
|
||||||
|
export function updateCloneVoice(id, payload) {
|
||||||
|
const list = listCloneVoices();
|
||||||
|
const idx = list.findIndex((item) => item.id === id);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
list[idx] = {
|
||||||
|
...list[idx],
|
||||||
|
title: String(payload.title || "").trim(),
|
||||||
|
content: {
|
||||||
|
...list[idx].content,
|
||||||
|
...payload.content,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
saveCloneVoices(list);
|
||||||
|
return list[idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} id */
|
||||||
|
export function deleteCloneVoice(id) {
|
||||||
|
const list = listCloneVoices().filter((item) => item.id !== id);
|
||||||
|
saveCloneVoices(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} id */
|
||||||
|
export function getCloneVoice(id) {
|
||||||
|
return listCloneVoices().find((item) => item.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string} title */
|
||||||
|
export function cloneVoiceTitleExists(title, excludeId = null) {
|
||||||
|
const t = String(title || "").trim();
|
||||||
|
return listCloneVoices().some(
|
||||||
|
(item) => item.title === t && item.id !== excludeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析选中音色对应的阿里云 voiceId
|
||||||
|
* @param {string} selectedVoiceId
|
||||||
|
* @param {SoundPromptRecord[]} [clones]
|
||||||
|
*/
|
||||||
|
export function resolveAliyunVoiceId(selectedVoiceId, clones = listCloneVoices()) {
|
||||||
|
if (!selectedVoiceId) return "Cherry";
|
||||||
|
if (selectedVoiceId.startsWith("sys_")) {
|
||||||
|
return findSystemVoiceFromId(selectedVoiceId);
|
||||||
|
}
|
||||||
|
const clone = clones.find((c) => c.id === selectedVoiceId);
|
||||||
|
return String(clone?.content?.aliyunVoiceId || "").trim() || "Cherry";
|
||||||
|
}
|
||||||
|
|
||||||
|
function findSystemVoiceFromId(id) {
|
||||||
|
return findSystemVoice(id)?.aliyunVoiceId || "Cherry";
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
|
|
||||||
} from "../config/videoPipeline.js";
|
} from "../config/videoPipeline.js";
|
||||||
|
|
||||||
|
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const PIPELINE_SCRIPT = "douyin_pipeline.js";
|
const PIPELINE_SCRIPT = "douyin_pipeline.js";
|
||||||
@@ -138,6 +140,11 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
|
|
||||||
// 02 音视频生成
|
// 02 音视频生成
|
||||||
|
|
||||||
|
/** 选中音色 id:sys_* 系统音色 或 clone_* 克隆音色 */
|
||||||
|
selectedVoiceId: "sys_Cherry",
|
||||||
|
|
||||||
|
voiceManageModalVisible: false,
|
||||||
|
|
||||||
voiceEmotion: "自然",
|
voiceEmotion: "自然",
|
||||||
|
|
||||||
speechRate: 1,
|
speechRate: 1,
|
||||||
@@ -220,6 +227,8 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
|
|
||||||
avatarOptions: () => [{ label: "请选择形象", value: null }],
|
avatarOptions: () => [{ label: "请选择形象", value: null }],
|
||||||
|
|
||||||
|
selectedVoiceLabel: (state) => getVoiceDisplayLabel(state.selectedVoiceId),
|
||||||
|
|
||||||
keywordCountFocus: (state) => countLines(state.keywordsFocus),
|
keywordCountFocus: (state) => countLines(state.keywordsFocus),
|
||||||
|
|
||||||
keywordCountDescribe: (state) => countLines(state.keywordsDescribe),
|
keywordCountDescribe: (state) => countLines(state.keywordsDescribe),
|
||||||
@@ -401,6 +410,26 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openVoiceManageDialog() {
|
||||||
|
|
||||||
|
this.voiceManageModalVisible = true;
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
closeVoiceManageDialog() {
|
||||||
|
|
||||||
|
this.voiceManageModalVisible = false;
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
|
selectVoice(voiceId) {
|
||||||
|
|
||||||
|
this.selectedVoiceId = voiceId;
|
||||||
|
|
||||||
|
this.voiceManageModalVisible = false;
|
||||||
|
|
||||||
|
},
|
||||||
|
|
||||||
resetAll() {
|
resetAll() {
|
||||||
|
|
||||||
this.$reset();
|
this.$reset();
|
||||||
|
|||||||
14
src/utils/voiceLabel.js
Normal file
14
src/utils/voiceLabel.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { findSystemVoice } from "../data/systemVoices.js";
|
||||||
|
import { getCloneVoice } from "../services/soundPromptStorage.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string | null | undefined} voiceId
|
||||||
|
*/
|
||||||
|
export function getVoiceDisplayLabel(voiceId) {
|
||||||
|
if (!voiceId) return "选择音色";
|
||||||
|
if (voiceId.startsWith("sys_")) {
|
||||||
|
return findSystemVoice(voiceId)?.title || "选择音色";
|
||||||
|
}
|
||||||
|
const clone = getCloneVoice(voiceId);
|
||||||
|
return clone?.title || "选择音色";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user