Xisound Preset 全链路规范 v1.0
文档定位
- 产生背景:2026-05-08 Source/Sink 重构暴露 preset 保存机制问题(Bug #5/#6)· preset 参数和界面不统一 · 保存后加载 UI 不刷新
- 权威性:所有模块的 preset 保存/加载流程必须遵守本文档。禁止自建 preset 逻辑、禁止跳过 store 直写 UI
- 核心贡献:定义 Preset 全生命周期 + UI-Param-Preset 三向同步契约 + 脏标记机制 + 跨 session 共享策略
- 上游:D3-FE-ARCH-007 模块 UI 实现规范 §5 三向同步时序 · Source/Sink Review v7.0 §10.12 Save/Load Project
- 下游:各 app tech-arch 的 preset 模块实施 · 后端 PresetProfileService
1. Preset 的产品定位
1.1 什么是 preset
Preset(预设) = 一个模块实例的参数集合快照 + 元数据,用户可以:
- 保存当前调好的参数为 "MyRockBass"
- 后续任何时候加载 "MyRockBass" 恢复原参数
- 跨 session 跨工程重用("MyRockBass" 在 A 工程调好后可在 B 工程继续用)
- 分享给同事(导出 JSON / 上传到团队库)
1.2 Preset vs Profile
| 对象 | 作用域 | 举例 |
|---|---|---|
| Preset(本文) | 单个模块实例的参数 | EQ#3 的 "Rock Bass" 预设(freq/gain/q 三频段) |
| Profile | 整个链路的场景切换 | "白天模式"(link.json 的 runtimeState)· 含多模块参数 |
关系:Profile 切换时可能会加载多个模块的各自 preset · 是组合关系不是继承关系。Profile 的规范参考 Source/Sink Review v7.0 §10.12。
1.3 Preset 的使用场景
| 场景 | 流程 |
|---|---|
| 日常调音 | 调参数 → 满意 → 保存为 preset "v1.0" → 继续改 → 觉得不好 → 恢复 "v1.0" |
| A/B 对比 | Preset "vA" / "vB" 切换加载 · 比较音色差异 |
| 跨车型复用 | 调好宝马车型 → 保存 preset → 应用到奥迪车型(参数微调) |
| 模板开发 | XiForge 开发新模块时 · 定义 3-5 个典型 preset("Smooth" / "Aggressive" / "Flat") |
2. 数据模型
2.1 Preset Schema(@xi/protocol)
// packages/protocol/src/preset.ts
import { z } from 'zod'
export const ModulePresetSchema = z.object({
// 基础元数据
id: z.string(), // UUID 或 "${moduleType}_${name}"
name: z.string().min(1).max(50), // 用户可见名称
moduleType: z.string(), // 模块类型(如 'eq_parametric_v1')· 必须匹配才能加载
description: z.string().optional(), // 长描述
tags: z.array(z.string()).optional(), // 搜索标签
// 版本控制
version: z.string(), // SemVer 如 "1.0.0"
schemaVersion: z.string(), // 所属 module 的 schema 版本 · 用于迁移
createdAt: z.number(), // Unix timestamp
updatedAt: z.number(),
// 作者/来源
author: z.object({
userId: z.string().optional(),
name: z.string(),
tenant: z.string().optional(),
}),
source: z.enum(['user', 'official', 'community', 'imported']),
// 参数集合(核心内容)
params: z.record(z.union([
z.number(),
z.string(),
z.boolean(),
z.array(z.number()),
z.record(z.any()), // 嵌套结构,如 EQ 的 bands 数组
])),
// Signal Flow 绑定(可选)
bindings: z.array(z.object({
paramKey: z.string(),
signalName: z.string(),
mode: z.enum(['absolute', 'offset', 'multiplier']),
})).optional(),
// 统计与排序
usageCount: z.number().default(0), // 使用次数(用于排序)
favoriteAt: z.number().optional(), // 收藏时间戳
thumbnail: z.string().optional(), // base64 缩略图(用于可视化 preset)
})
export type ModulePreset = z.infer<typeof ModulePresetSchema>
2.2 关键字段说明
| 字段 | 强制 | 说明 |
|---|---|---|
id |
✅ | 唯一标识 · 推荐 UUID · 跨 session 也唯一 |
name |
✅ | 用户可见 · 最多 50 字符 · 用户可修改 |
moduleType |
✅ | 关键 · preset 只能加载到相同 moduleType 的模块上 · mismatch 则拒绝加载 |
schemaVersion |
✅ | 关联 module schema 版本 · 用于处理 schema 变更时的 preset 升级(§7) |
params |
✅ | 参数键值对 · key 必须匹配 module 的 ParamDef.key |
bindings |
⚠️ | 可选 · 如果 preset 包含 Signal Flow 绑定则带上(加载时恢复绑定) |
2.3 结构性 vs 非结构性参数
关键概念(参考 Source/Sink Review v7.0 §11.5):
- 非结构性参数(99% 的参数):gain/frequency/threshold 等 · preset 加载时实时热更新 · 不重建模块实例
- 结构性参数(少数 · 如 sourceType / fftSize / channelCount):会改变模块运行时状态 · preset 加载时必须重建模块实例(stop → destroy → create → start)
UI 标识:结构性参数在 Tuning Dialog 有 🔒 图标 + 红色外边框(见 D3-FE-ARCH-007 §4.2)
加载时的处理:前端检测 preset 中有 structural 参数 → 弹确认框 → 用户同意 → 发送 reapply-link-with-preset 命令(而非普通的 load-preset)
3. Preset 生命周期与流程
3.1 七大核心操作
| 操作 | 触发 | 说明 |
|---|---|---|
| Save | 用户点"保存为 preset" | 快照当前 paramValues → 写入 preset store + 后端 |
| Save-as | 用户点"另存为" | 基于当前参数 + 新名字 · 创建新 preset |
| Load | 用户从下拉选择 | fetch preset → updateParamBulk → UI 响应式刷新 → DSP 下发 |
| Rename | 右键 preset → 重命名 | 只改 name 字段 · 不重新 save params |
| Delete | 右键 preset → 删除 | 弹确认 · 后端 soft delete(可恢复 30 天) |
| Duplicate | 右键 preset → 克隆 | 完整复制 · 名字加 " (Copy)" · 新 id |
| Export/Import | 右键 preset → 导出/导入 JSON | 跨租户/跨环境共享 · 导入时校验 schema 兼容 |
3.2 Save 流程(完整时序)
sequenceDiagram
participant User
participant UI as TuningDialog.PresetSaveBtn
participant Store as usePresetStore + useLinkStore
participant WS as WebSocket
participant BE as Backend PresetService
participant DB as PostgreSQL / SQLite
User->>UI: 点击"保存为 preset"
UI->>UI: 弹出 name 输入框
User->>UI: 输入 "Rock Bass"
UI->>Store: presetStore.savePreset({name, params: linkStore.getModuleParams(moduleId)})
Store->>Store: 1. 生成 preset 对象(id, moduleType, schemaVersion...)
Store->>Store: 2. 本地缓存 optimistic update
Store->>WS: 3. 发送 {type: 'save-preset', payload: preset}
WS->>BE: 传递
BE->>DB: INSERT INTO presets ...
DB-->>BE: 返回 persisted preset (含 server 字段如 createdAt)
BE-->>WS: ack {type: 'preset-saved', preset}
WS-->>Store: 4. 更新本地缓存(合并 server 字段)
Store-->>UI: 5. Toast "已保存: Rock Bass"
Store->>Store: 6. hasUnsavedChanges = false · 清除脏标记●
3.3 Load 流程(完整时序)
sequenceDiagram
participant User
participant UI as TuningDialog.PresetDropdown
participant Store as presetStore + linkStore
participant WS as WebSocket
participant BE as Backend PresetService
participant DSP as DSP Module
User->>UI: 下拉选择 "Rock Bass"
UI->>Store: presetStore.loadPreset(moduleId, presetId)
alt structural 参数变化
Store->>Store: 检测到 structural 变化
Store->>UI: 弹确认 "此操作将重建模块实例"
User->>UI: 确认
Store->>WS: 发送 reapply-link-with-preset
WS->>BE: 后端重建模块 · 平滑交接
BE->>DSP: stop old → create new → start
else 仅非结构性参数
Store->>Store: 1. fetchPreset(presetId)
Store->>Store: 2. linkStore.updateParamBulk(moduleId, preset.params)
Store->>Store: 3. 如有 preset.bindings → signalFlowStore 加载
Store->>Store: 4. UI 响应式自动刷新所有控件
Store->>WS: 5. 发送 {type: 'update-params-batch', payload: {moduleId, params}}
WS->>BE: 传递
BE->>DSP: apply 参数(带淡入淡出防爆音)
BE-->>WS: ack
end
Store->>Store: 6. activePresetId = presetId
Store->>Store: 7. hasUnsavedChanges = false
Store-->>UI: 8. Toast "已加载: Rock Bass"
3.4 Rename 流程(轻量)
sequenceDiagram
User->>UI: 右键 preset · 选"重命名"
UI->>UI: 输入新名字
UI->>Store: presetStore.renamePreset(id, newName)
Store->>WS: {type: 'rename-preset', id, newName}
WS->>BE: UPDATE presets SET name = ?
BE-->>WS: ack
Store->>Store: 更新本地 preset.name
UI->>UI: 下拉/列表刷新
3.5 Delete 流程(含确认)
sequenceDiagram
User->>UI: 右键 preset · 选"删除"
UI->>UI: 弹确认 "确定删除 Rock Bass? (30 天可恢复)"
User->>UI: 确认
UI->>Store: presetStore.deletePreset(id)
Store->>WS: {type: 'delete-preset', id}
WS->>BE: UPDATE presets SET deletedAt = NOW() (soft delete)
BE-->>WS: ack
Store->>Store: 从本地缓存移除
Store->>Store: 如果是 activePresetId · 清空并标记 dirty
UI-->>User: Toast "已删除 · [撤销]"
4. UI-Param-Preset 三向同步契约(核心)
4.1 三种状态
任何时刻 preset 状态都是以下三种之一,且必须在 UI 上清晰可见:
| 状态 | 脏标记 | UI 显示 | 说明 |
|---|---|---|---|
| Clean(无变更) | 无 | "Rock Bass" | 当前参数 = activePreset.params |
| Dirty(有未保存变更) | ● | "Rock Bass ●" | 用户改了参数但没保存 |
| None(无 active preset) | - | "未加载 preset" | 参数独立 · 没有 preset 关联 |
4.2 状态转换规则
stateDiagram-v2
[*] --> None: 模块初始化(无 preset)
None --> Dirty: 用户改参数
Dirty --> None: 用户手动取消 active(不保存)
None --> Clean: 用户保存首个 preset
Clean --> Dirty: 用户改任何参数
Dirty --> Clean: 用户点"保存到当前 preset"
Dirty --> Clean: 用户切换到其他 preset(提示"放弃修改")
Clean --> Clean: 用户切换 preset(无 pending changes)
Dirty --> None: 用户点"删除 active preset"
note right of Dirty
UI 显示 "● Rock Bass"
关闭 Dialog 时弹确认
end note
4.3 关键约束
- UI 永远不保存 local state · 所有参数 binding 到
linkStore.paramValues[moduleId](响应式) - 改参数立即:
- 更新
linkStore.paramValues(UI 自动刷新) - 发 WS
control-command到 DSP - 标记
presetStore.hasUnsavedChanges = true(脏标记●) - 可选:每 300ms 写一次 preset draft(防止丢失)
- 切换 preset 必须用
updateParamBulk(批量)· 禁止for循环逐个updateParam - 关闭 Dialog 时:如果 dirty · 弹确认"放弃/保存/取消"
4.4 autoSaveDraft 机制(可选 · 推荐)
// presetStore 可选启用
const autoSaveDraft = ref(true) // 用户设置
watch(
() => linkStore.paramValues,
debounce(() => {
if (autoSaveDraft.value && activePresetId.value) {
saveDraft(activePresetId.value, { ...currentParams })
}
}, 300),
{ deep: true }
)
// draft 存 localStorage · 切换工程时保留
// 加载 preset 时先检查是否有 draft · 弹 "发现草稿,是否加载?"
5. usePresetStore(完整 API)
// stores/usePresetStore.ts
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import { debounce } from 'lodash-es'
import type { ModulePreset } from '@xi/protocol'
import { useLinkStore } from './linkStore'
export const usePresetStore = defineStore('preset', () => {
const linkStore = useLinkStore()
// 数据
const presetsByModule = ref<Map<string, ModulePreset[]>>(new Map()) // moduleId → presets
const activePresetByModule = ref<Map<string, string>>(new Map()) // moduleId → activePresetId
const dirtyModules = ref<Set<string>>(new Set()) // 哪些 moduleId 有未保存变更
// Getters
const getPresets = (moduleId: string) => presetsByModule.value.get(moduleId) || []
const getActivePreset = (moduleId: string) => {
const id = activePresetByModule.value.get(moduleId)
return id ? getPresets(moduleId).find(p => p.id === id) : null
}
const isDirty = (moduleId: string) => dirtyModules.value.has(moduleId)
const hasAnyDirty = computed(() => dirtyModules.value.size > 0)
// 核心操作
async function listPresets(moduleId: string, moduleType: string): Promise<ModulePreset[]> {
const list = await api.fetchPresets({ moduleType })
presetsByModule.value.set(moduleId, list)
return list
}
async function savePreset(moduleId: string, options: { name: string; description?: string; tags?: string[] }): Promise<ModulePreset> {
const module = linkStore.getModule(moduleId)
const params = linkStore.getModuleParams(moduleId)
const preset: ModulePreset = {
id: crypto.randomUUID(),
name: options.name,
moduleType: module.type,
description: options.description,
tags: options.tags,
version: '1.0.0',
schemaVersion: module.schemaVersion,
createdAt: Date.now(),
updatedAt: Date.now(),
author: { userId: auth.userId, name: auth.userName, tenant: auth.tenant },
source: 'user',
params,
bindings: signalFlowStore.getBindingsForModule(moduleId),
usageCount: 0,
}
// Optimistic update
const list = presetsByModule.value.get(moduleId) || []
presetsByModule.value.set(moduleId, [...list, preset])
activePresetByModule.value.set(moduleId, preset.id)
dirtyModules.value.delete(moduleId)
// 后端持久化
await api.savePreset(preset)
return preset
}
async function loadPreset(moduleId: string, presetId: string): Promise<void> {
const preset = await api.fetchPreset(presetId)
const module = linkStore.getModule(moduleId)
if (preset.moduleType !== module.type) {
throw new Error(`Preset moduleType mismatch: ${preset.moduleType} vs ${module.type}`)
}
// 检测 structural 参数变化
const hasStructural = checkStructuralChange(moduleId, preset.params)
if (hasStructural) {
const confirmed = await confirmDialog('此 preset 会重建模块实例 · 继续?')
if (!confirmed) return
await api.reapplyLinkWithPreset(moduleId, preset)
} else {
// 批量更新(关键 · 避免 N 次 WS)
await linkStore.updateParamBulk(moduleId, preset.params)
}
// Signal Flow bindings
if (preset.bindings) {
signalFlowStore.applyBindings(moduleId, preset.bindings)
}
activePresetByModule.value.set(moduleId, preset.id)
dirtyModules.value.delete(moduleId)
// 统计
await api.incrementUsageCount(preset.id)
}
async function renamePreset(presetId: string, newName: string): Promise<void> {
await api.renamePreset(presetId, newName)
// 更新本地
for (const [moduleId, list] of presetsByModule.value) {
const preset = list.find(p => p.id === presetId)
if (preset) {
preset.name = newName
preset.updatedAt = Date.now()
break
}
}
}
async function deletePreset(presetId: string): Promise<void> {
await api.deletePreset(presetId)
// 从本地移除
for (const [moduleId, list] of presetsByModule.value) {
const idx = list.findIndex(p => p.id === presetId)
if (idx >= 0) {
list.splice(idx, 1)
if (activePresetByModule.value.get(moduleId) === presetId) {
activePresetByModule.value.delete(moduleId)
dirtyModules.value.add(moduleId)
}
break
}
}
}
async function duplicatePreset(presetId: string, newName?: string): Promise<ModulePreset> {
// 后端复制 + 改名
const original = await api.fetchPreset(presetId)
const duplicate: ModulePreset = {
...original,
id: crypto.randomUUID(),
name: newName || `${original.name} (Copy)`,
createdAt: Date.now(),
updatedAt: Date.now(),
usageCount: 0,
}
await api.savePreset(duplicate)
// 添加到本地
for (const [moduleId, list] of presetsByModule.value) {
if (list.some(p => p.id === presetId)) {
list.push(duplicate)
break
}
}
return duplicate
}
// 脏标记管理(linkStore.updateParam 自动调用)
function markDirty(moduleId: string): void {
if (activePresetByModule.value.has(moduleId)) {
dirtyModules.value.add(moduleId)
}
}
function clearDirty(moduleId: string): void {
dirtyModules.value.delete(moduleId)
}
// 导出/导入
async function exportPreset(presetId: string): Promise<string> {
const preset = await api.fetchPreset(presetId)
return JSON.stringify(preset, null, 2) // 带元数据的完整 JSON
}
async function importPreset(json: string, moduleId: string): Promise<ModulePreset> {
const preset = ModulePresetSchema.parse(JSON.parse(json))
const module = linkStore.getModule(moduleId)
if (preset.moduleType !== module.type) {
throw new Error(`不匹配的 moduleType`)
}
// 分配新 id · 避免冲突
preset.id = crypto.randomUUID()
preset.source = 'imported'
await savePreset(moduleId, { name: preset.name, description: preset.description, tags: preset.tags })
return preset
}
return {
presetsByModule, activePresetByModule, dirtyModules, hasAnyDirty,
getPresets, getActivePreset, isDirty,
listPresets, savePreset, loadPreset, renamePreset, deletePreset, duplicatePreset,
markDirty, clearDirty, exportPreset, importPreset,
}
})
6. Preset 的作用域与共享策略
6.1 四级作用域
| 作用域 | 范围 | 共享 | 存储 |
|---|---|---|---|
| User | 单用户私有 | 否 | PostgreSQL + user_id |
| Team/Tenant | 团队/租户共享 | ✅ 同团队 | PostgreSQL + tenant_id |
| Public Official | Xisound 官方精选 | ✅ 全部 | PostgreSQL + source='official' |
| Community(Y2+) | XiVST 社区共享 | ✅ 全部 | XiVST Marketplace 持久化 |
6.2 UI 展示分层
Preset 下拉按以下顺序展示(带小分隔线):
[最近使用 Top 3]
· Rock Bass (used 28x)
· Clean Studio (used 15x)
· My Mix (used 8x)
─────────────────
[用户自己的]
· User-Preset-1
· User-Preset-2
─────────────────
[团队共享]
· Team-Standard-EQ
· Team-Pop-Mix
─────────────────
[官方]
· ⭐ Official: Smooth Bass
· ⭐ Official: Aggressive Lead
6.3 跨 session 保持
- user 级 preset:用户登录即可见 · 跨 session 持久
- team 级 preset:团队成员共享 · 实时同步(WS broadcast)
- active preset state:保存在 linkStore.runtimeState(随工程存盘)
7. 版本迁移与兼容性
7.1 问题场景
模块 schema 升级(如 EQ 从 3 段升级到 10 段)· 旧 preset 的 params 不完整 · 如何处理?
7.2 策略
// 加载 preset 时的 schema 兼容检查
async function loadPreset(moduleId: string, presetId: string) {
const preset = await api.fetchPreset(presetId)
const module = linkStore.getModule(moduleId)
if (preset.schemaVersion < module.schemaVersion) {
// 需要迁移
const migrated = await applySchemaMigration(preset, preset.schemaVersion, module.schemaVersion)
// 迁移规则:
// - 新增字段:用 default value 填充
// - 删除字段:忽略
// - 重命名字段:按 migration map 重写
// - 类型变化:按 migration 函数转换(可能失败 · 弹提示)
preset.params = migrated.params
preset.schemaVersion = module.schemaVersion
// 可选:自动升级保存 preset · 或提示用户确认
} else if (preset.schemaVersion > module.schemaVersion) {
throw new Error(`Preset 版本新于模块 · 请升级模块(${preset.schemaVersion} > ${module.schemaVersion})`)
}
// 正常 load
await linkStore.updateParamBulk(moduleId, preset.params)
}
7.3 Migration 表(由模块作者定义)
// 模块定义(@xi/store-core 的 ModuleDefinition)
interface ModuleDefinition {
type: string
schemaVersion: string
// ...
migrations?: Array<{
fromVersion: string
toVersion: string
apply: (oldParams: any) => any // 或声明式的 map
}>
}
8. 后端 API 契约(@xi/protocol)
8.1 HTTP API
| 端点 | 方法 | 用途 |
|---|---|---|
/api/presets |
GET | 列 preset · 支持 filter (moduleType/userId/tenant/source) |
/api/presets |
POST | 创建 preset |
/api/presets/:id |
GET | 详情 |
/api/presets/:id |
PATCH | 更新 name/description/tags |
/api/presets/:id |
DELETE | soft delete |
/api/presets/:id/duplicate |
POST | 克隆 |
/api/presets/:id/export |
GET | 导出 JSON |
/api/presets/import |
POST | 导入 JSON |
/api/presets/:id/usage |
POST | 增加 usage count |
8.2 WebSocket(实时通知)
// 后端 → 前端:preset 变更事件(通知其他 tab/session)
export const PresetChangedEventSchema = z.object({
type: z.literal('preset-changed'),
payload: z.object({
action: z.enum(['created', 'updated', 'deleted', 'renamed']),
preset: ModulePresetSchema,
userId: z.string(),
}),
})
// 前端 → 后端:批量加载 preset 到模块(关键 · 替代 N 次 update-param)
export const LoadPresetCommandSchema = z.object({
type: z.literal('load-preset-to-module'),
payload: z.object({
moduleId: z.string(),
presetId: z.string(),
withFade: z.boolean().default(true), // 淡入淡出防爆音
}),
})
9. 智能体实施 checklist
9.1 前端
- 实现完整
usePresetStore(§5)· 所有 7 个操作 API - Tuning Dialog 顶部必有 Preset 管理区(按 D3-FE-ARCH-007 §4.1)
- Preset 下拉按 §6.2 分层展示
- Dirty 状态 ● 标记清晰可见
- 切换 preset 时检查 structural · 弹确认
- Load 用
updateParamBulk批量 · 禁止 for 循环 - 关闭 Dialog 时 dirty 弹确认
- autoSaveDraft 可选启用(config)
- 导出/导入 JSON 功能
- 重命名 · 克隆 · 删除功能(右键菜单)
9.2 后端
-
PresetProfileService· CRUD + 软删除 - WS
load-preset-to-module命令处理(带 fade) - WS
preset-changed事件广播(多 session 同步) - HTTP API 对应所有端点
- Schema 迁移逻辑(§7)
- 权限校验(user/team/tenant 级作用域)
9.3 测试
- e2e:save → reload → UI 参数完全一致
- e2e:load 时 UI 所有 slider/select 刷新
- e2e:structural 参数 preset 加载弹确认
- e2e:dirty 关 Dialog 弹确认
- 单测:
updateParamBulk批量 WS 只发 1 次 - 单测:schema 迁移不丢失非冲突参数
- 压测:1000 preset 列表加载性能
10. DQ 清单
| DQ 编号 | 问题 | 建议 |
|---|---|---|
| DQ-PRESET-01 | preset 共享到团队时是否需要审核? | Y1 不审核(信任团队) · Y2 可选 |
| DQ-PRESET-02 | Export JSON 是否加密? | 不加密 · 但敏感参数(如 API key)不应在 preset 中 |
| DQ-PRESET-03 | autoSaveDraft 是否默认开启? | 默认开启 · 300ms 防抖 · 用户可在 settings 关闭 |
| DQ-PRESET-04 | 同时多个 tab 打开同一模块 · preset 变更如何同步? | 通过 preset-changed WS 事件 · 本地 store 自动更新 |
| DQ-PRESET-05 | Preset thumbnail 如何生成? | Y2 可选 · XiTune A/B 对比场景用 · Canvas 渲染 waveform 快照 |
11. 版本与变更记录
| 版本 | 日期 | 作者 | 说明 |
|---|---|---|---|
| v1.0 | 2026-05-09 | work-cline | 初稿 · ModulePreset schema · 7 核心操作 · 三向同步状态机 · usePresetStore 完整 API · 4 级作用域 · schema 迁移策略 |
附录 A · 引用
上游: - D3-FE-ARCH-001 顶层架构 - D3-FE-ARCH-007 模块 UI 实现规范(§4.1 Tuning Dialog · §5 三向同步) - Source/Sink Review v7.0 §10.12(Save/Load Project)
同级: - D3-FE-ARCH-008 运行时模式切换(Preset 跨模式保留)
下游: - D2-P1 XiStudio tech-arch:PresetManager 实施 - D2-P9 XiForge tech-arch:开发者定义 preset 模板 - D2-P10 XiVST tech-arch:Community preset 集成