diff --git a/src/components/workflow/Step02AudioVideo.vue b/src/components/workflow/Step02AudioVideo.vue index bfff05b..ffd093e 100644 --- a/src/components/workflow/Step02AudioVideo.vue +++ b/src/components/workflow/Step02AudioVideo.vue @@ -1,9 +1,17 @@ @@ -11,7 +19,15 @@ const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(wo 音色 - + 情绪 @@ -72,5 +88,7 @@ const { voiceEmotion, speechRate, voiceLanguage, avatarSelect } = storeToRefs(wo 暂无视频预览 + + diff --git a/src/components/workflow/VoiceCloneEditDialog.vue b/src/components/workflow/VoiceCloneEditDialog.vue new file mode 100644 index 0000000..1197324 --- /dev/null +++ b/src/components/workflow/VoiceCloneEditDialog.vue @@ -0,0 +1,133 @@ + + + + + + + 名称 * + + + + 参考文字 + + 部分模型需要使用,可选填写参考声音的文字内容 + + + 音色 ID(可选) + + + 仅限复刻音色。参考音频录制与一键复刻功能后续版本支持。 + + + {{ errorMessage }} + + + + + + + diff --git a/src/components/workflow/VoiceManageDialog.vue b/src/components/workflow/VoiceManageDialog.vue new file mode 100644 index 0000000..f6dc408 --- /dev/null +++ b/src/components/workflow/VoiceManageDialog.vue @@ -0,0 +1,197 @@ + + + + + + + 系统音色 + 克隆音色 + + + + + + + + + + {{ voice.title }} + {{ voice.desc }} + + 音色ID: {{ voice.aliyunVoiceId }} + + + + + + 暂无音色 + + + + + + + + + 加载中... + + 暂无克隆音色,点击右上角添加 + + + + + + {{ item.title }} + + + {{ item.content.promptText }} + + + 音色ID: {{ stripVoiceIdPrefix(item.content.aliyunVoiceId) }} + + + 未配置音色ID(将使用默认音色) + + + + + + + + + + + + + + + + diff --git a/src/data/systemVoices.js b/src/data/systemVoices.js new file mode 100644 index 0000000..15f2a1e --- /dev/null +++ b/src/data/systemVoices.js @@ -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; +} diff --git a/src/services/soundPromptStorage.js b/src/services/soundPromptStorage.js new file mode 100644 index 0000000..9247384 --- /dev/null +++ b/src/services/soundPromptStorage.js @@ -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"; +} diff --git a/src/stores/workflow.js b/src/stores/workflow.js index d716577..73373e8 100644 --- a/src/stores/workflow.js +++ b/src/stores/workflow.js @@ -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 音视频生成 + /** 选中音色 id:sys_* 系统音色 或 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(); diff --git a/src/utils/voiceLabel.js b/src/utils/voiceLabel.js new file mode 100644 index 0000000..32903b6 --- /dev/null +++ b/src/utils/voiceLabel.js @@ -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 || "选择音色"; +}
部分模型需要使用,可选填写参考声音的文字内容
+ 仅限复刻音色。参考音频录制与一键复刻功能后续版本支持。 +
{{ errorMessage }}
音色ID: {{ voice.aliyunVoiceId }}
+ 暂无音色 +
加载中...
+ 暂无克隆音色,点击右上角添加 +
+ {{ item.content.promptText }} +
+ 音色ID: {{ stripVoiceIdPrefix(item.content.aliyunVoiceId) }} +
+ 未配置音色ID(将使用默认音色) +