后端架构设计 (v5.0)
1. 技术栈
| 组件 | 技术 | 说明 |
|---|---|---|
| 运行时 | .NET 8 | 跨平台支持 |
| Web 框架 | ASP.NET Core 8 | WebSocket + REST API |
| WebSocket | 内置 Microsoft.AspNetCore.WebSockets |
全双工通信 |
| 序列化 | System.Text.Json |
JSON 消息解析 |
| TCP 传输 | System.Net.Sockets.TcpClient |
与 DSP 硬件通信 |
| 串口传输 | System.IO.Ports.SerialPort |
Windows 串口通信 |
| 持久化 | 本地 JSON 文件 | 链路配置存储 |
| 并发容器 | ConcurrentDictionary |
线程安全参数存储 |
| 音频 I/O | NAudio 2.x (WASAPI) | PC 仿真音频采集与播放 |
| 跨平台音频 | PortAudio 19.x | 跨平台音频备选方案 |
| 算法调用 | DllImport / P/Invoke | 调用 C 算法 DLL(DynamicChain) |
| 测试脚本 | YamlDotNet | 解析 YAML 格式自动化测试脚本 |
2. 整体架构
┌──────────────────────────────────────────────────────────────────────────────────┐
│ ASP.NET Core 8 Host │
│ │
│ ┌──────────────────┐ ┌──────────────────────────────────────────────────────┐ │
│ │ REST API │ │ WebSocket Server (/ws) │ │
│ │ /api/status │ │ ConcurrentDictionary<clientId, WebSocket> │ │
│ │ /api/link │ └─────────────────────┬────────────────────────────────┘ │
│ │ /api/debug/wav │ │ │
│ │ /api/debug/ │ │ HandleMessageAsync() │
│ │ metrics │ │ │
│ │ /api/audio/ │ ┌────────────────▼────────────────────────────┐ │
│ │ devices │ │ 消息路由层 (type switch) │ │
│ └──────────────────┘ └──┬──────┬──────┬──────┬──────┬──────┬───────┘ │
│ │ │ │ │ │ │ │
│ set_ │ link │ dsp │ ping │chain │sim_* │ debug_* │
│ param │ │ │ │ │ │ set_mode │
│ ▼ ▼ ▼ ▼ ▼ ▼ │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────────────────┐ │
│ │ ParamStore │ │ ChainService │ │ DspConnectionService │ │
│ │ ConcDict<k,v> │ │ JSON 持久化 │ │ TCP / SerialPort │ │
│ │ "iid.pid#ch"→str │ │ 链路解析/验证 │ └────────────┬─────────────────┘ │
│ └────────┬─────────┘ └────────┬─────────┘ │ │
│ │ │ │ │
│ │ ┌────────▼─────────────────────┐ │ │
│ │ │ ChainFlattener │ │ │
│ │ │ (flatten sub-graphs, │ │ │
│ │ │ resolve port specs) │ │ │
│ │ └────────┬─────────────────────┘ │ │
│ │ │ │ │
│ │ ┌────────▼──────────────────────────▼──────────────┐ │
│ │ │ BinaryFrameBuilder │ │
│ │ │ BuildSetParamFrame() / BuildLinkFrameV3() │ │
│ │ └───────────────────────────────────┬──────────────┘ │
│ │ │ byte[] │
│ │ ┌─────────────────▼──────────────┐ │
│ │ │ DspConnection │ │
│ │ │ TCP / SerialPort │ │
│ │ └─────────────────────────────────┘ │
│ │ │
│ │ set_param 双轨路由 │
│ │ ┌─────────────────────────────────────────────────────────────┐ │
│ │ │ 实体 DSP 在线 → BinaryFrameBuilder → DspConnection │ │
│ │ │ PC 仿真运行中 → AudioEngineService.SetParam() (P/Invoke) │ │
│ │ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ ┌───────────────────────────────────────────────────────┐ │
│ │ │ AudioEngineService(PC 仿真服务) │ │
│ │ │ NAudio WASAPI 采集/播放 + DynChainInterop P/Invoke │ │
│ │ │ │ │
│ │ │ ┌──────────────────────────────────────────────┐ │ │
│ │ │ │ DynamicChain.dll (C 算法库) │ │ │
│ │ │ │ DynChain_Create / LoadConfig / SetParam │ │ │
│ │ │ │ DynChain_Process / GetMetrics / SetLogCb │ │ │
│ │ │ └──────────────────────────────────────────────┘ │ │
│ │ └───────────────────────────────────────────────────────┘ │
│ │ │
│ │ ┌──────────────────────┐ ┌──────────────────────────┐ │
│ │ │ DebugDataService │ │ TestAutomationService │ │
│ │ │ 指标/日志/WAV 抓取 │ │ YAML 测试脚本执行引擎 │ │
│ │ └──────────────────────┘ └──────────────────────────┘ │
│ │ │
│ └───────────────── broadcast param_update ────────────────────────── │
│ │
│ ┌──────────────────────────────────────────────────────────────────────────┐ │
│ │ WorkModeManager(模式管理) │ │
│ │ _currentMode: chain_builder | tuning | test_verify │ │
│ │ 参数角色验证 (tunable/system) + write_link 权限检查 │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
write_link 处理流水线:
write_link → ChainService (JSON persist)
│
▼
ChainFlattener
(flatten sub-graphs,
resolve port specs)
│
▼
BinaryFrameBuilder
BuildLinkFrameV3()
│
▼
DspConnection
3. 各模块详细设计
3.1 WebSocket Server
职责:管理多客户端连接生命周期,接收/发送 JSON 消息。
连接管理:
// 每个连接分配唯一 8 字符 ID
ConcurrentDictionary<string, WebSocket> _clients
// 连接建立时:
// 1. 分配 clientId
// 2. 推送 dsp_status(让客户端立即知道当前连接状态)
// 3. 进入接收循环
接收循环:
// 缓冲区 65536 bytes,支持分片消息合并
while (!result.CloseStatus.HasValue) {
string json = Encoding.UTF8.GetString(buffer, 0, result.Count)
await HandleMessageAsync(clientId, ws, json)
result = await ws.ReceiveAsync(...)
}
3.2 消息路由层
路由表:
| type | Handler | 说明 |
|---|---|---|
ping |
HandlePing() |
返回 pong + UTC 时间戳 |
set_param |
HandleSetParam() |
设置单个参数,触发 DSP 发送(支持双轨路由) |
set_params |
HandleSetParam() |
兼容别名(同 set_param) |
get_param |
HandleGetParam() |
查询单个参数 |
get_all_params |
HandleGetAllParams() |
查询实例全量参数 |
write_link |
HandleWriteLink() |
保存链路 JSON,触发 DSP 链路下发(受模式权限限制) |
read_link |
HandleReadLink() |
从文件加载链路 JSON |
connect_dsp |
HandleConnectDsp() |
建立 TCP/串口连接 |
disconnect_dsp |
HandleDisconnectDsp() |
断开 DSP 连接 |
get_status |
BuildDspStatusMessage() |
返回服务端状态 |
set_mode |
HandleSetMode() |
切换工作模式(chain_builder/tuning/test_verify) |
get_dsp_chain |
HandleGetDspChain() |
获取当前 DSP 链路配置 |
sim_start |
HandleSimStart() |
启动 PC 仿真(指定输入/输出声卡) |
sim_stop |
HandleSimStop() |
停止 PC 仿真 |
list_audio_devices |
HandleListAudioDevices() |
枚举系统音频设备列表 |
debug_subscribe |
HandleDebugSubscribe() |
订阅调试数据推送(指定推送间隔) |
debug_unsubscribe |
HandleDebugUnsubscribe() |
取消订阅调试数据推送 |
wav_capture_start |
HandleWavCaptureStart() |
在指定节点启动 WAV 数据抓取 |
| 其他 | Error(...) |
返回 error 消息 |
3.3 ParamStore(全局参数存储)
数据结构:
// 线程安全的全局参数字典
ConcurrentDictionary<string, string> _paramStore
// Key 格式: "${instanceId}.${paramId}"(paramId 已含通道后缀)
// 例:
// "gain#1.enable" → "true"
// "gain#1.gainDb#0" → "-3.5"
// "delay#1.delaySamples#3" → "96"
注意:值统一以字符串存储(来自 JSON 的 .ToString()),由 BinaryFrameBuilder 解析类型。
set_param 完整流程:
前端消息: { type:"set_param", instanceId:"gain#1", paramId:"gainDb", value:-3.5, channel:0 }
1. 拼接 storeKey = "gain#1.gainDb#0"
2. paramStore["gain#1.gainDb#0"] = "-3.5"
3. TryFlushToDspAsync("gain#1", "gainDb#0", "-3.5") [fire & forget]
→ BinaryFrameBuilder.BuildSetParamFrame()
→ dspConn.SendAsync(frame)
4. BroadcastExcept(senderWs, { type:"param_update", ... }) [fire & forget]
5. 返回 { type:"set_param_ack", success:true }
3.4 ChainService(链路管理服务)
职责: - 接收前端的链路 JSON,持久化到本地文件 - 解析链路配置,验证模块 instanceId 合法性 - 将原始链路 JSON(含子图)持久化存储,供 ChainFlattener 消费
class ChainService {
private readonly string _linkPath = "./data/current_link.json";
private string? _currentLinkJson;
// 保存链路 JSON(覆盖写入,含子图结构)
public async Task SaveAsync(LinkConfig linkConfig)
// 读取链路 JSON(文件不存在返回 null)
public async Task<string?> LoadLinkAsync()
}
3.4a ChainFlattener(链路展平服务)
职责:将前端的 LinkConfig(可能包含子图)转换为 DSP 可理解的平坦 FlattenedLinkConfig。
子图展平规则:
1. 对当前链路中的每个 SubGraphNode,递归展平其子 ChainDefinition
2. 将子链路中所有模块实例 ID 加上 "${subGraphNodeInstanceId}." 前缀(例如 "group#1.delay#1")
3. 映射子图外部端口连接:进出 SubGraphNode 的连接重映射到子图边界处的实际内部模块
4. 递归执行直到不再存在子图节点
// Services/ChainFlattener.cs
public class ChainFlattener
{
public FlattenedLinkConfig Flatten(LinkConfig config)
{
var flat = new FlattenedLinkConfig();
FlattenChain(config, config.RootChainId, "", flat);
return flat;
}
private void FlattenChain(LinkConfig config, string chainId, string prefix, FlattenedLinkConfig flat)
{
var chain = config.Chains[chainId];
foreach (var node in chain.Nodes)
{
if (node is SubGraphNodeConfig sg)
{
// recurse with prefix extended
FlattenChain(config, sg.SubGraphId, prefix + sg.InstanceId + ".", flat);
// remap edges crossing the sub-graph boundary
RemapSubGraphEdges(config, chain, sg, prefix, flat);
}
else
{
flat.Modules.Add(new FlatModule {
InstanceId = prefix + node.InstanceId,
TypeId = node.ModuleType,
Ports = node.Ports.Select(p => new PortConfig(p)).ToList()
});
}
}
// Add intra-chain connections (not crossing sub-graph boundaries)
foreach (var edge in chain.Edges)
{
if (!IsSubGraphBoundaryEdge(chain, edge))
{
flat.Connections.Add(new FlatConnection {
FromModule = prefix + edge.FromModule,
FromPort = edge.FromPort,
ToModule = prefix + edge.ToModule,
ToPort = edge.ToPort,
Channels = edge.Channels,
SampleRate = edge.SampleRate
});
}
}
}
}
3.5 ConversionService(参数转换服务)
职责:将前端 UI 单位的参数值转换为 DSP 内部格式(供后续扩展,当前版本参数以字符串透传)。
static class ConversionService {
// dB → linear(增益模块)
static float DbToLinear(float dB)
static float LinearToDb(float linear)
// 距离/时间 → 采样点数(延迟模块)
static int CmToSamples(float cm, int sampleRate, float speedOfSound = 343f)
static int MsToSamples(float ms, int sampleRate)
// EQ 参数 → Biquad 系数(5 个浮点数)
static float[] CalculateBiquadCoefficients(float freq, float gainDb, float q,
string type, int sampleRate)
}
参数转换时机:
- 当前版本:后端不做转换,原值透传到 DSP;DSP 侧接收后解析
- 未来版本:对 Gain 模块,后端可在发送时自动将 gainDb 转为 linear;
对 Delay 模块,如前端发送 cm 或 ms,后端可转换为 samples
3.6 ModuleRegistry(模块注册表)
职责:后端维护模块类型定义,用于参数验证、DSP 二进制帧中的模块类型 ID 映射,以及参数角色管理。
class ModuleRegistry {
// 模块类型 → DSP 模块类型 ID(用于二进制帧)
private readonly Dictionary<string, uint> _moduleTypeIdMap = new() {
{ "channel_gain_v1", 0x16673001 },
{ "ut_delay_20ch_v1", 0x16672001 },
{ "common_eq_v1", 0x16676001 },
// ...更多模块
};
// 参数角色:tunable = 可调;system = 仅 Chain Builder 可改
private readonly Dictionary<string, Dictionary<string, string>> _paramRoles = new() {
["channel_gain_v1"] = new() {
["enable"] = "system",
["smoothTime"] = "system",
["gainDb"] = "tunable",
["mute"] = "tunable",
["phase"] = "tunable",
},
["ut_delay_20ch_v1"] = new() {
["enable"] = "system",
["delaySamples"] = "tunable",
},
};
// 获取 DSP 模块类型 ID
public uint GetDspModuleTypeId(string moduleType)
// 验证 instanceId 格式("moduleType#index")
public bool ValidateInstanceId(string instanceId)
// 解析 instanceId → (moduleType, index)
public (string moduleType, int index) ParseInstanceId(string instanceId)
// 获取参数角色(tunable / system)
public string GetParamRole(string instanceId, string paramId)
}
3.7 BinaryFrameBuilder(帧构造器)
职责:将高层逻辑(字符串参数、展平后的链路配置)序列化为 DSP 二进制帧。
帧格式(详见 Protocol_Design.md):
static class BinaryFrameBuilder {
private static ushort _seq = 0;
// 构造 set_param 帧(CMD = 0x01)
static byte[] BuildSetParamFrame(string instanceId, string paramId, string valueStr)
// 构造 set_link 帧 v3(CMD = 0x02),接收展平后的链路配置
// 连接条目使用模块索引(uint8)和端口索引(uint8),而非字符串名称
static byte[] BuildLinkFrameV3(FlattenedLinkConfig flat)
// 内部:值类型推断 + 序列化
static (byte valueType, byte[] valueBytes) SerializeValue(string valueStr)
// 内部:CRC8/SMBUS(多项式 0x07)
static byte Crc8(byte[] data, int start, int len)
}
BuildLinkFrameV3 构建步骤:
1. 构建模块表(numModules 个条目)
2. 对每条连接,将 fromModule 字符串解析为模块索引,将 fromPort 字符串解析为该模块端口数组中的端口索引
3. 用索引构建连接表(保持二进制帧紧凑,适配嵌入式 DSP)
set_link DATA 格式(CMD = 0x02,v3):
Offset Size 字段
0 2 模块数量 (uint16 LE)
2 N*M 模块条目列表:
每个条目:
4B moduleTypeId (uint32 LE)
1B instanceId 字节长度
NB instanceId (UTF-8)
1B 端口数量
P*K 端口条目列表:
1B portId 字节长度
NB portId (UTF-8)
1B direction (0=input,1=output)
2B channels (int16 LE, -1=inherit)
2B sampleRate (int16 LE, -1=inherit)
连接段:
0 2 连接数量 (uint16 LE)
2 C*6 连接条目列表:
每个条目:
1B fromModule 索引 (uint8)
1B fromPort 索引 (uint8,在该模块端口数组中)
1B toModule 索引 (uint8)
1B toPort 索引 (uint8,在该模块端口数组中)
2B channels (int16 LE)
2B sampleRate (int16 LE)
3.8 DspConnection(DSP 通信层)
职责:统一封装 TCP 和串口两种连接方式。
class DspConnection {
public bool IsConnected { get; private set; }
public string ConnectionType { get; private set; } // "tcp"|"serial"|"none"
public string Target { get; private set; } // "host:port" 或 "COM3@115200"
public Task<bool> ConnectTcpAsync(string host, int port)
public Task<bool> ConnectSerialAsync(string comPort, int baudRate)
public Task DisconnectAsync()
public Task SendAsync(byte[] data)
// 接收 DSP 响应(为后续双向通信预留)
public Task StartReceiveLoopAsync(Func<byte[], Task> onReceived)
}
3.9 广播机制
// 向除 except 之外的所有已连接客户端广播消息
async Task BroadcastExcept(WebSocket? except, string json)
// 向所有已连接客户端广播消息
async Task BroadcastAll(string json)
// 使用场景:
// 1. set_param 后广播 param_update(多端同步)
// 2. DSP 连接/断开后广播 dsp_status(所有端感知状态变化)
// 3. write_link 后广播 link_updated(可选,通知其他端链路已更新)
// 4. set_mode 后广播 mode_changed(所有端同步模式状态)
// 5. sim_start/stop 后广播 sim_status(所有端感知仿真状态)
// 6. DLL 日志回调触发 log_stream 推送(所有订阅端)
3.10 WorkModeManager(模式管理)
职责:维护全局工作模式状态,根据模式对操作权限进行管控。
模式状态:
后端维护当前全局工作模式,初始为 chain_builder:
// 全局模式状态(thread-safe)
private volatile string _currentMode = "chain_builder";
// 合法值:chain_builder | tuning | test_verify
set_mode 处理:
// 消息:{ type: "set_mode", mode: "tuning" }
async Task HandleSetMode(string clientId, WebSocket ws, JsonElement msg) {
string mode = msg.GetProperty("mode").GetString()!;
if (mode is not ("chain_builder" or "tuning" or "test_verify")) {
await SendError(ws, $"无效模式: {mode}");
return;
}
_currentMode = mode;
// 广播模式变更给所有客户端
await BroadcastAll(JsonSerializer.Serialize(new {
type = "mode_changed", mode = _currentMode
}));
}
参数角色检查(Tuning 模式):
HandleSetParam 中增加角色验证:
// Tuning 模式下,拒绝修改 system 参数
if (_currentMode == "tuning") {
string role = _moduleRegistry.GetParamRole(msg.instanceId, msg.paramId);
if (role == "system") {
await Send(ws, new { type="set_param_ack", success=false,
error="调音模式下不允许修改系统参数" });
return;
}
}
链路写入权限检查:
// tuning / test_verify 模式下拒绝 write_link
if (_currentMode != "chain_builder") {
await Send(ws, new { type="write_link_ack", success=false,
error=$"当前模式({_currentMode})不允许修改链路结构" });
return;
}
3.11 AudioEngineService(PC 仿真服务)
设计目标:通过 AudioEngineService 调用 DynamicChain.dll(C 算法库),结合 NAudio WASAPI 实现 PC 侧实时音频仿真,与实体 DSP 接口保持一致。
DLL P/Invoke 封装(Interop/DynChainInterop.cs):
internal static class DynChainInterop {
private const string DllName = "DynamicChain";
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_Create(out IntPtr ppChain);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_Destroy(IntPtr pChain);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_LoadConfig(IntPtr pChain,
[MarshalAs(UnmanagedType.LPUTF8Str)] string configJson, uint len);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_SetParam(IntPtr pChain,
[MarshalAs(UnmanagedType.LPUTF8Str)] string instanceId,
[MarshalAs(UnmanagedType.LPUTF8Str)] string paramId,
IntPtr pValue, uint size);
// Process:ppIn/ppOut 为 float*[] 指针数组(需 unsafe 或 GCHandle 固定)
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int DynChain_Process(IntPtr pChain,
float** ppIn, float** ppOut, uint nbSamples);
// 调试接口
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_GetMetrics(IntPtr pChain, IntPtr pMetricsJson, uint bufSize);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_SetLogCallback(IntPtr pChain, LogCallbackDelegate cb);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_SetCaptureHook(IntPtr pChain,
[MarshalAs(UnmanagedType.LPUTF8Str)] string instanceId,
[MarshalAs(UnmanagedType.LPUTF8Str)] string capturePoint,
int durationMs,
[MarshalAs(UnmanagedType.LPUTF8Str)] string captureId);
public delegate void LogCallbackDelegate(int level, string message);
}
AudioEngineService 实现(Services/AudioEngineService.cs):
class AudioEngineService : IDisposable {
private IntPtr _chain = IntPtr.Zero;
private WasapiCapture? _capture;
private WasapiOut? _output;
private bool _running;
public bool IsRunning => _running;
// 启动仿真:输入声卡 + 输出声卡
public async Task StartAsync(string inputDeviceId, string outputDeviceId) {
// 1. 创建 DynamicChain 实例
DynChainInterop.DynChain_Create(out _chain);
// 2. 注册日志回调
DynChainInterop.DynChain_SetLogCallback(_chain, OnDspLog);
// 3. 加载当前链路配置
string linkJson = await _chainService.LoadLinkAsync() ?? "{}";
DynChainInterop.DynChain_LoadConfig(_chain, linkJson, (uint)linkJson.Length);
// 4. 配置 NAudio WASAPI
_capture = new WasapiCapture(GetDevice(inputDeviceId)) {
ShareMode = AudioClientShareMode.Exclusive
};
_output = new WasapiOut(GetDevice(outputDeviceId), AudioClientShareMode.Exclusive, false, 10);
// 5. 连接音频回调
_capture.DataAvailable += OnAudioData;
_capture.StartRecording();
_running = true;
}
private unsafe void OnAudioData(object sender, WaveInEventArgs e) {
// 将 NAudio 交错 PCM 转换为分离通道浮点数
// 调用 DynChain_Process
// 写入输出缓冲
// (需固定内存以传递 float** 指针)
GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
}
// 对外暴露:供 HandleSetParam 调用(PC 仿真时直接注入参数,无需发二进制帧)
public void SetParam(string instanceId, string paramId, float value) {
if (_chain == IntPtr.Zero) return;
unsafe {
DynChainInterop.DynChain_SetParam(_chain, instanceId, paramId, &value, sizeof(float));
}
}
public void Stop() {
_running = false;
_capture?.StopRecording();
if (_chain != IntPtr.Zero) {
DynChainInterop.DynChain_Destroy(_chain);
_chain = IntPtr.Zero;
}
}
}
消息路由集成(set_param 双轨发送):
// set_param 双轨发送:实体 DSP 走二进制帧,PC 仿真走 P/Invoke
if (_dspConn.IsConnected) {
byte[] frame = BinaryFrameBuilder.BuildSetParamFrame(instanceId, paramId, valueStr);
await _dspConn.SendAsync(frame);
}
if (_audioEngine.IsRunning) {
// 直接注入到算法链(转换为 float/int/bool)
_audioEngine.SetParam(instanceId, paramId, ConvertToFloat(valueStr));
}
sim_start / sim_stop / list_audio_devices 消息处理:
// sim_start: { type:"sim_start", input:{type:"soundcard",deviceName:"..."}, output:{...} }
async Task HandleSimStart(WebSocket ws, JsonElement msg) {
string inputDev = msg.GetProperty("input").GetProperty("deviceName").GetString()!;
string outputDev = msg.GetProperty("output").GetProperty("deviceName").GetString()!;
await _audioEngine.StartAsync(inputDev, outputDev);
await Send(ws, new { type = "sim_status", running = true, status = "running" });
await BroadcastAll(JsonSerializer.Serialize(new { type="sim_status", running=true }));
}
// sim_stop: { type:"sim_stop" }
async Task HandleSimStop(WebSocket ws) {
_audioEngine.Stop();
await BroadcastAll(JsonSerializer.Serialize(new {
type = "sim_status", running = false, status = "stopped"
}));
}
// list_audio_devices: 枚举系统声卡设备
async Task HandleListAudioDevices(WebSocket ws) {
// 枚举 MMDevice 列表(输入设备 + 输出设备)
var devices = EnumerateWasapiDevices();
await Send(ws, new { type = "audio_devices_ack", devices });
}
3.12 DebugDataService(调试数据服务)
职责:从算法库采集运行时指标,向已订阅客户端定期推送调试数据,管理 WAV 节点抓取钩子,转发算法库日志。
指标采集与推送:
// Services/DebugDataService.cs
class DebugDataService {
private IntPtr _chain; // 引用 AudioEngineService 的 DynamicChain 实例
private readonly ConcurrentDictionary<string, int> _subscribedClients = new();
private int _intervalMs = 500;
// 从算法库采集指标,返回 JSON
public string CollectMetricsJson() {
var buf = new byte[4096];
unsafe { fixed (byte* p = buf) {
DynChainInterop.DynChain_GetMetrics(_chain, (IntPtr)p, (uint)buf.Length);
}}
return Encoding.UTF8.GetString(buf).TrimEnd('\0');
}
// 订阅管理
public void Subscribe(string clientId, int intervalMs) {
_subscribedClients[clientId] = intervalMs;
_intervalMs = intervalMs;
}
public void Unsubscribe(string clientId) => _subscribedClients.TryRemove(clientId, out _);
// 周期推送(后端定时器触发,推送给已订阅的客户端)
public async Task PushMetricsLoop(CancellationToken ct) {
while (!ct.IsCancellationRequested) {
if (_subscribedClients.Count > 0) {
string json = BuildDebugMetricsMessage();
await BroadcastTo(_subscribedClients.Keys, json);
}
await Task.Delay(_intervalMs, ct);
}
}
}
WAV 节点抓取:
// wav_capture_start: { type:"wav_capture_start", instanceId:"...", capturePoint:"output", durationMs:1000 }
async Task HandleWavCaptureStart(WebSocket ws, JsonElement msg) {
string instanceId = msg.GetProperty("instanceId").GetString()!;
string capturePoint = msg.GetProperty("capturePoint").GetString()!;
int durationMs = msg.GetProperty("durationMs").GetInt32();
string captureId = Guid.NewGuid().ToString("N")[..8];
// 在算法库注册抓取 Hook
DynChainInterop.DynChain_SetCaptureHook(_chain, instanceId, capturePoint,
durationMs, captureId);
await Send(ws, new { type = "wav_capture_ack", captureId });
// 算法库回调完成时通知前端
_captureCallbacks[captureId] = async (wavPath) => {
await Send(ws, new {
type = "wav_capture_done",
captureId,
fileSizeBytes = new FileInfo(wavPath).Length
});
};
}
// REST:GET /api/debug/wav/{captureId} → 返回 WAV 文件二进制流
日志流转发:
// 算法库日志回调 → WebSocket log_stream 推送
private void OnDspLog(int level, string message) {
string levelStr = level switch {
0 => "DEBUG", 1 => "INFO", 2 => "WARN", 3 => "ERROR", _ => "INFO"
};
string json = JsonSerializer.Serialize(new {
type = "log_stream",
level = levelStr,
source = "dsp",
message,
timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
});
_ = BroadcastAll(json); // fire & forget
}
debug_subscribe / debug_unsubscribe 消息:
async Task HandleDebugSubscribe(string clientId, JsonElement msg) {
int intervalMs = msg.GetProperty("intervalMs").GetInt32();
_debugService.Subscribe(clientId, intervalMs);
}
async Task HandleDebugUnsubscribe(string clientId) {
_debugService.Unsubscribe(clientId);
}
3.13 TestAutomationService(自动化测试)
职责:解析 YAML 测试脚本,按步骤自动执行参数设置、延时等待、WAV 抓取、RMS 验证,并生成 JUnit XML 报告。
YAML 测试脚本执行:
// Services/TestAutomationService.cs
class TestAutomationService {
// 运行单个 YAML 测试文件
public async Task<TestResult> RunTestAsync(string yamlPath) {
var script = ParseYaml(yamlPath);
var result = new TestResult { Name = script.Name };
// 确保目标连接
await ConnectTarget(script.Target);
// 按 steps 顺序执行
foreach (var step in script.Steps) {
switch (step.Action) {
case "set_param":
await ExecuteSetParam(step);
break;
case "wait_ms":
await Task.Delay(step.Ms);
break;
case "capture_wav":
await ExecuteCapture(step);
break;
case "verify_rms":
bool pass = VerifyRms(step);
result.Assertions.Add(new AssertionResult {
Description = $"RMS@ch{step.Channel}",
Pass = pass,
Expected = step.ExpectedRmsDb,
Actual = MeasureRms(step.File, step.Channel)
});
break;
}
}
result.Pass = result.Assertions.All(a => a.Pass);
return result;
}
// 批量运行,输出 JUnit XML
public async Task RunSuiteAsync(string suiteDir, string reportPath) {
var files = Directory.GetFiles(suiteDir, "*.yaml");
var results = await Task.WhenAll(files.Select(RunTestAsync));
ExportJUnitXml(results, reportPath);
}
// WAV 文件 RMS 验证(dBFS)
float MeasureRms(string wavPath, int channel) {
using var reader = new WaveFileReader(wavPath);
float sumSq = 0; int count = 0;
// 读取指定通道样本,跳过其他通道,累加平方和
float rms = (float)Math.Sqrt(sumSq / count);
return 20f * (float)Math.Log10(rms + 1e-10f);
}
}
命令行入口:
// Program.cs 扩展
if (args.Contains("--test")) {
string yamlPath = args[Array.IndexOf(args, "--test") + 1];
var result = await testService.RunTestAsync(yamlPath);
Console.WriteLine(result.Pass ? "PASS" : "FAIL");
Environment.Exit(result.Pass ? 0 : 1);
}
if (args.Contains("--test-suite")) {
string dir = args[Array.IndexOf(args, "--test-suite") + 1];
string report = args.Contains("--report")
? args[Array.IndexOf(args, "--report") + 1]
: "./test_report.xml";
await testService.RunSuiteAsync(dir, report);
}
4. 数据模型
4.1 链路配置(Models/LinkConfig.cs)
v3.0 起,后端数据模型精确镜像前端 TypeScript LinkConfig v2.2 结构,替代原 DspLinkConfig。
// Models/LinkConfig.cs
public record PortDescriptorConfig(
string Id,
string Direction, // "input" | "output"
int Channels, // -1 = inherit
int SampleRate, // -1 = inherit
string Label,
bool Required
);
public record ChainEdgeConfig(
string Id,
string FromModule,
string FromPort,
string ToModule,
string ToPort,
int Channels,
int SampleRate
);
public record ChainNodeConfig(
string InstanceId,
string ModuleType,
int Order,
bool Enabled,
PositionConfig Position,
List<PortDescriptorConfig> Ports
);
public record SubGraphNodeConfig(
string InstanceId,
string /* = "subgraph" */ ModuleType,
string SubGraphId,
PositionConfig Position,
List<PortDescriptorConfig> Ports
) : ChainNodeConfig(InstanceId, ModuleType, 0, true, Position, Ports);
public record ChainDefinitionConfig(
string Id,
string Name,
List<ChainNodeConfig> Nodes, // includes SubGraphNodeConfig
List<ChainEdgeConfig> Edges,
List<PortDescriptorConfig>? ExternalPorts = null
);
public record LinkConfig(
string Version, // "2.2"
string RootChainId,
GlobalConfig Global,
Dictionary<string, ChainDefinitionConfig> Chains
);
4.2 展平后链路配置(Models/FlattenedLinkConfig.cs)
// Models/FlattenedLinkConfig.cs
// After flattening:
public record PortConfig(string PortId, string Direction, int Channels, int SampleRate);
public record FlatModule(string InstanceId, string TypeId, List<PortConfig> Ports);
public record FlatConnection(
string FromModule, string FromPort,
string ToModule, string ToPort,
int Channels, int SampleRate
);
public record FlattenedLinkConfig(
List<FlatModule> Modules,
List<FlatConnection> Connections
);
4.3 全局与位置配置(Models/GlobalConfig.cs)
public class GlobalConfig {
public int Channels { get; set; }
public int SampleRate { get; set; }
public int FrameSize { get; set; }
}
public record PositionConfig(float X, float Y);
4.4 DSP 状态(Models/DspStatus.cs)
public class DspStatus {
public bool IsConnected { get; set; }
public string Protocol { get; set; } // "tcp"|"serial"|"none"
public string Target { get; set; } // "192.168.1.100:9000"
public int SampleRate { get; set; }
public int Channels { get; set; }
}
4.5 工作模式枚举(Models/WorkMode.cs)
public enum WorkMode {
ChainBuilder, // chain_builder:可修改链路结构和所有参数
Tuning, // tuning:只允许修改 tunable 参数,禁止修改链路
TestVerify // test_verify:自动化测试模式,禁止修改链路
}
4.6 测试脚本模型(Models/TestScript.cs)
// 对应 YAML 测试脚本结构
public class TestScript {
public string Name { get; set; } // 测试名称
public string Target { get; set; } // 连接目标(sim / tcp:host:port / serial:COM3)
public string Chain { get; set; } // 使用的链路配置文件路径
public List<TestStep> Steps { get; set; } // 步骤列表
}
public class TestStep {
public string Action { get; set; } // set_param / wait_ms / capture_wav / verify_rms
public string InstanceId { get; set; }
public string ParamId { get; set; }
public object Value { get; set; }
public int Ms { get; set; } // wait_ms 用
public string File { get; set; } // capture_wav / verify_rms 用
public int Channel { get; set; } // verify_rms 用
public float ExpectedRmsDb { get; set; } // verify_rms 期望值
public float ToleranceDb { get; set; } // verify_rms 容差
}
public class TestResult {
public string Name { get; set; }
public bool Pass { get; set; }
public List<AssertionResult> Assertions { get; set; } = new();
}
public class AssertionResult {
public string Description { get; set; }
public bool Pass { get; set; }
public float Expected { get; set; }
public float Actual { get; set; }
}
4.7 调试指标模型(Models/DebugMetrics.cs)
public class DebugMetrics {
public long TimestampMs { get; set; } // UTC 毫秒时间戳
public float CpuLoadPercent { get; set; } // 算法链 CPU 占用(%)
public int DroppedFrames { get; set; } // 丢帧计数
public Dictionary<string, ModuleMetrics> Modules { get; set; }
}
public class ModuleMetrics {
public string InstanceId { get; set; }
public float ProcessTimeUs { get; set; } // 单帧处理耗时(μs)
public float[] OutputRmsDb { get; set; } // 各通道输出 RMS(dBFS)
}
5. 消息路由表
| type | Handler | 说明 |
|---|---|---|
ping |
HandlePing() |
返回 pong + UTC 时间戳 |
set_param |
HandleSetParam() |
设置单个参数;双轨路由至实体 DSP 和/或 PC 仿真 |
set_params |
HandleSetParam() |
兼容别名(同 set_param) |
get_param |
HandleGetParam() |
查询单个参数 |
get_all_params |
HandleGetAllParams() |
查询实例全量参数 |
write_link |
HandleWriteLink() |
保存链路 JSON,触发 DSP 链路下发(受模式权限限制) |
read_link |
HandleReadLink() |
从文件加载链路 JSON |
get_dsp_chain |
HandleGetDspChain() |
获取当前 DSP 链路配置 |
connect_dsp |
HandleConnectDsp() |
建立 TCP/串口连接 |
disconnect_dsp |
HandleDisconnectDsp() |
断开 DSP 连接 |
get_status |
BuildDspStatusMessage() |
返回服务端状态(含仿真状态和当前模式) |
set_mode |
HandleSetMode() |
切换工作模式,广播 mode_changed |
sim_start |
HandleSimStart() |
启动 PC 仿真(指定输入/输出声卡) |
sim_stop |
HandleSimStop() |
停止 PC 仿真 |
list_audio_devices |
HandleListAudioDevices() |
枚举 WASAPI 音频设备列表 |
debug_subscribe |
HandleDebugSubscribe() |
订阅调试数据定期推送 |
debug_unsubscribe |
HandleDebugUnsubscribe() |
取消订阅调试数据推送 |
wav_capture_start |
HandleWavCaptureStart() |
在指定节点启动 WAV 数据抓取 |
| 其他 | Error(...) |
返回 error 消息 |
6. 关键数据流
6.1 set_param 完整流程
Frontend
→ WS: { type:'set_param', instanceId:'gain#1', paramId:'gainDb', value:-3.5, channel:0 }
Backend
1. HandleSetParam():
a. 工作模式检查:若 _currentMode == "tuning" 且 paramRole == "system" → 拒绝
b. storeKey = "gain#1.gainDb#0"
c. paramStore[storeKey] = "-3.5"
d. [async] BinaryFrameBuilder.BuildSetParamFrame("gain#1","gainDb#0","-3.5") → bytes
DspConnection.SendAsync(bytes) (实体 DSP 在线时执行)
e. [async] _audioEngine.SetParam("gain#1","gainDb#0", -3.5f) (PC 仿真运行时执行)
f. [async] BroadcastExcept(senderWs, {type:"param_update", instanceId:"gain#1",
paramId:"gainDb#0", value:"-3.5"})
2. → WS: { type:'set_param_ack', instanceId:'gain#1', paramId:'gainDb#0', success:true }
6.2 write_link 完整流程
Frontend
→ WS: { type:'write_link', link: { version:"2.2", rootChainId:"main", chains:{...}, ... } }
Backend
1. HandleWriteLink():
a. 工作模式检查:若 _currentMode != "chain_builder" → 拒绝并返回错误
b. linkConfig = JsonSerializer.Deserialize<LinkConfig>(msg)
c. await _chainService.SaveAsync(linkConfig) → ./data/current_link.json(含子图)
d. [async] var flat = _chainFlattener.Flatten(linkConfig)
→ 递归展平所有子图,前缀拼接实例 ID
→ 重映射子图边界连接
e. [async] var frame = _frameBuilder.BuildLinkFrameV3(flat)
→ 构建模块表(带端口描述)
→ 将连接中的模块/端口字符串解析为索引
→ 序列化为 v3 二进制帧
f. await _dspConnection.SendAsync(frame)
2. → WS: { type:'write_link_ack', success:true }
6.3 客户端重连恢复
Frontend (新连接)
← WS: { type:'dsp_status', connected:true, protocol:'tcp', target:'...' } [后端主动推送]
Frontend
→ WS: { type:'read_link' }
← WS: { type:'read_link_ack', success:true, link:{...} }
Frontend
→ WS: { type:'get_all_params', instanceId:'gain#1' }
← WS: { type:'get_all_params_ack', instanceId:'gain#1', params:{"gainDb#0":"-3.5",...} }
6.4 PC 仿真 set_param 双轨路由流程
Frontend
→ WS: { type:'set_param', instanceId:'delay#1', paramId:'delaySamples', value:96, channel:2 }
Backend (PC 仿真运行中,实体 DSP 也已连接)
1. paramStore["delay#1.delaySamples#2"] = "96"
2. 双轨并发发送:
轨道 A(实体 DSP):
BinaryFrameBuilder.BuildSetParamFrame("delay#1","delaySamples#2","96") → bytes
DspConnection.SendAsync(bytes) [TCP/串口]
轨道 B(PC 仿真):
_audioEngine.SetParam("delay#1","delaySamples#2", 96f)
→ DynChainInterop.DynChain_SetParam(_chain, ...) [P/Invoke]
3. BroadcastExcept(senderWs, param_update)
4. → WS: { type:'set_param_ack', success:true }
6.5 调试指标采集推送流程
Frontend
→ WS: { type:'debug_subscribe', intervalMs:500 }
Backend
1. HandleDebugSubscribe() → _debugService.Subscribe(clientId, 500)
2. PushMetricsLoop(后台定时任务,500ms 间隔):
a. DynChain_GetMetrics(_chain, buf, bufSize) → metricsJson
b. 构造 debug_metrics 消息:
{ type:'debug_metrics', timestamp:..., cpuLoad:..., modules:{...} }
c. BroadcastTo(subscribedClients, json)
Frontend
← WS: { type:'debug_metrics', timestamp:1700000000000, cpuLoad:12.5,
modules:{ "gain#1":{ processTimeUs:45.2, outputRmsDb:[-18.3,...] } } }
...(每 500ms 推送一次)
Frontend
→ WS: { type:'debug_unsubscribe' }
→ 停止推送
7. 文件结构
backend/
├── Program.cs 应用入口;注册服务;支持 --test / --test-suite 命令行
├── Handlers/
│ └── WebSocketHandler.cs WebSocket 连接管理 + 消息路由(含全部新消息类型)
├── Services/
│ ├── ParamService.cs 参数存储 (ConcurrentDictionary)
│ ├── ChainService.cs 链路持久化与解析
│ ├── ChainFlattener.cs 新增:链路展平(子图→平坦模块列表)
│ ├── DspConnectionService.cs TCP/串口通信封装
│ ├── ConversionService.cs 参数单位转换
│ ├── ModuleRegistryService.cs 模块注册表 + 参数角色表 GetParamRole()
│ ├── AudioEngineService.cs PC 仿真(NAudio WASAPI + P/Invoke DLL)
│ ├── DebugDataService.cs 调试指标采集、日志转发、WAV 抓取管理
│ └── TestAutomationService.cs YAML 测试脚本执行引擎
├── Interop/
│ └── DynChainInterop.cs DynamicChain.dll P/Invoke 声明
├── Models/
│ ├── LinkConfig.cs 新增:前端链路 JSON v2.2 对应 C# 模型
│ ├── FlattenedLinkConfig.cs 新增:展平后的链路配置
│ ├── DspStatus.cs DSP 状态数据模型
│ ├── BinaryFrame.cs 帧构造器(BuildSetParamFrame / BuildLinkFrameV3)
│ ├── WorkMode.cs 工作模式枚举
│ ├── TestScript.cs YAML 测试脚本数据模型
│ └── DebugMetrics.cs 调试指标数据模型
├── Controllers/
│ ├── StatusController.cs GET /api/status(含仿真状态与当前模式)
│ ├── LinkController.cs GET /api/link
│ └── DebugController.cs GET /api/debug/wav/{captureId}、GET /api/debug/metrics
├── data/
│ └── current_link.json 链路快照(运行时写入,含完整子图结构)
├── tests/
│ ├── test_gain_precision.yaml 增益精度测试脚本
│ └── test_delay_accuracy.yaml 延迟精度测试脚本
└── appsettings.json 配置(端口、串口、AudioEngine、Debug 等)
8. 配置参数(appsettings.json)
{
"Backend": {
"ListenUrl": "http://0.0.0.0:5000",
"DataDirectory": "./data",
"WsBufferSize": 65536,
"CorsAllowAll": true
},
"Dsp": {
"DefaultProtocol": "none",
"DefaultTcpHost": "192.168.1.100",
"DefaultTcpPort": 9000,
"DefaultComPort": "COM3",
"DefaultBaudRate": 115200
},
"AudioEngine": {
"DllPath": "./DynamicChain.dll",
"DefaultInputDevice": "",
"DefaultOutputDevice": "",
"FrameSize": 240,
"SampleRate": 48000
},
"Debug": {
"DefaultMetricsIntervalMs": 500,
"WavCaptureDir": "./data/captures",
"MaxLogBuffer": 2000
}
}
9. REST API
| 方法 | 路径 | 说明 | 响应 |
|---|---|---|---|
| GET | /api/status |
查询服务端状态(含仿真状态、当前工作模式) | { status, clients, version, dsp, simRunning, mode, paramCount } |
| GET | /api/link |
获取当前链路 JSON | 链路 JSON 内容(文件不存在返回 404) |
| GET | /api/debug/wav/{captureId} |
下载指定 captureId 的 WAV 抓取文件 | WAV 文件二进制流(Content-Type: audio/wav) |
| GET | /api/debug/metrics |
获取当前调试指标快照 | DebugMetrics JSON(PC 仿真未运行时返回 503) |
| GET | /api/audio/devices |
枚举系统 WASAPI 音频设备列表 | { inputs:[...], outputs:[...] } |
10. 扩展点
| 需求 | 实现方式 |
|---|---|
| 接收 DSP 响应 | DspConnection.StartReceiveLoopAsync() 解析 ACK 帧,推送给前端 |
| 参数值范围验证 | ParamService.SetParam() 中查询 ModuleRegistry 的 Schema |
| 自动 dB→linear 转换 | ConversionService.DbToLinear() 在 BuildSetParamFrame() 前调用 |
| 链路变更通知 | write_link 后广播 link_updated 事件给所有客户端 |
| 多 DSP 支持 | DspConnectionService 改为 Dictionary<string, DspConnection> |
| 参数变更日志 | HandleSetParam() 追加 CSV/SQLite 写入 |
| PortAudio 备选方案 | 将 AudioEngineService 中 NAudio 替换或包装为 PortAudio 19.x,实现 Linux/macOS 支持 |
| 测试报告扩展 | TestAutomationService.ExportJUnitXml() 改为支持 HTML 或 Allure 报告格式 |
| 子图嵌套深度限制 | ChainFlattener 中增加递归深度计数器,超限时返回错误 |
| 展平结果缓存 | ChainFlattener 对未变更链路缓存 FlattenedLinkConfig,避免重复展平 |
11. 已知限制
| 限制 | 说明 |
|---|---|
| 无 DSP 接收解析 | 当前仅发送,不解析 DSP 硬件回包 |
| 无参数范围验证 | 前端传来的 value 未做范围检查 |
| 串口仅 Windows | SerialPort 在 Linux 需要 /dev/ttyUSBx |
| 无 TLS | WebSocket 为 ws://,生产环境需 wss:// |
| 内存存储 | 重启后 paramStore 清空(可通过 read_link + get_all_params 恢复) |
| 链路下发无 ACK | BuildLinkFrameV3 fire-and-forget,无法确认 DSP 是否成功加载 |
| WASAPI 仅 Windows | NAudio WASAPI 模式不支持 Linux/macOS;跨平台需改用 PortAudio 19.x |
| DLL 平台限制 | DynamicChain.dll 为 Windows x64 二进制,跨平台需对应平台构建版本 |
| GC 延迟风险 | OnAudioData 回调中使用 GCLatencyMode.SustainedLowLatency 降低但无法消除 GC 停顿 |
| 子图循环引用 | ChainFlattener 当前不检测链路图中的循环子图引用,可能导致无限递归 |
新增:混合 ID 策略、音效模式管理与子图同步
Binary set_param 帧格式更新(v3)
旧版(字符串路径):[CMD=0x01][LEN][paramId_string\0][value:f32] ≈ 30 字节
新版(数字路径):[CMD=0x01][LEN=9][moduleIdx:u8][paramTypeId:u16LE][channelIdx:u16LE][valueType:u8=0x01][value:f32LE] = 14 字节(节省 53%)
HandleSetParam 路由逻辑:
// WebSocket JSON 仍使用字符串 paramId
var msg = JsonSerializer.Deserialize<SetParamMsg>(rawMsg);
// dimIndices → channelIdx 编码
ushort channelIdx = msg.DimIndices switch {
null or [] => (ushort)(msg.Channel ?? 0),
[var d0] => (ushort)d0,
[var d0, var d1] => (ushort)((d0 << 8) | d1),
_ => throw new InvalidOperationException()
};
// 字符串 → 数字 ID 映射
var (moduleIdx, paramTypeId) = _paramIdMapper.Resolve(msg.InstanceId, msg.ParamId);
// 构建数字二进制帧
var frame = _frameBuilder.BuildSetParamFrameV3(moduleIdx, paramTypeId, channelIdx, float.Parse(msg.Value));
await _dspConnection.SendAsync(frame);
ParamIdMapper 服务(Services/ParamIdMapper.cs)
public class ParamIdMapper
{
private Dictionary<string, byte> _moduleIndices = new(); // instanceId → idx
private Dictionary<(string typeId, string paramId), ushort> _paramTypeIds = new();
// 在 write_link 展平后调用重建
public void RebuildFromFlatConfig(FlattenedLinkConfig flat, IModuleRegistry registry)
{
_moduleIndices.Clear();
for (byte i = 0; i < flat.Modules.Count; i++)
_moduleIndices[flat.Modules[i].InstanceId] = i;
// 从 ModuleRegistry 中读取每个 typeId 的 paramTypeId 映射
foreach (var mod in flat.Modules)
{
var schemas = registry.GetParamSchemas(mod.TypeId);
foreach (var s in schemas)
_paramTypeIds[(mod.TypeId, s.ParamId)] = s.ParamTypeId;
}
}
public (byte moduleIdx, ushort paramTypeId) Resolve(string instanceId, string paramId)
{
var typeId = _flatModules[instanceId].TypeId;
return (_moduleIndices[instanceId], _paramTypeIds[(typeId, paramId)]);
}
}
ChainSyncService(Services/ChainSyncService.cs)
子图回读问题解决方案:
// get_dsp_chain → 直接返回 Backend 存储的 LinkConfig(含子图,不询问 DSP)
case "get_dsp_chain":
var link = await _chainService.LoadLinkAsync();
await SendAsync(ws, new { type = "dsp_chain", chain = link });
break;
// read_dsp_chain → 向 DSP 发 CMD=0x06,获取平坦模块列表(仅用于校验)
case "read_dsp_chain":
await _dspConnection.SendAsync(new byte[]{ 0x06,0,0,0,0 });
// DSP 回应 CMD=0x86,由 DspResponseHandler 触发 ChainSyncService.ValidateAsync()
break;
public class ChainSyncService
{
public async Task ValidateAsync(List<DspModuleReport> dspModules)
{
var stored = await _chainSvc.LoadLinkAsync();
var flat = _flattener.Flatten(stored);
var missing = flat.Modules.Where(m => !dspModules.Any(d => d.InstanceId == m.InstanceId)).ToList();
var extra = dspModules.Where(d => !flat.Modules.Any(m => m.InstanceId == d.InstanceId)).ToList();
if (missing.Any() || extra.Any())
await _ws.BroadcastAsync(new { type="chain_sync_warning", missing, extra });
}
}
PresetService(Services/PresetService.cs)
// 存储路径: data/presets/{instanceId}/{presetId}.json
public class PresetService
{
public Task<List<ModulePreset>> ListPresetsAsync(string instanceId);
public Task SavePresetAsync(ModulePreset preset);
public Task<ModulePreset?> LoadPresetAsync(string instanceId, string presetId);
public Task DeletePresetAsync(string instanceId, string presetId);
}
// WS handlers: save_preset, load_preset, delete_preset, list_presets
// Responses: preset_save_ack, preset_data, preset_delete_ack, preset_list
ProfileService(Services/ProfileService.cs)
// 存储路径: data/profiles/{profileId}.json
public class ProfileService
{
public Task<List<AmbianceProfile>> ListProfilesAsync();
public Task SaveProfileAsync(AmbianceProfile profile);
public Task<AmbianceProfile?> LoadProfileAsync(string profileId);
public Task DeleteProfileAsync(string profileId);
}
// WS handlers: save_profile, load_profile, delete_profile, list_profiles
// set_ambiance → 发 CMD=0x05 到 DSP
ParamBinGenerator(Services/ParamBinGenerator.cs)
// 新增 DSP 命令:CMD=0x04(下发 ParamBin),CMD=0x05(切换 ambiance)
public class ParamBinGenerator
{
/*
* ParamBin 格式:
* Header(8): "PCFG" + version:u8 + numAmbiances:u8 + numModules:u8 + reserved:u8
* Module Table(numModules×24): instanceId:char[16] + typeNumId:u32 + numPresets:u8 + pad:u8[3]
* Ambiance Table(numAmbiances×(16+numModules)): name:char[16] + presetIndices:u8[numModules]
* Param Data: per module per preset: paramCount:u16 + (typeId:u16+chIdx:u16+val:f32)[N]
*/
public async Task<byte[]> GenerateAsync(ParamBinConfig config) { ... }
public async Task SendToDspAsync(byte[] binData) { ... } // CMD=0x04
}
// WS handler:
case "generate_param_bin":
var cfg = JsonSerializer.Deserialize<ParamBinConfig>(msg);
var bin = await _paramBinGenerator.GenerateAsync(cfg);
await _paramBinGenerator.SendToDspAsync(bin);
await SendAsync(ws, new { type="param_bin_ack", byteSize=bin.Length, numAmbiances=cfg.TargetProfiles.Count });
break;
case "set_ambiance":
await _dspConnection.SendAsync(new byte[]{ 0x05,1,0,0,0,(byte)msg.AmbianceIndex });
await SendAsync(ws, new { type="set_ambiance_ack", ambianceIndex=msg.AmbianceIndex });
break;
新增数据模型(Models/Preset.cs)
public record ModulePreset(
string InstanceId, string PresetId, string Name,
Dictionary<string, string> Params // key: "paramId[d0][d1]", value: 字符串
);
public record AmbianceProfile(
string ProfileId, string Name,
Dictionary<string, string> ModulePresets // instanceId → presetId
);
public record ParamBinConfig(
List<AmbianceProfile> TargetProfiles,
Dictionary<string, int> ModulePresetLimits
);
public record DspModuleReport(string InstanceId, string TypeId);
新增文件结构
Backend/
├── Services/
│ ├── ChainSyncService.cs 子图同步校验(比对存储 vs DSP 回读)
│ ├── PresetService.cs 模块级 preset 持久化
│ ├── ProfileService.cs 全局音效模式持久化
│ ├── ParamBinGenerator.cs ParamBin 二进制生成与下发
│ └── ParamIdMapper.cs 字符串↔数字 ID 映射表
├── Models/
│ └── Preset.cs ModulePreset / AmbianceProfile / ParamBinConfig
└── data/
├── current_link.json 完整 LinkConfig(含子图,权威来源)
├── presets/ {instanceId}/{presetId}.json
└── profiles/ {profileId}.json
新增:IDspTransport 传输接口 与 AudioEngine 服务 (v5.0)
设计背景
原 DspConnection 类将 TCP 和 Serial 混在一起,耦合度较高。v5.0 重构为接口+实现分离:前端 Vue3 负责 AudioEngine 操作 UI(声卡选择、资源监控),后端通过 WebSocket 暴露 audio_engine_* 消息。
IDspTransport 接口
// Services/Transport/IDspTransport.cs
namespace TuningTool.Backend.Services.Transport
{
public interface IDspTransport : IAsyncDisposable
{
bool IsConnected { get; }
string Target { get; } // "192.168.1.100:9000" or "COM3@115200"
string Protocol { get; } // "tcp" | "serial" | "none"
Task<bool> ConnectAsync();
Task DisconnectAsync();
/// <summary>发送二进制帧(fire-and-forget,内部队列)</summary>
Task DeliverAsync(byte[] frame);
/// <summary>注册接收回调(DSP主动上报时触发)</summary>
void RegisterReceiver(Action<byte[]> onReceive);
}
}
TcpTransport
// Services/Transport/TcpTransport.cs
public class TcpTransport : IDspTransport
{
private TcpClient? _client;
private NetworkStream? _stream;
private readonly string _host;
private readonly int _port;
private Action<byte[]>? _receiver;
private CancellationTokenSource? _cts;
public bool IsConnected => _client?.Connected == true && _stream != null;
public string Target => $"{_host}:{_port}";
public string Protocol => "tcp";
public TcpTransport(string host, int port) { _host = host; _port = port; }
public async Task<bool> ConnectAsync()
{
_client = new TcpClient();
await _client.ConnectAsync(_host, _port);
_stream = _client.GetStream();
_cts = new CancellationTokenSource();
_ = ReceiveLoopAsync(_cts.Token);
return true;
}
public async Task DeliverAsync(byte[] frame)
{
if (_stream != null)
await _stream.WriteAsync(frame);
}
public void RegisterReceiver(Action<byte[]> onReceive) => _receiver = onReceive;
private async Task ReceiveLoopAsync(CancellationToken ct)
{
var buf = new byte[4096];
while (!ct.IsCancellationRequested && _stream != null)
{
int n = await _stream.ReadAsync(buf, ct);
if (n > 0) _receiver?.Invoke(buf[..n]);
}
}
public async Task DisconnectAsync()
{
_cts?.Cancel();
_stream?.Close(); _client?.Close();
_stream = null; _client = null;
}
public async ValueTask DisposeAsync() => await DisconnectAsync();
}
SerialTransport
// Services/Transport/SerialTransport.cs
public class SerialTransport : IDspTransport
{
private SerialPort? _port;
private readonly string _comPort;
private readonly int _baudRate;
private Action<byte[]>? _receiver;
public bool IsConnected => _port?.IsOpen == true;
public string Target => $"{_comPort}@{_baudRate}";
public string Protocol => "serial";
public SerialTransport(string comPort, int baudRate = 115200)
{ _comPort = comPort; _baudRate = baudRate; }
public Task<bool> ConnectAsync()
{
_port = new SerialPort(_comPort, _baudRate, Parity.None, 8, StopBits.One);
_port.DataReceived += (_, __) =>
{
int n = _port.BytesToRead;
var buf = new byte[n];
_port.Read(buf, 0, n);
_receiver?.Invoke(buf);
};
_port.Open();
return Task.FromResult(true);
}
public Task DeliverAsync(byte[] frame)
{
_port?.Write(frame, 0, frame.Length);
return Task.CompletedTask;
}
public void RegisterReceiver(Action<byte[]> onReceive) => _receiver = onReceive;
public Task DisconnectAsync() { _port?.Close(); return Task.CompletedTask; }
public ValueTask DisposeAsync() { _port?.Dispose(); return ValueTask.CompletedTask; }
}
AudioEngine 服务(PC 仿真声卡管理)
设计决策:UI 放在前端
PC 仿真音频引擎的操作 UI(声卡选择、挂载模块列表、系统资源)集成在 Vue3 前端,通过 WebSocket 消息与后端通信。优点: - 统一 UI 风格(Vue3 组件) - 无需额外 WinForms/WPF 窗口 - 可嵌入主界面侧边栏或浮动面板
后端 AudioEngineService 负责:
1. 枚举 WASAPI 声卡设备(依赖 NAudio)
2. 初始化/切换输出设备
3. 定期推送 CPU/内存占用
4. 管理挂载的算法模块链路
// Services/AudioEngineService.cs
// NuGet: NAudio (2.2.1)
using NAudio.CoreAudioApi;
public class AudioEngineService
{
private MMDevice? _currentDevice;
/// <summary>枚举所有 WASAPI 输出设备</summary>
public List<AudioDeviceInfo> ListOutputDevices()
{
var enumerator = new MMDeviceEnumerator();
return enumerator
.EnumerateAudioEndPoints(DataFlow.Render, DeviceState.Active)
.Select((d, i) => new AudioDeviceInfo
{
Index = i,
Id = d.ID,
Name = d.FriendlyName,
SampleRate = d.AudioClient.MixFormat.SampleRate,
Channels = d.AudioClient.MixFormat.Channels
}).ToList();
}
public bool SetDevice(string deviceId) { /* 初始化 WASAPI 渲染 */ return true; }
/// <summary>实时资源快照(由定时推送协程调用)</summary>
public AudioEngineStatus GetStatus()
{
var proc = System.Diagnostics.Process.GetCurrentProcess();
return new AudioEngineStatus
{
CpuPercent = 0, // 可接入 PerformanceCounter
MemoryMB = (int)(proc.WorkingSet64 / 1024 / 1024),
IsRunning = _currentDevice != null,
DeviceName = _currentDevice?.FriendlyName ?? "未选择",
SampleRate = _currentDevice?.AudioClient?.MixFormat?.SampleRate ?? 0,
LoadedModules = new List<string>() // 来自 DynChain
};
}
}
public record AudioDeviceInfo
{
public int Index { get; init; }
public string Id { get; init; } = "";
public string Name { get; init; } = "";
public int SampleRate { get; init; }
public int Channels { get; init; }
}
public record AudioEngineStatus
{
public int CpuPercent { get; init; }
public int MemoryMB { get; init; }
public bool IsRunning { get; init; }
public string DeviceName { get; init; } = "";
public int SampleRate { get; init; }
public List<string> LoadedModules { get; init; } = new();
}
新增 WebSocket 消息(AudioEngine)
前端→后端 (C→S)
| type | 说明 | 关键字段 |
|---|---|---|
list_audio_devices |
枚举声卡设备 | 无 |
set_audio_device |
选择声卡 | deviceId: string |
start_audio_engine |
启动音频引擎 | sampleRate?: number |
stop_audio_engine |
停止音频引擎 | 无 |
get_audio_engine_status |
获取当前状态 | 无 |
后端→前端 (S→C)
| type | 说明 | 关键字段 |
|---|---|---|
audio_device_list |
设备列表响应 | devices: AudioDeviceInfo[] |
audio_device_ack |
选择响应 | success: bool, deviceName: string |
audio_engine_status |
状态(含资源) | AudioEngineStatus 对象 |
audio_engine_started |
引擎已启动 | deviceName: string |
audio_engine_stopped |
引擎已停止 | 无 |
后端每 2 秒通过定时器推送一次 audio_engine_status 到所有已连接客户端。
新增文件结构(v5.0 增量)
Backend/
├── Services/
│ ├── Transport/
│ │ ├── IDspTransport.cs
│ │ ├── TcpTransport.cs
│ │ └── SerialTransport.cs
│ └── AudioEngineService.cs
└── Models/
└── AudioEngineModels.cs (AudioDeviceInfo, AudioEngineStatus)
新增:ToStoreString 布尔修正、apply_params、get_module_params、Preset 架构更新 (v5.1)
ToStoreString 辅助函数(布尔值大小写修正)
问题背景:C# 的 JsonElement.ToString() 对 JSON true/false 返回大写首字母的 "True"/"False"。前端 JavaScript 使用严格相等 === 'true' 进行布尔判断,导致从 paramStore 读回的布尔参数无法匹配。
修复方案:新增 ToStoreString() 辅助函数,统一将 JSON 布尔值序列化为小写字符串:
// Program.cs, ~line 574
static string ToStoreString(JsonElement el) => el.ValueKind switch {
JsonValueKind.True => "true",
JsonValueKind.False => "false",
_ => el.ToString()
};
应用范围:所有将 JsonElement 值写入 paramStore 的路径均已改为调用 ToStoreString(),具体包括:
| Handler | 说明 |
|---|---|
HandleSetParam |
单参数写入 |
HandleSavePreset |
Preset 参数快照 |
HandleLoadPreset |
从文件加载 preset 后写回 paramStore |
HandleApplyParams |
批量参数写入(新增) |
HandleSetAmbiance |
音效模式切换时参数写入 |
paramStore 值规范(更新后):
HandleApplyParams(批量参数应用)
消息类型:apply_params
设计背景:前端主导的 preset 加载架构。前端将 preset 参数应用到自身 Pinia store 后,通过 apply_params 批量推送给后端,后端写入 paramStore 并向 DSP 下发。此设计使前端成为参数状态的权威来源,避免了后端 preset 文件中参数与前端状态不同步的问题。
接收消息格式:
{
"type": "apply_params",
"instanceId": "gain#1",
"params": {
"gainDb#0": "-3.5",
"gainDb#1": "-3.5",
"mute#0": "false",
"enable": "true"
}
}
处理逻辑:
async Task HandleApplyParams(WebSocket ws, JsonElement msg) {
string instanceId = msg.GetProperty("instanceId").GetString()!;
var paramsEl = msg.GetProperty("params");
foreach (var kv in paramsEl.EnumerateObject()) {
string storeKey = $"{instanceId}.{kv.Name}";
// ToStoreString 确保布尔值以小写存储
string storeVal = ToStoreString(kv.Value);
_paramStore[storeKey] = storeVal;
// 异步向 DSP 下发每个参数(fire & forget)
_ = TryFlushToDspAsync(instanceId, kv.Name, storeVal);
}
await Send(ws, new {
type = "apply_params_ack",
instanceId = instanceId,
success = true
});
}
响应消息:
与 set_param 的区别:
| 特性 | set_param |
apply_params |
|---|---|---|
| 参数数量 | 单个 | 批量(任意多个) |
| 广播 param_update | 是 | 否(批量覆盖,不广播) |
| 主要用途 | UI 实时调参 | 前端主导 preset 加载后推送 |
| 响应类型 | set_param_ack |
apply_params_ack |
HandleGetModuleParams(后端参数回读)
消息类型:get_module_params
设计背景:前端"Read from backend"按钮的后端实现,用于读回校验——确认后端 paramStore 中确实收到了前端推送的参数,供调试和验证使用。
接收消息格式:
处理逻辑:
async Task HandleGetModuleParams(WebSocket ws, JsonElement msg) {
string instanceId = msg.GetProperty("instanceId").GetString()!;
string prefix = instanceId + ".";
// 从 paramStore 中过滤出该实例的所有参数,去掉前缀
var result = _paramStore
.Where(kv => kv.Key.StartsWith(prefix))
.ToDictionary(
kv => kv.Key[prefix.Length..], // 去掉 "instanceId." 前缀
kv => kv.Value
);
await Send(ws, new {
type = "module_params",
instanceId = instanceId,
@params = result
});
}
响应消息:
{
"type": "module_params",
"instanceId": "gain#1",
"params": {
"gainDb#0": "-3.5",
"gainDb#1": "-3.5",
"mute#0": "false",
"mute#1": "false",
"enable": "true"
}
}
与 get_all_params / get_all_params_ack 的关系:
get_module_params / module_params 是专为"前端回读校验"场景设计的简化接口,语义明确(只读 paramStore,不涉及 DSP 通信),与通用的 get_all_params 并行存在。
HandleSavePreset 更新(前端参数权威架构)
更新说明:原版 HandleSavePreset 仅从 paramStore 快照参数(后端权威模式)。新版同时支持前端在消息体中直接传入 params,即前端权威模式。
更新后的消息格式:
{
"type": "save_preset",
"instanceId": "gain#1",
"presetId": "bass_boost",
"name": "Bass Boost",
"params": {
"gainDb#0": "3.0",
"gainDb#1": "3.0",
"mute#0": "false",
"enable": "true"
}
}
params 字段为可选。若前端提供 params,后端:
1. 将 params 中每条记录写入 paramStore(通过 ToStoreString() 保证布尔小写)
2. 为每条记录调用 TryFlushToDspAsync(异步下发到 DSP)
3. 将 params 保存到 preset 文件中
若前端未提供 params(向后兼容),后端从 paramStore 中为该 instanceId 快照参数并保存。
Preset 文件格式(含 params 字段):
{
"instanceId": "gain#1",
"presetId": "bass_boost",
"name": "Bass Boost",
"params": {
"gainDb#0": "3.0",
"gainDb#1": "3.0",
"mute#0": "false",
"enable": "true"
}
}
HandleListPresets 更新(包含 params 字段)
更新说明:原版 list_presets 响应只返回 preset 的元数据(presetId、name)。新版从文件中读取完整 preset 内容,在列表条目中包含 params 字段,使前端在启动时可以一次性将所有 preset 的参数加载到 presetDataStore,无需在每次切换 preset 时单独发送 load_preset 请求。
响应消息更新:
旧版(仅元数据):
{
"type": "preset_list",
"instanceId": "gain#1",
"presets": [
{ "presetId": "flat", "name": "Flat" },
{ "presetId": "bass_boost", "name": "Bass Boost" }
]
}
新版(含 params):
{
"type": "preset_list",
"instanceId": "gain#1",
"presets": [
{
"presetId": "flat",
"name": "Flat",
"params": {
"gainDb#0": "0.0",
"gainDb#1": "0.0",
"mute#0": "false",
"enable": "true"
}
},
{
"presetId": "bass_boost",
"name": "Bass Boost",
"params": {
"gainDb#0": "3.0",
"gainDb#1": "3.0",
"mute#0": "false",
"enable": "true"
}
}
]
}
前端收到 preset_list 后,将每个条目的 params 存入 presetDataStore[instanceId][presetId],后续 preset 加载通过 apply_params 直接推送,无需再次请求后端。
消息路由表更新(v5.1 增量)
在 ## 5. 消息路由表 中新增以下条目:
| type | Handler | 说明 |
|---|---|---|
apply_params |
HandleApplyParams() |
批量写入 paramStore 并下发 DSP;前端主导 preset 加载 |
get_module_params |
HandleGetModuleParams() |
读取 paramStore 中某实例的全部参数;供前端回读校验 |
ParamStore 注意事项更新
布尔值存储规范(v5.1 起强制):
paramStore 中所有布尔类型参数值必须使用小写字符串 "true" / "false"。任何将 JsonElement 写入 paramStore 的路径均须经过 ToStoreString() 处理,而非直接调用 JsonElement.ToString()。
错误示例:_paramStore[key] = el.ToString() → 可能存入 "True" / "False"
正确示例:_paramStore[key] = ToStoreString(el) → 始终存入 "true" / "false"
影响的前端逻辑:前端在比较 paramStore 中读回的布尔值时,使用 === 'true' 能够正确匹配,无需额外做大小写转换。
新增(v6.0):C DLL 集成架构、多核 DSP 仿真、ChangeThread、CodeGen
12. C DLL 与 C# AudioEngine 集成架构
12.1 设计背景
DynamicChain.dll(或 Linux 下的 libDynamicChain.so)是由 C 语言实现的 DSP 算法框架。C# 后端通过 P/Invoke 调用该 DLL,实现 PC 侧实时音频仿真。集成的核心挑战有三点:
- 内存安全:C 侧管理自身堆内存,C# GC 不能移动传给 C 的缓冲区
- 音频数据布局:NAudio WASAPI 返回交错(interleaved)PCM,DLL 期望平面(planar)float**
- DLL 双态构建:同一套 C 源码分别构建 嵌入式静态库(.a/.lib)和 PC 动态库(.dll/.so),C# 只调用后者
12.2 DLL 双态构建策略
dspalgo/
├── build/
│ ├── CMakeLists_pc.cmake → 目标: DynamicChain.dll (x64, cdecl, dllexport)
│ └── CMakeLists_dsp.cmake → 目标: libDynamicChain.a (裸机, Cortex-A/SHARC)
PC 构建关键 CMake 配置:
# CMakeLists_pc.cmake
add_library(DynamicChain SHARED
framework/dynchain_core.c
framework/dynchain_registry.c
framework/dynchain_parser.c
framework/dynchain_topo.c
platform/platform_dynamic.c
modules/gain/gain_module.c
modules/delay/delay_module.c
# ... 其他模块
)
target_compile_definitions(DynamicChain PRIVATE
DYNCHAIN_EXPORT # 触发 __declspec(dllexport) / __attribute__((visibility("default")))
PLATFORM_PC # 启用 malloc/free 路径
FRAME_SIZE=240
MAX_SAMPLE_RATE=48000
)
set_target_properties(DynamicChain PROPERTIES
C_STANDARD 11
POSITION_INDEPENDENT_CODE ON
)
导出宏(dynchain_interface.h):
#ifdef DYNCHAIN_EXPORT
#ifdef _WIN32
#define DYNCHAIN_API __declspec(dllexport) __cdecl
#else
#define DYNCHAIN_API __attribute__((visibility("default")))
#endif
#else
#define DYNCHAIN_API
#endif
12.3 P/Invoke 层设计(对齐 DSPAlgo v7)
// Interop/DynChainInterop.cs
// 对齐 DSPAlgo Architecture v7:
// - SetParam/GetParam 接口去掉 channelIdx,仅保留 (instanceId, paramId, pData, dataSize)
// - 模块接口统一为 ModuleInstance* 第一参数
internal static class DynChainInterop
{
private const string DllName = "DynamicChain";
// ── 生命周期 ─────────────────────────────────────────────────
/// <summary>分配并初始化 DynamicChain 实例(平台层负责内存)</summary>
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_GetMemSize(out uint pSize);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_Init(IntPtr pMem, uint memSize, out IntPtr ppChain);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_Destroy(IntPtr pChain);
// ── 链路配置 ─────────────────────────────────────────────────
/// <summary>从二进制帧加载链路配置(对应 write_link 下发的帧)</summary>
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_LoadLinkFrame(IntPtr pChain,
[In] byte[] frame, uint frameLen);
/// <summary>热重建链路(不重置参数,用于 ChangeThread 后重新排布)</summary>
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_Rebuild(IntPtr pChain);
// ── 参数 ─────────────────────────────────────────────────────
/// <summary>
/// v7 简化接口:paramId 内嵌通道信息(如 "gainDb#0"),无独立 channelIdx。
/// pData 指向 float/int32/uint8,dataSize 为字节数。
/// </summary>
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_SetParam(IntPtr pChain,
[MarshalAs(UnmanagedType.LPUTF8Str)] string instanceId,
[MarshalAs(UnmanagedType.LPUTF8Str)] string paramId,
IntPtr pData, uint dataSize);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_GetParam(IntPtr pChain,
[MarshalAs(UnmanagedType.LPUTF8Str)] string instanceId,
[MarshalAs(UnmanagedType.LPUTF8Str)] string paramId,
IntPtr pData, uint dataSize);
// ── 音频处理 ─────────────────────────────────────────────────
/// <summary>
/// ppIn/ppOut:float*[] 平面(planar)通道指针数组,需 GCHandle 固定。
/// nbSamples:本帧采样点数(= FrameSize,默认 240)。
/// </summary>
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern unsafe int DynChain_Process(IntPtr pChain,
float** ppIn, int inChannels,
float** ppOut, int outChannels,
uint nbSamples);
// ── 调试 ─────────────────────────────────────────────────────
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_GetMetrics(IntPtr pChain,
IntPtr pJsonBuf, uint bufSize);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_SetLogCallback(IntPtr pChain,
LogCallbackDelegate cb);
[DllImport(DllName, CallingConvention = CallingConvention.Cdecl)]
public static extern int DynChain_SetCaptureHook(IntPtr pChain,
[MarshalAs(UnmanagedType.LPUTF8Str)] string instanceId,
[MarshalAs(UnmanagedType.LPUTF8Str)] string capturePoint,
int durationMs,
[MarshalAs(UnmanagedType.LPUTF8Str)] string captureId);
public delegate void LogCallbackDelegate(int level,
[MarshalAs(UnmanagedType.LPUTF8Str)] string message);
}
12.4 内存管理策略
| 资源 | 分配方 | C# 侧管理方式 |
|---|---|---|
| DynamicChain 实例状态 | DLL 内部(platform_dynamic.c malloc) | DynChain_Destroy() 时释放 |
| 模块 pState 缓冲区 | DLL 内部 | 同上 |
| 音频帧 float[] 缓冲区 | C# 侧 | GCHandle.Alloc(buf, GCHandleType.Pinned) 固定 |
| 参数传递临时缓冲 | C# 栈 | stackalloc + unsafe fixed |
| Wire 数据缓冲区 | DLL 内部(Init 时一次性分配) | 不可在 Process 期间重新分配 |
音频缓冲区固定示例:
// Services/AudioEngineCore.cs
private GCHandle[] _inHandles;
private GCHandle[] _outHandles;
private float[][] _inBufs; // [channels][frameSize]
private float[][] _outBufs;
private unsafe IntPtr AllocPlanarPtrs(float[][] bufs, GCHandle[] handles, int channels, int frameSize)
{
// 分配一个 float*[] 指针数组(非 GC 托管内存)
var ptrArray = (float**)Marshal.AllocHGlobal(channels * sizeof(float*));
for (int ch = 0; ch < channels; ch++)
{
handles[ch] = GCHandle.Alloc(bufs[ch], GCHandleType.Pinned);
ptrArray[ch] = (float*)handles[ch].AddrOfPinnedObject();
}
return (IntPtr)ptrArray;
}
private void FreePlanarPtrs(IntPtr ptrArray, GCHandle[] handles)
{
foreach (var h in handles) if (h.IsAllocated) h.Free();
Marshal.FreeHGlobal(ptrArray);
}
12.5 音频数据格式转换(Interleaved ↔ Planar)
NAudio WASAPI DataAvailable 回调提供交错(interleaved)格式,DLL 要求平面(planar)格式:
// 交错 → 平面(WASAPI Int16LE → float planar)
private void DeinterleaveToFloat(byte[] rawBytes, int byteCount,
float[][] planar, int channels, int frameSize)
{
int sampleCount = byteCount / (channels * 2); // Int16 = 2 bytes
for (int i = 0; i < sampleCount && i < frameSize; i++)
for (int ch = 0; ch < channels; ch++)
{
short s = BitConverter.ToInt16(rawBytes, (i * channels + ch) * 2);
planar[ch][i] = s / 32768f;
}
}
// 平面 → 交错(float planar → WASAPI Int16LE)
private void InterleaveFromFloat(float[][] planar, byte[] rawBytes,
int channels, int frameSize)
{
for (int i = 0; i < frameSize; i++)
for (int ch = 0; ch < channels; ch++)
{
short s = (short)Math.Clamp(planar[ch][i] * 32767f, -32768f, 32767f);
BitConverter.TryWriteBytes(rawBytes.AsSpan((i * channels + ch) * 2), s);
}
}
12.6 DLL 加载与多 DLL 支持
自定义模块 DLL(CodeGen 生成后编译的模块库)可通过 NativeLibrary.Load() 动态注册到 DynamicChain:
// Services/DllModuleLoader.cs
public class DllModuleLoader
{
// 已加载的额外模块 DLL 句柄
private readonly List<IntPtr> _loadedLibs = new();
/// <summary>动态加载自定义模块 DLL,向 DynamicChain 注册其模块表</summary>
public bool LoadModuleDll(IntPtr pChain, string dllPath)
{
// 1. 加载 DLL
IntPtr libHandle = NativeLibrary.Load(dllPath);
if (libHandle == IntPtr.Zero) return false;
// 2. 找到注册入口函数:每个模块 DLL 必须导出 "RegisterModules(pChain)"
if (!NativeLibrary.TryGetExport(libHandle, "RegisterModules", out IntPtr regFn))
return false;
// 3. 调用注册函数
var registerDelegate = Marshal.GetDelegateForFunctionPointer<RegisterModulesDelegate>(regFn);
int ret = registerDelegate(pChain);
if (ret == 0) _loadedLibs.Add(libHandle);
return ret == 0;
}
public void UnloadAll()
{
foreach (var h in _loadedLibs) NativeLibrary.Free(h);
_loadedLibs.Clear();
}
private delegate int RegisterModulesDelegate(IntPtr pChain);
}
13. 多核 DSP 仿真架构(类 AWE Server)
13.1 设计目标
真实 DSP 硬件通常为多核架构(如 ADI ADSP-21569 含 2 个 SHARC+ 核,ARM+DSP 异构多核等)。PC 侧仿真需要:
- 用多线程模拟多个 DSP 核心,每个核心有独立算法链
- 核心间通过环形缓冲区传递音频数据
- 支持每个核心独立配置线程数(模拟核心内部流水线/SIMD 并行)
- 核心间帧同步(barrier),保证延迟确定性
- 实时资源监控(每核 CPU 占用、帧时间)
这与 AWE Server 的设计思路高度吻合:AWE Server 也将算法链分布在多个处理线程(subsystem)上,各 subsystem 独立运行并通过 RouteBlock 传递信号。
13.2 核心架构图
┌─────────────────────────────────────────────────────────────────────────┐
│ AudioEngineService(主协调者) │
│ │
│ NAudio WASAPI ──► AudioInputRouter │
│ │ │
│ ┌─────────────────┼──────────────────┐ │
│ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ DspCore #0 │ │ DspCore #1 │ │ DspCore #2 │ ... │
│ │ (Thread T0) │ │ (Thread T1) │ │ (Thread T2) │ │
│ │ gain#1 │ │ eq#1 │ │ mdrc#1 │ │
│ │ delay#1 │ │ mixer#1 │ │ limiter#1 │ │
│ │ DynChain.dll │ │ DynChain.dll │ │ DynChain.dll │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ RingBuffer │ RingBuffer │ │
│ └─────────────────┴──────────────────┘ │
│ │ │
│ AudioOutputMixer │
│ │ │
│ NAudio WASAPI ◄── │
└─────────────────────────────────────────────────────────────────────────┘
13.3 DspCoreSimulator(单核仿真)
// Services/Simulation/DspCoreSimulator.cs
public class DspCoreSimulator : IDisposable
{
public int CoreId { get; }
public string Name { get; } // "Core-0", "Core-1" ...
public bool IsRunning { get; private set; }
// 该核心上挂载的模块实例 ID 列表
public IReadOnlyList<string> AssignedModules => _assignedModules;
// 性能统计
public float CpuLoadPercent { get; private set; }
public float LastFrameTimeUs { get; private set; }
public int DroppedFrames { get; private set; }
// ── 内部字段 ───────────────────────────────────────
private readonly List<string> _assignedModules = new();
private IntPtr _chain = IntPtr.Zero;
private Thread? _thread;
private CancellationTokenSource _cts = new();
// 输入/输出帧缓冲环(lock-free,容量 4 帧)
private readonly AudioRingBuffer _inputRing;
private readonly AudioRingBuffer _outputRing;
// 核心内部线程数(模拟 DSP 核心内多线程/SIMD 并行,如 Helios 双线程)
private int _intraThreadCount = 1;
// ── 帧同步屏障(由 DspCorePool 统一管理)─────────────
internal Barrier? FrameBarrier;
public DspCoreSimulator(int coreId, int channels, int frameSize, int sampleRate)
{
CoreId = coreId;
Name = $"Core-{coreId}";
_inputRing = new AudioRingBuffer(channels, frameSize, capacity: 4);
_outputRing = new AudioRingBuffer(channels, frameSize, capacity: 4);
}
public void Start()
{
// 为该核心创建独立的 DynChain 实例
DynChainInterop.DynChain_GetMemSize(out uint sz);
IntPtr mem = Marshal.AllocHGlobal((int)sz);
DynChainInterop.DynChain_Init(mem, sz, out _chain);
IsRunning = true;
_thread = new Thread(ProcessLoop)
{
Name = $"DSP-{Name}",
Priority = ThreadPriority.AboveNormal, // 模拟 DSP 实时优先级
IsBackground = true
};
_thread.Start();
}
public void Stop()
{
IsRunning = false;
_cts.Cancel();
_thread?.Join(500);
if (_chain != IntPtr.Zero)
{
DynChainInterop.DynChain_Destroy(_chain);
_chain = IntPtr.Zero;
}
}
/// <summary>
/// 核心内部线程数设置(模拟 DSP 核内多线程,如 SHARC+ Dual-Issue 或 ARM NEON)。
/// intraCount=1:单线程顺序执行;intraCount=N:模块级并行(需 DLL 支持 SIMD hint)
/// </summary>
public void SetIntraThreadCount(int count)
{
_intraThreadCount = Math.Clamp(count, 1, 8);
// 通知 DynChain 设置并行处理数(DLL 端通过 thread hint 决定是否展开循环)
if (_chain != IntPtr.Zero)
{
unsafe
{
int hint = _intraThreadCount;
DynChainInterop.DynChain_SetParam(_chain, "__sys__", "intraThreadHint",
(IntPtr)(&hint), sizeof(int));
}
}
}
/// <summary>将音频帧推入该核心的输入缓冲(由 AudioInputRouter 调用)</summary>
public bool PushInputFrame(float[][] samples) => _inputRing.TryWrite(samples);
/// <summary>从该核心的输出缓冲取出处理结果(由 AudioOutputMixer 调用)</summary>
public bool PopOutputFrame(float[][] samples) => _outputRing.TryRead(samples);
// 在独立线程中循环处理帧
private unsafe void ProcessLoop()
{
var inBufs = AllocPlanarBuffers(_inputRing.Channels, _inputRing.FrameSize);
var outBufs = AllocPlanarBuffers(_inputRing.Channels, _inputRing.FrameSize);
var inHandles = new GCHandle[inBufs.Length];
var outHandles = new GCHandle[outBufs.Length];
// 固定缓冲区,避免 GC 移动
for (int i = 0; i < inBufs.Length; i++) inHandles[i] = GCHandle.Alloc(inBufs[i], GCHandleType.Pinned);
for (int i = 0; i < outBufs.Length; i++) outHandles[i] = GCHandle.Alloc(outBufs[i], GCHandleType.Pinned);
var inPtrs = (float**)Marshal.AllocHGlobal(inBufs.Length * sizeof(float*));
var outPtrs = (float**)Marshal.AllocHGlobal(outBufs.Length * sizeof(float*));
for (int i = 0; i < inBufs.Length; i++) inPtrs[i] = (float*)inHandles[i].AddrOfPinnedObject();
for (int i = 0; i < outBufs.Length; i++) outPtrs[i] = (float*)outHandles[i].AddrOfPinnedObject();
var sw = System.Diagnostics.Stopwatch.StartNew();
while (!_cts.Token.IsCancellationRequested)
{
// 等待输入帧
if (!_inputRing.TryRead(inBufs, spinWaitUs: 200))
{
DroppedFrames++;
continue;
}
sw.Restart();
// 在核心内多线程 hint 下处理(DLL 内部决策是否并行)
DynChainInterop.DynChain_Process(_chain,
inPtrs, inBufs.Length,
outPtrs, outBufs.Length,
(uint)_inputRing.FrameSize);
LastFrameTimeUs = (float)(sw.Elapsed.TotalMilliseconds * 1000);
// 写出输出帧
_outputRing.TryWrite(outBufs);
// 帧同步屏障:等待同一 tick 内所有核心完成本帧
FrameBarrier?.SignalAndWait();
}
// 清理
for (int i = 0; i < inBufs.Length; i++) inHandles[i].Free();
for (int i = 0; i < outBufs.Length; i++) outHandles[i].Free();
Marshal.FreeHGlobal((IntPtr)inPtrs);
Marshal.FreeHGlobal((IntPtr)outPtrs);
}
private static float[][] AllocPlanarBuffers(int channels, int frameSize)
{
var bufs = new float[channels][];
for (int i = 0; i < channels; i++) bufs[i] = new float[frameSize];
return bufs;
}
public void Dispose() => Stop();
}
13.4 DspCorePool(核心池协调者)
// Services/Simulation/DspCorePool.cs
public class DspCorePool : IDisposable
{
private readonly List<DspCoreSimulator> _cores = new();
private Barrier? _frameBarrier;
private readonly int _channels;
private readonly int _frameSize;
private readonly int _sampleRate;
// instanceId → coreId 映射(由 ChangeThread 机制维护)
private readonly Dictionary<string, int> _moduleThreadMap = new();
public IReadOnlyList<DspCoreSimulator> Cores => _cores;
public DspCorePool(int channels, int frameSize, int sampleRate)
{
_channels = channels; _frameSize = frameSize; _sampleRate = sampleRate;
}
/// <summary>按 coreCount 初始化核心池,创建帧同步屏障</summary>
public void Initialize(int coreCount)
{
foreach (var c in _cores) c.Dispose();
_cores.Clear();
for (int i = 0; i < coreCount; i++)
_cores.Add(new DspCoreSimulator(i, _channels, _frameSize, _sampleRate));
// 帧同步屏障:所有核心 + 1(I/O 路由线程)
_frameBarrier = new Barrier(coreCount + 1);
foreach (var c in _cores) c.FrameBarrier = _frameBarrier;
}
/// <summary>启动所有核心线程</summary>
public void StartAll()
{
foreach (var c in _cores) c.Start();
}
/// <summary>停止所有核心线程</summary>
public void StopAll()
{
foreach (var c in _cores) c.Stop();
}
/// <summary>
/// 将模块分配到指定核心(ChangeThread 操作)。
/// 调用后需重建受影响的核心链路(RebuildCore)。
/// </summary>
public void AssignModule(string instanceId, int coreId)
{
if (coreId < 0 || coreId >= _cores.Count)
throw new ArgumentOutOfRangeException(nameof(coreId));
_moduleThreadMap[instanceId] = coreId;
}
public int? GetModuleCoreId(string instanceId)
=> _moduleThreadMap.TryGetValue(instanceId, out int id) ? id : null;
public Dictionary<string, int> GetAllAssignments()
=> new(_moduleThreadMap);
/// <summary>重建指定核心的 DynChain 链路(分配变更后调用)</summary>
public async Task RebuildCoreAsync(int coreId, FlattenedLinkConfig fullFlat,
BinaryFrameBuilder builder)
{
var core = _cores[coreId];
// 按 coreId 过滤模块
var coreModules = fullFlat.Modules
.Where(m => (_moduleThreadMap.TryGetValue(m.InstanceId, out int tid) ? tid : 0) == coreId)
.ToList();
var coreConnections = fullFlat.Connections
.Where(c => coreModules.Any(m => m.InstanceId == c.FromModule)
|| coreModules.Any(m => m.InstanceId == c.ToModule))
.ToList();
var coreFlat = new FlattenedLinkConfig(coreModules, coreConnections);
byte[] frame = builder.BuildLinkFrameV3(coreFlat);
// 暂停核心处理,下发新链路帧,重建
DynChainInterop.DynChain_LoadLinkFrame(core.ChainHandle, frame, (uint)frame.Length);
DynChainInterop.DynChain_Rebuild(core.ChainHandle);
}
/// <summary>向对应核心发送 SetParam(通过 moduleThreadMap 路由)</summary>
public unsafe void SetParam(string instanceId, string paramId, float value)
{
int coreId = _moduleThreadMap.TryGetValue(instanceId, out int id) ? id : 0;
if (coreId < _cores.Count)
_cores[coreId].SetParam(instanceId, paramId, value);
}
public void Dispose()
{
StopAll();
foreach (var c in _cores) c.Dispose();
_cores.Clear();
}
}
13.5 AudioRingBuffer(核间音频传输)
// Services/Simulation/AudioRingBuffer.cs
/// <summary>
/// 单生产者单消费者 lock-free 环形帧缓冲(float planar)。
/// 容量 capacity 帧,超出时生产者丢弃(模拟 DSP 硬件 FIFO 溢出行为)。
/// </summary>
public class AudioRingBuffer
{
public int Channels { get; }
public int FrameSize { get; }
private readonly float[][][] _frames; // [capacity][channels][frameSize]
private volatile int _writeIdx = 0;
private volatile int _readIdx = 0;
private readonly int _capacity;
public AudioRingBuffer(int channels, int frameSize, int capacity = 4)
{
Channels = channels;
FrameSize = frameSize;
_capacity = capacity;
_frames = new float[capacity][][];
for (int i = 0; i < capacity; i++)
{
_frames[i] = new float[channels][];
for (int ch = 0; ch < channels; ch++)
_frames[i][ch] = new float[frameSize];
}
}
public bool TryWrite(float[][] src)
{
int next = (_writeIdx + 1) % _capacity;
if (next == _readIdx) return false; // 满,丢帧
for (int ch = 0; ch < Channels; ch++)
Array.Copy(src[ch], _frames[_writeIdx][ch], FrameSize);
_writeIdx = next;
return true;
}
public bool TryRead(float[][] dst, int spinWaitUs = 0)
{
if (_readIdx == _writeIdx)
{
if (spinWaitUs > 0) Thread.SpinWait(spinWaitUs * 10);
return _readIdx != _writeIdx;
}
for (int ch = 0; ch < Channels; ch++)
Array.Copy(_frames[_readIdx][ch], dst[ch], FrameSize);
_readIdx = (_readIdx + 1) % _capacity;
return true;
}
}
13.6 多核仿真 WebSocket 消息
前端→后端(C→S)
| type | 说明 | 关键字段 |
|---|---|---|
sim_set_core_count |
设置仿真核心数 | count: number |
sim_set_core_intra_threads |
设置指定核心内部线程数 | coreId: number, intraThreads: number |
sim_get_core_status |
获取所有核心状态 | 无 |
后端→前端(S→C)
| type | 说明 | 关键字段 |
|---|---|---|
sim_core_status |
各核心状态快照 | cores: CoreStatus[](含 cpuLoad, frameTimeUs, droppedFrames, assignedModules) |
sim_core_count_ack |
核心数变更确认 | count: number, success: bool |
后端每 500ms 定时推送一次 sim_core_status(与 debug_metrics 合并推送):
{
"type": "sim_core_status",
"timestamp": 1700000000000,
"cores": [
{
"coreId": 0,
"name": "Core-0",
"cpuLoadPercent": 12.5,
"lastFrameTimeUs": 1240,
"droppedFrames": 0,
"intraThreads": 2,
"assignedModules": ["gain#1", "delay#1"]
},
{
"coreId": 1,
"name": "Core-1",
"cpuLoadPercent": 28.3,
"lastFrameTimeUs": 2710,
"droppedFrames": 0,
"intraThreads": 1,
"assignedModules": ["eq#1", "mdrc#1", "limiter#1"]
}
]
}
14. ChangeThread 线程分配系统
14.1 功能定位
ChangeThread 允许用户(通过前端 UI)将某个算法模块从当前所在的仿真核心(线程)迁移到另一个核心。这对应实际 DSP 开发中将模块从一个处理器核心移到另一个核心的工程实践。
是否在 DSP 硬件上支持:取决于 DSP 厂商 framework 是否提供运行时 core-to-core 迁移。部分厂商(如 ADI)需要重新烧写链路配置;部分 RTOS 框架(如 AWE)支持动态迁移。前端 UI 在连接实体 DSP 时应通过 dsp_capabilities 消息查询支持情况,不支持时禁用 ChangeThread 操作。
14.2 前端 UI 设计要点
前端在链路编辑画布中,每个模块节点上提供 "Thread" 下拉菜单(仅在 PC 仿真运行时可用):
- 显示当前所属核心(如
Core-0) - 列出所有可用核心(
Core-0 ~ Core-N) - 选择后发送
assign_module_thread消息 - 模块节点颜色/标签随核心分配变化(不同核心对应不同色调)
14.3 WebSocket 协议
前端→后端(C→S)
// 分配单个模块到指定核心
{
"type": "assign_module_thread",
"instanceId": "eq#1",
"coreId": 1
}
// 批量分配(可选,用于链路初始化时一次性设定所有分配)
{
"type": "assign_module_threads_batch",
"assignments": {
"gain#1": 0,
"delay#1": 0,
"eq#1": 1,
"mdrc#1": 1,
"limiter#1": 1
}
}
// 查询当前分配表
{ "type": "get_thread_assignments" }
后端→前端(S→C)
// assign_module_thread 响应
{
"type": "assign_module_thread_ack",
"instanceId": "eq#1",
"coreId": 1,
"success": true,
"rebuildRequired": true // 是否需要重建链路(通常为 true)
}
// 分配表快照
{
"type": "thread_assignments",
"assignments": {
"gain#1": 0,
"delay#1": 0,
"eq#1": 1,
"mdrc#1": 1,
"limiter#1": 1
}
}
// 重建完成通知(RebuildCore 完成后广播)
{
"type": "sim_rebuild_done",
"affectedCores": [0, 1],
"success": true
}
14.4 后端 ThreadAssignmentService
// Services/ThreadAssignmentService.cs
public class ThreadAssignmentService
{
// 全局分配表(instanceId → coreId)
private readonly ConcurrentDictionary<string, int> _assignments = new();
private readonly DspCorePool _corePool;
private readonly ChainFlattener _flattener;
private readonly BinaryFrameBuilder _frameBuilder;
private readonly ChainService _chainService;
public ThreadAssignmentService(DspCorePool pool, ChainFlattener flattener,
BinaryFrameBuilder builder, ChainService chainSvc)
{
_corePool = pool;
_flattener = flattener;
_frameBuilder = builder;
_chainService = chainSvc;
}
/// <summary>
/// 分配模块到核心,并异步重建受影响核心的链路。
/// 返回受影响的 coreId 列表。
/// </summary>
public async Task<int[]> AssignModuleAsync(string instanceId, int coreId)
{
int oldCore = _assignments.TryGetValue(instanceId, out int old) ? old : 0;
_assignments[instanceId] = coreId;
_corePool.AssignModule(instanceId, coreId);
// 读取当前完整链路,重建受影响的核心
var linkJson = await _chainService.LoadLinkAsync() ?? "{}";
var linkConfig = JsonSerializer.Deserialize<LinkConfig>(linkJson)!;
var flat = _flattener.Flatten(linkConfig);
var affectedCores = new HashSet<int> { oldCore, coreId };
foreach (int aid in affectedCores)
await _corePool.RebuildCoreAsync(aid, flat, _frameBuilder);
return affectedCores.ToArray();
}
/// <summary>批量更新分配表并重建所有受影响核心</summary>
public async Task<int[]> AssignBatchAsync(Dictionary<string, int> assignments)
{
var affected = new HashSet<int>();
foreach (var (iid, cid) in assignments)
{
if (_assignments.TryGetValue(iid, out int old)) affected.Add(old);
affected.Add(cid);
_assignments[iid] = cid;
_corePool.AssignModule(iid, cid);
}
var linkJson = await _chainService.LoadLinkAsync() ?? "{}";
var linkConfig = JsonSerializer.Deserialize<LinkConfig>(linkJson)!;
var flat = _flattener.Flatten(linkConfig);
foreach (int aid in affected)
await _corePool.RebuildCoreAsync(aid, flat, _frameBuilder);
return affected.ToArray();
}
public Dictionary<string, int> GetAllAssignments() => new(_assignments);
}
14.5 write_link 与 ChangeThread 集成
write_link 触发后,ChainFlattener 对链路展平,DspCorePool 按分配表拆分模块列表:
write_link
│
▼
ChainFlattener.Flatten(linkConfig)
│ FlattenedLinkConfig(全局模块列表)
▼
ThreadAssignmentService.AssignDefaultsIfMissing()
│ 未分配的模块默认放到 Core-0
▼
DspCorePool.RebuildAllCores(flat, frameBuilder)
│ 按 _moduleThreadMap 分别为每个核心生成 sub-flat
▼
各 DspCoreSimulator.LoadLinkFrame(subFrame)
│
▼
各核心独立运行新链路
14.6 DSP 硬件侧 ChangeThread 支持查询
// 后端在连接 DSP 后查询其能力(发 CMD=0x07)
// DSP 回包中包含 supportsRuntimeCoreAssign 标志
// 后端广播给所有前端
{
"type": "dsp_capabilities",
"supportsRuntimeCoreAssign": false,
"coreCount": 2,
"maxModulesPerCore": 32
}
前端收到 supportsRuntimeCoreAssign: false 时,将模块的 Thread 下拉菜单置灰,并显示提示 "当前 DSP 固件不支持运行时核心迁移,请在 Chain Builder 模式下完成分配后重新下发链路。"
15. CodeGen 代码生成系统
15.1 功能定位
CodeGen 系统允许用户通过前端 UI 定义自定义 DSP 算法模块,后端自动生成符合 DSPAlgo Architecture v7 标准的 C 代码框架和 CMake 构建脚本,用户只需填入实际算法逻辑,即可编译出可挂载到 DynamicChain 的模块 DLL。
工作流程:
前端 UI
定义模块(名称、参数、端口)
│
▼
WebSocket: codegen_module
│
▼
后端 CodeGenService
生成 .h / .c / CMakeLists.txt / manifest.json
│
▼
REST: GET /api/codegen/download/{jobId}
返回 .zip
│
▼
用户解压,填写算法实现
│
▼
cmake --build → CustomModule.dll
│
▼
WebSocket: load_module_dll (DllModuleLoader)
│
▼
DynamicChain 注册新模块,前端模块库刷新
15.2 前端模块定义接口
// types/codegen.ts
interface CodegenModuleSpec {
moduleName: string; // 模块类名(PascalCase),如 "CustomLimiter"
typeId: string; // "0x16679001"(十六进制字符串,用户自定义或自动分配)
description?: string; // 模块说明
params: CodegenParamSpec[];
inputPorts: CodegenPortSpec[];
outputPorts: CodegenPortSpec[];
}
interface CodegenParamSpec {
name: string; // 参数名,如 "threshold"
paramTypeId: number; // 参数类型 ID(uint16),在模块内唯一
dataType: 'float' | 'int32' | 'uint8';
defaultValue: number;
minValue?: number;
maxValue?: number;
channelized: boolean; // true:每通道独立参数(数组);false:全局参数
description?: string;
}
interface CodegenPortSpec {
name: string; // 端口名,如 "in", "out"
channels: number; // -1=继承
sampleRate: number; // -1=继承
}
WebSocket 请求消息:
{
"type": "codegen_module",
"spec": {
"moduleName": "CustomLimiter",
"typeId": "0x16679001",
"description": "用户自定义限幅器",
"params": [
{ "name": "threshold", "paramTypeId": 1, "dataType": "float",
"defaultValue": -6.0, "minValue": -60.0, "maxValue": 0.0,
"channelized": false, "description": "限幅阈值(dBFS)" },
{ "name": "releaseMs", "paramTypeId": 2, "dataType": "float",
"defaultValue": 100.0, "minValue": 1.0, "maxValue": 2000.0,
"channelized": false, "description": "释放时间(ms)" }
],
"inputPorts": [{ "name": "in", "channels": -1, "sampleRate": -1 }],
"outputPorts": [{ "name": "out", "channels": -1, "sampleRate": -1 }]
}
}
15.3 CodeGenService
// Services/CodeGenService.cs
public class CodeGenService
{
private readonly string _outputDir; // "./data/codegen"
public CodeGenService(IConfiguration config)
{
_outputDir = config["CodeGen:OutputDir"] ?? "./data/codegen";
Directory.CreateDirectory(_outputDir);
}
/// <summary>根据模块规格生成所有代码文件,返回 jobId(目录名)</summary>
public async Task<CodeGenResult> GenerateAsync(CodegenModuleSpec spec)
{
string jobId = $"{spec.moduleName}_{DateTime.UtcNow:yyyyMMdd_HHmmss}";
string jobDir = Path.Combine(_outputDir, jobId);
Directory.CreateDirectory(jobDir);
var files = new List<GeneratedFile>
{
GenerateHeader(spec),
GenerateImpl(spec),
GenerateRegister(spec),
GenerateCMake(spec),
GenerateManifest(spec)
};
foreach (var f in files)
await File.WriteAllTextAsync(Path.Combine(jobDir, f.Name), f.Content);
// 打包 zip
string zipPath = Path.Combine(_outputDir, $"{jobId}.zip");
System.IO.Compression.ZipFile.CreateFromDirectory(jobDir, zipPath);
return new CodeGenResult { JobId = jobId, Files = files, ZipPath = zipPath };
}
// ── 生成头文件 ────────────────────────────────────────────────────────
private GeneratedFile GenerateHeader(CodegenModuleSpec spec)
{
string macro = spec.moduleName.ToUpper();
var sb = new System.Text.StringBuilder();
sb.AppendLine($"/* Auto-generated by TuningTool CodeGen. DO NOT EDIT header guard. */");
sb.AppendLine($"#ifndef {macro}_MODULE_H");
sb.AppendLine($"#define {macro}_MODULE_H");
sb.AppendLine();
sb.AppendLine("#include \"module_interface.h\"");
sb.AppendLine("#include \"dynchain_types.h\"");
sb.AppendLine();
sb.AppendLine($"/* Module type ID */");
sb.AppendLine($"#define {macro}_TYPEID {spec.TypeId}U");
sb.AppendLine($"#define {macro}_TYPENAME \"{spec.ModuleName}\"");
sb.AppendLine();
sb.AppendLine($"/* Parameter type IDs */");
foreach (var p in spec.Params)
sb.AppendLine($"#define {macro}_PARAM_{p.Name.ToUpper()} {p.ParamTypeId}U");
sb.AppendLine();
sb.AppendLine($"/* Parameter struct */");
sb.AppendLine($"typedef struct {{");
sb.AppendLine($" uint8_t enable;");
foreach (var p in spec.Params)
{
string ctype = p.DataType switch { "float" => "float", "int32" => "int32_t", _ => "uint8_t" };
string arrSuffix = p.Channelized ? "[DYNCHAIN_MAX_CHANNELS]" : "";
sb.AppendLine($" {ctype,-10} {p.Name}{arrSuffix}; /* {p.Description ?? ""} */");
}
sb.AppendLine($"}} {spec.ModuleName}Params;");
sb.AppendLine();
sb.AppendLine($"/* Runtime state (user-defined, placed after params in pState) */");
sb.AppendLine($"typedef struct {{");
sb.AppendLine($" {spec.ModuleName}Params params;");
sb.AppendLine($" /* TODO: add runtime state fields here */");
sb.AppendLine($"}} {spec.ModuleName}State;");
sb.AppendLine();
sb.AppendLine($"/* Standard module interface functions */");
sb.AppendLine($"uint32_t {spec.ModuleName}_GetMemSize(const ModuleInstance* pInst);");
sb.AppendLine($"int32_t {spec.ModuleName}_Init (ModuleInstance* pInst);");
sb.AppendLine($"int32_t {spec.ModuleName}_Process (ModuleInstance* pInst);");
sb.AppendLine($"int32_t {spec.ModuleName}_SetParam (ModuleInstance* pInst,");
sb.AppendLine($" uint16_t paramId, const void* pData, uint32_t dataSize);");
sb.AppendLine($"int32_t {spec.ModuleName}_GetParam (ModuleInstance* pInst,");
sb.AppendLine($" uint16_t paramId, void* pData, uint32_t dataSize);");
sb.AppendLine();
sb.AppendLine($"/* DLL registration entry point (called by DllModuleLoader) */");
sb.AppendLine($"DYNCHAIN_API int32_t RegisterModules(void* pChain);");
sb.AppendLine();
sb.AppendLine($"#endif /* {macro}_MODULE_H */");
return new GeneratedFile($"{spec.ModuleName}.h", sb.ToString());
}
// ── 生成实现文件 ──────────────────────────────────────────────────────
private GeneratedFile GenerateImpl(CodegenModuleSpec spec)
{
string macro = spec.moduleName.ToUpper();
var sb = new System.Text.StringBuilder();
sb.AppendLine($"/* Auto-generated by TuningTool CodeGen. Fill in TODO sections. */");
sb.AppendLine($"#include \"{spec.ModuleName}.h\"");
sb.AppendLine($"#include <string.h>");
sb.AppendLine($"#include <math.h>");
sb.AppendLine();
sb.AppendLine($"/* Helper: get typed state pointer */");
sb.AppendLine($"#define GET_STATE(pInst) (({spec.ModuleName}State*)(pInst)->pState)");
sb.AppendLine();
// GetMemSize
sb.AppendLine($"uint32_t {spec.ModuleName}_GetMemSize(const ModuleInstance* pInst) {{");
sb.AppendLine($" (void)pInst;");
sb.AppendLine($" return (uint32_t)sizeof({spec.ModuleName}State);");
sb.AppendLine($"}}");
sb.AppendLine();
// Init
sb.AppendLine($"int32_t {spec.ModuleName}_Init(ModuleInstance* pInst) {{");
sb.AppendLine($" {spec.ModuleName}State* s = GET_STATE(pInst);");
sb.AppendLine($" memset(s, 0, sizeof(*s));");
sb.AppendLine($" /* Default params */");
sb.AppendLine($" s->params.enable = 1;");
foreach (var p in spec.Params)
sb.AppendLine($" /* s->params.{p.Name} = {p.DefaultValue}f; */");
sb.AppendLine($" /* TODO: initialize runtime state */");
sb.AppendLine($" return 0;");
sb.AppendLine($"}}");
sb.AppendLine();
// Process
sb.AppendLine($"int32_t {spec.ModuleName}_Process(ModuleInstance* pInst) {{");
sb.AppendLine($" {spec.ModuleName}State* s = GET_STATE(pInst);");
sb.AppendLine($" if (!s->params.enable) {{");
sb.AppendLine($" /* Pass-through when disabled */");
sb.AppendLine($" WireInstance* pIn = pInst->pWires[0]; /* first input wire */");
sb.AppendLine($" WireInstance* pOut = pInst->pWires[{spec.InputPorts.Count}]; /* first output wire */");
sb.AppendLine($" uint32_t ch, n = Wire_BlockSize(pIn);");
sb.AppendLine($" for (ch = 0; ch < Wire_Channels(pIn); ch++)");
sb.AppendLine($" memcpy(Wire_ChanPtr(pOut,ch), Wire_ChanPtr(pIn,ch), n*sizeof(float));");
sb.AppendLine($" return 0;");
sb.AppendLine($" }}");
sb.AppendLine();
sb.AppendLine($" /* TODO: implement DSP algorithm */");
sb.AppendLine($" return 0;");
sb.AppendLine($"}}");
sb.AppendLine();
// SetParam
sb.AppendLine($"int32_t {spec.ModuleName}_SetParam(ModuleInstance* pInst,");
sb.AppendLine($" uint16_t paramId, const void* pData, uint32_t dataSize) {{");
sb.AppendLine($" {spec.ModuleName}State* s = GET_STATE(pInst);");
sb.AppendLine($" switch (paramId) {{");
sb.AppendLine($" case 0: /* enable */");
sb.AppendLine($" s->params.enable = *(const uint8_t*)pData; return 0;");
foreach (var p in spec.Params)
{
string cast = p.DataType switch { "float" => "float", "int32" => "int32_t", _ => "uint8_t" };
sb.AppendLine($" case {macro}_PARAM_{p.Name.ToUpper()}:");
sb.AppendLine($" s->params.{p.Name} = *({cast}*)pData; return 0;");
}
sb.AppendLine($" default: return -1;");
sb.AppendLine($" }}");
sb.AppendLine($"}}");
sb.AppendLine();
// GetParam
sb.AppendLine($"int32_t {spec.ModuleName}_GetParam(ModuleInstance* pInst,");
sb.AppendLine($" uint16_t paramId, void* pData, uint32_t dataSize) {{");
sb.AppendLine($" {spec.ModuleName}State* s = GET_STATE(pInst);");
sb.AppendLine($" switch (paramId) {{");
sb.AppendLine($" case 0: *( uint8_t*)pData = s->params.enable; return 0;");
foreach (var p in spec.Params)
{
string cast = p.DataType switch { "float" => "float", "int32" => "int32_t", _ => "uint8_t" };
sb.AppendLine($" case {macro}_PARAM_{p.Name.ToUpper()}: *({cast}*)pData = s->params.{p.Name}; return 0;");
}
sb.AppendLine($" default: return -1;");
sb.AppendLine($" }}");
sb.AppendLine($"}}");
return new GeneratedFile($"{spec.ModuleName}.c", sb.ToString());
}
// ── 生成注册文件 ──────────────────────────────────────────────────────
private GeneratedFile GenerateRegister(CodegenModuleSpec spec)
{
string macro = spec.moduleName.ToUpper();
var sb = new System.Text.StringBuilder();
sb.AppendLine($"/* Auto-generated module registration entry point */");
sb.AppendLine($"#include \"{spec.ModuleName}.h\"");
sb.AppendLine($"#include \"dynchain_interface.h\"");
sb.AppendLine();
sb.AppendLine($"static const ModuleFuncTable k_{spec.ModuleName}FuncTable = {{");
sb.AppendLine($" .typeNumId = {macro}_TYPEID,");
sb.AppendLine($" .typeName = {macro}_TYPENAME,");
sb.AppendLine($" .getMemSize = {spec.ModuleName}_GetMemSize,");
sb.AppendLine($" .init = {spec.ModuleName}_Init,");
sb.AppendLine($" .process = {spec.ModuleName}_Process,");
sb.AppendLine($" .setParam = {spec.ModuleName}_SetParam,");
sb.AppendLine($" .getParam = {spec.ModuleName}_GetParam,");
sb.AppendLine($"}};");
sb.AppendLine();
sb.AppendLine($"DYNCHAIN_API int32_t RegisterModules(void* pChain) {{");
sb.AppendLine($" return DynChain_RegisterModule(pChain, &k_{spec.ModuleName}FuncTable);");
sb.AppendLine($"}}");
return new GeneratedFile($"{spec.ModuleName}_register.c", sb.ToString());
}
// ── 生成 CMakeLists.txt ───────────────────────────────────────────────
private GeneratedFile GenerateCMake(CodegenModuleSpec spec)
{
string macro = spec.moduleName.ToUpper();
var sb = new System.Text.StringBuilder();
sb.AppendLine($"# Auto-generated CMakeLists for {spec.ModuleName}");
sb.AppendLine($"cmake_minimum_required(VERSION 3.16)");
sb.AppendLine($"project({spec.ModuleName} C)");
sb.AppendLine();
sb.AppendLine($"# Point to dspalgo/include");
sb.AppendLine($"set(DSPALGO_INCLUDE_DIR \"${{CMAKE_SOURCE_DIR}}/../dspalgo/include\"");
sb.AppendLine($" CACHE PATH \"Path to dspalgo/include\")");
sb.AppendLine();
sb.AppendLine($"add_library({spec.ModuleName} SHARED");
sb.AppendLine($" {spec.ModuleName}.c");
sb.AppendLine($" {spec.ModuleName}_register.c");
sb.AppendLine($")");
sb.AppendLine();
sb.AppendLine($"target_include_directories({spec.ModuleName} PRIVATE");
sb.AppendLine($" ${{DSPALGO_INCLUDE_DIR}}");
sb.AppendLine($" ${{CMAKE_SOURCE_DIR}}");
sb.AppendLine($")");
sb.AppendLine();
sb.AppendLine($"target_compile_definitions({spec.ModuleName} PRIVATE");
sb.AppendLine($" DYNCHAIN_EXPORT");
sb.AppendLine($" PLATFORM_PC");
sb.AppendLine($")");
sb.AppendLine();
sb.AppendLine($"# Link against main DynamicChain DLL import lib (Windows)");
sb.AppendLine($"if(WIN32)");
sb.AppendLine($" target_link_libraries({spec.ModuleName} PRIVATE");
sb.AppendLine($" \"${{CMAKE_SOURCE_DIR}}/../DynamicChain.lib\")");
sb.AppendLine($"endif()");
sb.AppendLine();
sb.AppendLine($"set_target_properties({spec.ModuleName} PROPERTIES");
sb.AppendLine($" C_STANDARD 11");
sb.AppendLine($" OUTPUT_NAME \"{spec.ModuleName}\"");
sb.AppendLine($")");
return new GeneratedFile("CMakeLists.txt", sb.ToString());
}
// ── 生成模块描述清单(供前端模块库加载用)────────────────────────────
private GeneratedFile GenerateManifest(CodegenModuleSpec spec)
{
var manifest = new
{
moduleName = spec.ModuleName,
typeId = spec.TypeId,
description = spec.Description,
version = "1.0",
inputPorts = spec.InputPorts.Select(p => new {
id = p.Name, direction = "input", channels = p.Channels, sampleRate = p.SampleRate
}),
outputPorts = spec.OutputPorts.Select(p => new {
id = p.Name, direction = "output", channels = p.Channels, sampleRate = p.SampleRate
}),
params = spec.Params.Select(p => new {
name = p.Name,
paramTypeId = p.ParamTypeId,
dataType = p.DataType,
defaultValue = p.DefaultValue,
minValue = p.MinValue,
maxValue = p.MaxValue,
channelized = p.Channelized,
description = p.Description
})
};
string json = JsonSerializer.Serialize(manifest,
new JsonSerializerOptions { WriteIndented = true });
return new GeneratedFile("module_manifest.json", json);
}
}
public record CodeGenResult(string JobId, List<GeneratedFile> Files, string ZipPath);
public record GeneratedFile(string Name, string Content);
15.4 REST API 端点
| 方法 | 路径 | 说明 | 响应 |
|---|---|---|---|
POST |
/api/codegen/module |
生成模块代码 | { jobId, files:[{name,content}] } |
GET |
/api/codegen/download/{jobId} |
下载生成的 .zip 包 | ZIP 文件二进制流 |
GET |
/api/codegen/templates |
列出可用的模块模板 | { templates:[{id,name,description}] } |
POST |
/api/codegen/load-dll |
热加载已编译的模块 DLL | { success, loadedModules:[...] } |
15.5 WebSocket 消息(CodeGen)
| type | 方向 | 说明 |
|---|---|---|
codegen_module |
C→S | 提交模块定义,触发代码生成 |
codegen_result |
S→C | 返回生成结果(jobId + 文件列表) |
load_module_dll |
C→S | 热加载指定路径的模块 DLL |
module_dll_loaded |
S→C | DLL 加载成功,包含新模块的 typeId 和 typeName |
refresh_module_library |
S→C | 广播通知所有客户端刷新模块库(新模块可用) |
前端模块库刷新流程:
用户上传已编译的 CustomLimiter.dll
→ WebSocket: load_module_dll { dllPath: "./data/modules/CustomLimiter.dll" }
→ 后端 DllModuleLoader.LoadModuleDll()
→ DynChain 注册 CustomLimiter(typeId=0x16679001)
→ 后端读取该 DLL 旁的 module_manifest.json
→ WebSocket 广播: module_dll_loaded { typeId, typeName, manifest }
→ WebSocket 广播: refresh_module_library
前端收到 refresh_module_library
→ 发送 get_module_library
→ 后端返回完整模块列表(含新增的 CustomLimiter)
→ 前端模块库面板刷新,CustomLimiter 出现在列表中
16. 文件结构更新(v6.0 增量)
backend/
├── Services/
│ ├── Simulation/
│ │ ├── DspCoreSimulator.cs 单核仿真(独立线程 + DynChain 实例)
│ │ ├── DspCorePool.cs 核心池协调者(初始化、路由、帧同步屏障)
│ │ └── AudioRingBuffer.cs 核间 lock-free 音频帧缓冲
│ ├── ThreadAssignmentService.cs ChangeThread 模块→核心分配管理
│ ├── CodeGenService.cs 代码生成(头文件/实现/CMake/Manifest)
│ └── DllModuleLoader.cs 自定义模块 DLL 动态加载与注册
├── Interop/
│ └── DynChainInterop.cs 更新:对齐 DSPAlgo v7 接口
├── Models/
│ └── CodeGenModels.cs CodegenModuleSpec / CodeGenResult / GeneratedFile
├── Controllers/
│ └── CodeGenController.cs REST: /api/codegen/*
└── data/
├── codegen/ 生成的代码文件(按 jobId 子目录组织)
└── modules/ 用户上传的自定义模块 DLL
17. 消息路由表更新(v6.0 增量)
在 ## 5. 消息路由表 中新增以下条目:
| type | Handler | 说明 |
|---|---|---|
sim_set_core_count |
HandleSimSetCoreCount() |
设置 PC 仿真核心数,重建核心池 |
sim_set_core_intra_threads |
HandleSimSetCoreIntraThreads() |
设置指定核心内部线程数(模拟 DSP 核内并行) |
sim_get_core_status |
HandleSimGetCoreStatus() |
获取所有核心实时状态 |
assign_module_thread |
HandleAssignModuleThread() |
将模块分配到指定核心;触发受影响核心重建 |
assign_module_threads_batch |
HandleAssignModuleThreadsBatch() |
批量更新模块→核心分配表 |
get_thread_assignments |
HandleGetThreadAssignments() |
获取当前完整分配表 |
codegen_module |
HandleCodegenModule() |
提交模块定义,生成 C 代码框架 |
load_module_dll |
HandleLoadModuleDll() |
热加载自定义模块 DLL,向 DynChain 注册 |
18. 配置参数更新(v6.0 增量)
在 appsettings.json 的 AudioEngine 节中新增:
{
"AudioEngine": {
"DllPath": "./DynamicChain.dll",
"DefaultInputDevice": "",
"DefaultOutputDevice":"",
"FrameSize": 240,
"SampleRate": 48000,
"SimCoreCount": 2,
"SimFrameSyncMode": "barrier",
"InputRingCapacity": 4,
"OutputRingCapacity": 4
},
"CodeGen": {
"OutputDir": "./data/codegen",
"ModulesDir": "./data/modules",
"DspalgoIncludeDir": "../dspalgo/include"
}
}
19. 扩展点(v6.0 新增)
| 需求 | 实现方式 |
|---|---|
| DSP 硬件 ChangeThread | 连接后查询 dsp_capabilities,发 CMD=0x08(core assign),硬件侧需 firmware 支持 |
| 核心间信号路由可视化 | 前端在链路画布中以不同颜色区分模块所属核心,边(连线)跨核时显示虚线 |
| 自定义模块 DLL 热卸载 | NativeLibrary.Free() + DynChain_UnregisterModule() + 广播 refresh_module_library |
| CodeGen 模板扩展 | data/codegen/templates/ 目录下放置 .hbs Handlebars 模板,CodeGenService 使用 Handlebars.Net 渲染 |
| 自动 typeId 分配 | CodeGenService 维护 typeId_registry.json,为新模块自动分配不冲突的 typeId(基于项目保留范围 0x16679000~0x1667FFFF) |
| 仿真 MCPS 限额 | DspCoreSimulator 增加 MaxMcpsLimit 配置,超过阈值时触发 sim_overload 警告广播 |
| 跨平台支持 | DynChainInterop 中 DllName 改为运行时判断:Windows = "DynamicChain.dll",Linux = "libDynamicChain.so" |
新增(v7.0):Legacy 兼容通信模式
20. Legacy 兼容模式总体设计
20.1 背景与定位
除了基于 DynamicChain + 链路构建的新式工作流,系统还需要兼容老版本调音工具的通信模式。老版调音工具通过 SocketClient 类直接以 PID(Parameter ID) 地址对设备进行读写,无需链路配置,是一个纯粹的通信工具。
Legacy 模式的核心特征: - 无链路构建:不发送链路帧,设备固件中的信号链已由工厂固化 - PID 直寻址:每个参数对应唯一的 4 字节 PID - 配置文件驱动 UI:前端通过导入一个 JSON 配置文件自动生成控件界面 - 兼容老协议帧:后端使用与老版 SocketClient 相同的帧格式(UART ADI 帧 / ETH 二进制帧 / ETH AWE 文本帧) - Preset/Profile 支持:基于 PID 原始字节做参数快照
20.2 工作模式切换
系统在原有三种模式(chain_builder / tuning / test_verify)基础上增加 legacy 模式:
set_mode: "legacy" → 进入 Legacy 兼容模式
禁用 write_link / sim_start / codegen 等 DynChain 功能
启用 legacy_* 消息处理器
| 模式 | 链路构建 | DynChain 仿真 | Legacy 通信 | CodeGen |
|---|---|---|---|---|
chain_builder |
✓ | ✓ | ✗ | ✓ |
tuning |
✗ | ✓ | ✗ | ✗ |
test_verify |
✗ | ✓ | ✗ | ✗ |
legacy |
✗ | ✗ | ✓ | ✗ |
20.3 整体架构图
┌─────────────────────────────────────────────────────────────────────┐
│ Frontend(Vue3) │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ LegacyConfigImport │ │
│ │ 导入 JSON 配置文件 → 自动生成控件面板 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ legacy_load_config legacy_config_ui ↓ │
└───────────┼──────────────────────────────────────────────────────────┘
│ WebSocket
┌───────────▼──────────────────────────────────────────────────────────┐
│ Backend(ASP.NET Core 8) │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────────────────┐ │
│ │ LegacyConfigSvc │ │ 消息路由(legacy_* 消息类型) │ │
│ │ 解析导入的JSON │ │ legacy_set_param / legacy_get_param │ │
│ │ 生成 UI 描述符 │ │ legacy_save_preset / legacy_load_preset │ │
│ └────────┬────────┘ │ legacy_save_profile / legacy_load_profile│ │
│ │ └──────────────────────┬───────────────────┘ │
│ │ │ │
│ ┌────────▼────────────────────────────────────▼──────────────────┐ │
│ │ LegacyCommService │ │
│ │ 协议类型: uart_adi / eth / eth_awe │ │
│ │ ┌────────────────────────────────────────────────────────┐ │ │
│ │ │ LegacyFrameBuilder │ │ │
│ │ │ BuildUartAdiWriteFrame() / BuildUartAdiReadFrame() │ │ │
│ │ │ BuildEthWriteFrame() / BuildEthReadFrame() │ │ │
│ │ │ BuildAweWriteCmd() / BuildAweReadCmd() │ │ │
│ │ │ CRC32() │ │ │
│ │ └────────────────────────────────────────────────────────┘ │ │
│ │ │ │
│ │ LegacyConversionEngine │ │
│ │ dBtoLinear / linearTodB / msToSamples / customFormula │ │
│ └──────────────────────────────┬───────────────────────────────────┘ │
│ │ IDspTransport │
│ ┌──────▼──────┐ │
│ │ TcpTransport │ │
│ │ SerialTrans │ │
│ └─────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ LegacyPresetService │ │
│ │ data/legacy/presets/{moduleGroup}/{presetId}.json │ │
│ │ data/legacy/profiles/{profileId}.json │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
21. Legacy 配置文件格式(JSON)
21.1 文件结构
前端导入的 JSON 配置文件完整描述设备的参数空间、UI 布局和转换规则:
{
"version": "1.0",
"deviceName": "Car Audio DSP v2",
"description": "门限/延迟/EQ 调音配置",
"connection": {
"protocol": "uart_adi",
"defaultTarget": "COM3",
"defaultBaudRate": 115200,
"defaultTcpHost": "192.168.1.141",
"defaultTcpPort": 15007,
"defaultAweBuffer": "Esefun1.TuneBuf[0],Esefun1"
},
"moduleGroups": [
{
"id": "gain_ctrl",
"name": "增益控制",
"modules": [
{
"id": "master_gain",
"name": "主增益",
"parameters": [
{
"id": "master_gain_db",
"name": "增益 (dB)",
"pid": "0x16670010",
"dataType": "float",
"dataSize": 4,
"channels": 1,
"control": "slider",
"min": -60.0,
"max": 12.0,
"step": 0.1,
"defaultValue": 0.0,
"readBack": true,
"conversion": {
"type": "dBtoLinear",
"toDevice": "Math.pow(10, x / 20)",
"fromDevice": "20 * Math.log10(x)"
}
},
{
"id": "master_mute",
"name": "静音",
"pid": "0x16670011",
"dataType": "uint8",
"dataSize": 4,
"channels": 1,
"control": "toggle",
"defaultValue": 0,
"conversion": { "type": "identity" }
}
]
}
]
},
{
"id": "delay_ctrl",
"name": "延迟控制",
"modules": [
{
"id": "channel_delay",
"name": "通道延迟",
"parameters": [
{
"id": "delay_ch0",
"name": "Ch0 延迟 (ms)",
"pid": "0x16672010",
"dataType": "int32",
"dataSize": 4,
"channels": 1,
"control": "slider",
"min": 0.0,
"max": 500.0,
"step": 0.02,
"defaultValue": 0.0,
"conversion": {
"type": "msToSamples",
"sampleRate": 48000,
"toDevice": "Math.round(x * 48000 / 1000)",
"fromDevice": "x * 1000 / 48000"
}
}
]
}
]
},
{
"id": "eq_band",
"name": "参数均衡",
"modules": [
{
"id": "peq_band1",
"name": "Band 1",
"parameters": [
{
"id": "peq_b1_gain",
"name": "增益 (dB)",
"pid": "0x16676010",
"dataType": "floatArray",
"dataSize": 20,
"channels": 5,
"control": "slider",
"min": -15.0,
"max": 15.0,
"step": 0.1,
"defaultValue": 0.0,
"layout": "perChannel",
"conversion": { "type": "identity" }
}
]
}
]
}
]
}
21.2 字段说明
connection 节
| 字段 | 说明 |
|---|---|
protocol |
uart_adi / eth / eth_awe |
defaultTarget |
串口名(COM3)或 TCP 主机(192.168.1.141) |
defaultBaudRate |
串口波特率(默认 115200) |
defaultAweBuffer |
AWE 协议的缓冲区名,格式:"bufferVar,subsystemName",如 "Esefun1.TuneBuf[0],Esefun1" |
parameter 节
| 字段 | 类型 | 说明 |
|---|---|---|
pid |
string |
十六进制 PID,如 "0x16670010",解析为 uint32 |
dataType |
string |
float/int32/int16/uint8/floatArray/int32Array/bytes |
dataSize |
int |
下发字节数(单通道)。floatArray 时为所有通道总字节数 |
channels |
int |
数组长度(floatArray 等多通道参数) |
control |
string |
前端控件类型:slider/toggle/dropdown/display/numberInput |
readBack |
bool |
是否支持读回(发 GET 帧) |
conversion.type |
string |
identity/dBtoLinear/linearTodB/msToSamples/samplesToMs/formula |
conversion.toDevice |
string |
写设备前的转换公式(JS 表达式,变量 x) |
conversion.fromDevice |
string |
读回后的反转换公式 |
22. Legacy 通信协议实现
22.1 UART ADI 帧格式(对齐 SocketClient)
写请求帧(小数据,负载 < 1009 字节):
┌────────┬─────────┬───────┬──────────┬──────────────┬──────────┬─────────┐
│ Header │ FrameID │ CMDWR │ PID │ SizePayload │ Data │ CRC32 │
│ 2B │ 2B LE │ 1B │ 4B LE │ 2B 字节序交换 │ N bytes │ 4B LE │
│ AA 55 │ 0x0000 │ 0x01 │ pid uint │ swapped │ payload │ crc32 │
└────────┴─────────┴───────┴──────────┴──────────────┴──────────┴─────────┘
注:SizePayload 字节序交换:len_L=(size&0xFF)<<8 | size>>8(对应 SocketClient 的大小端转换逻辑)
读请求帧:
│ Header │ FrameID │ CMDWR │ PID │ SizePayload │ CRC32 │
│ AA 55 │ 0x0000 │ 0x00 │ 4B LE │ 2B 交换 │ 4B LE │
读/写响应帧:
┌────────┬─────────┬─────────┬──────────────┬──────────┬─────────┐
│ Header │ FrameID │ ErrCode │ SizePayload │ Data │ CRC32 │
│ 2B │ 2B LE │ 1B │ 2B │ N bytes │ 4B LE │
│ AA 55 │ │ 0=OK │ 读请求时有效 │ │ │
└────────┴─────────┴─────────┴──────────────┴──────────┴─────────┘
大数据分包帧(负载 ≥ 1009 字节,每包 900 字节):
┌────────┬─────────┬───────┬──────────────┬──────────────────────────────┬──────────┬─────────┐
│ Header │ FrameID │ CMDWR │ PID=0x166700 │ PackageHeader(12B) │ Data │ CRC32 │
│ AA 55 │ │ 0x01 │ 04 (大包PID) │ CMDPID(4)+TotalSz(4)+Pos(4) │ ≤900B │ 4B LE │
└────────┴─────────┴───────┴──────────────┴──────────────────────────────┴──────────┴─────────┘
22.2 ETH 二进制帧格式
写命令(adb_com_format_setCmd + Data):
┌────────────┬──────────┬────────────┬──────────────┬──────────────────┐
│ cmdType │ Param_ID │ Param_Size │ packagesz │ Data │
│ 4B LE │ 4B LE │ 4B LE │ 4B LE │ packagesz bytes │
│ 0x44332211 │ pid │ totalSize │ ≤100000 │ │
└────────────┴──────────┴────────────┴──────────────┴──────────────────┘
读命令:cmdType = 0x11223344,Data 为空
ETH 无 CRC32 校验。
22.3 ETH AWE 文本帧格式
写命令(文本,换行符结尾):
{subsystem},write_int_array,{bufName},{cmdMagic},0x{pid_hex},0x{size_hex},{data_big_endian}...
读触发(3步流程):
Step1 Write: {subsystem},write_int_array,{bufName},0xAABBCCDD,0x{pid_hex},0x{size_hex}\n
Step2 Read: read_int_array,{readStartBuf},3\n (读响应大小)
Step3 Read: read_int_array,{readStartBuf},{responseSize}\n (读响应数据)
数据格式:大端十六进制字符串,每 4 字节表示为 "0xAABBCCDD",逗号分隔。
22.4 LegacyFrameBuilder 实现
// Services/Legacy/LegacyFrameBuilder.cs
public class LegacyFrameBuilder
{
private static ushort _frameId = 0;
private const ushort UART_ADI_HEADER = 0x55AA; // 小端存储 → 线上字节 AA 55
private const byte UART_CMD_WRITE = 0x01;
private const byte UART_CMD_READ = 0x00;
private const uint UART_LARGE_PID = 0x16670004; // 大包传输 PID
// ── UART ADI 帧 ──────────────────────────────────────────────────
/// <summary>构建小数据写帧(负载 < 1009 字节)</summary>
public byte[] BuildUartWriteFrame(uint pid, byte[] payload)
{
ushort frameId = _frameId++;
ushort swappedSize = SwapUInt16((ushort)payload.Length);
// 帧头: AA55(2) + FrameID(2) + CMDWR(1) + PID(4) + SizeSwapped(2)
var header = new byte[11];
BitConverter.GetBytes(UART_ADI_HEADER).CopyTo(header, 0); // AA 55
BitConverter.GetBytes(frameId).CopyTo(header, 2);
header[4] = UART_CMD_WRITE;
BitConverter.GetBytes(pid).CopyTo(header, 5);
BitConverter.GetBytes(swappedSize).CopyTo(header, 9);
return AppendCrc32(Concat(header, payload));
}
/// <summary>构建读请求帧</summary>
public byte[] BuildUartReadFrame(uint pid, int readSize)
{
ushort frameId = _frameId++;
ushort swappedSize = SwapUInt16((ushort)readSize);
var header = new byte[11];
BitConverter.GetBytes(UART_ADI_HEADER).CopyTo(header, 0);
BitConverter.GetBytes(frameId).CopyTo(header, 2);
header[4] = UART_CMD_READ;
BitConverter.GetBytes(pid).CopyTo(header, 5);
BitConverter.GetBytes(swappedSize).CopyTo(header, 9);
return AppendCrc32(header);
}
/// <summary>构建大数据分包帧序列(每包 900 字节)</summary>
public IEnumerable<byte[]> BuildUartLargeFrames(uint pid, byte[] payload)
{
int pos = 0;
while (pos < payload.Length)
{
int chunkSize = Math.Min(900, payload.Length - pos);
var chunk = payload[pos..(pos + chunkSize)];
// 分包 PID = 0x16670004,负载 = 12字节包头 + 数据块
ushort swappedSize = SwapUInt16((ushort)(chunkSize + 12));
var frameHeader = new byte[11];
BitConverter.GetBytes(UART_ADI_HEADER).CopyTo(frameHeader, 0);
BitConverter.GetBytes(_frameId++).CopyTo(frameHeader, 2);
frameHeader[4] = UART_CMD_WRITE;
BitConverter.GetBytes(UART_LARGE_PID).CopyTo(frameHeader, 5);
BitConverter.GetBytes(swappedSize).CopyTo(frameHeader, 9);
// 12 字节包头: CMDPID(4) + TotalPayloadSize(4) + PayloadPosition(4)
var pkgHeader = new byte[12];
BitConverter.GetBytes(pid).CopyTo(pkgHeader, 0);
BitConverter.GetBytes((uint)payload.Length).CopyTo(pkgHeader, 4);
BitConverter.GetBytes((uint)pos).CopyTo(pkgHeader, 8);
yield return AppendCrc32(Concat(frameHeader, pkgHeader, chunk));
pos += chunkSize;
}
}
/// <summary>解析 UART 响应帧:返回 (errCode, payload);CRC 校验失败抛出异常</summary>
public (byte errCode, byte[] payload) ParseUartResponse(byte[] response)
{
if (response.Length < 11)
throw new InvalidDataException("响应帧过短");
// 验证帧头
ushort hdr = BitConverter.ToUInt16(response, 0);
if (hdr != UART_ADI_HEADER)
throw new InvalidDataException($"帧头不匹配: 0x{hdr:X4}");
byte errCode = response[4];
ushort payloadLen = BitConverter.ToUInt16(response, 5);
// 验证 CRC32(对前 7 字节 + payload 计算)
int dataLen = 7 + payloadLen;
uint expectedCrc = BitConverter.ToUInt32(response, dataLen);
uint actualCrc = ComputeCrc32(response, 0, (uint)dataLen);
if (expectedCrc != actualCrc)
throw new InvalidDataException($"CRC32 校验失败: expected={expectedCrc:X8}, actual={actualCrc:X8}");
byte[] payload = response[7..(7 + payloadLen)];
return (errCode, payload);
}
// ── ETH 二进制帧 ────────────────────────────────────────────────
/// <summary>构建 ETH 写帧(分包迭代器)</summary>
public IEnumerable<byte[]> BuildEthWriteFrames(uint pid, byte[] payload)
{
int pos = 0;
const int MAX_PKG = 100_000;
while (pos < payload.Length)
{
int chunkSize = Math.Min(MAX_PKG, payload.Length - pos);
var hdr = new byte[16];
BitConverter.GetBytes(0x44332211u).CopyTo(hdr, 0); // cmdType = write
BitConverter.GetBytes(pid).CopyTo(hdr, 4);
BitConverter.GetBytes((uint)payload.Length).CopyTo(hdr, 8);
BitConverter.GetBytes((uint)chunkSize).CopyTo(hdr, 12);
yield return Concat(hdr, payload[pos..(pos + chunkSize)]);
pos += chunkSize;
}
}
/// <summary>构建 ETH 读帧</summary>
public byte[] BuildEthReadFrame(uint pid, int readSize)
{
var hdr = new byte[16];
BitConverter.GetBytes(0x11223344u).CopyTo(hdr, 0); // cmdType = read
BitConverter.GetBytes(pid).CopyTo(hdr, 4);
BitConverter.GetBytes((uint)readSize).CopyTo(hdr, 8);
BitConverter.GetBytes(0u).CopyTo(hdr, 12);
return hdr;
}
// ── AWE 文本帧 ──────────────────────────────────────────────────
/// <summary>构建 AWE 写命令字符串</summary>
public string BuildAweWriteCmd(string subsystem, string bufName,
uint pid, byte[] payload)
{
string pidHex = $"0x{pid:x8}";
string sizeHex = $"0x{payload.Length / 4:x8}";
string dataStr = string.Join(",", ChunkByFour(payload)
.Select(w => $"0x{w[3]:x2}{w[2]:x2}{w[1]:x2}{w[0]:x2}")); // 大端排列
return $"{subsystem},write_int_array,{bufName},0x44332211,{pidHex},{sizeHex},{dataStr}\n";
}
/// <summary>构建 AWE 读触发命令(Step1 写)</summary>
public string BuildAweReadTriggerCmd(string subsystem, string bufName,
uint pid, int readSize)
{
string pidHex = $"0x{pid:x8}";
string sizeHex = $"0x{readSize / 4:x8}";
return $"{subsystem},write_int_array,{bufName},0xAABBCCDD,{pidHex},{sizeHex}\n";
}
/// <summary>构建 AWE 读回命令(Step2/Step3)</summary>
public string BuildAweReadDataCmd(string readStartBuf, int count)
=> $"read_int_array,{readStartBuf},{count}\n";
// ── 内部工具 ─────────────────────────────────────────────────────
private static ushort SwapUInt16(ushort v)
=> (ushort)((v & 0xFF) << 8 | (v >> 8));
private static byte[] AppendCrc32(byte[] data)
{
uint crc = ComputeCrc32(data, 0, (uint)data.Length);
var result = new byte[data.Length + 4];
data.CopyTo(result, 0);
BitConverter.GetBytes(crc).CopyTo(result, data.Length);
return result;
}
private static byte[] Concat(params byte[][] parts)
{
int total = parts.Sum(p => p.Length);
var result = new byte[total];
int pos = 0;
foreach (var p in parts) { p.CopyTo(result, pos); pos += p.Length; }
return result;
}
private static IEnumerable<byte[]> ChunkByFour(byte[] data)
{
for (int i = 0; i < data.Length; i += 4)
yield return data[i..Math.Min(i + 4, data.Length)];
}
// CRC32 标准查表法(多项式 0xEDB88320,兼容 SocketClient)
private static readonly uint[] Crc32Tab = BuildCrc32Table();
private static uint[] BuildCrc32Table()
{
var table = new uint[256];
for (uint i = 0; i < 256; i++)
{
uint c = i;
for (int k = 0; k < 8; k++)
c = (c & 1) != 0 ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
table[i] = c;
}
return table;
}
public static uint ComputeCrc32(byte[] data, uint offset, uint len)
{
uint crc = 0xFFFFFFFF;
for (uint i = offset; i < offset + len; i++)
crc = Crc32Tab[(crc ^ data[i]) & 0xFF] ^ (crc >> 8);
return crc ^ 0xFFFFFFFF;
}
}
22.5 LegacyCommService(通信服务)
// Services/Legacy/LegacyCommService.cs
public class LegacyCommService
{
private IDspTransport? _transport;
private readonly LegacyFrameBuilder _builder = new();
public string Protocol { get; private set; } = "none"; // uart_adi / eth / eth_awe
public bool IsConnected => _transport?.IsConnected ?? false;
// AWE 协议专用配置
private string _aweBufName = "Esefun1.TuneBuf[0]";
private string _aweReadBuf = "Esefun1.TuneBuf[3]";
private string _aweSubsystem = "Esefun1";
public async Task ConnectAsync(LegacyConnectionConfig cfg)
{
Protocol = cfg.Protocol;
_transport = cfg.Protocol switch
{
"uart_adi" => new SerialTransport(cfg.Target, cfg.BaudRate),
"eth" => new TcpTransport(cfg.TcpHost, cfg.TcpPort),
"eth_awe" => new TcpTransport(cfg.TcpHost, cfg.TcpPort),
_ => throw new NotSupportedException($"不支持的协议: {cfg.Protocol}")
};
if (cfg.Protocol == "eth_awe" && cfg.AweBuffer != null)
{
var parts = cfg.AweBuffer.Split(',');
_aweBufName = parts[0];
_aweSubsystem = parts.Length > 1 ? parts[1] : "Esefun1";
_aweReadBuf = System.Text.RegularExpressions.Regex
.Replace(_aweBufName, @"\[\d+\]", "[3]");
}
await _transport.ConnectAsync();
}
public async Task DisconnectAsync()
{
if (_transport != null)
{
await _transport.DisconnectAsync();
_transport = null;
}
}
/// <summary>发送写参数帧(自动处理分包和大数据)</summary>
public async Task<bool> WriteParamAsync(uint pid, byte[] rawBytes)
{
if (_transport == null) return false;
switch (Protocol)
{
case "uart_adi":
if (rawBytes.Length < 1009)
{
// 单帧
var frame = _builder.BuildUartWriteFrame(pid, rawBytes);
await _transport.DeliverAsync(frame);
var resp = await ReceiveUartResponseAsync();
var (err, _) = _builder.ParseUartResponse(resp);
return err == 0;
}
else
{
// 分包发送
foreach (var chunk in _builder.BuildUartLargeFrames(pid, rawBytes))
{
await _transport.DeliverAsync(chunk);
var resp = await ReceiveUartResponseAsync();
var (err, _) = _builder.ParseUartResponse(resp);
if (err != 0) return false;
await Task.Delay(100); // 分包间隔(对齐 SocketClient Thread.Sleep(100))
}
return true;
}
case "eth":
foreach (var frame in _builder.BuildEthWriteFrames(pid, rawBytes))
await _transport.DeliverAsync(frame);
return true;
case "eth_awe":
string cmd = _builder.BuildAweWriteCmd(_aweSubsystem, _aweBufName,
pid, rawBytes);
await _transport.DeliverAsync(System.Text.Encoding.ASCII.GetBytes(cmd));
return true;
default:
return false;
}
}
/// <summary>发送读参数帧,返回原始字节</summary>
public async Task<byte[]?> ReadParamAsync(uint pid, int readSize)
{
if (_transport == null) return null;
switch (Protocol)
{
case "uart_adi":
var reqFrame = _builder.BuildUartReadFrame(pid, readSize);
await _transport.DeliverAsync(reqFrame);
var resp = await ReceiveUartResponseAsync();
var (err, payload) = _builder.ParseUartResponse(resp);
return err == 0 ? payload : null;
case "eth":
var readFrame = _builder.BuildEthReadFrame(pid, readSize);
await _transport.DeliverAsync(readFrame);
return await ReceiveEthResponseAsync(readSize);
case "eth_awe":
// Step1: 触发写
string trig = _builder.BuildAweReadTriggerCmd(_aweSubsystem, _aweBufName, pid, readSize);
await _transport.DeliverAsync(System.Text.Encoding.ASCII.GetBytes(trig));
// Step2: 读大小
string step2 = _builder.BuildAweReadDataCmd(_aweReadBuf, 3);
await _transport.DeliverAsync(System.Text.Encoding.ASCII.GetBytes(step2));
var sizeResp = await ReceiveAweResponseAsync();
int actualSize = ParseAweSize(sizeResp);
// Step3: 读数据
string step3 = _builder.BuildAweReadDataCmd(_aweReadBuf, actualSize);
await _transport.DeliverAsync(System.Text.Encoding.ASCII.GetBytes(step3));
var dataResp = await ReceiveAweResponseAsync();
return ParseAweData(dataResp);
default:
return null;
}
}
private async Task<byte[]> ReceiveUartResponseAsync(int timeoutMs = 500)
{
// 使用 IDspTransport.RegisterReceiver 接收回调
var tcs = new TaskCompletionSource<byte[]>();
_transport!.RegisterReceiver(data => tcs.TrySetResult(data));
using var cts = new CancellationTokenSource(timeoutMs);
cts.Token.Register(() => tcs.TrySetException(new TimeoutException("UART 响应超时")));
return await tcs.Task;
}
private async Task<byte[]> ReceiveEthResponseAsync(int expectedSize, int timeoutMs = 500)
{
var tcs = new TaskCompletionSource<byte[]>();
var buf = new List<byte>();
_transport!.RegisterReceiver(data =>
{
buf.AddRange(data);
if (buf.Count >= expectedSize) tcs.TrySetResult(buf.ToArray());
});
using var cts = new CancellationTokenSource(timeoutMs);
cts.Token.Register(() => tcs.TrySetResult(buf.ToArray()));
return await tcs.Task;
}
private async Task<string> ReceiveAweResponseAsync(int timeoutMs = 5000)
{
var tcs = new TaskCompletionSource<string>();
_transport!.RegisterReceiver(data =>
{
string s = System.Text.Encoding.ASCII.GetString(data);
if (s.Contains('\n')) tcs.TrySetResult(s);
});
using var cts = new CancellationTokenSource(timeoutMs);
cts.Token.Register(() => tcs.TrySetResult(""));
return await tcs.Task;
}
private static int ParseAweSize(string resp)
{
// AWE 响应格式:以十六进制字符串返回,提取第三个 uint32
var parts = resp.Trim().Split(',', ' ');
if (parts.Length >= 3 && parts[2].StartsWith("0x"))
return (int)Convert.ToUInt32(parts[2], 16);
return 0;
}
private static byte[] ParseAweData(string resp)
{
var parts = resp.Trim().Split(',', ' ')
.Where(s => s.StartsWith("0x")).ToArray();
var result = new List<byte>();
foreach (var p in parts)
{
uint val = Convert.ToUInt32(p, 16);
// 大端转小端
result.Add((byte)(val >> 24));
result.Add((byte)(val >> 16));
result.Add((byte)(val >> 8));
result.Add((byte)(val));
}
return result.ToArray();
}
}
public record LegacyConnectionConfig(
string Protocol, // "uart_adi" | "eth" | "eth_awe"
string Target, // 串口名或 TCP Host
int BaudRate,
string TcpHost,
int TcpPort,
string? AweBuffer // "Esefun1.TuneBuf[0],Esefun1"
);
23. LegacyConversionEngine(参数值转换)
// Services/Legacy/LegacyConversionEngine.cs
public static class LegacyConversionEngine
{
/// <summary>
/// 将前端 UI 值转换为设备原始字节(按 parameter.conversion 规则)。
/// </summary>
public static byte[] ToDeviceBytes(LegacyParamDef param, double uiValue)
{
double deviceValue = ApplyFormula(param.Conversion.ToDevice, uiValue);
return Serialize(param.DataType, deviceValue, param.DataSize);
}
/// <summary>将设备原始字节反转换为 UI 值</summary>
public static double FromDeviceBytes(LegacyParamDef param, byte[] raw)
{
double deviceValue = Deserialize(param.DataType, raw);
return ApplyFormula(param.Conversion.FromDevice, deviceValue);
}
/// <summary>对浮点数组参数(floatArray)按通道转换</summary>
public static byte[] ToDeviceBytesArray(LegacyParamDef param, double[] uiValues)
{
var result = new List<byte>();
foreach (double v in uiValues)
{
double dv = ApplyFormula(param.Conversion.ToDevice, v);
result.AddRange(SerializeSingle(param.DataType, dv));
}
return result.ToArray();
}
// ── 内置转换公式 ──────────────────────────────────────────────────
private static double ApplyFormula(string formula, double x) => formula switch
{
null or "" or "x" => x,
"dBtoLinear" => Math.Pow(10.0, x / 20.0),
"linearTodB" => 20.0 * Math.Log10(Math.Max(x, 1e-10)),
"msToSamples" => Math.Round(x * 48000.0 / 1000.0),
"samplesToMs" => x * 1000.0 / 48000.0,
_ => EvalSimple(formula, x) // 简单内联公式
};
/// <summary>
/// 支持简单单变量公式(不引入外部 JS 引擎),支持:
/// x * k, x / k, x + k, x - k
/// Math.pow(10, x/20), 20 * Math.log10(x), Math.round(x * k / m)
/// 复杂公式请在 conversion.type 中使用内置别名。
/// </summary>
private static double EvalSimple(string expr, double x)
{
// 简单线性: "x * 0.001" 或 "x / 1000"
if (System.Text.RegularExpressions.Regex.IsMatch(expr, @"^x\s*[*/]\s*[\d.]+$"))
{
var m = System.Text.RegularExpressions.Regex.Match(expr, @"([*/])\s*([\d.]+)");
double k = double.Parse(m.Groups[2].Value);
return m.Groups[1].Value == "*" ? x * k : x / k;
}
// dB 互换简写
if (expr.Contains("pow(10") && expr.Contains("/20")) return Math.Pow(10.0, x / 20.0);
if (expr.Contains("log10")) return 20.0 * Math.Log10(Math.Max(x, 1e-10));
if (expr.Contains("round") && expr.Contains("48000")) return Math.Round(x * 48000.0 / 1000.0);
return x; // fallback: identity
}
// ── 序列化/反序列化 ───────────────────────────────────────────────
private static byte[] Serialize(string dataType, double value, int totalSize)
{
var buf = new byte[totalSize];
switch (dataType)
{
case "float":
BitConverter.GetBytes((float)value).CopyTo(buf, 0); break;
case "int32":
BitConverter.GetBytes((int)Math.Round(value)).CopyTo(buf, 0); break;
case "int16":
BitConverter.GetBytes((short)Math.Round(value)).CopyTo(buf, 0); break;
case "uint8":
buf[0] = (byte)Math.Round(value); break;
}
return buf;
}
private static byte[] SerializeSingle(string dataType, double value)
=> Serialize(dataType, value, dataType switch {
"float" => 4, "int32" => 4, "int16" => 2, _ => 4 });
private static double Deserialize(string dataType, byte[] raw) => dataType switch
{
"float" => BitConverter.ToSingle(raw, 0),
"int32" => BitConverter.ToInt32(raw, 0),
"int16" => BitConverter.ToInt16(raw, 0),
"uint8" => raw[0],
"floatArray" => BitConverter.ToSingle(raw, 0), // 取第一通道
_ => 0.0
};
}
24. LegacyConfigService(配置管理)
// Services/Legacy/LegacyConfigService.cs
public class LegacyConfigService
{
private LegacyDeviceConfig? _config;
/// <summary>加载并解析前端上传的配置 JSON</summary>
public LegacyConfigLoadResult Load(string configJson)
{
_config = JsonSerializer.Deserialize<LegacyDeviceConfig>(configJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (_config == null)
return new LegacyConfigLoadResult(false, "JSON 解析失败", null);
// 生成前端 UI 描述符
var uiDescriptor = BuildUiDescriptor(_config);
return new LegacyConfigLoadResult(true, null, uiDescriptor);
}
public LegacyDeviceConfig? Config => _config;
public LegacyParamDef? FindParam(string paramId)
=> _config?.ModuleGroups
.SelectMany(g => g.Modules)
.SelectMany(m => m.Parameters)
.FirstOrDefault(p => p.Id == paramId);
public LegacyParamDef? FindParamByPid(uint pid)
=> _config?.ModuleGroups
.SelectMany(g => g.Modules)
.SelectMany(m => m.Parameters)
.FirstOrDefault(p => p.PidUInt32 == pid);
/// <summary>
/// 将配置文件结构转换为前端控件描述符(前端据此动态渲染 UI)
/// </summary>
private static LegacyUiDescriptor BuildUiDescriptor(LegacyDeviceConfig cfg)
{
var groups = cfg.ModuleGroups.Select(g => new UiGroupDescriptor
{
Id = g.Id,
Name = g.Name,
Modules = g.Modules.Select(m => new UiModuleDescriptor
{
Id = m.Id,
Name = m.Name,
Controls = m.Parameters.Select(p => new UiControlDescriptor
{
ParamId = p.Id,
Name = p.Name,
Pid = p.Pid,
ControlType = p.Control,
DataType = p.DataType,
DataSize = p.DataSize,
Channels = p.Channels,
Min = p.Min,
Max = p.Max,
Step = p.Step,
DefaultValue = p.DefaultValue,
ReadBack = p.ReadBack,
Layout = p.Layout
}).ToList()
}).ToList()
}).ToList();
return new LegacyUiDescriptor
{
DeviceName = cfg.DeviceName,
Description = cfg.Description,
Connection = cfg.Connection,
Groups = groups
};
}
}
25. Legacy Preset / Profile 系统
25.1 Preset 设计
Legacy 模式的 Preset 基于 PID → 原始字节 映射,不依赖 instanceId。存储格式:
// data/legacy/presets/{moduleGroupId}/{presetId}.json
{
"presetId": "bass_boost",
"name": "Bass Boost",
"groupId": "eq_band",
"description": "低频增强",
"params": {
"0x16676010": "3FC00000",
"0x16676014": "BFC00000",
"0x16672010": "00000060"
}
}
params字段:键为 PID 十六进制字符串,值为设备原始字节的十六进制字符串(SocketClient 风格)。
25.2 Profile 设计
Profile 是跨 moduleGroup 的 Preset 组合:
// data/legacy/profiles/{profileId}.json
{
"profileId": "night_mode",
"name": "夜间模式",
"presets": {
"gain_ctrl": "low_volume",
"eq_band": "soft_eq",
"delay_ctrl": "standard_delay"
}
}
25.3 LegacyPresetService
// Services/Legacy/LegacyPresetService.cs
public class LegacyPresetService
{
private readonly string _presetDir;
private readonly string _profileDir;
public LegacyPresetService(IConfiguration cfg)
{
_presetDir = cfg["Legacy:PresetDir"] ?? "./data/legacy/presets";
_profileDir = cfg["Legacy:ProfileDir"] ?? "./data/legacy/profiles";
Directory.CreateDirectory(_presetDir);
Directory.CreateDirectory(_profileDir);
}
// ── Preset CRUD ─────────────────────────────────────────────────
public async Task SavePresetAsync(LegacyPreset preset)
{
string dir = Path.Combine(_presetDir, preset.GroupId);
Directory.CreateDirectory(dir);
string path = Path.Combine(dir, $"{preset.PresetId}.json");
await File.WriteAllTextAsync(path,
JsonSerializer.Serialize(preset, new JsonSerializerOptions { WriteIndented = true }));
}
public async Task<LegacyPreset?> LoadPresetAsync(string groupId, string presetId)
{
string path = Path.Combine(_presetDir, groupId, $"{presetId}.json");
if (!File.Exists(path)) return null;
string json = await File.ReadAllTextAsync(path);
return JsonSerializer.Deserialize<LegacyPreset>(json);
}
public async Task<List<LegacyPreset>> ListPresetsAsync(string groupId)
{
string dir = Path.Combine(_presetDir, groupId);
if (!Directory.Exists(dir)) return new();
var result = new List<LegacyPreset>();
foreach (var f in Directory.GetFiles(dir, "*.json"))
{
var p = JsonSerializer.Deserialize<LegacyPreset>(await File.ReadAllTextAsync(f));
if (p != null) result.Add(p);
}
return result;
}
public Task DeletePresetAsync(string groupId, string presetId)
{
string path = Path.Combine(_presetDir, groupId, $"{presetId}.json");
if (File.Exists(path)) File.Delete(path);
return Task.CompletedTask;
}
// ── Profile CRUD ─────────────────────────────────────────────────
public async Task SaveProfileAsync(LegacyProfile profile)
{
string path = Path.Combine(_profileDir, $"{profile.ProfileId}.json");
await File.WriteAllTextAsync(path,
JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }));
}
public async Task<LegacyProfile?> LoadProfileAsync(string profileId)
{
string path = Path.Combine(_profileDir, $"{profileId}.json");
if (!File.Exists(path)) return null;
return JsonSerializer.Deserialize<LegacyProfile>(await File.ReadAllTextAsync(path));
}
public async Task<List<LegacyProfile>> ListProfilesAsync()
{
var result = new List<LegacyProfile>();
foreach (var f in Directory.GetFiles(_profileDir, "*.json"))
{
var p = JsonSerializer.Deserialize<LegacyProfile>(await File.ReadAllTextAsync(f));
if (p != null) result.Add(p);
}
return result;
}
}
public record LegacyPreset(
string PresetId, string Name, string GroupId, string Description,
Dictionary<string, string> Params // PID hex → raw bytes hex
);
public record LegacyProfile(
string ProfileId, string Name,
Dictionary<string, string> Presets // groupId → presetId
);
26. WebSocket 消息协议(Legacy 模式)
26.1 消息路由表(Legacy 增量)
| type | 方向 | Handler | 说明 |
|---|---|---|---|
legacy_load_config |
C→S | HandleLegacyLoadConfig() |
上传配置 JSON;返回 UI 描述符 |
legacy_connect |
C→S | HandleLegacyConnect() |
建立 UART/TCP 连接(Legacy 模式) |
legacy_disconnect |
C→S | HandleLegacyDisconnect() |
断开 Legacy 连接 |
legacy_set_param |
C→S | HandleLegacySetParam() |
写参数(自动转换 + 帧组装) |
legacy_get_param |
C→S | HandleLegacyGetParam() |
读参数(自动反转换) |
legacy_set_params_batch |
C→S | HandleLegacySetParamsBatch() |
批量写参数(apply preset 时用) |
legacy_save_preset |
C→S | HandleLegacySavePreset() |
保存 Preset |
legacy_load_preset |
C→S | HandleLegacyLoadPreset() |
加载 Preset(读文件 + 批量下发) |
legacy_delete_preset |
C→S | HandleLegacyDeletePreset() |
删除 Preset |
legacy_list_presets |
C→S | HandleLegacyListPresets() |
列出指定 groupId 的 Preset |
legacy_save_profile |
C→S | HandleLegacySaveProfile() |
保存 Profile |
legacy_load_profile |
C→S | HandleLegacyLoadProfile() |
加载 Profile(多组 Preset 批量下发) |
legacy_list_profiles |
C→S | HandleLegacyListProfiles() |
列出所有 Profile |
legacy_get_status |
C→S | HandleLegacyGetStatus() |
返回连接状态 + 当前配置名 |
26.2 消息详细格式
legacy_load_config(上传配置文件)
// 前端 → 后端
{
"type": "legacy_load_config",
"config": { /* LegacyDeviceConfig 完整 JSON */ }
}
// 后端 → 前端(返回 UI 描述符)
{
"type": "legacy_config_loaded",
"success": true,
"ui": {
"deviceName": "Car Audio DSP v2",
"groups": [
{
"id": "gain_ctrl",
"name": "增益控制",
"modules": [
{
"id": "master_gain",
"name": "主增益",
"controls": [
{
"paramId": "master_gain_db",
"name": "增益 (dB)",
"pid": "0x16670010",
"controlType": "slider",
"dataType": "float",
"dataSize": 4,
"channels": 1,
"min": -60.0,
"max": 12.0,
"step": 0.1,
"defaultValue": 0.0,
"readBack": true
}
]
}
]
}
]
}
}
legacy_connect
// 前端 → 后端
{
"type": "legacy_connect",
"protocol": "uart_adi",
"target": "COM3",
"baudRate": 115200
}
// 或 TCP:
{
"type": "legacy_connect",
"protocol": "eth",
"tcpHost": "192.168.1.141",
"tcpPort": 15007
}
// 或 AWE:
{
"type": "legacy_connect",
"protocol": "eth_awe",
"tcpHost": "192.168.1.100",
"tcpPort": 15007,
"aweBuffer": "Esefun1.TuneBuf[0],Esefun1"
}
// 后端 → 前端
{ "type": "legacy_connect_ack", "success": true, "protocol": "uart_adi", "target": "COM3" }
legacy_set_param(写参数)
// 前端 → 后端(UI 值,可选 rawHex 直接下发)
{
"type": "legacy_set_param",
"paramId": "master_gain_db",
"value": -6.0,
"channel": 0
}
// 或直接指定 pid + rawHex(绕过转换):
{
"type": "legacy_set_param",
"pid": "0x16670010",
"rawHex": "3DCCCCCD",
"dataSize": 4
}
// 后端 → 前端
{
"type": "legacy_set_param_ack",
"paramId": "master_gain_db",
"pid": "0x16670010",
"success": true
}
legacy_get_param(读参数)
// 前端 → 后端
{
"type": "legacy_get_param",
"paramId": "master_gain_db"
}
// 后端 → 前端
{
"type": "legacy_get_param_ack",
"paramId": "master_gain_db",
"pid": "0x16670010",
"rawHex": "3DCCCCCD",
"value": -6.0,
"success": true
}
legacy_save_preset
{
"type": "legacy_save_preset",
"presetId": "bass_boost",
"name": "Bass Boost",
"groupId": "eq_band",
"params": {
"0x16676010": "3FC00000",
"0x16676014": "BFC00000"
}
}
// 响应
{ "type": "legacy_preset_ack", "action": "save", "presetId": "bass_boost", "success": true }
legacy_load_preset
// 前端请求
{ "type": "legacy_load_preset", "groupId": "eq_band", "presetId": "bass_boost" }
// 后端:
// 1. 从文件加载 Preset.params
// 2. 对每个 PID 调用 LegacyCommService.WriteParamAsync()(不做转换,直接下发 rawHex)
// 3. 广播 param 更新给所有前端
{ "type": "legacy_preset_loaded",
"presetId": "bass_boost",
"groupId": "eq_band",
"params": { "0x16676010": "3FC00000", ... },
"success": true }
legacy_load_profile
{ "type": "legacy_load_profile", "profileId": "night_mode" }
// 后端按 presets map 依次加载每个 groupId 的 Preset
{ "type": "legacy_profile_loaded",
"profileId": "night_mode",
"loadedPresets": { "gain_ctrl": "low_volume", "eq_band": "soft_eq" },
"success": true }
27. 数据流示例
27.1 Legacy 模式完整工作流
① 前端切换到 Legacy 模式
→ WS: { type: "set_mode", mode: "legacy" }
← WS: { type: "mode_changed", mode: "legacy" }
② 导入配置文件
→ WS: { type: "legacy_load_config", config: { ... } }
← WS: { type: "legacy_config_loaded", ui: { groups: [...] } }
前端据 ui.groups 动态渲染控件面板
③ 建立连接
→ WS: { type: "legacy_connect", protocol: "uart_adi", target: "COM3", baudRate: 115200 }
← WS: { type: "legacy_connect_ack", success: true }
④ 调参(UI 滑块拖动)
→ WS: { type: "legacy_set_param", paramId: "master_gain_db", value: -6.0 }
后端:查配置 → pid=0x16670010, dBtoLinear(-6) = 0.5012 → float LE 字节
→ LegacyFrameBuilder.BuildUartWriteFrame(0x16670010, [0D CC CC 3F])
→ 发送帧 + 等待响应
← WS: { type: "legacy_set_param_ack", paramId: "master_gain_db", success: true }
← WS: { type: "legacy_param_update", paramId: "master_gain_db", value: -6.0 }(广播给其他客户端)
⑤ 保存 Preset
前端将当前所有控件值以 rawHex 形式打包
→ WS: { type: "legacy_save_preset", presetId: "test1", name: "测试1",
groupId: "gain_ctrl", params: { "0x16670010": "0DCCCC3F", ... } }
← WS: { type: "legacy_preset_ack", action: "save", success: true }
⑥ 加载 Preset(一键下发)
→ WS: { type: "legacy_load_preset", groupId: "gain_ctrl", presetId: "test1" }
后端:读文件 → 按 PID 逐条调 WriteParamAsync → 完成
← WS: { type: "legacy_preset_loaded", presetId: "test1", success: true }
← WS: { type: "legacy_params_applied", params: { "master_gain_db": -6.0, ... } }(广播)
27.2 UART ADI 帧级数据流(-6dB 写入)
UI 值:-6.0 dB
↓ LegacyConversionEngine.ToDeviceBytes("float", dBtoLinear(-6.0))
→ deviceValue = 10^(-6/20) = 0.50119
→ float LE bytes: [DF 7C 00 3F]
↓ LegacyFrameBuilder.BuildUartWriteFrame(pid=0x16670010, payload=[DF 7C 00 3F])
→ header.Header = 0x55AA (wire bytes: AA 55)
→ header.FrameID = 0x0000 (wire bytes: 00 00)
→ header.CMDWR = 0x01
→ header.PID = 0x16670010 (LE: 10 00 67 16)
→ SizePayload(交换) = SwapUInt16(4) = 0x0400 (wire bytes: 00 04)
→ frame data: AA 55 | 00 00 | 01 | 10 00 67 16 | 00 04 | DF 7C 00 3F
→ CRC32(frame[0..14]) → append 4 bytes
→ 最终帧(19 bytes): AA 55 00 00 01 10 00 67 16 00 04 DF 7C 00 3F XX XX XX XX
↓ SerialTransport.DeliverAsync(frame)
↑ 设备应答(11 bytes): AA 55 00 00 00 00 00 + CRC(4)
↑ ErrCode=0 ✓
28. 文件结构更新(v7.0 增量)
backend/
├── Services/
│ └── Legacy/
│ ├── LegacyCommService.cs 连接/读写/协议适配
│ ├── LegacyFrameBuilder.cs UART ADI / ETH / AWE 帧组装与解析
│ ├── LegacyConversionEngine.cs 参数值↔设备字节 转换引擎
│ ├── LegacyConfigService.cs 配置文件解析 + UI 描述符生成
│ └── LegacyPresetService.cs Preset/Profile 持久化
├── Models/
│ └── LegacyModels.cs
│ LegacyDeviceConfig / LegacyModuleGroup / LegacyModule
│ LegacyParamDef / LegacyConversionDef
│ LegacyUiDescriptor / UiGroupDescriptor / UiModuleDescriptor / UiControlDescriptor
│ LegacyPreset / LegacyProfile
│ LegacyConnectionConfig
└── data/
└── legacy/
├── presets/
│ ├── gain_ctrl/
│ │ ├── flat.json
│ │ └── bass_boost.json
│ └── eq_band/
│ └── soft_eq.json
└── profiles/
└── night_mode.json
29. 配置参数更新(v7.0 增量)
{
"Legacy": {
"PresetDir": "./data/legacy/presets",
"ProfileDir": "./data/legacy/profiles",
"ConfigDir": "./data/legacy/configs",
"UartTimeout": 500,
"UartLargePackageIntervalMs": 100,
"EthMaxPackageSize": 100000,
"AdbMaxPackageSize": 2000
}
}
30. 扩展点(v7.0 新增)
| 需求 | 实现方式 |
|---|---|
| QPST 支持 | 新增 QpstTransport : IDspTransport,封装 QACT RTC API(GetRTCDataV2/SetRTCDataV2),需引入 QUTestApp 依赖 |
| ADB 支持 | 新增 AdbTransport : IDspTransport,通过 adb shell cat/push 实现 FIFO 传输 |
| I2C/SPI 支持 | 新增 AardvarkTransport : IDspTransport,调用 Total Phase Aardvark API |
| 多协议同时下发 | LegacyCommService 改为 List<IDspTransport>,写参数时广播到所有传输层 |
| 配置文件版本管理 | LegacyConfigService 维护 ./data/legacy/configs/ 目录,支持多设备配置切换 |
| 参数读回轮询 | 新增 legacy_start_readback_poll / legacy_stop_readback_poll 消息,按 intervalMs 定期读回并推送 |
| 十六进制编辑器模式 | 前端特殊控件 controlType: "hexEditor",直接编辑 rawHex 后通过 legacy_set_param.rawHex 下发 |