This commit is contained in:
fengchuanhn@gmail.com
2026-05-18 10:09:55 +08:00
parent fb1c3c55d6
commit beb5eaeb42
7 changed files with 605 additions and 1 deletions

View File

@@ -1,9 +1,17 @@
<script setup>
import { computed } 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 voiceButtonLabel = computed(() => workflow.selectedVoiceLabel);
function onOpenVoiceManage() {
workflow.openVoiceManageDialog();
}
</script>
<template>
@@ -11,7 +19,15 @@ const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(wo
<div class="grid grid-cols-3 gap-2">
<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 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>
</div>
</div>
<VoiceManageDialog />
</DashboardCard>
</template>

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

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

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

View File

@@ -16,6 +16,8 @@ import {
} from "../config/videoPipeline.js";
import { getVoiceDisplayLabel } from "../utils/voiceLabel.js";
const PIPELINE_SCRIPT = "douyin_pipeline.js";
@@ -138,6 +140,11 @@ export const useWorkflowStore = defineStore("workflow", {
// 02 音视频生成
/** 选中音色 idsys_* 系统音色 或 clone_* 克隆音色 */
selectedVoiceId: "sys_Cherry",
voiceManageModalVisible: false,
voiceEmotion: "自然",
speechRate: 1,
@@ -220,6 +227,8 @@ export const useWorkflowStore = defineStore("workflow", {
avatarOptions: () => [{ label: "请选择形象", value: null }],
selectedVoiceLabel: (state) => getVoiceDisplayLabel(state.selectedVoiceId),
keywordCountFocus: (state) => countLines(state.keywordsFocus),
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() {
this.$reset();

14
src/utils/voiceLabel.js Normal file
View 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 || "选择音色";
}