dsh-vst3-studio
DeepSeek Harness(dsh)的 VST3 音频工作室插件:让 AI 直接操作本机的 VST3 插件来捏音色、把 MIDI 渲染成音频、串效果链,并用客观指标自己验证结果。
底层用 nvst3-host(Node N-API 原生模块,封装官方 Steinberg VST3 SDK 3.8,MIT)。所有原生操作跑在隔离子进程里:插件崩溃只死子进程,dsh 不受影响,下一次调用自动重启。
它能做什么
| 能力 | 工具 | 实测 |
|---|---|---|
| 发现本机插件 | vst3_scan |
本机扫到 114 条 → 去重过滤后得到干净的可用清单 |
| 看懂一个插件 | vst3_inspect |
Serum 2:2623 个参数 / 32 个分组 / 0 进 2 出 |
| 按名字捏音色 | vst3_params vst3_set_params |
[OSC A] A Level、[Env 1] Env 1 Attack(支持 "250ms"、"Off"、mode:'plain') |
| MIDI → 音频 | vst3_render |
4 音符 MIDI → Serum 2 → WAV,音高实测精确命中 C4/E4/G4/C5 |
| 效果链处理 | vst3_render + inputWav |
渲染结果 → OTT → 新 WAV(peak 0.284 → 0.572) |
| 音色资产库(带版本历史) | vst3_patch |
同名再存自动升 v1/v2 且保留旧版;两版渲染 peak 0.724 vs 0.056 |
| 批量变体 A/B 对照 | vst3_variants |
一次渲 3 个 Attack/音量变体,RMS 0.01170 / 0.01114 / 0.00175 并排对照 |
| 预设库索引 | vst3_presets |
本机索引到 824+ 条:627 个 .SerumPreset + 90 个压缩包内预设(152MB .SerumPack 不解包即可读)+ 107 个 .fxp + FL/厂商目录 |
| 预设来源可增长 | vst3_presets |
内置常用位置表(19 个来源);用户说"我的预设放在 X"→ addSource 持久化,立刻可索引 |
| 标准 VST3 预设互通 | vst3_patch |
.vstpreset 导出 → 导入 → 17 个参数一致 → 渲染出声(peak 1.4830) |
| 导出到项目文件夹 | vst3_patch |
exportBundle 一套三件(.vstpreset + .recipe.md + .patch.json);exportAll 批量导出整个库(实测 6/6) |
| 自我验证闭环 | vst3_analyze |
音高/RMS/峰值/削波/音头/频谱重心,音高精度 0.0005% |
| MIDI 资产管理 | vst3_midi |
生成/解析 .mid(音名或 MIDI 号,支持 tempo map) |
安装
# 在包含本目录的路径下执行;web 是当前 GUI 用的 profile
dsh plugin --profile web add ./dsh-vst3-studio-0.1.6.tgz
安装后重启 dsh,会话里会出现 11 个 vst3_* 工具。详细步骤与验证方法见 INSTALL.md。
依赖
nvst3-host自带 win32-x64 / darwin-arm64 / linux-x64 / linux-arm64 预编译二进制,不需要编译工具链、不需要 VST3 SDK。
快速上手
一次典型的"捏音色 → 出音频 → 验证"流程:
1. vst3_env # 确认宿主可用
2. vst3_scan { query: "serum" } # 找到插件路径
3. vst3_inspect { path: ".../Serum2.vst3" } # 看有哪些分组和参数量
4. vst3_params { path: "...", unit: "OSC A", query: "level" } # 找到要改的参数名
5. vst3_midi { action: "create", output: "riff.mid",
notes: [{"pitch":"C4","start":0,"dur":0.35}, ...] }
6. vst3_render { path: ".../Serum2.vst3", midiFile: "riff.mid",
params: [{"name":"A Level","value":0.9},
{"name":"Env 1 Attack","value":"50ms"}] }
→ 返回 WAV 路径 + 峰值/削波/音高分析
7. vst3_analyze { file: "<上一步的 outputWav>" } # 客观复核
8. vst3_patch { action: "save", name: "my-lead", ... } # 满意就沉淀成音色
为什么第 7 步重要:AI 没有耳朵,只能靠指标判断。渲染结果里已经带了分析,但怀疑时单独复核一遍更稳。
工具一览
vst3_env
环境自检:原生模块版本、宿主子进程状态(pid / 启动次数 / 崩溃次数)、生效配置、安全边界。开工前先调一次。
vst3_scan
扫描 VST3 插件。默认扫平台默认目录,可用 dirs 指定。已自动:
- 过滤掉
Component Controller Class(加载它不会出声,是最常见的踩坑点); - 合并「bundle 路径」与「bundle 内二进制路径」的重复项(上游会为同一个插件返回 4 条);
- 按名称/厂商过滤(
query)、只看乐器(instrumentOnly)。结果缓存 5 分钟。
vst3_inspect
插件详情:基本信息、音频/事件总线通道数、自报延迟与尾音、参数总数与分组、预设 program 列表。捏音色前必调:它告诉你这是乐器还是效果器、尾音多长(影响渲染留多长尾巴)。
vst3_params
按 query 关键词或 unit 分组检索参数,分页返回 id / name / unit / value(归一化) / plain(可读值) / readOnly。合成器动辄上千参数(Serum 2 有 2623 个),必须配合关键词使用。
vst3_set_params
按名字或 id 批量改参数:
- 数字默认按归一化 0..1(VST3 原生口径);
mode: "plain"按插件显示的物理值;- 字符串走插件解析,如
"250ms"、"Off"、"440Hz"。
名字支持唯一子串匹配,命中多个时会报错并列出候选——这是刻意的:猜错旋钮会让整个调音过程跑偏,而模型会以为改动生效了。
本工具只改当前进程内实例,用于试探;要出音频请在
vst3_render的chain[].params里给,要沉淀用vst3_patch。
vst3_patch
音色资产库与预设互通(带版本历史)。动作:
| 动作 | 作用 |
|---|---|
save / capture |
把"插件 + 参数"沉淀成音色。同名再存自动升版本并保留旧版。capture 是带血统的 save:name 可省略,自动采用插件自报的 presetName;加 fresh: true 则用全新实例取干净基线(从零新建的起点) |
load / list / delete |
载入(回读与默认值差异)、列出、删除(含全部版本,可按 version 回退) |
provenance |
不加载插件,直接读状态文件里的段与血统(这音色哪来的、哪个插件版本、schema 几) |
recipe |
导出人可读的参数配方(Markdown 表格:参数名 + 界面显示值) |
exportVstpreset |
只导标准 .vstpreset(给其它 DAW) |
exportBundle |
一次导出一套三件:.vstpreset + .recipe.md + .patch.json,默认落在项目文件夹的 vst3-exports/ |
exportAll |
把整个音色库批量导出成一套套文件(交付 / 备份 / 换机器) |
importVstpreset |
导入别人的 .vstpreset,先让插件真加载验证再入库 |
vst3_midi
create 把音符数组写成 .mid(音名 "C4" 或 MIDI 号,velocity 支持 0..1 或 1..127);inspect 解析并返回音符/速度/时长。旋律资产:同一段 MIDI 换不同音色反复对比,比每次重新描述音符可靠。
vst3_variants ⭐
批量变体 + A/B 对照:给一个基础音色(path/stateFile/params)和一组变体(每个只写要覆盖的参数),逐个渲染成独立 WAV,并返回关键指标对照表(峰值/响度/削波/频谱重心/音高)。
vst3_variants {
path: ".../Serum2.vst3", stateFile: "<vst3_patch 存的音色>",
notes: [{"pitch":"C4","start":0,"dur":1.0}],
variants: [
{ "label": "attack-5ms", "params": [{"name":"Env 1 Attack","value":"5ms"}] },
{ "label": "attack-200ms", "params": [{"name":"Env 1 Attack","value":"200ms"}] },
{ "label": "quiet", "params": [{"name":"A Level","value":0.1}] }
]
}
AI 没有耳朵,指标只能帮你排除明显问题(削波、全静音、音高不对),不能判断好不好听——所以它会把每个变体的 WAV 路径列出来让人试听定夺。
vst3_presets ⭐
预设索引与 program 接口。它让 AI 能"看见"你的音色库:
index—— 扫描并统计:Serum 的.SerumPreset与.SerumPack压缩包内部(不解包整包)、标准 VST3 预设目录里的.vstpreset、旧式.fxp/.fxb、FL Studio 的.fst。返回分类/标签/schema 版本分布。search—— 多关键词 AND 检索(匹配名称/作者/描述/分类/标签/插件名),可按标签组合、分类、作者、格式过滤,可只看尚未收编的。info—— 某个预设的完整元数据 + 能否被程序化加载的准确判断。sources/addSource/removeSource—— 预设来源管理(内置表 + 用户运行时添加并持久化)。programs/select—— 插件官方 program 列表枚举与切换(含"名字是否有信息量""是否实现 IProgramListData""这次切换是否真的改变了音色")。
⚠️ 索引 ≠ 能加载。 详见下面「预设的加载与保存」一节。
vst3_render ⭐
核心工具,两种模式:
- 合成:
chain第一级放乐器,给notes或midiFile; - 处理:给
inputWav,chain放效果器。
每级可带 params、stateFile、bypass。渲染时自动:按插件实际总线配置通道、参数在激活前设好并冲刷、用 getLatency() 补偿延迟、按自报尾音 + 能量衰减决定尾部长度、越界补静音。返回 WAV 路径 + 峰值/RMS/削波 + 完整分析 + 可操作的提示(全静音、削波、参数未生效、连奏重叠都会明说)。
vst3_analyze
对任意 WAV 做客观分析:时长、峰值(dBFS)、响度(RMS)、削波样本数、直流偏置、能量包络、音头时间点、逐段音高(带置信度)、频谱重心(明亮度)。
两条捏音色的路线
路线 A:从现成预设出发,改几个参数,存成新预设(推荐日常用)
1. vst3_presets { action: "search", query: "reese bass", tags: ["Wavetable","Mono"] }
→ AI 从你的库里挑 3-5 个候选,把 source 路径给你
2. 【你手动一次】在插件界面里载入中意的那个(Serum 的预设文件读不了,只有 GUI 能加载)
3. vst3_patch { action: "capture", name: "my-reese-v1", path: "<插件路径>" }
→ 收编成资产,自动带上血统(原预设名/作者/版本)
4. vst3_params / vst3_set_params → 按名字微调(A Level、Filter Cutoff…)
5. vst3_render → 渲染试听 + 客观指标
6. vst3_patch { action: "capture", name: "my-reese-v2", ... } → 存成 v2(v1 保留可回退)
7. vst3_patch { action: "exportBundle", name: "my-reese-v2" } → 导出到项目文件夹
.vstpreset 还能反向走:别人给的 .vstpreset 用 importVstpreset 直接进来(会先加载验证再入库)。
路线 B:从零新建一个音色
1. vst3_patch { action: "capture", name: "serum-init", path: "...", fresh: true }
→ fresh 用**全新实例**取状态,拿到插件的干净初始基线(实测 2183 字节 = 纯 Init)
2. vst3_params { query: "level" } / { unit: "OSC A" } → 摸清有哪些振荡器/包络/滤波器参数
3. vst3_set_params,或直接在 vst3_render 的 chain[].params 里给参数 → 一轮轮试
4. vst3_variants { variants: [...] } → 一次渲多个变体 + 指标对照表 + 多个 WAV 供试听
5. vst3_analyze → 确认音高/响度/明亮度符合预期
6. vst3_patch { action: "capture", name: "my-new-lead" } → 沉淀
AI 没有耳朵,两条路线都靠"渲染 + 客观指标 + 你试听"收敛;vst3_variants 就是为这个设计的。
预设库扫描:内置位置表 + 可增长
vst3_presets { action: "sources" } 列出所有扫描位置及各自找到多少文件。内置表覆盖:
| 类别 | 位置 |
|---|---|
| Serum 2 | 从 Serum2Prefs.json 读出的实际路径、Documents\Xfer\Serum 2 Presets(含 Presets\User) |
| Serum 1 | Documents\Xfer\Serum Presets(.fxp,可用 Serum 2 自带的旧版导入迁移) |
| 标准 VST3 | Documents\VST3 Presets、%APPDATA%\VST3 Presets、%ProgramData%\VST3 Presets(macOS / Linux 对应位置同理) |
| FL Studio | Documents\Image-Line\FL Studio\Presets\Plugin presets、...\Downloads\Plugin presets |
| 厂商目录 | Native Instruments / reFX / LennarDigital / u-he / Arturia / Spectrasonics / iZotope / Vital 等在 Documents 下的目录 |
用户说"我的预设放在 X"时直接加进来,持久化、重启仍有效:
vst3_presets { action: "addSource", dir: "D:\\我的音色库", label: "我的 Serum 自建预设" }
vst3_presets { action: "removeSource", dir: "D:\\我的音色库" }
(配置里的 presetDirs 也能加,但那个要改配置 + 重启;addSource 是给运行时用的。)
导出的产物放哪
默认落在项目文件夹下的 vst3-exports/(工作区路径由 dsh 会话环境推导,推导不出就退回 dsh 进程当前目录),具体放哪由 AI 决定:
vst3_patch { action: "exportBundle", name: "my-lead" } # → <项目>/vst3-exports/
vst3_patch { action: "exportBundle", name: "my-lead", output: "D:/交付/音色" } # → 指定目录(绝对或相对)
vst3_patch { action: "exportAll" } # 整个音色库批量导出
每次导出一套文件,主产物是插件自家格式(适配过的插件),另外附通用格式与配方:
| 文件 | 用途 |
|---|---|
<名字>.SerumPreset / <名字>.fxp |
插件自家预设格式(主产物):Serum 拖进自己的预设库就能在浏览器里看到;Nexus 是 .fxp。没适配的插件则没有这个文件,只有下面的 .vstpreset |
<名字>.vstpreset |
标准 VST3 预设,Cubase / Studio One / Reaper 可导入(插件自家浏览器不认这种文件) |
<名字>.recipe.md |
人可读参数配方(参数名 + 界面显示值),照着设就能在插件 GUI 里复现 |
<名字>.patch.json |
清单:血统、schema 版本、全部非默认参数、文件位置 |
只要预设文件用 vst3_patch { action: "exportPreset", name: "..." } 单独导出即可(同样默认落到项目目录)。
为什么仍然保留
recipe.md:.SerumPreset能写出(见下节),但读不进来,所以"把一个第三方 Serum 预设搬到别处"这件事,参数配方依然是最稳的路径。
插件专门适配层
不同 VST3 插件的"预设机制"差异巨大,而这直接决定 AI 该怎么干活。所以有一个适配器注册表,每个插件一个适配器,读与写都要按插件自己的格式来:
| 插件 | 预设格式(读) | 能否编程加载 | 导出时默认写成 | AI 的正确做法 | 状态 |
|---|---|---|---|---|---|
| reFX Nexus | .fxp(公开的 VST2 预设块) |
✅ 能 | .fxp |
chain[].presetFile 直接加载 → 改参数 → capture 收编 → 导出 .fxp |
✅ 已适配(实测 3171 个预设) |
| Xfer Serum 2 | .SerumPreset(私有) |
❌ 不能 | .SerumPreset |
索引挑候选 → 你在 GUI 载入一次 → capture 收编 → 导出 .SerumPreset |
✅ 已适配 |
| 其它 VST3 | 未知 / 由 GUI 管理 | 大多不能 | .vstpreset(通用) |
从零捏,或 GUI 载入后收编 | 通用回退 |
vst3_presets 的返回里每条预设都带 directlyLoadable 字段(true 可以直接加载,false 需要 GUI 收编);渲染结果里的 warnings 会在预设加载失败或渲染出静音时明确报警,而不是假装成功。
用 Nexus 预设(最顺的一条路)
1. vst3_presets { action: "search", format: "fxp", query: "bass", limit: 10 }
→ 每条都标注 ✅可直接加载
2. vst3_render { chain: [{ path: "<Nexus.vst3>", presetFile: "<预设.fxp>" }],
notes: [...] }
→ 直接出声(内部会自动处理 Nexus 的力度怪癖)
3. vst3_patch { action: "capture", name: "my-nexus-bass", path: "<Nexus.vst3>",
presetFile: "<预设.fxp>" }
→ 收编成我们的资产(实测:收编后脱离原预设文件复现,peak 完全一致 0.99107)
4. 之后就是常规流程:改参数 / A/B 变体 / 导出 .vstpreset / 版本回退
适配器里声明的"怪癖"
怪癖来自实测,集中声明在适配器里,而不是散落在渲染引擎各处:
| 怪癖 | 说明 |
|---|---|
forceNoteVelocity |
Nexus 实测只在 MIDI 力度 1.0 时出声;0.95/0.9/0.8/0.5 全部完全静音(逐进程隔离验证)。渲染时会自动把力度设为 1.0,并把这件事实报给模型。要控音量请改插件音量参数。 |
silentAfterPresetLoad |
加载不被接受的预设后静音而不报错——所以载入预设却渲染出静音时会给出明确警告,而不是报"渲染成功" |
streamingSamples |
采样流式插件,首次发声可能延迟,尾音要留足 |
另外针对插件不稳定(Nexus 在长驻进程里反复加载后会间歇性崩溃,实测遇到过一次访问违例): 宿主子进程崩溃后会自动用干净进程重试一次,两次都失败才报错并指向日志。崩溃只杀子进程,dsh 不受影响。
加一个新插件的适配器
- 在
core/下新建一个文件(如serum.ts、nexus.ts),放纯函数:识别、读元数据、取可加载负载、写原生预设。 - 在
core/plugin-adapters.ts的ADAPTERS里加一个条目,声明四件事:
| 字段 | 回答的问题 |
|---|---|
matches(identity) |
「怎么认出这个插件」(按名字/classId/路径,别只按厂商) |
presetLoad |
「它的预设能不能被宿主直接加载」→ 决定 AI 是直接 presetFile 还是必须走 GUI 收编 |
presetFiles |
「读」:怎么读元数据、怎么取可加载负载 |
presetWrite |
「写」:导出时默认写成什么格式(如 .SerumPreset、.fxp);没有就回落通用 .vstpreset |
quirks |
「实测出来的怪癖」:力度、静音、流式采样等 |
- 加单元测试:写出的原生文件必须能被自己的解析器读回且 hash 校验通过;条件允许时拿真实厂商文件做逐字节复现。
工具层与渲染引擎都不用改——它们只问适配器。
在 src/core/plugin-adapters.ts 的 ADAPTERS 里加一项即可(工具层与渲染引擎都不用改):
const myAdapter: PluginAdapter = {
id: 'myplugin',
title: '某某插件',
matches: (id) => id.vendor === '某某厂商', // 怎么识别它
presetLoad: 'direct', // 'direct' 还是 'gui-only'
presetFiles: {
extensions: ['.mypreset'],
readMeta: (data, file) => ({ name, formatLabel, directlyLoadable: true }), // 索引用
toLoadPayload: (data, file) => ({ payload, note }), // 怎么变成可加载负载
},
quirks: { forceNoteVelocity: 1.0, silentAfterPresetLoad: true },
presetNote: '一句话说明这个插件的预设机制现状',
}
接口在类型层面强制区分「能直接加载」与「只能 GUI 收编」,避免把不同插件的预设机制混在一个 if 里越写越乱。
预设的加载与保存
这是最容易被误解的部分,所以结论都基于实测(不是推测)。
一句话结论
VST3 的标准做法就是"预设 = 组件状态,由宿主管预设文件"(Steinberg 官方文档原文:the data of a preset is nothing more than its state)。本插件的资产库正是这么做的,而且是唯一能覆盖"任意插件"的通道。插件自家的预设格式(Serum 的 .SerumPreset)属于它的私有 GUI 通道,标准宿主从设计上够不着。
Serum 专门适配:能做什么、不能做什么
| 事项 | 状态 | 说明 |
|---|---|---|
| 读预设元数据 | ✅ | .SerumPreset = XferJson + 明文 JSON(名称/作者/描述/标签/schema 版本)。本机 626 个工厂预设 + 压缩包内 90 个全部可索引 |
| 校验预设完整性 | ✅ | 破解出 hash = md5(zstd 压缩流),可验证文件是否损坏 |
索引 .SerumPack |
✅ | 实测是标准 ZIP,不解压整包即可读出内部预设元数据 |
| 读音色血统 | ✅ | 从我们保存的状态信封里读出插件自报的 presetName/presetAuthor/插件版本/schema 版本——收编时自动命名 |
直接加载 .SerumPreset |
❌ | 已用 9 种重建组合证明不可行(含 schema 版本完全相同、hash 重算正确的 v9 预设)。根因:预设负载含 GUI/session 节点(kUIParam*、SerumGUI、ClipPlayer…),而 IComponent::setState 只接受纯处理器状态 |
写出 .SerumPreset |
✅ | 导出默认就是它。做法见下:复用状态里 processor 段的 zstd 压缩流写回 XferJson 容器,不重新压缩 → hash 天然成立、负载逐字节一致。已用真实工厂预设做逐字节复现验证(重建结果与 PD - Analog Butter.SerumPreset 完全一致,含 Serum 把版本号写成 4.0 这个细节) |
「读不进来、却写得出去」是怎么做到的
关键在于我们本来就有 Serum 自己的那份负载:getState() 吐出来的状态信封里有两个 XferJson 容器——
| 段 | JSON 头 | 内容 |
|---|---|---|
processor |
{"component":"processor", hash, product, version…} |
音色本体(msgpack tagged tree,zstd 压缩) |
controller |
{"component":"controller", presetName, presetAuthor…} |
界面态与预设元数据 |
而 .SerumPreset 文件就是一个 XferJson 容器,负载正是同一份音色本体,只是 JSON 头换成了 {"fileType":"SerumPreset", presetName, presetAuthor, tags…}。
所以写出 = 复用 processor 段的压缩流 + 换一个预设头 + hash = md5(压缩流):
不需要理解那个私有 tagged-tree 的类型枚举(那正是"读"做不到的原因),也不需要重新压缩。
vst3_patch 的导出(exportBundle / exportPreset)对 Serum 默认就产出 .SerumPreset。
边界:
.SerumPreset里只有音色,不含界面态那一段,所以载入后界面上的旋钮位置会回到默认——音色本身不受影响。
所以你该怎么用(两条务实路径)
- 捏新音色:
vst3_capture存进资产库 → 可版本化、可 A/B、可渲染 →exportBundle会同时给出.SerumPreset(拖回 Serum 用)与.vstpreset(给别的 DAW)。 - 用现成的 Serum 预设:
vst3_presets search让 AI 帮你从 800+ 条里挑候选 → 你在 Serum 界面里载入它(一次几秒) →vst3_capture收编 → 之后它就被 AI 完全接管,改完再导出成你自己的.SerumPreset。 收编时会自动带上血统,vst3_patch provenance随时能查"这个音色是从哪个预设来的"。 另外vst3_patch recipe会导出一份人可读的参数配方(参数名 + 界面显示值),所以音色还可以被逐项手抄复现。
通用 VST3 适配
| 能力 | 说明 |
|---|---|
标准 .vstpreset 导入/导出 |
格式:48 字节头('VST3' + version + 32 字节 classId + int64 chunk 偏移)+ 数据区 + chunk list。我们已能把状态信封拆成 Comp/Cont 两块,所以互通成本很低。实测往返:导出 → 导入 → 17 个参数一致 → 渲染出声 |
| 标准预设目录扫描 | Documents\VST3 Presets\<厂商>\<插件>\、%APPDATA%\VST3 Presets\...、%ProgramData%\VST3 Presets\... |
| program 列表枚举与切换 | 带"名字是否有信息量""是否实现 IProgramListData""这次切换是否真的改变了音色"的判断。实测对比:Transient Master 的 128 个 program 有真实预设名(Drum Crusher 等,可按名切换且真的变声);Serum 2 的 128 个槽全叫 Prog N 且切换后音频逐位相同(空槽)——工具会明确告诉你这是空槽,别以为换了音色 |
| 状态保真度 | 状态往返后参数读回值可能有细微差异(Serum 2 实测 12 个包络曲线参数 0.4→0.5),预热对齐后音频差异约 2.67% 样本、最大 -25dB。不要用"载入后重推全部参数"去修——实测更差(9.4%),因为有些参数(Bank 是 kIsProgramChange、还有只读参数)不该写 |
踩过的坑(都已修 + 有回归测试)
- 保存状态前必须冲刷:
setParameter只是排队,要一次process()才进处理器。不冲刷就saveState会存下一份 Init 状态(2183 字节)而参数全丢——这曾让"先改参数再保存"静默失效。 process({numSamples:0})确实算冲刷(实测与真实块等效),但必须无条件执行,不能只在显式传参数时做。.SerumPack虽大(152MB),但只需读中央目录 + 单个条目即可拿到元数据,不必整包解压。- 宿主子进程绝不能用 Electron 二进制来跑(dsh 桌面版就是 Electron 应用):直接跑会秒退(退出码 0、连日志都不写);加
ELECTRON_RUN_AS_NODE=1后能跑普通脚本,但require('nvst3-host')会把进程直接打崩(退出码0xFFFF7003,崩在 N-API 加载处,try/catch拦不住)。所以监督器会优先去找系统真 node(PATH → 常见安装位置 → nvm/fnm/volta),找不到才警告式兜底到 Electron,并在第一个候选没握手就退出时自动换下一个。详见INSTALL.md对应条目。 - 同一个插件上有两种"静默改值",都不报错(0.1.2 起主动告警):显示值带
%的参数(如 Serum 2 的Main Vol、A Level)给裸数字会被解析成 100%(钳到最大);布尔参数写字符串"On"会被解析成 Off。判据是"插件回读值与请求的数值对不上",命中就报⚠ 疑似被插件改写。正确写法:带%的写"55%",带时间写"1.2s",带频率写"1200Hz",带电平写"-9dB",布尔量用 plain1/0。 - 不能凭"参数设成功了"就断定声音变了:实测同一轮里
Filter 1 Freq给 400/1200/4000Hz 渲出的 WAV 逐字节相同(MD5 一致),因为路由默认没把振荡器送进滤波器;而A WT Pos一动,频谱重心立刻从 4673Hz 变成 736Hz。判断改动是否真生效要比对音频(哈希/指标),不能只看参数回读。 - 导出目录不能靠猜:桌面版里
DSH_SESSION_JSONL只注入给 shell 工具、没有注入插件进程,所以只靠环境变量的启发式必然失败,导出目录会静默掉到 dsh 的进程工作目录(实测D:\Program Files\DSH Desktop,既不该写也常常写不进去)。0.1.2 起改为:环境变量 → 直接扫<DSH_HOME>/sessions/取最近写入的会话目录反推工作区 → 进程工作目录 → dsh 自己的目录,并且每一级都实测可写才采用。 - 结果渲染的
if/else链别拿else当兜底:vst3_patch曾把非save/load/list的动作全归到delete分支,于是exportBundle/capture/recipe都会假报一句「已删除」,看着像音色库被清空(实际文件一个没动)。已改成显式判delete,并加了集成断言。 - 适配层不能只做"读",还得做"写":只做读时,导出对 Serum 也一律落
.vstpreset——而 Serum 的浏览器只认.SerumPreset,等于导了个它看不见的文件。现在适配器同时声明presetFiles(读)与presetWrite(写),导出默认走原生格式。 - JSON 里的数字写法会破坏逐字节复现:Serum 把 schema 版本写成
"version":4.0,而JSON.parse→JSON.stringify会规范化成4,重建出的容器就比原文件少 2 字节。语义等价,但既然目标是与厂商产物完全一致,就按它的写法序列化(测试直接拿真实工厂预设做逐字节比对)。 - 厂商名不能单独当插件判据:Xfer 除了 Serum 还有 OTT / Kickstart / Transient Master,而它们的 VST3 状态结构极像(同为 XferJson、同样有 processor/controller 段)。按厂商判定会把 OTT 的音色写成
.SerumPreset(归属与后缀都错)。现在按名字/classId/路径判定,厂商只作兜底弱信号。 output.schema是additionalProperties:false,多一个字段就整条被拒(两个实例):(1) 把nativePresetFile加进必填列表却只在导出分支返回 →save/capture/list全报missing required property;(2)exportAll从 0.1.0 起就返回未声明的total/returned→ 这个动作一直是坏的,GUI 里一调就失败。根因是集成测试直接调tool.output.render(),绕过了 DSH 的返回值校验。现在集成测试每次调用都过一遍test/lib/schema.mjs的同构校验器,并新增test/tool-schema.test.mjs覆盖不需要宿主的动作。
配置项
改配置不要改安装包里的文件,在 profile 补丁里按相同 id 覆盖整行:
$DSH_HOME/profiles/web/cordis.patch.yml:
- id: dsh-vst3-studio
name: dsh-vst3-studio
config:
scanDirs: [] # 额外扫描目录;空=平台默认位置
allowDirs: ['C:/Program Files/Common Files/VST3'] # 只允许加载这些目录下的插件;空=不限制
assetDir: 'D:/audio/vst3-assets' # 音色资产库(默认 $DSH_HOME/vst3-studio/assets)
renderDir: 'D:/audio/renders' # 渲染输出目录
sampleRate: 48000
maxBlockSize: 512
requestTimeoutMs: 120000 # 普通命令超时
renderTimeoutMs: 600000 # 渲染命令超时(超时会杀掉子进程)
maxRenderSec: 900 # 单次渲染音频总长上限(秒)
bitDepth: 16 # 16 / 24 / 32
maxCrashes: 5 # 60 秒窗口内崩溃超过这个数就停止自动重启
analyzeByDefault: true # 渲染时默认顺带做分析
logFile: 'D:/audio/host.log' # 宿主子进程日志(排查现场用)
nodePath: '' # 跑宿主子进程的 node;空=自动(Electron 桌面版下会自动找系统 node)
# 预设索引相关
presetDirs: [] # 额外要索引的预设目录(除自动发现的之外)
serumPresetPath: '' # 覆盖 Serum 预设根目录;空=从 Serum2Prefs.json 自动读
nexusContentPath: '' # 覆盖 Nexus 库路径;空=注册表 → scanDirs 浅层搜索 → addSource 手动加
includeSerumPacks: true # 是否索引 .SerumPack 压缩包内部的预设
maxPackBytes: 536870912 # 单个压缩包允许读取的上限(字节,默认 512MB)
maxIndexEntries: 5000 # 预设索引条目上限
exportDir: '' # 导出根目录;空=自动(项目文件夹 + /vst3-exports)
架构
dsh 主进程
└─ src/index.ts 插件壳:配置 + 装配 11 个工具
├─ host/supervisor.ts 子进程监督:TCP 回环 IPC、超时杀进程、崩溃自动重启、
│ 运行时解析(优先真 node,Electron 下自动绕开自身)
│ └─ host/worker.ts 宿主子进程:唯一 require('nvst3-host') 的地方
├─ core/render.ts 离线渲染引擎
├─ core/params.ts 参数索引(按名定位、歧义报错、归一化换算)
└─ core/{wav,midi,dsp}.ts 纯函数:音频读写 / SMF / 客观分析
为什么一定要子进程隔离:VST3 插件是同机第三方原生二进制,加载即在你的用户权限下执行外部代码。同进程加载一旦崩溃会直接带走 dsh。隔离后最坏情况只是子进程死掉。已实测:SIGKILL 子进程后下一次调用自动重启(spawnCount +1、崩溃计数 +1);请求超时会强制终止子进程(插件卡死时唯一可靠的自救手段),随后自动恢复。
为什么 IPC 走 TCP 回环而不是 stdio 管道:dsh 沙箱会拒绝以管道 stdio 启动的子进程(实测 spawn EPERM),而 fork() 的 IPC 通道在 Windows 上也是命名管道,同样会被挡。父进程监听 127.0.0.1 随机端口 + 每次随机 token 握手,子进程反向连回,全程不碰管道,还能脱离 dsh 单独调试。
安全边界
- 加载 VST3 = 执行本机原生代码。默认不限制路径(通用性优先),但可以用
allowDirs收紧到固定目录。vst3_env会把这个边界如实告诉模型。 - IPC 只监听回环地址,且有随机 token 校验,不会把原生宿主暴露到局域网。
- 渲染有
maxRenderSec上限,防止超长 MIDI 把内存吃光。
实测边界(诚实清单)
能做到
- 参数级捏音色:改
Env 1 Attack0→0.6 使起音首 20ms 能量差 227 倍;A Level1.0 vs 0.2 峰值差 25 倍——都是可测的。 - MIDI → 音频忠实:渲染出的音高精确命中目标音(分析器已用已知正弦波标定,误差 0.0005%)。
- 音色可复现:
saveState落盘后重新渲染,音乐会按新音色改变(peak 0.284 → 0.724)。
做不到,别指望
- 没有 GUI:点不了插件里的预设浏览器。Serum 的
.SerumPreset是 GUI 层专有格式,读不了;只能做 VST3 状态往返。所以"拿现成预设当起点"基本走不通,得从参数捏 + 状态复用。 - Kontakt 8 这类采样器:音色库映射依赖 GUI,无 GUI 基本没法用。
- 复音分析不可信:单音高估计器面对和弦会给"公共周期/虚拟基频"(C-E-G 实测报 65.54Hz,不是任何一个组成音)。连奏重叠时同理——插件会在
hint里明确警告"此时音高是混合结果,不能判断单音准不准",并建议把音符拉开重渲。 - 音高分析范围 58Hz–8kHz:超出或帧内不足 ~4 个周期时返回
hz=0而不猜。 - 32-bit int / 64-bit float WAV 读入会掉精度到 float32(下游渲染链本来就是 float32,这是刻意的)。
- 没有审美:好不好听最终得你的耳朵判断。建议每次改动都渲染出来试听。
排错
| 现象 | 原因与处理 |
|---|---|
无法加载 native 模块 nvst3-host |
插件目录缺依赖。沙箱环境需 npm install --ignore-scripts(预编译二进制随包发布,本就不需要编译) |
加载插件报 VST3_LOAD_FAILED |
路径错 / 不是有效 VST3 模块。Windows 上 .vst3 通常是目录,末尾后缀不能省。错误信息尾部若是乱码(原生模块按 ANSI 取值导致),看 [提示] 那段即可 |
渲染全静音(peakDbfs: null) |
该音色需要先打开振荡器/音量参数(找 Level/Enable/Volume 调大),或需要 stateFile 载入音色,或这其实是效果器(没有音频输入) |
| 削波样本数 > 0 | 音量参数给大了,或用 gainDb 给负值 |
stages[].unresolved 非空 |
参数名写错或有歧义。用 vst3_params 核对,歧义时返回的 candidates 会列出候选 |
| 命令超时后报"宿主子进程已被强制终止" | 插件在该参数/采样率下死循环。换个参数或插件重试即可(会自动重启) |
| 崩溃次数持续增长 | 某个插件不稳定。换插件;或调大 maxCrashes 观察。现场在 logFile 里 |
| 连奏时分析只给出一个音高 | 这是正确行为(音符重叠成一段)。要逐音验证音准就把音符拉开 0.1–0.2s |
开发
npm install --ignore-scripts # 沙箱环境必须加 --ignore-scripts
npm run build # tsc → dist/
npm test # 84 个单测(WAV/MIDI/DSP/监督器隔离)
npm run test:integration # 47 项真机集成测试(需要本机有 Serum 2 与 OTT)
集成测试会真实加载插件、渲染音频、验证音高,产物落在 .tmp/itest/。
本机实测性能:Serum 2 渲染 2–3 秒音频耗时 150–330ms;分析 10 秒 48kHz 立体声约 96ms。
许可
MIT。nvst3-host 与其内置的 VST3 SDK(自 v3.7.7 起)同为 MIT,商用/闭源无授权顾虑。
No comments yet. Be the first to write one.