跳转至

前端架构设计 (v5.0)

1. 技术栈

组件 技术 说明
UI 框架 Vue3 Composition API 响应式组件系统
语言 TypeScript 5.x 类型安全
状态管理 Pinia 模块化 Store,含 useSimStore、useDebugStore
构建工具 Vite 5.x 快速热重载
样式 CSS Variables + Scoped CSS 主题化暗色风格
通信 原生 WebSocket API 与后端全双工通信

2. 整体布局结构

┌──────────────────────────────────────────────────────────────────────┐
│  TopBar: 连接状态 | 模式选择器 | SimControl | 保存/加载 | 连接DSP     │
├────────────┬─────────────────────────────────────┬───────────────────┤
│            │                                     │                   │
│ ModulePanel│          ChainCanvas                │   ParamPanel      │
│ (模块调色板)│          (链路编辑画布)              │   (参数编辑面板)   │
│            │                                     │                   │
│ ·Gain ─┐  │  [Gain#1]──►[Delay#1]──►[EQ#1]     │  Gain#1 参数      │
│ ·Delay  │  │       ▲ 拖拽添加模块                │   ·enable         │
│ ·EQ     └─►│   双击节点打开参数面板               │   ·gainDb #0~     │
│ ·...       │   节点角标显示通道数/采样率           │   ·mute #0~       │
│            │                                     │                   │
├────────────┴──────────────────────────┬──────────┴───────────────────┤
│ ▼ Debug Panel(可折叠底部抽屉)        │                              │
│ [延时][算力][内存][WAV抓取][日志][参数回读][单位转换]                  │
│ ┌──────────────────────────────────────────────────────────────────┐ │
│ │  (当前 Tab 内容区)                                               │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘

3. 目录结构

frontend/src/
├── App.vue                          根组件(布局骨架)
├── main.ts                          应用入口,注册 Pinia、Router
├── components/
│   ├── layout/
│   │   ├── TopBar.vue               顶部工具栏(连接按钮、模式切换器、SimControl集成、保存/加载、DSP状态指示)
│   │   ├── ModulePanel.vue          左侧模块调色板(按类别分组,支持拖拽)
│   │   └── ParamPanel.vue           右侧参数面板容器(根据选中模块动态渲染)
│   │
│   ├── chain/
│   │   ├── ChainCanvas.vue          主链路画布(SVG实现,支持平移/缩放;端口规格可视化,只读模式)
│   │   ├── ChainNode.vue            单个模块节点(可选中、可拖拽、右键菜单;端口信息角标:通道数/采样率)
│   │   ├── ChainEdge.vue            节点连线(贝塞尔曲线 SVG path;连线颜色反映规格匹配状态)
│   │   ├── ChainToolbar.vue         画布工具栏(缩放复位、全选、删除)
│   │   ├── SubGraphNode.vue         Sub-graph 组节点渲染(双击进入子画布;两侧显示端口 Pin)
│   │   ├── PortPin.vue              ChainNode / SubGraphNode 上的交互式端口 Pin(显示端口 id、通道数;拖拽创建边)
│   │   └── BreadcrumbNav.vue        Sub-graph 嵌套导航面包屑(如 "Root > Surround Proc")
│   │
│   ├── modules/                     各模块专属参数面板(每类一个 Vue 组件)
│   │   ├── GainModulePanel.vue      增益模块:多通道增益滑杆 + mute + phase;system 参数在 Tuning 模式下禁用
│   │   ├── DelayModulePanel.vue     延迟模块:多通道延迟 + 单位切换(samples/ms/cm)
│   │   ├── MixerModulePanel.vue     Mixer 模块:动态输入端口数量配置(+/- Input 按钮)
│   │   └── GenericModulePanel.vue   通用降级面板(未注册专属组件时使用;若 maxDynamicInputPorts 已设置则显示端口数配置器)
│   │
│   ├── controls/                    通用 UI 控件
│   │   ├── Knob.vue                 旋钮(鼠标拖拽调节,支持双击重置)
│   │   ├── Slider.vue               水平/垂直滑杆(带刻度)
│   │   ├── Toggle.vue               开关按钮
│   │   ├── NumberInput.vue          数字输入框(带单位标签,支持键盘上下箭头微调)
│   │   ├── ChannelStrip.vue         多通道条状布局容器
│   │   └── Toast.vue                消息气泡提示
│   │
│   ├── debug/                       调试面板组件
│   │   ├── DebugPanel.vue           调试面板容器(底部可折叠抽屉 + Tab 切换)
│   │   ├── DebugLatencyPanel.vue    系统延时视图
│   │   ├── DebugComputePanel.vue    算力分析视图
│   │   ├── DebugMemoryPanel.vue     内存使用视图
│   │   ├── DebugWavPanel.vue        WAV 节点抓取
│   │   ├── DebugLogPanel.vue        日志流显示
│   │   └── ConversionToolPanel.vue  单位转换工具
│   │
│   ├── sim/                         仿真控制组件
│   │   └── SimControl.vue           PC 仿真启停控制器(嵌入 TopBar)
│   │
│   └── dialogs/
│       ├── ConnectDialog.vue        DSP 连接配置(TCP/串口 二选一)
│       └── SaveLoadDialog.vue       链路文件保存/加载
├── stores/
│   ├── useConnectionStore.ts        WebSocket + DSP 连接状态
│   ├── useChainStore.ts             链路结构(LinkConfig、ChainDefinition、节点、连线、子图导航);含 setEditable()、navigateInto/Back、动态端口管理
│   ├── useParamStore.ts             全量参数值缓存(双向同步)
│   ├── useModuleRegistryStore.ts    模块类型注册表(Schema 定义);ParamSchema 含 role 字段
│   ├── useUIStore.ts                UI 状态(选中节点、面板折叠、主题、currentMode、setMode())
│   ├── useSimStore.ts               PC 仿真状态管理(SimState、startSim、stopSim、listAudioDevices)
│   └── useDebugStore.ts             调试数据状态管理(DebugState、DebugMetrics、日志缓冲)
├── types/
│   ├── communication.ts             WebSocket 消息类型(含调试/仿真新消息类型)
│   ├── chain.ts                     链路数据结构 (v2.2): LinkConfig / ChainDefinition / ChainNode / SubGraphNode / ChainEdge / PortDescriptor
│   ├── module.ts                    模块定义(ModuleDescriptor 含 defaultPorts / maxDynamicInputPorts;ParamSchema 含 role)
│   ├── params.ts                    参数 Schema(ParamSchema, ParamType)
│   └── ui.ts                        UI 状态类型(含 AppMode)
├── utils/
│   ├── conversion.ts                单位转换(dB↔linear, ms↔samples, cm↔samples)
│   ├── wsClient.ts                  WebSocket 客户端(连接、重连、心跳、消息分发)
│   └── moduleRegistry.ts           前端模块注册入口(统一注册所有 Descriptor)
└── styles/
    ├── variables.css                CSS 变量(颜色、字体、间距)
    └── animations.css               过渡/载入动画

4. 核心类型定义

4.1 链路数据结构 (types/chain.ts)

// ── types/chain.ts ──────────────────────────────────────────────────────

// Single port on a module (input or output)
export interface PortDescriptor {
  id:         string              // port identifier: "input", "input_0", "output", etc.
  direction:  'input' | 'output'
  channels:   number              // -1 = inherit from upstream / edge
  sampleRate: number              // -1 = inherit
  label:      string              // display name in UI
  required:   boolean             // must be connected; false = optional (e.g. Mixer side inputs)
}

// Regular module node
export interface ChainNode {
  instanceId:  string
  moduleType:  string
  order:       number             // topological order index; set by backend after flatten
  enabled:     boolean
  position:    { x: number; y: number }
  ports:       PortDescriptor[]  // all input + output ports for this instance
}

// Sub-graph group node (encapsulates a nested chain)
export interface SubGraphNode {
  instanceId:  string             // "group#1"
  moduleType:  'subgraph'
  subGraphId:  string             // key in LinkConfig.chains
  position:    { x: number; y: number }
  ports:       PortDescriptor[]  // mirrors the sub-graph's externalPorts
}

// Wire connecting fromModule.fromPort → toModule.toPort
export interface ChainEdge {
  id:         string
  fromModule: string              // source instanceId
  fromPort:   string              // source port id (e.g. "output")
  toModule:   string              // destination instanceId
  toPort:     string              // destination port id (e.g. "input_0", "input_1")
  channels:   number              // auto-calculated by ChainCanvas signal propagation
  sampleRate: number              // auto-calculated
}

// A self-contained chain definition (used for root and all sub-graphs)
export interface ChainDefinition {
  id:            string
  name:          string
  nodes:         (ChainNode | SubGraphNode)[]
  edges:         ChainEdge[]
  externalPorts?: PortDescriptor[]  // only for sub-graphs: the exposed interface to parent
}

// Root configuration (replaces DSPLink)
export interface LinkConfig {
  version:     '2.2'
  rootChainId: string
  global: {
    channels:   number           // default system channels (e.g. 20)
    sampleRate: number           // default sample rate (e.g. 48000)
    frameSize:  number           // samples per frame (e.g. 240)
  }
  chains: Record<string, ChainDefinition>  // key = chain id
}

4.2 模块 Schema 定义 (types/module.ts)

export type ParamType = 'float' | 'int' | 'bool' | 'enum'

// 工作模式类型
export type AppMode = 'chain_builder' | 'tuning' | 'test_verify'

// 单个参数的 Schema 描述
export interface ParamSchema {
  paramId:    string            // 参数名(不含通道后缀),如 "gainDb"
  label:      string            // UI 显示名,如 "增益"
  type:       ParamType
  unit?:      string            // 显示单位,如 'dB' | 'ms' | 'cm' | 'samples'
  min?:       number
  max?:       number
  step?:      number
  default:    number | boolean | string
  perChannel: boolean           // 是否为逐通道参数
  channels?:  number            // perChannel=true 时的通道数量
  options?:   { value: string; label: string }[]  // enum 类型可选项
  role:       'tunable' | 'system'  // 决定 Tuning/Test 模式下是否锁定
}

// 参数更新事件(来自 WebSocket param_update 推送)
export interface ParamUpdateEvent {
  instanceId: string
  paramId:    string            // 含通道后缀,如 "gainDb#0"
  value:      number | boolean | string
}

5. Pinia Store 设计

5.1 useConnectionStore — 连接状态管理

// 状态
interface State {
  wsStatus:    'disconnected' | 'connecting' | 'connected' | 'error'
  wsUrl:       string
  dspConnected: boolean
  dspProtocol: 'tcp' | 'serial' | 'none'
  dspTarget:   string          // 如 "192.168.1.100:9000" 或 "COM3@115200"
  latencyMs:   number          // 心跳往返延迟
  lastError:   string | null
}

// 动作
connect(url: string): void
disconnect(): void
connectDsp(config: { protocol: 'tcp', host: string, port: number }
         | { protocol: 'serial', comPort: string, baudRate: number }): void
disconnectDsp(): void
send(msg: object): void        // 通过 wsClient 发送消息

内部逻辑: - connect() 调用 wsClient.connect(url) - wsClient 收到各类消息后,通过 on(type, handler) 分发到对应 Store

5.2 useChainStore — 链路结构管理

// 状态
interface State {
  linkConfig:       LinkConfig          // replaces the old flat nodes[]/edges[]
  currentChainId:   string             // which chain is currently displayed in canvas (default: rootChainId)
  chainNavHistory:  string[]           // stack of chain ids for breadcrumb / back navigation
  selectedNodeId:   string | null
  isDirty:          boolean            // 有未保存的变更
  isEditable:       boolean            // Chain Builder 模式时为 true
}

// 计算属性
orderedNodes: (ChainNode | SubGraphNode)[]  // 按 order 字段排序后的当前链路节点列表
selectedNode: ChainNode | SubGraphNode | null  // 当前选中节点

// 动作
addModule(moduleType: string, position?: {x: number, y: number}): ChainNode
  // 生成唯一 instanceId,order = 当前最大 order + 1
  // 同时根据 ModuleDescriptor.defaultPorts 初始化端口列表
removeModule(instanceId: string): void
  // 同时删除关联连线
reorderModule(instanceId: string, newOrder: number): void
  // 重新排列 order,更新后触发 DSP 链路下发
toggleModule(instanceId: string, enabled: boolean): void
selectNode(instanceId: string | null): void
setEditable(editable: boolean): void  // 由 useUIStore.setMode() 调用

// Sub-graph navigation
navigateInto(subGraphId: string): void  // push currentChainId to chainNavHistory, set currentChainId = subGraphId
navigateBack(): void                    // pop chainNavHistory, restore currentChainId
currentChain(): ChainDefinition         // returns linkConfig.chains[currentChainId]

// Dynamic port management (for Mixer and similar modules)
addDynamicInputPort(instanceId: string): void
  // Appends a new input PortDescriptor (e.g. input_2, input_3…) up to maxDynamicInputPorts
removeDynamicInputPort(instanceId: string, portId: string): void
  // Removes the specified input port and any connected edges

saveLink(): Promise<void>
  // 发送 write_link WebSocket 消息(payload: LinkConfig v2.2),持久化到后端
loadLink(): Promise<void>
  // 发送 read_link,从后端加载,调用 applyLinkFromServer
applyLinkFromServer(link: LinkConfig): void
  // 用服务端数据覆盖本地状态(重连恢复场景)

instanceId 生成规则

function generateInstanceId(moduleType: string, nodes: (ChainNode | SubGraphNode)[]): string {
  const base = moduleType.replace(/_v\d+$/, '')    // "channel_gain_v1" → "channel_gain"
  const existing = nodes.filter(n => n.moduleType === moduleType)
  return `${base}#${existing.length + 1}`           // "channel_gain#1"
}

5.3 useParamStore — 参数值管理

// 状态:存储所有模块实例的所有参数
// key = "${instanceId}.${paramId}"(含通道后缀),如 "gain#1.gainDb#0"
// value = 参数值(已转换为 UI 单位,如 dB 而非 linear)
interface State {
  params:      Map<string, number | boolean | string>
  pendingKeys: Set<string>      // 已发送但未收到 ack 的 key(用于 UI 显示"待确认"状态)
}

// 动作
setParam(instanceId: string, paramId: string, value: any, channel?: number): void
  // 1. 本地更新 params Map
  // 2. 构造 WebSocket set_param 消息发送到后端
  // 3. 将 key 加入 pendingKeys

applyRemoteUpdate(instanceId: string, paramId: string, value: any): void
  // 来自服务端 param_update 推送,直接更新本地(不发 WebSocket)

onSetParamAck(instanceId: string, paramId: string): void
  // 收到 set_param_ack 后,从 pendingKeys 移除

fetchAllParams(instanceId: string): Promise<void>
  // 发送 get_all_params,收到 ack 后批量更新 params Map

// 计算辅助
getParam(instanceId: string, paramId: string, channel?: number): any
  // 从 params Map 读取,key = "instanceId.paramId" 或 "instanceId.paramId#channel"

参数键格式

"gain#1.enable"          → gain#1 模块的 enable(全局参数)
"gain#1.gainDb#0"        → gain#1 模块通道 0 的 gainDb
"delay#1.delaySamples#3" → delay#1 模块通道 3 的延迟

5.4 useModuleRegistryStore — 模块注册表

interface State {
  descriptors: Map<string, ModuleDescriptor>  // key = typeId
}

registerModule(desc: ModuleDescriptor): void
getDescriptor(typeId: string): ModuleDescriptor | undefined
getAllByCategory(category: string): ModuleDescriptor[]
getAllDescriptors(): ModuleDescriptor[]

5.5 useUIStore — UI 状态管理

type AppMode = 'chain_builder' | 'tuning' | 'test_verify'

interface UIState {
  currentMode:        AppMode           // 当前工作模式
  selectedNodeId:     string | null
  debugPanelOpen:     boolean           // 底部调试面板是否展开
  debugActiveTab:     string            // 当前调试 Tab
  theme:              'dark' | 'light'
}

// 模式切换动作
function setMode(mode: AppMode): void {
  // 1. 发送 set_mode WebSocket 消息到后端
  wsClient.send({ type: 'set_mode', mode })
  // 2. 更新本地状态(后端确认前乐观更新)
  currentMode.value = mode
  // 3. 切换链路画布编辑权限
  chainStore.setEditable(mode === 'chain_builder')
}

后端确认后收到 mode_changed 消息:

wsClient.on('mode_changed', msg => {
  uiStore.currentMode = msg.mode
})

5.6 useSimStore — PC 仿真状态管理

interface SimState {
  target:      'pc_sim' | 'real_dsp'    // 当前仿真目标
  simRunning:  boolean
  simStatus:   'stopped' | 'starting' | 'running' | 'error'
  audioInput: {
    type:       'soundcard' | 'file'
    deviceName?: string                  // 声卡设备名
    filePath?:   string                  // 音频文件路径
  }
  audioOutput: {
    type:       'soundcard' | 'file'
    deviceName?: string
    filePath?:   string
  }
  errorMessage: string | null
}

// 动作
startSim(): void          // → ws: { type: 'sim_start', input: {...}, output: {...} }
stopSim(): void           // → ws: { type: 'sim_stop' }
listAudioDevices(): Promise<string[]>   // → ws: { type: 'list_audio_devices' }

后端推送 sim_status 消息,前端更新状态:

wsClient.on('sim_status', msg => {
  simStore.simRunning  = msg.running
  simStore.simStatus   = msg.status
  if (msg.error) simStore.errorMessage = msg.error
})

5.7 useDebugStore — 调试数据状态管理

interface DebugMetrics {
  timestamp: number
  latency: {
    totalMs:  number
    byModule: { instanceId: string; latencyMs: number }[]
  }
  compute: { instanceId: string; cpuUs: number }[]
  memory: {
    poolUsed:  number
    poolTotal: number
    byModule:  { instanceId: string; bytes: number }[]
  }
}

interface LogEntry {
  level:     'DEBUG' | 'INFO' | 'WARN' | 'ERROR'
  timestamp: string
  message:   string
}

interface DebugState {
  metrics:         DebugMetrics | null  // 最新 debug_metrics 数据
  logs:            LogEntry[]           // 日志缓冲(最多 2000 条)
  isCapturing:     boolean              // WAV 抓取进行中
  captureId:       string | null        // 当前抓取任务 ID
  metricsInterval: number              // debug_metrics 推送间隔(ms,0=关闭)
}

// 动作
startMetricsPolling(intervalMs: number): void  // 发送 debug_subscribe 开启推送
stopMetricsPolling(): void
startWavCapture(instanceId: string, capturePoint: string, durationMs: number): void
downloadWav(captureId: string): void
clearLogs(): void

6. 前端模块注册表(内置定义)

位于 utils/moduleRegistry.ts,应用启动时注册到 useModuleRegistryStore

每个模块描述符使用更新后的 ModuleDescriptor 接口:

export interface ModuleDescriptor {
  typeId:               string
  displayName:          string
  category:             string
  defaultPorts:         PortDescriptor[]       // replaces old portRule field
  maxDynamicInputPorts?: number                // optional: for Mixer, user can add inputs up to this count
  paramSchemas:         ParamSchema[]
  vueComponent?:        string
}

6.1 Gain 模块(channel_gain_v1

{
  typeId:       'channel_gain_v1',
  displayName:  'Channel Gain',
  category:     'gain',
  vueComponent: 'GainModulePanel',
  defaultPorts: [
    { id: 'input',  direction: 'input',  channels: 20, sampleRate: 48000, label: 'Audio In',  required: true  },
    { id: 'output', direction: 'output', channels: 20, sampleRate: 48000, label: 'Audio Out', required: true  }
  ],
  paramSchemas: [
    { paramId: 'enable',     label: '启用',     type: 'bool',  default: true,  perChannel: false, role: 'system'  },
    { paramId: 'smoothTime', label: '平滑',     type: 'float', unit: 'ms',
      min: 0, max: 1000, step: 1, default: 10, perChannel: false,             role: 'system'  },
    { paramId: 'gainDb',     label: '增益',     type: 'float', unit: 'dB',
      min: -96, max: 24, step: 0.1, default: 0, perChannel: true, channels: 20, role: 'tunable' },
    { paramId: 'mute',       label: '静音',     type: 'bool',  default: false, perChannel: true, channels: 20, role: 'tunable' },
    { paramId: 'phase',      label: '相位翻转', type: 'bool',  default: false, perChannel: true, channels: 20, role: 'tunable' }
  ]
}

参数角色说明: - enablesystem(Tuning 模式下禁用) - smoothTimesystem(Tuning 模式下禁用) - gainDbtunable(所有模式均可调节) - mutetunable - phasetunable

端口说明:Gain 模块端口数量固定(passthrough),无需动态端口 UI。

6.2 Delay 模块(ut_delay_20ch_v1

{
  typeId:       'ut_delay_20ch_v1',
  displayName:  'Delay 20ch',
  category:     'delay',
  vueComponent: 'DelayModulePanel',
  defaultPorts: [
    { id: 'input',  direction: 'input',  channels: 20, sampleRate: 48000, label: 'Audio In',  required: true },
    { id: 'output', direction: 'output', channels: 20, sampleRate: 48000, label: 'Audio Out', required: true }
  ],
  paramSchemas: [
    { paramId: 'enable',       label: '启用', type: 'bool', default: true, perChannel: false, role: 'system' },
    { paramId: 'delaySamples', label: '延迟', type: 'int',  unit: 'samples',
      min: 0, max: 960, step: 1, default: 0, perChannel: true, channels: 20, role: 'tunable' }
  ]
}

7. 关键组件设计

7.1 TopBar.vue — 顶部工具栏

TopBar 集成了连接状态、工作模式选择器和 SimControl 仿真控制器:

┌──────────────────────────────────────────────────────────────────────┐
│  DSP Tuning Tool v4.0    [● 已连接 192.168.1.100]  [RTT: 4ms]       │
│                                                                       │
│  模式: [ Chain Builder ▼ ]   [SimControl 区域]  [保存][加载][连接DSP] │
└──────────────────────────────────────────────────────────────────────┘

模式选择器交互:

// TopBar.vue
const uiStore = useUIStore()

function onModeSelect(mode: AppMode) {
  uiStore.setMode(mode)
}

7.2 SimControl.vue — 仿真控制器

嵌入 TopBar 右侧,仅在 Chain Builder / Test 模式下显示:

┌──────────────────────────────────────────────────────────────────┐
│  仿真目标: ● PC仿真  ○ 实体DSP                                   │
│  输入: [ 声卡: 立体声混音 ▼ ]  或  [ 文件: test.wav ]            │
│  输出: [ 声卡: 扬声器 ▼       ]                                  │
│  [ ▶ 启动仿真 ]   状态: 未运行                                   │
└──────────────────────────────────────────────────────────────────┘
// SimControl.vue
const simStore = useSimStore()
const uiStore  = useUIStore()

// 仅在特定模式下显示
const visible = computed(() =>
  uiStore.currentMode === 'chain_builder' || uiStore.currentMode === 'test_verify'
)

async function onStart() {
  await simStore.listAudioDevices()
  simStore.startSim()
}

7.3 GainModulePanel.vue

功能:展示 Gain 模块所有参数的 UI,包含: - 顶部 enable 开关 + smoothTime 输入框(system 参数在 Tuning 模式下置灰禁用) - 通道列表(可滚动,最多 20 行):每行含通道编号、通道名、增益滑杆、dB 数值输入、mute 按钮、phase 按钮

┌────────────────────────────────────────────────────────────────────┐
│  Channel Gain #1               [enable ●]    Smooth: [10] ms       │
├────────────────────────────────────────────────────────────────────┤
│  CH  名称   Gain (dB)                        [M]ute  [Φ]phase      │
│  0   FLH   ████████████●────────────── -3.0  [ M ]   [ Φ ]        │
│  1   FRH   ████████████●────────────── -3.0  [ M ]   [ Φ ]        │
│  2   FLC   ████████████████●──────────  0.0  [ M ]   [ Φ ]        │
│  3   FRC   ████████████████●──────────  0.0  [ M ]   [ Φ ]        │
│  ...(可滚动)                                                      │
├────────────────────────────────────────────────────────────────────┤
│  [全部 0dB]  [全部静音]  [全部解除静音]                              │
└────────────────────────────────────────────────────────────────────┘

模式感知的参数锁定

const uiStore     = useUIStore()
const paramStore  = useParamStore()
const descriptor  = registryStore.getDescriptor('channel_gain_v1')

// system 参数在 Tuning 模式下禁用
function isParamDisabled(schema: ParamSchema): boolean {
  if (schema.role === 'system' && uiStore.currentMode === 'tuning') return true
  return false
}

参数交互

// 拖动增益滑杆(节流 16ms,结束时立即发送)
function onGainInput(ch: number, val: number) {
  localGains[ch] = val  // 本地立即反映(流畅 UI)
}
function onGainChange(ch: number, val: number) {
  paramStore.setParam(props.instanceId, 'gainDb', val, ch)  // 松手时发送到后端
}

// 静音开关(即时发送)
function onMuteToggle(ch: number, muted: boolean) {
  paramStore.setParam(props.instanceId, 'mute', muted, ch)
}

注意:Gain 模块端口数量固定(passthrough),GainModulePanel.vue 不显示动态端口配置 UI。

7.4 DelayModulePanel.vue

功能:展示 Delay 模块参数,支持单位实时切换(samples / ms / cm),切换单位仅转换显示值,不发送参数。

┌────────────────────────────────────────────────────────────────────┐
│  Delay 20ch #1                 [enable ●]                          │
│  显示单位:  [Samples] ◉ ms  ○ cm                                   │
├────────────────────────────────────────────────────────────────────┤
│  CH  名称   延迟                                                    │
│  0   FLH   ●────────────────────────────── 0 samples (0.00ms)      │
│  1   FRH   ─────●────────────────────────  48 samples (1.00ms)     │
│  2   FLC   ──────────●───────────────────  96 samples (2.00ms)     │
│  ...(可滚动)                                                      │
└────────────────────────────────────────────────────────────────────┘

单位切换逻辑

const displayUnit = ref<'samples' | 'ms' | 'cm'>('samples')

// 只用于显示(不影响存储和发送)
function toDisplayValue(samples: number): string {
  const v = convertDelay(samples, 'samples', displayUnit.value, convCfg)
  if (displayUnit.value === 'samples') return `${v} samples`
  if (displayUnit.value === 'ms')      return `${v.toFixed(2)} ms`
  return `${v.toFixed(1)} cm`
}

// 输入框提交时,将显示值转回 samples 发送
function onDelayCommit(ch: number, displayVal: number) {
  const samples = Math.round(convertDelay(displayVal, displayUnit.value, 'samples', convCfg))
  paramStore.setParam(props.instanceId, 'delaySamples', samples, ch)
}

7.5 ChainCanvas.vue

功能: - 读取 useChainStore().currentChain() 获取当前层级的节点和连线 - SVG 画布渲染所有 ChainNodeSubGraphNodeChainEdge - 对 moduleType === 'subgraph' 的节点渲染 SubGraphNode.vue - 每个节点展示 PortPin.vue 组件对应其全部端口(输入 Pin 在左侧,输出 Pin 在右侧) - 端口 Pin 颜色编码:红色 = required + 未连接,橙色 = 通道数不匹配,绿色 = 连接有效 - 每个 PortPin 上显示来自端口规格的通道数和采样率角标 - 当 chainNavHistory.length > 0 时,在画布顶部展示 BreadcrumbNav.vue - 鼠标拖动节点更新 position(仅影响 UI 布局,不影响 order) - 从输出端口 Pin 拖拽到输入端口 Pin 可创建新的 ChainEdge - 双击普通节点:选中并在 ParamPanel 显示对应参数 - Ctrl+点击:多选 - Delete 键:删除选中节点/连线 - 非 Chain Builder 模式下,画布为只读(禁止拖拽节点、禁止创建/删除连线) - ChainEdge 连线颜色反映规格匹配状态(匹配=蓝色,不匹配=橙色警告)

节点颜色(按 category)

const categoryColor: Record<string, string> = {
  gain:         '#4CAF50',
  delay:        '#2196F3',
  filter:       '#FF9800',
  dynamics:     '#E91E63',
  mixing:       '#9C27B0',
  spatialAudio: '#00BCD4',
  voice:        '#FF5722',
  debug:        '#607D8B',
}

7.6 SubGraphNode.vue (新增)

功能:渲染特殊的虚线边框组节点,用于表示嵌套子图。

  • 渲染带虚线边框的组节点
  • 显示子图名称以及两侧的端口 Pin
  • 双击处理器调用 useChainStore().navigateInto(subGraphId)
  • 在 Tuning / Test-Verify 模式下,双击被禁用(只读)
// SubGraphNode.vue
const chainStore = useChainStore()
const uiStore    = useUIStore()

const isReadOnly = computed(() =>
  uiStore.currentMode !== 'chain_builder'
)

function onDoubleClick() {
  if (isReadOnly.value) return
  chainStore.navigateInto(props.node.subGraphId)
}
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
  ○ input   [ Surround Proc (subgraph) ]  output ○
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
         (双击进入子画布 / Chain Builder only)

7.7 PortPin.vue (新增)

功能:ChainNode 或 SubGraphNode 边缘的小圆形/三角形交互端口 Pin。

  • Tooltip 显示:portId、direction、channel count、sampleRate
  • 从输出端口 Pin 拖拽 → 创建新的 ChainEdge 连接到目标输入端口 Pin
  • 颜色状态:
  • 灰色 = 未连接
  • 绿色 = 已连接且规格有效
  • 橙色 = 通道数不匹配
  • 红色 = required + 未连接
// PortPin.vue
interface Props {
  port:        PortDescriptor
  instanceId:  string
  connected:   boolean
  valid:        boolean          // channel/sampleRate match check
}

const pinColor = computed(() => {
  if (!props.connected && props.port.required) return 'var(--accent-red)'
  if (!props.connected)                        return 'var(--text-secondary)'
  if (!props.valid)                            return 'var(--accent-orange)'
  return 'var(--accent-green)'
})

7.8 BreadcrumbNav.vue (新增)

功能:显示子图嵌套导航路径,支持点击跳转到任意祖先层级。

  • 渲染:[Root Chain] > [Group#1 Name] > ...
  • 每个祖先层级可点击以导航回该层
  • 直观呈现当前深度
// BreadcrumbNav.vue
const chainStore = useChainStore()

const crumbs = computed(() => {
  return chainStore.chainNavHistory.map(id => ({
    id,
    name: chainStore.linkConfig.chains[id]?.name ?? id
  }))
})

function navigateTo(index: number) {
  // pop history back to the selected index
  const steps = chainStore.chainNavHistory.length - index
  for (let i = 0; i < steps; i++) chainStore.navigateBack()
}
Root Chain  >  Surround Proc  >  [当前层]
    ↑点击可返回        ↑点击可返回

7.9 MixerModulePanel.vue — Mixer 动态端口配置

ModuleDescriptor.maxDynamicInputPorts 已设置,则 MixerModulePanel.vue(或 GenericModulePanel.vue 降级处理)显示 ± 输入端口按钮:

┌────────────────────────────────────────────────────────────────────┐
│  Mixer #1                                    [enable ●]            │
├────────────────────────────────────────────────────────────────────┤
│  输入端口:  input_0  input_1  input_2   [ + Input ]  [ - Input ]   │
│  (最多 maxDynamicInputPorts 个输入)                                 │
└────────────────────────────────────────────────────────────────────┘
// MixerModulePanel.vue (或 GenericModulePanel.vue 中的条件块)
const chainStore   = useChainStore()
const descriptor   = registryStore.getDescriptor(props.moduleType)

const canAddPort = computed(() => {
  const inputCount = currentNode.value.ports.filter(p => p.direction === 'input').length
  return inputCount < (descriptor.value?.maxDynamicInputPorts ?? 0)
})

function onAddInput() {
  chainStore.addDynamicInputPort(props.instanceId)
}

function onRemoveInput() {
  const inputPorts = currentNode.value.ports.filter(p => p.direction === 'input')
  if (inputPorts.length > 1) {
    chainStore.removeDynamicInputPort(props.instanceId, inputPorts[inputPorts.length - 1].id)
  }
}

7.10 DebugPanel.vue — 底部调试面板

可折叠抽屉,位于界面底部,包含 7 个 Tab:

┌──────────────────────────────────────────────────────────────────────┐
│  [延时] [算力] [内存] [WAV抓取] [日志] [参数回读] [单位转换]    [▲折叠]│
├──────────────────────────────────────────────────────────────────────┤
│                                                                       │
│  (当前 Tab 内容区)                                                    │
│                                                                       │
└──────────────────────────────────────────────────────────────────────┘
// DebugPanel.vue
const debugStore = useDebugStore()
const uiStore    = useUIStore()
const activeTab  = computed({
  get: () => uiStore.debugActiveTab,
  set: (v) => { uiStore.debugActiveTab = v }
})

// 面板展开时自动开启 metrics 轮询
watch(() => uiStore.debugPanelOpen, (open) => {
  if (open) debugStore.startMetricsPolling(1000)
  else      debugStore.stopMetricsPolling()
})

Tab 可用性按模式

Debug Tab Chain Builder Tuning Test/Verify
延时 部分(框架延时) 基础 完整
算力 可用 可用 可用
内存 可用 可用 可用
WAV抓取 不可用 不可用 可用
日志 可用 可用 可用
参数回读 不可用 基础 完整
单位转换 可用 可用 可用

7.11 DebugLatencyPanel.vue — 系统延时

┌─────────────────────────────────────────────────────────────┐
│  系统总延时: 22.5 ms                                         │
│                                                              │
│  模块贡献延时:                                               │
│  ├─ channel_gain#1  ─── 0.0 ms(增益无延时)                 │
│  ├─ ut_delay_20ch#1 ─── 20.0 ms(最大延迟通道)              │
│  └─ 系统处理帧延时  ─── 2.5 ms(240 smp @ 48kHz)            │
│                                                              │
│  WS RTT: 4 ms                                               │
└─────────────────────────────────────────────────────────────┘

数据来源:后端定期推送 debug_metrics 消息,前端 useDebugStore.metrics.latency 存储。

7.12 DebugComputePanel.vue — 算力分析

┌─────────────────────────────────────────────────────────────┐
│  总 CPU 占用: 12.4%   帧周期: 5.0ms                          │
│                                                              │
│  channel_gain#1     ████░░░░░░░  45 μs  (0.9%)             │
│  ut_delay_20ch#1    ██████████░  95 μs  (1.9%)             │
│  [空闲]             ───────────  4860 μs                    │
└─────────────────────────────────────────────────────────────┘

数据来源:useDebugStore.metrics.compute[],每模块包含 instanceIdcpuUs

7.13 DebugMemoryPanel.vue — 内存分析

┌─────────────────────────────────────────────────────────────┐
│  MemPool 使用率: 19.5% (204,800 / 1,048,576 bytes)          │
│  ████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░                  │
│                                                              │
│  channel_gain#1    sizeof(GainModuleData)   = 1,864 bytes  │
│  ut_delay_20ch#1   sizeof(DelayModuleData)  = 77,056 bytes │
└─────────────────────────────────────────────────────────────┘

数据来源:useDebugStore.metrics.memory

7.14 DebugWavPanel.vue — WAV 节点抓取

┌─────────────────────────────────────────────────────────────┐
│  抓取节点: [ ut_delay_20ch#1 ▼ ]  时长: [1000] ms           │
│                                                              │
│  [ 开始抓取 ]   [ 下载 WAV ]   [ 播放 ]                      │
│                                                              │
│  状态: 就绪                                                  │
│  最近文件: gain1_output_20240320_143022.wav (2.1 MB)        │
└─────────────────────────────────────────────────────────────┘

交互流程

// 1. 发送抓取命令
wsClient.send({ type: 'wav_capture_start', instanceId: 'ut_delay_20ch#1',
                capturePoint: 'output', durationMs: 1000 })
// 2. 收到确认
wsClient.on('wav_capture_ack', msg => { captureId.value = msg.captureId })
// 3. 抓取完成通知
wsClient.on('wav_capture_done', msg => { /* 触发下载 */ })
// 4. 下载 WAV 文件
window.open(`/api/debug/wav/${captureId.value}`)

7.15 DebugLogPanel.vue — 日志显示

┌─────────────────────────────────────────────────────────────┐
│  [清空]  [暂停]  过滤: [INFO ▼]  搜索: [______________]     │
├─────────────────────────────────────────────────────────────┤
│  [INFO ] 14:30:22 DynamicChain: LoadConfig done, 2 modules  │
│  [INFO ] 14:30:22 channel_gain#1: Init complete              │
│  [INFO ] 14:30:22 ut_delay_20ch#1: Init complete             │
│  [DEBUG] 14:30:23 SetParam gain#1.gainDb#0 = -3.5           │
│  [WARN ] 14:30:25 MemPool 80% full                          │
└─────────────────────────────────────────────────────────────┘

日志通过 WebSocket log_stream 消息实时推送,useDebugStore 维护最多 2000 条日志缓冲。

7.16 ConversionToolPanel.vue — 单位转换工具

┌─────────────────────────────────────────────────────────────┐
│  增益转换:   [−3.5] dB  ←→  [0.6681] Linear               │
│  延迟转换:   [48] samples ←→ [1.000] ms ←→ [34.3] cm       │
│  (采样率:    [48000] Hz  速度:  [343] m/s)                   │
│  频率转换:   [440] Hz  ←→ [MIDI 69]  ←→ [A4]               │
└─────────────────────────────────────────────────────────────┘

纯前端计算,复用 utils/conversion.ts,无需后端通信。


8. 三种工作模式 UI

8.1 模式说明

模式 标识符 用途
链路构建 chain_builder 搭建 DSP 链路拓扑、配置系统参数
调音 tuning 实时调节可调参数,system 参数锁定
测试验证 test_verify 完整访问权限,配合 Debug Panel 验收

8.2 各模式下的 UI 锁定策略

UI 元素 Chain Builder Tuning Test/Verify
添加模块按钮 可用 禁用 禁用
拖拽节点位置 可用 仅查看 仅查看
节点 order 调整 可用 禁用 禁用
global channels/sampleRate 可用 禁用 禁用
system 参数(enable 等) 可用 禁用 可用
tunable 参数(gainDb 等) 可用 可用 可用
从 DSP 回读链路 可用 不可用 可用
Debug Panel 部分 基础 完整
Sub-graph 进入(双击) 可用 禁用 禁用
动态端口数量编辑 可用 禁用 禁用

8.3 参数角色与模式的联动实现

GainModulePanel.vue(以及所有专属参数面板)中,通过 ParamSchema.role 判断控件是否应禁用:

// 通用判断函数(可提取到 composables/useParamAccess.ts)
export function useParamAccess() {
  const uiStore = useUIStore()

  function isDisabled(schema: ParamSchema): boolean {
    if (uiStore.currentMode === 'tuning' && schema.role === 'system') return true
    return false
  }

  return { isDisabled }
}

在 Chain Builder 模式下:子图导航(进入/退出)以及动态端口数量编辑均可用。在 Tuning 和 Test-Verify 模式下,双击子图节点被禁用。


9. WebSocket 客户端 (utils/wsClient.ts)

class WsClient {
  private ws: WebSocket | null = null
  private handlers = new Map<string, Set<(msg: any) => void>>()
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null
  private reconnectDelay = 3000

  connect(url: string): void
  disconnect(): void
  send(msg: object): void      // JSON.stringify + ws.send

  on(type: string, handler: (msg: any) => void): void
  off(type: string, handler: (msg: any) => void): void

  private onMessage(event: MessageEvent): void {
    const msg = JSON.parse(event.data)
    this.handlers.get(msg.type)?.forEach(h => h(msg))
  }

  private startHeartbeat(): void {
    this.heartbeatTimer = setInterval(() => {
      const t0 = Date.now()
      this.send({ type: 'ping' })
      this.once('pong', () => {
        connectionStore.latencyMs = Date.now() - t0
      })
    }, 5000)
  }
}

export const wsClient = new WsClient()

消息分发注册(在 stores 初始化时)

// useConnectionStore
wsClient.on('dsp_status', msg => {
  dspConnected.value = msg.connected
  dspProtocol.value  = msg.protocol
  dspTarget.value    = msg.target
})

// useUIStore
wsClient.on('mode_changed', msg => {
  uiStore.currentMode = msg.mode
})

// useParamStore
wsClient.on('param_update', msg => {
  paramStore.applyRemoteUpdate(msg.instanceId, msg.paramId, msg.value)
})
wsClient.on('get_all_params_ack', msg => {
  Object.entries(msg.params).forEach(([paramId, val]) => {
    paramStore.applyRemoteUpdate(msg.instanceId, paramId, val)
  })
})

// useChainStore
wsClient.on('read_link_ack', msg => {
  if (msg.success && msg.link) chainStore.applyLinkFromServer(msg.link)
})
// Note: write_link message payload uses LinkConfig v2.2 format.
// No new message types are needed for multi-port or sub-graph features
// (these are pure frontend features).

// useSimStore
wsClient.on('sim_status', msg => {
  simStore.simRunning  = msg.running
  simStore.simStatus   = msg.status
  if (msg.error) simStore.errorMessage = msg.error
})

// useDebugStore
wsClient.on('debug_metrics', msg => {
  debugStore.metrics = msg.metrics
})
wsClient.on('log_stream', msg => {
  debugStore.logs.push(...msg.entries)
  if (debugStore.logs.length > 2000)
    debugStore.logs.splice(0, debugStore.logs.length - 2000)
})
wsClient.on('wav_capture_done', msg => {
  debugStore.isCapturing = false
  debugStore.captureId   = msg.captureId
})


10. 参数发送策略

对高频控件(Slider 拖动)实施拖动节流 + 释放立即发送策略:

// composables/useThrottledParam.ts
export function useThrottledParam(instanceId: string, paramId: string) {
  let raf: number | null = null
  let lastVal: any

  function onInput(value: any, channel?: number) {
    // 1. 本地立即更新(UI 响应)
    paramStore.params.set(buildKey(instanceId, paramId, channel), value)
    // 2. RAF 节流发送(约 60fps)
    lastVal = value
    if (!raf) {
      raf = requestAnimationFrame(() => {
        wsClient.send({ type: 'set_param', instanceId, paramId, value: lastVal, channel })
        raf = null
      })
    }
  }

  function onCommit(value: any, channel?: number) {
    // 松手时取消节流,强制立即发送最终值
    if (raf) { cancelAnimationFrame(raf); raf = null }
    paramStore.setParam(instanceId, paramId, value, channel)
  }

  return { onInput, onCommit }
}

11. 断线重连恢复流程

1. WsClient 检测到连接断开 → 3s 后自动重连
2. WebSocket 重连成功
3. 接收后端推送 dsp_status → connectionStore 更新
4. 发送 read_link → 接收 read_link_ack → chainStore.applyLinkFromServer()
5. 遍历 currentChain().nodes,逐个发送 get_all_params
6. 接收 get_all_params_ack → paramStore 批量填充
7. 若 Debug Panel 处于展开状态,重新发送 debug_subscribe 恢复 metrics 推送
8. Vue 响应式系统自动刷新所有 UI 组件

12. 样式规范

/* styles/variables.css */
:root {
  /* 背景色系(深色主题) */
  --bg-primary:   #0d1117;
  --bg-secondary: #161b22;
  --bg-surface:   #21262d;
  --bg-border:    #30363d;

  /* 文字 */
  --text-primary:   #e6edf3;
  --text-secondary: #7d8590;

  /* 强调色 */
  --accent-blue:   #1f6feb;
  --accent-green:  #3fb950;
  --accent-orange: #d29922;
  --accent-red:    #f85149;

  /* 模块类别色 */
  --cat-gain:         #3fb950;
  --cat-delay:        #1f6feb;
  --cat-filter:       #d29922;
  --cat-dynamics:     #f85149;
  --cat-mixing:       #bc8cff;
  --cat-spatialAudio: #39d353;
  --cat-voice:        #fd7d35;
  --cat-debug:        #7d8590;

  /* 间距 */
  --sp-xs:  4px;
  --sp-sm:  8px;
  --sp-md: 16px;
  --sp-lg: 24px;
}

13. Vite 配置(代理与跨域)

// vite.config.ts
export default defineConfig({
  server: {
    host: '0.0.0.0',   // 局域网可访问
    port: 5173,
    proxy: {
      '/ws': {
        target: 'ws://localhost:5000',
        ws: true        // WebSocket 代理
      },
      '/api': {
        target: 'http://localhost:5000',
        changeOrigin: true
      }
    }
  }
})

WebSocket 连接 URL 策略:

// 代理模式(推荐):自动复用当前访问 host
const wsUrl = `${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/ws`


新增:多维参数与音效模式管理

更新的 ParamSchema 类型(types/module.ts

export interface ParamDimension {
  name:    string    // "channel", "band", "filter_stage"
  count:   number    // 该维度的元素数量
  labels?: string[]  // 可选显示标签
}

// 更新后的 ParamSchema(替代旧版 channels: number 字段)
export interface ParamSchema {
  paramId:      string
  paramTypeId:  number              // 数字 ID(用于 Binary 协议,u16)
  type:         'float' | 'int' | 'bool' | 'string'
  min?:         number
  max?:         number
  step?:        number
  default:      number | boolean | string
  unit?:        string
  label:        string
  dimensions?:  ParamDimension[]   // 替代旧 channels 字段
  // undefined/空 → 标量参数;长度1 → ChannelStrip UI;长度2 → ParamGrid 2D UI
  role:         'tunable' | 'system'
}

类型定义(types/preset.ts

// 单模块参数快照(preset 条目元数据)
export interface ModulePreset {
  presetId:   string
  name:       string
  instanceId: string    // 所属模块实例
  // key: "paramId"(标量)/ "paramId#ch"(逐通道)
  params?:    Record<string, number | boolean | string>
  isDirty?:   boolean   // 内存态:未保存的修改
}

// 一种音效模式(全链路 preset 组合)
export interface AmbianceProfile {
  profileId:     string
  name:          string      // "HIFI", "动感", "Rock"
  icon?:         string
  modulePresets: Record<string, string>  // instanceId → presetId
}

// ParamBin 生成配置
export interface ParamBinConfig {
  targetProfiles:     AmbianceProfile[]
  modulePresetLimits: Record<string, number>  // instanceId → 最大允许 preset 数
}

Pinia Stores — Preset / Profile

usePresetStorestores/presetStore.ts

// 状态
const activeProfileId   = ref<string | null>(null)
const activeProfileData = ref<AmbianceProfile | null>(null)

// instanceId → presetId:每个模块当前选中的 preset
const moduleActivePreset = ref<Record<string, string | null>>({})

// instanceId → PresetItem[]:前端缓存的 preset 列表(由 PresetPanel 填充)
const presetLists = ref<Record<string, PresetItem[]>>({})

// instanceId → 计数器:PresetPanel 每次完成参数写入后递增,
// GainTuningDialog 等 dialog 通过 watch 该值触发 initChannelData()
const presetLoadSignals = ref<Record<string, number>>({})

// instanceId → presetId → { paramId: value }
// 前端完整存储每个 preset 的参数快照(frontend-owned 参数数据)
const presetDataStore = ref<Record<string, Record<string, Record<string, number | boolean | string>>>>({})

关键 Actions:

Action 说明
setModulePreset(instanceId, presetId) 更新 moduleActivePreset不再修改 activeProfileData.modulePresets(Profile 数据只读)
activateProfile(profile) 写入 activeProfileIdactiveProfileData,并将 profile 的 modulePresets 映射写入 moduleActivePreset
deactivateProfile() 清除 activeProfileId / activeProfileData,保留 moduleActivePreset 自身状态
signalPresetLoaded(instanceId) presetLoadSignals[instanceId] 加一,通知 dialog 重读参数
setPresetData(instanceId, presetId, params) 将参数快照存入 presetDataStore
getPresetData(instanceId, presetId) presetDataStore 读取参数快照;未命中返回 null
deletePresetData(instanceId, presetId) 删除 presetDataStore 中对应条目

设计原则activeProfileData 是 Profile 在前端的只读镜像。 用户切换模块的 preset 时,只更新 moduleActivePreset,不污染 activeProfileData。 Profile 的 modulePresets 映射只能通过 ProfileSidebar 详情视图的“保存”按钮显式覆写。

useProfileStore(按职责分离,逻辑由 ProfileSidebar 驱动)

// ProfileSidebar 中调用 presetStore.activateProfile(profile)
// 触发 set_ambiance WebSocket 消息
interface ProfileState {
  profiles:        AmbianceProfile[]
  activeProfileId: string | null
}
// Actions: createProfile, saveProfile, deleteProfile, activateProfile

useParamBinStore — ParamBin 生成状态

interface ParamBinState {
  config:      ParamBinConfig | null
  generating:  boolean
  lastResult:  { success: boolean; byteSize: number; numAmbiances: number } | null
}
// Actions: configureParamBin, generateParamBin

目录结构

frontend/src/components/
├── PresetPanel.vue              模块级 preset 面板(嵌入参数 dialog 顶部)
├── preset/
│   ├── PresetListItem.vue       preset 列表条目(名称 + dirty 指示 + 操作)
│   └── PresetDropdown.vue       快速选 preset 的下拉
├── profile/
│   ├── ProfileSidebar.vue       右侧全局音效模式管理侧边栏
│   ├── ProfileListItem.vue      profile 列表条目
│   └── ProfileQuickSwitch.vue   TopBar 快速切换 ambiance 按钮组
└── paramgen/
    ├── AmbianceMatrix.vue        音效矩阵主视图(Ambiance x Module 网格)
    ├── AmbianceRow.vue           矩阵的一行(一个 ambiance profile)
    ├── ModulePresetCell.vue      矩阵单元格(选该 module 的哪套 preset)
    └── ParamBinPanel.vue         生成配置 + 生成按钮

frontend/src/components/controls/
└── ParamGrid.vue                 二维参数网格

关键组件说明

PresetPanel.vue:嵌入每个模块参数 dialog 顶部 - Preset 列表(每条目右侧有 delete 按钮) - "新建 Preset" / "保存当前" / "↓ 从后端读取" 按钮 - 点击 preset 条目 → 调用 applyPresetLocally() 从前端缓存直接应用参数 - 不再在用户切换 preset 时自动保存 profile(profile 数据不可变)

ProfileSidebar.vue:右侧全局侧边栏,TopBar 上的 "Profile" 按钮切换显示/隐藏 - Profile 列表(名称 + 激活状态指示) - 激活 Profile → usePresetStore.activateProfile(profile) → 发送 set_ambiance WS 消息 - 详情视图中显示每个模块当前 active preset 的下拉,点击“保存”按钮后才写回 activeProfileData

AmbianceMatrix.vue:Param Gen 视图(从 TopBar 的 "Param Gen" 按钮进入) - 行 = ambiance profile(可增删行);列 = 当前链路中的各模块实例 - 列头显示:instanceId + 允许的最大 preset 数(可配置) - 单元格 = ModulePresetCell.vue(下拉选择该模块的哪套 preset) - "生成 ParamBin" 按钮 → 发送 generate_param_bin WS 消息

ParamGrid.vue:二维参数网格 - 行 = 第一维(如通道);列 = 第二维(如 band);每格 = compact NumberInput.vue - 由 ParamPanel 根据 ParamSchema.dimensions.length === 2 自动渲染

三种工作模式更新

操作 Chain Builder Tuning Test/Verify
Preset 新建/保存/删除 ❌ 只读
Profile 激活(set_ambiance)
ParamBin 生成
Ambiance Matrix 编辑

混合 ID 说明

前端 WebSocket 消息继续使用字符串 paramId(不变):

{ "type": "set_param", "instanceId": "eq#1", "paramId": "bandFreq",
  "dimIndices": [2, 5], "value": "120.0" }
dimIndices 为新增可选字段,对应多维参数的各维度索引。 后端将 dimIndices 编码为 channelIdx:1D → 直接索引;2D → (dim0 << 8) | dim1


近期代码变更(v5.1)

1. Preset 数据所有权:Frontend-Owned 架构

变更前:Preset 参数数据由后端持有,前端每次加载 preset 都需发送 load_preset 并等待后端推送 preset_data

变更后:Preset 参数数据完整存储在前端 presetDataStore(内存),加载 preset 不再需要后端往返。

数据结构

// presetStore.ts
// instanceId → presetId → { "paramId#ch": value, ... }
const presetDataStore = ref<Record<string, Record<string, Record<string, number | boolean | string>>>>({})

数据来源(三种方式)

来源 触发时机 机制
新建/保存 Preset 用户点击“新建”或“保存 *” linkStore.getModuleParams() 快照当前参数,存入 presetDataStore
启动水化 WebSocket list_presets 响应中含 params 字段 PresetPanel 的 handleWs 将每个 preset 的 params 缓存到 presetDataStore
后端回落 本地无缓存(首次或缓存丢失) 发送 load_preset,收到 preset_data 后写入 linkStore

linkStore 新增 API(stores/linkStore.ts

// 获取某模块所有参数的完整快照(用于新建/保存 preset)
function getModuleParams(instanceId: string): Record<string, number | boolean | string> {
  const module = getModule(instanceId)
  if (!module) return {}
  const result: Record<string, number | boolean | string> = {}
  module.paramValues.forEach((value, key) => { result[key] = value })
  return result
}

// 清除某模块所有参数(不触发 updateTimestamp,避免误标 dirty)
function clearModuleParams(instanceId: string) {
  const module = getModule(instanceId)
  if (!module) return
  module.paramValues.clear()
}

2. Preset 加载链

用户点击某个 preset 条目后,完整的数据流如下:

用户点击 preset 条目
PresetPanel.loadPreset(presetItem)
        ├─ presetDataStore 有缓存?
        │       YES → applyPresetLocally(storedParams)
        │       NO  → send load_preset  ← 后端回落
applyPresetLocally(storedParams)
        ├─ 1. fullParams = { ...getModuleDefaults(), ...storedParams }
        │      // defaults 打底,确保 mute/phase/enable 等始终被设置
        ├─ 2. linkStore.clearModuleParams(instanceId)
        │      // 清除旧参数,不触发 updateTimestamp
        ├─ 3. 遍历 fullParams → linkStore.setParamValue(...)
        │      // 写入新参数到 paramValues Map
        ├─ 4. presetStore.signalPresetLoaded(instanceId)
        │      // presetLoadSignals[instanceId]++
        └─ 5. send apply_params (instanceId, params: fullParams)
               // 异步通知后端(不等响应)

GainTuningDialog 响应链:

// GainTuningDialog.vue
watch(() => presetStore.presetLoadSignals[props.moduleInstanceId], (newVal) => {
  if (newVal && props.visible && props.moduleInstanceId) {
    initChannelData(props.moduleInstanceId)
    // 从 linkStore 重新读取 gainDb / mute / phase 等参数,刷新 UI
  }
})

WebSocket 消息对照:

场景 前端发出 后端收到
加载 preset(有缓存) apply_params(含完整 params) 直接应用到 DSP,无需读文件
加载 preset(无缓存) load_preset 读文件 → 推送 preset_data / param_bulk_update
新建/保存 preset save_preset(含完整 params) 持久化到文件
校验后端状态 get_module_params 返回 module_params,前端对比缓存

3. Profile 系统:Profile 数据不可变

变更前:用户在 PresetPanel 中切换模块 preset 时,会自动调用 save_profile 更新 Profile 的 modulePresets 映射。

变更后:Profile 数据对用户操作不可变(immutable from user perspective)。

规则

  • setModulePreset() 只更新 moduleActivePreset不再修改 activeProfileData.modulePresets
  • Profile 的 modulePresets 映射只能通过 ProfileSidebar 详情视图的“保存”按钮显式覆写。
  • 这样可以避免用户在调音过程中无意间修改 Profile 定义。

Profile 激活时的强制重载

presetStore.activeProfileId 发生变化(切换 Profile)时,PresetPanel 会强制重新应用本模块的 preset,即使 presetId 与切换前相同:

// PresetPanel.vue
watch(
  () => presetStore.activeProfileId,
  (newProfileId, oldProfileId) => {
    if (newProfileId && newProfileId !== oldProfileId) {
      const presetId = presetStore.getModulePreset(props.instanceId)
      if (!presetId) return
      isDirty.value = false
      const stored = presetStore.getPresetData(props.instanceId, presetId)
      if (stored) {
        applyPresetLocally(stored)   // 即使 presetId 不变,也强制应用
      } else {
        send({ type: 'load_preset', instanceId: props.instanceId, presetId })
      }
    }
  }
)

原因:Profile 切换代表用户的意图是“应用该 Profile 的完整状态”。 即使某模块的 presetId 与上次 Profile 相同,也必须重新下发参数到后端(确保 DSP 状态与 Profile 一致)。


4. boolean 序列化兼容修复(GainTuningDialog)

问题:C# 后端序列化 boolean 时使用首字母大写形式("True" / "False"), JavaScript String(true) 产生 "true" / "false"(全小写)。 直接使用 === 'true' 比较会导致从 linkStore 读取 mute / phase 状态时出现判断错误。

修复GainTuningDialog.vueinitChannelData()):

// 修复前(仅处理 JS boolean 和小写字符串)
channelMuted.value[i] = muteVal === true || muteVal === 'true'

// 修复后(兼容 C# 序列化的 "True"/"False" 以及数字 1)
const muteVal = linkStore.getParamValue(instanceId, 'mute', i)
channelMuted.value[i] = muteVal === true
  || (typeof muteVal === 'string' && muteVal.toLowerCase() === 'true')
  || muteVal === 1

const phaseVal = linkStore.getParamValue(instanceId, 'phase', i)
channelPhase.value[i] = phaseVal === true
  || (typeof phaseVal === 'string' && phaseVal.toLowerCase() === 'true')
  || phaseVal === 1

同样的 .toLowerCase() 比较也适用于 enable 参数的读取:

const enableVal = linkStore.getParamValue(instanceId, 'enable')
enabled.value = enableVal === true
  || (typeof enableVal === 'string' && enableVal.toLowerCase() === 'true')
  || enableVal === 1
  || enableVal === '1'

5. 诊断按钮:"↓ 从后端读取"

PresetPanel.vue 底部新增 "↓ 从后端读取" 按钮,用于调音师验证后端 DSP 实际接收到的参数是否与前端缓存一致。

交互流程:

用户点击"↓ 从后端读取"
send get_module_params (instanceId)
后端返回 module_params { instanceId, params: { ... } }
        ├─ 对比 activePresetId 的 presetDataStore 缓存
        │   有差异 → isDirty = true(标记未保存修改)
        ├─ 将后端 params 写入 linkStore(刷新 dialog 显示实际 DSP 状态)
        └─ signalPresetLoaded(instanceId)(触发 dialog 重绘)

6. 启动水化(Hydration on Startup)

PresetPanel.vueonMounted 时发送 list_presets。 若后端在响应中为每个 preset 附带 params 字段,前端会在收到 preset_list 消息时直接水化 presetDataStore

// PresetPanel.vue — handleWs
case 'preset_list':
  if (msg.instanceId === props.instanceId) {
    presets.value = (msg.presets ?? []).map(...)
    presetStore.setPresetList(props.instanceId, presets.value)
    // 若后端在 list_presets 中附带了 params,缓存到 presetDataStore
    for (const p of (msg.presets ?? [])) {
      if (p.params && typeof p.params === 'object') {
        presetStore.setPresetData(props.instanceId, p.presetId, p.params)
      }
    }
  }
  break

水化完成后,所有后续 preset 加载均无需后端往返,延迟降至接近零。



附录 A:调音兼容模式(XML Tuning Mode)

A.1 背景与目标

旧版调音工具(非本系统)以 XML 配置文件驱动 UI,仅支持参数转换与下发,不需要链路搭建。新系统需要提供一个无需更改现有源码、导入 XML 配置即可自动生成调音界面的兼容模式,实现对旧版工具的快速替换。

核心约束: - 不修改现有模块定义(moduleLibrary.tstypes/ 等) - 不修改现有链路编辑逻辑 - 仅新增文件,以及对 App.vue / TopBar.vue / types/ui.ts 的极小改动


A.2 可行性结论

完全可行。 Vue 3 浏览器环境内置 DOMParser API 可直接解析 XML 字符串,无需任何第三方库。XML 文件中已包含自动生成 UI 所需的全部信息:

XML 字段 用途
<SubPID> 下发参数 ID(如 0x302
<ChannelID> 通道索引
<BandID> 频段/组索引
<Value>min, max, default, step</Value> 参数范围
<TypeStr> ComboBox 选项 / EQ 滤波器类型
<HASMP>mute, phase</HASMP> Mixer 是否含静音/相位控制
<StringUnit> 单位显示字符串

A.3 XML 控件类型 → Vue 组件映射

原始 XML 中定义了以下顶层控件标签(均可能嵌套在 <TabPage> 内):

XML 标签 描述 对应 Vue 组件
<Mixer> 带滑杆的增益/参数控制(含可选静音/相位按钮) XmlMixerWidget.vue
<Button> 开关/旁路按钮(二值 toggle) XmlButtonWidget.vue
<ComboBox> 下拉选择框 XmlComboBoxWidget.vue
<EQ> EQ 频段表格(频率/增益/Q + 类型选择) XmlEQWidget.vue
<TabControl> + <TabPage> 选项卡容器 XmlTabWidget.vue
<Plot> EQ 频响曲线画布(只读可视化) XmlPlotWidget.vue
<VProgressBar> 垂直电平表(只读) XmlProgressBarWidget.vue
<Label> 文本标签 XmlLabelWidget.vue
<GridViewBase> 参数表格(行列网格) XmlGridWidget.vue

重要说明: 现有 GainTuningDialog.vue 对应 XML 中的 <Mixer> + HASMP=1,1 组合(即 Gain Module = XML Mixer Module with Mute & Phase)。


A.4 XML 参数解析数据结构

// src/utils/xmlConfigParser.ts

// 单个控件的通用解析结果
export interface XmlWidgetDef {
  widgetType: 'mixer' | 'button' | 'combobox' | 'eq' | 'tab' | 'plot' | 'progressbar' | 'label' | 'grid'
  name: string
  channelId: number
  bandId?: number
  subPID: string          // 原始十六进制字符串,如 "0x302"
  position: { x: number; y: number }
  size: { w: number; h: number }

  // Mixer 专用
  valueRange?: { min: number; max: number; default: number; step: number }
  hasMute?: boolean       // HASMP[0]
  hasPhase?: boolean      // HASMP[1]
  unit?: string

  // Button 专用(布尔切换)
  // valueRange 复用

  // ComboBox 专用
  options?: string[]      // TypeStr 逗号分割

  // EQ 专用
  bandCount?: number
  filterTypes?: string[]  // TypeStr
  freqRange?: { min: number; max: number; default: number; step: number }
  gainRange?: { min: number; max: number; default: number; step: number }
  qRange?: { min: number; max: number; default: number; step: number }

  // TabControl 专用
  tabs?: XmlTabDef[]
}

export interface XmlTabDef {
  name: string
  widgets: XmlWidgetDef[]
}

// 一个 XML 文件解析后的完整模块配置
export interface XmlModuleConfig {
  moduleName: string        // 来自文件名(去掉 .xml)
  fileName: string
  windowSize: { w: number; h: number }
  widgets: XmlWidgetDef[]   // 顶层控件列表(TabControl 内的子控件在 tabs 字段中)
}

// 解析函数签名
export function parseXmlConfig(xmlString: string, fileName: string): XmlModuleConfig

A.5 通信协议

在调音兼容模式下,参数下发消息格式如下:

// WebSocket 消息类型扩展(types/communication.ts 新增)
export interface XmlParamSetMessage {
  type: 'xml_param_set'
  moduleId: string        // XML 文件名(如 "ChannelGain_20ch")
  subPID: string          // 参数地址,如 "0x302"
  channelId: number       // 通道索引
  bandId: number          // 频段/组索引(无频段则为 0)
  value: number | boolean | string
  // 以下由用户在模块卡片配置页指定
  hardware: string        // 目标通信硬件标识(如 "CAN0", "SPI1", "UART2")
  dataType: 'float32' | 'int16' | 'int32' | 'uint8' | 'uint16'
}

用户在打开模块前,需在模块卡片上配置: 1. 目标硬件 (hardware):指定通过哪个通信接口下发 2. 数据类型 (dataType):浮点/整型/字节等


A.6 新增文件结构

frontend/src/
├── utils/
│   └── xmlConfigParser.ts          XML 解析工具(使用浏览器原生 DOMParser)
├── stores/
│   └── useXmlTuningStore.ts        XML 调音模式状态
│                                   - 已加载的 XmlModuleConfig[]
│                                   - 每个模块的 hardware / dataType 配置
│                                   - 当前打开的调音对话框
└── components/
    └── xml-tuning/
        ├── XmlTuningMode.vue        调音兼容模式主界面
        │                            - 顶部:导入 XML 按钮(支持批量)
        │                            - 中央:模块卡片网格(每个 XML 一张卡)
        │                            - 卡片双击 → 打开 XmlTuningDialog
        ├── XmlModuleCard.vue        模块卡片
        │                            - 显示模块名称
        │                            - 配置 hardware / dataType
        │                            - 双击打开调音窗口
        ├── XmlTuningDialog.vue      调音对话框(可拖动浮动窗口)
        │                            - 根据 XmlModuleConfig.widgets 动态渲染控件
        │                            - 控件变更 → 发送 xml_param_set 消息
        └── widgets/
            ├── XmlMixerWidget.vue       垂直滑杆 + 数值输入 + 可选 M/φ 按钮
            ├── XmlButtonWidget.vue      Toggle 按钮(旁路/开关)
            ├── XmlComboBoxWidget.vue    下拉选择框
            ├── XmlEQWidget.vue          EQ 频段表格(类型/频率/增益/Q)
            ├── XmlTabWidget.vue         选项卡容器(递归渲染子 widgets)
            ├── XmlPlotWidget.vue        EQ 频响曲线画布(Canvas 渲染)
            ├── XmlProgressBarWidget.vue 电平表(只读,接收后端推送)
            ├── XmlLabelWidget.vue       文本标签
            └── XmlGridWidget.vue        参数表格(行列网格)

A.7 需要修改的现有文件(极小改动)

文件 改动内容
src/types/ui.ts AppMode 联合类型新增 'xml_tuning'
src/components/layout/TopBar.vue 模式选择器新增"调音兼容"选项
src/App.vue 新增 v-if="mode === 'xml_tuning'" 分支渲染 XmlTuningMode.vue

其余所有现有代码(模块定义、链路编辑、参数同步、WebSocket 通信核心)均无需修改


A.8 用户操作流程

1. 在 TopBar 选择「调音兼容」模式
2. 点击「导入 XML」→ 文件选择框(支持多选 .xml 文件)
3. 前端解析 XML → 生成模块卡片网格
   每张卡片显示:模块名称 + hardware 配置 + dataType 配置
4. 用户双击某个模块卡片
5. 打开 XmlTuningDialog(浮动可拖动窗口)
   - 窗口标题 = 模块名
   - 自动渲染所有 XML 定义的控件(Mixer/Button/ComboBox/EQ/Tab 等)
6. 调整参数 → 触发 xml_param_set 消息
   消息包含:{ subPID, channelId, bandId, value, hardware, dataType }
7. 后端接收并转发至对应硬件接口

A.9 XmlMixerWidget 与现有 GainTuningDialog 的对应关系

XML 中的 <Mixer> 配合 HASMP=1,1 即等价于现有 GainTuningDialog.vue 的功能:

XML Mixer 字段 现有 GainTuningDialog 功能
Value: -96, 24, 0, 0.1 gainDb 范围 (-96 ~ 24 dB, step 0.1)
HASMP: 1, 1 显示 Mute 按钮 + Phase (φ) 按钮
StringUnit: dB 单位标签显示 "dB"
SubPID: 0x302 参数地址(set_param 中的 paramId)
ChannelID perChannel 通道索引

因此 XmlMixerWidget.vue 可复用现有 GainTuningDialog.vue 的滑杆 + 静音/相位切换逻辑,仅需将硬编码参数替换为 XML 解析结果驱动。


A.10 关键实现片段

XML 解析(xmlConfigParser.ts)

export function parseXmlConfig(xmlString: string, fileName: string): XmlModuleConfig {
  const parser = new DOMParser()
  const doc = parser.parseFromString(xmlString, 'text/xml')
  const root = doc.querySelector('root')!

  const parseValueRange = (el: Element | null) => {
    if (!el) return undefined
    const [min, max, def, step] = el.textContent!.split(',').map(Number)
    return { min, max, default: def, step }
  }

  const parseModules = (container: Element, tag: string): XmlWidgetDef[] => {
    return Array.from(container.querySelectorAll(`:scope > ${tag} > Module`)).map(mod => {
      const pos = mod.querySelector('Position')?.textContent?.split(',').map(Number) ?? [0, 0]
      const size = mod.querySelector('Size')?.textContent?.split(',').map(Number) ?? [60, 20]
      const hasmp = mod.querySelector('HASMP')?.textContent?.split(',').map(Number) ?? [0, 0]

      return {
        widgetType: tag.toLowerCase() as any,
        name: mod.querySelector('Name')?.textContent ?? '',
        channelId: parseInt(mod.querySelector('ChannelID')?.textContent ?? '0'),
        bandId: parseInt(mod.querySelector('BandID')?.textContent ?? '0'),
        subPID: mod.querySelector('SubPID')?.textContent?.trim() ?? '0x0',
        position: { x: pos[0], y: pos[1] },
        size: { w: size[0], h: size[1] },
        valueRange: parseValueRange(mod.querySelector('Value')),
        hasMute: hasmp[0] === 1,
        hasPhase: hasmp[1] === 1,
        unit: mod.querySelector('StringUnit')?.textContent ?? undefined,
        options: mod.querySelector('TypeStr')?.textContent?.split(',') ?? undefined,
      } satisfies XmlWidgetDef
    })
  }

  // ... 解析 TabControl、EQ 等
}

动态控件渲染(XmlTuningDialog.vue)

<template>
  <div class="xml-tuning-dialog">
    <div v-for="widget in config.widgets" :key="widget.name + widget.channelId">
      <XmlMixerWidget      v-if="widget.widgetType === 'mixer'"      :def="widget" @change="onParamChange" />
      <XmlButtonWidget     v-else-if="widget.widgetType === 'button'"     :def="widget" @change="onParamChange" />
      <XmlComboBoxWidget   v-else-if="widget.widgetType === 'combobox'"   :def="widget" @change="onParamChange" />
      <XmlEQWidget         v-else-if="widget.widgetType === 'eq'"         :def="widget" @change="onParamChange" />
      <XmlTabWidget        v-else-if="widget.widgetType === 'tab'"        :def="widget" @change="onParamChange" />
      <XmlPlotWidget       v-else-if="widget.widgetType === 'plot'"       :def="widget" />
      <XmlProgressBarWidget v-else-if="widget.widgetType === 'progressbar'" :def="widget" />
    </div>
  </div>
</template>

<script setup lang="ts">
const onParamChange = (subPID: string, channelId: number, bandId: number, value: number | boolean | string) => {
  wsClient.send({
    type: 'xml_param_set',
    moduleId: props.config.moduleName,
    subPID,
    channelId,
    bandId,
    value,
    hardware: props.hardware,
    dataType: props.dataType,
  })
}
</script>