MiMo TTS Studio 的前端状态管理用了 6 个 Pinia Store。这不是过度设计——每个 Store 对应一个清晰的业务域,Store 之间通过依赖注入(useXxxStore())协作。本文拆解每个 Store 的职责边界和协作关系。
Q1:6 个 Store 分别管什么?
A: 先看全景图:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| ┌─────────────────────────────────────────────────┐ │ Store 架构 │ │ │ │ ┌──────────────┐ │ │ │ configStore │ ← 全局配置(Provider/导出格式) │ │ └──────┬───────┘ │ │ │ 被读取 │ │ ┌──────▼───────┐ ┌──────────────┐ │ │ │characterStore│ │ ttsStore │ ◄── 核心调度 │ │ │ (角色管理) │ │ (TTS生成/并发) │ │ │ └──────┬───────┘ └──────┬───────┘ │ │ │ 读写 │ 调用 │ │ ┌──────▼───────┐ ┌──────▼───────┐ │ │ │ lineStore │ │ projectStore│ │ │ │ (台词CRUD) │ │ (项目聚合层) │ │ │ └──────────────┘ └──────────────┘ │ │ │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ useTheme │ │ useKeyboard │ │ │ │ (深浅主题) │ │ (快捷键) │ │ │ └──────────────┘ └──────────────┘ │ └─────────────────────────────────────────────────┘
|
| Store |
文件 |
职责 |
状态数量 |
| characterStore |
characterStore.js |
角色的 CRUD、磁盘持久化、项目目录扫描恢复 |
~10 |
| lineStore |
lineStore.js |
单条台词的状态管理、最后生成结果 |
~8 |
| ttsStore |
ttsStore.js |
TTS 核心引擎:生成/试听/批量/并发池 |
~25 |
| configStore |
configStore.js |
全局配置:API地址、导出格式、TTS Provider |
~15 |
| useTheme |
useTheme.js |
深色/浅色主题切换 |
~3 |
| useKeyboardShortcuts |
useKeyboardShortcuts.js |
键盘快捷键注册与处理 |
~5 |
Q2:characterStore 是怎么实现磁盘持久化的?”角色即文件夹”是什么意思?
A: 这是整个数据模型的核心设计——每个角色在磁盘上就是一个文件夹,角色属性 = 文件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
|
function createDefaultCharacter(name = '默认角色') { return { id: generateId('char'), name, voiceId: 'mimo_default', voiceSource: 'preset', voiceDesignPrompt: '', cloneVoicePath: '', cloneVoiceMime: 'audio/wav', styleTags: [], speed: 1.0, lines: [] }; }
function charDir(ch) { return (projectPath.value + '/' + ch.name).replace(/\/+/g, '/'); }
|
磁盘上的实际文件:
1 2 3 4 5 6 7 8 9
| projectPath/ └── NPC张三/ ← 角色文件夹(名字即角色名) ├── voice.json ← 音色配置 ├── script.txt ← 台词文本(每行一句) ├── lines_meta.json ← 台词元数据(ID/音频路径/状态/时长) └── audio/ ← 生成的音频文件 ├── NPC张三_L01.wav ├── NPC张三_L02.wav └── ...
|
为什么这样设计?
| 设计选择 |
好处 |
| 角色=文件夹 |
文件管理器里一目了然,可以直接操作 |
| script.txt 存纯文本 |
可以用任何编辑器编辑台词,Git 友好(diff 友好) |
| voice.json 存 JSON |
结构化配置,程序读写方便 |
| audio/ 子目录 |
音频文件和配置分离,不会弄混 |
| lines_meta.json |
记录哪些台词已合成、状态如何,启动时快速恢复 |
保存到磁盘的实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| async function saveCharacterToDisk(ch) { if (!projectPath.value) return;
const dir = charDir(ch); const audioDir = dir + '/audio';
await ensureDir(dir); await ensureDir(audioDir);
const voiceConfig = { voiceId: ch.voiceId, voiceSource: ch.voiceSource, voiceDesignPrompt: ch.voiceDesignPrompt, cloneVoicePath: ch.cloneVoicePath || '', styleTags: ch.styleTags, speed: ch.speed }; await writeFile(dir + '/voice.json', JSON.stringify(voiceConfig, null, 2));
const scriptText = ch.lines.map(l => l.text).join('\n'); await writeFile(dir + '/script.txt', scriptText);
const metaData = ch.lines.map(l => ({ id: l.id, audioPath: l.audioPath, status: l.status, duration: l.duration })); await writeFile(dir + '/lines_meta.json', JSON.stringify(metaData, null, 2)); }
|
防抖落盘:每次修改后不是立刻写盘,而是等 1 秒无操作后再写(通过 watch + debounce 实现),避免频繁 IO。
Q3:lineStore 和 characterStore 的关系是什么?
A: lineStore 不独立拥有台词数据——它只是 characterStore.currentCharacter.lines 的便捷操作层:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| export const useLineStore = defineStore('line', () => { const isGenerating = ref(false); const lastGeneration = ref(null); const associatedAudio = ref(null);
function addLine(text, charId) { } function updateLine(lineId, updates) { } function removeLine(lineId) { } function reorderLines(fromIdx, toIdx) { } function importLines(textArray) { }
});
|
为什么不把所有逻辑都放 characterStore?
因为 ttsStore 需要频繁访问”当前正在生成”的状态(isGenerating、lastGeneration),如果这些状态塞进 characterStore,会让角色模型的职责变模糊。
分离的好处:
- characterStore 专注:角色增删改查 + 磁盘 IO
- lineStore 专注:单条台词的操作 + 生成状态
- ttsStore 专注:TTS 引擎调用 + 并发控制
三者关系:ttsStore 调用 lineStore 更新状态,lineStore 操作 characterStore 的数据,characterStore 负责最终落盘。
Q4:ttsStore 作为核心调度者,它怎么协调其他 Store?
A: ttsStore 是整个前端最复杂的 Store——它是 TTS 操作的唯一入口,内部通过 useXxxStore() 获取其他 Store 的引用:
1 2 3 4 5 6 7 8
| export const useTtsStore = defineStore('tts', () => { const charStore = useCharacterStore(); const configStore = useConfigStore(); const lineStore = useLineStore();
|
正确的跨 Store 协作模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| async function doGenerateTTS(outputPath, outputFilename, options = {}) { const charStore = useCharacterStore(); const configStore = useConfigStore(); const lineStore = useLineStore();
lineStore.isGenerating = true;
try { const provider = configStore.ttsProvider || 'mimo'; const format = configStore.exportFormat;
const ch = charStore.currentCharacter;
const result = await ipc.invoke(ipcApiRoute.ttsGenerate, params);
if (result?.success) { lineStore.lastGeneration = result; lineStore.associatedAudio = { type: 'audio', path: result.audioPath }; }
return result; } finally { lineStore.isGenerating = false; } }
|
调用链路图(以生成一条台词为例):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 用户点击 "生成" 按钮 ↓ ttsStore.generateLine(charId, lineId) ↓ ├── validateCloneConfig(ch) // 校验克隆配置 │ └── charStore.characters.find() ↓ │ line.status = 'generating' │ charStore.saveCharacterToDisk() // 持久化状态变更 ↓ │ ttsStore.doGenerateTTS(path, filename, options) │ ├── configStore → 读取 exportFormat / ttsProvider │ ├── charStore → 读取 voiceId / speed / voiceSource │ ├── ipc.invoke('tts:generate', params) → 主进程 TTSService │ ↓ │ │ lineStore.lastGeneration = result // 记录结果 │ │ lineStore.associatedAudio = {...} // 关联音频 │ ↓ │ line.status = 'done' 或 'error' │ charStore.saveCharacterToDisk() // 再次持久化 ↓ 返回结果 → UI 更新波形/播放器
|
Q5:configStore 管了哪些全局配置?怎么保证配置不丢失?
A: configStore 是唯一一个应用级全局配置的 Store:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
| export const useConfigStore = defineStore('config', () => { const ttsProvider = ref('mimo'); const apiBase = ref(''); const apiKey = ref('');
const voxCpmEndpoint = ref(''); const voxCpmApiKey = ref(''); const voxCpmModel = ref('voxcpm2');
const exportFormat = ref('wav');
const allowSyntheticFallback = ref(true); const directorMode = ref({ role: '', scene: '', guide: '' });
async function saveConfig() { const data = { ttsProvider, apiBase, apiKey, }; await ipc.invoke(ipcApiRoute.configSave, data); }
async function loadConfig() { const data = await ipc.invoke(ipcApiRoute.configGet); if (data) { Object.assign(); } }
return { ttsProvider, apiBase, apiKey, , saveConfig, loadConfig }; });
|
配置持久化链路
1 2 3 4 5
| configStore.saveConfig() ↓ IPC: 'config:save' ↓ 主进程 ConfigController.save() ↓ service/config.js → 写入 app/data/config.json ↓ 下次启动 → config.load() → 自动恢复
|
配置独立于角色数据存储——换项目不丢配置,换电脑可以迁移配置文件。
Q6:useTheme 和 useKeyboardShortcuts 为什么也是 Store?
A: 严格来说它们更像是 Composable(组合式函数),但用 Pinia Store 的原因是:
- 需要跨组件共享状态 —— 多个组件需要知道当前是亮/暗主题
- 需要响应式 —— 切换主题后所有组件立即更新
- 保持一致性 —— 团队约定”共享状态都用 Store”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| export const useTheme = defineStore('theme', () => { const isDark = ref(true);
function toggleTheme() { isDark.value = !isDark.value; document.documentElement.setAttribute( 'data-theme', isDark.value ? 'dark' : 'light' ); }
function initTheme() { const saved = localStorage.getItem('theme'); if (saved !== null) isDark.value = saved === 'dark'; toggleTheme(); }
return { isDark, toggleTheme, initTheme }; });
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| export const useKeyboardShortcuts = defineStore('shortcuts', () => { const shortcuts = ref([ { key: 'Ctrl+Enter', action: 'generateCurrent', label: '生成当前台词' }, { key: 'Ctrl+Shift+G', action: 'generateAll', label: '全部生成' }, { key: 'Space', action: 'togglePlayPause', label: '播放/暂停' }, { key: 'Ctrl+S', action: 'saveProject', label: '保存' }, ]);
function registerGlobal() { window.addEventListener('keydown', handleKeyDown); }
function unregisterGlobal() { window.removeEventListener('keydown', handleKeyDown); }
});
|
总结
| Store |
核心职责 |
依赖谁 |
被谁依赖 |
| configStore |
全局配置读写 |
无(仅 IPC) |
ttsStore, 所有 UI 组件 |
| characterStore |
角色 CRUD + 磁盘持久化 |
IPC (file) |
lineStore, ttsStore |
| lineStore |
台词操作 + 生成状态 |
characterStore |
ttsStore, UI 组件 |
| ttsStore |
TTS 引擎调度 + 并发控制 |
configStore, characterStore, lineStore |
UI 组件 |
| useTheme |
主题切换 |
无 |
App.vue, Naive UI |
| useKeyboardShortcuts |
快捷键 |
ttsStore |
Index.vue |
设计原则总结:
- 单向依赖:configStore → characterStore → lineStore → ttsStore,不循环
- 单一职责:每个 Store 只管自己的领域
- 延迟获取:跨 Store 引用在函数内部用
useXxxStore() 获取,避免 stale closure
- 磁盘是真相源:内存状态可以丢,但磁盘文件不能丢;启动时从磁盘恢复
下一篇深入 IPC 封装 —— 浏览器/Electron 双环境兼容的设计。