跳转至
MIGRATED

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 关键约束

  1. UI 永远不保存 local state · 所有参数 binding 到 linkStore.paramValues[moduleId](响应式)
  2. 改参数立即
  3. 更新 linkStore.paramValues(UI 自动刷新)
  4. 发 WS control-command 到 DSP
  5. 标记 presetStore.hasUnsavedChanges = true(脏标记●)
  6. 可选:每 300ms 写一次 preset draft(防止丢失)
  7. 切换 preset 必须用 updateParamBulk(批量)· 禁止 for 循环逐个 updateParam
  8. 关闭 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 集成