前端架构设计 (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 消息:
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' }
]
}
参数角色说明:
- enable → system(Tuning 模式下禁用)
- smoothTime → system(Tuning 模式下禁用)
- gainDb → tunable(所有模式均可调节)
- mute → tunable
- phase → tunable
端口说明: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 画布渲染所有 ChainNode、SubGraphNode 和 ChainEdge
- 对 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()
}
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[],每模块包含 instanceId 和 cpuUs。
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
usePresetStore(stores/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) |
写入 activeProfileId、activeProfileData,并将 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.vue 的 initChannelData()):
// 修复前(仅处理 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.vue 在 onMounted 时发送 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.ts、types/ 等)
- 不修改现有链路编辑逻辑
- 仅新增文件,以及对 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>