From b9e638d1bac3f5ef20509fa0a5e8fe04ac01ce65 Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Mon, 14 Sep 2026 13:38:47 +0800 Subject: [PATCH 01/12] =?UTF-8?q?docs(scripted):=20=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=20sidecar=20=E4=BD=93=E7=B3=BB=E8=AE=BE?= =?UTF-8?q?=E8=AE=A1=E5=AE=9A=E7=A8=BF=EF=BC=88Python/Node/Lua=20=E8=B7=A8?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E6=96=B9=E6=A1=88=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 九项锁定决策全部落稿:路线 B(跨进程 sidecar,含 Lua 也不开进程内第二路径)、 manifest runtime/entry/deps 扩展(宿主侧字段,SDK 冻结 ABI 零改动)、防遗留四防线 (stdio EOF / Job Object·PDEATHSIG·kqueue / 心跳 / 启动清扫)+ epoch 防尾包、 崩溃=会话重建的自恢复、三面传输(控制面 JSON/stdio、音频面 shm 镜像 IAudioSegment、 曲线面远程句柄+shm 大载荷兜底)、runtime 四级探测+半自动下载(AppUpdateManager 同款 通道,落地即走 InstallExtensions 热装)、平台矩阵按 runtime 实际能力声明(win-x86 唯 Lua 可达)、三语言 façade 目录 scripting/*、M0-M3 里程碑与验收(format→effect→voice, instrument 不承诺)+ PyVoice 端到端附录。 连带:术语表增 5 行裁决(sidecar/运行时包/epoch/会话重建 vs 重新加载/三面), AGENTS.md 增脚本插件占位节(含第三版本轴 ProtocolVersion 勿对齐告诫、脚本样例免 build); 核实 LoadV1 流程时补记一处必改连带项——未知 type 的资源判据须认 runtime/entry (ExtensionManager.cs:246),防脚本判决拼错被当资源包静默 loaded。 --- AGENTS.md | 12 +- docs/naming-glossary.md | 5 + docs/scripted-plugin-sidecar-design.md | 522 +++++++++++++++++++++++++ 3 files changed, 538 insertions(+), 1 deletion(-) create mode 100644 docs/scripted-plugin-sidecar-design.md diff --git a/AGENTS.md b/AGENTS.md index c4a16d78..7551b1d7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,17 @@ 2. **Pack**: `pwsh tests/pack-tlx.ps1` → bundles `tests/packages/*` into `tests/tlx/*.tlx`. 3. **Install**: `pwsh tests/install-tlx.ps1 [names...]` → extracts each `.tlx` into `%APPDATA%\TuneLab\Extensions\\`, which is where the app actually loads from. - **TuneLab must be closed** first — a running instance locks the extension dlls. + **TuneLab must be closed** first — a running instance locks the extension dlls. + +## Scripted plugins (sidecar) — planned, see docs/scripted-plugin-sidecar-design.md + +- Python/Node/Lua plugins run **out of process** (JSON-RPC over stdio); the design doc is the source of + truth until sections of it land. Façade libraries will live under `scripting/{python,node,lua}/` + (not in the .sln; per-façade test commands will be listed here when M1 lands). +- Sample scripted test plugins under `tests/plugins/` are manifest + sources only — **no csproj, no + build step**: they go straight into `tests/packages//` and are covered by pack/install steps 2–3 + above. `ProtocolVersion` (wire protocol) is a third, independent version axis — see design doc §1.2; + do not align it with `SdkVersion` or `AssemblyVersion`. ## ⚠️ Frozen public ABI: TuneLab.SDK & TuneLab.Foundation diff --git a/docs/naming-glossary.md b/docs/naming-glossary.md index 9aae9695..0a18afad 100644 --- a/docs/naming-glossary.md +++ b/docs/naming-glossary.md @@ -38,6 +38,11 @@ | 吸附网格的档位(1/16、三连的 1/12…) | `quantization`(`IQuantization` / `QuantizationBase` / `QuantizationDivision` / 动作 id `quantization.*`) | 量化 | 符号面别写 `snap` / `grid`(模型可见的句子里可以用 "the snap grid" 作解释性同位语,如 `editor status` 那行;**符号与 id 一律 quantization**) | | 一条动作要作用在哪个成员上(一个动词 × 一个闭集里的成员) | `argument`(`ActionParameter` / `ActionArgument` / `run_action` 的 `argument` 字段),那一批统称**选择器参数** selector parameter | 选择器参数 | 别写 `option` / `value` / `target` 指它;命令自己的入参仍叫 `parameter`(`ParametersJsonSchema`),动作的这一个是 `argument`——两个词分工见 command-surface.md §5.6 | | 右侧那条可开合的容器 + 它的页签 | `sidebar`(动作 id `sidebar.*` / `editor status` 的 `sidebar` 字段 / `SideTabBar`、`SideBarTab`) | 侧栏 | 「侧边栏」「抽屉」;容器叫**侧栏**,里面显示的那一块叫**面板**(`Part Panel`…),两个词别互换 | +| 脚本插件(Python/Node/Lua)的**进程外执行单元**及其宿主侧管理对象 | `sidecar`(`SidecarHost` / `TuneLab\Extensions\Scripted\`) | 伴生进程 | 「子进程」「外部进程」不作术语用(`ExtensionInstaller` 才是真·外部进程) | +| 解释器(python/node/lua)作为**零代码扩展包**分发 | `runtime` 资源包(manifest `type: "runtime"` + `flavor` / `runtime-version` 字段) | 运行时包 | 「语言包」(歧义:i18n 那边也叫语言包) | +| 防尾包的**进程迭代计数**(sidecar 每次 spawn +1,旧迭代 publication 一律丢弃) | `epoch` | 世代 | 「代次」「版本号」(version 另有 SDK/wire 两轴,勿混) | +| 宿主**弃会话、带新 context 重做**这一条恢复路径(tempo 变化既有;脚本崩溃复用) | rebuild(文档词;代码尚无统一符号) | 会话重建 | 与「重新加载」分工:**会话重建** = 崩溃/换源时宿主内部自动路径;**重新加载** = 用户重 spawn sidecar 的手动动作,别互换 | +| 三面传输的**分道命名**(脚本插件协议) | control plane / audio plane / curve plane(方法组见 `scripting/wire/v1.json`) | 控制面 / 音频面 / 曲线面 | 「数据面」不用——它盖不住"曲线面也是数据";三面各指各的 | ### 三条边界的说明 diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md new file mode 100644 index 00000000..7c7354a0 --- /dev/null +++ b/docs/scripted-plugin-sidecar-design.md @@ -0,0 +1,522 @@ +# 脚本插件 Sidecar 系统设计(Python / Node.js / Lua) + +> 状态:**定稿待评审**。基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 +> 配套阅读:[plugin-development.md](plugin-development.md)(V1 插件契约)、[sdk-api-evolution.md](sdk-api-evolution.md)(冻结 ABI 纪律)、[naming-glossary.md](naming-glossary.md)。 +> 本文只设计**宿主侧与协议侧**;`TuneLab.SDK` / `TuneLab.Foundation` 一行不动(§1.3)。 + +--- + +## 0. 定位与非目标 + +脚本 sidecar 体系把 Python / Node.js / Lua 插件接入 V1 扩展系统:脚本插件与 .NET 插件共用同一 +manifest 体系、注册表、路由、设置、侧栏 UI、`.tlx` 打包安装管线;**唯一区别是实现源在进程外**。 + +- 路线 = 跨进程 sidecar(JSON-RPC over stdio + 共享内存数据面)。**不开第二路径**:Lua 虽有干净的 + 进程内嵌入选项(KeraLua/NLua),一旦并立,"一份协议、每语言一薄层 façade"的核心优势即崩解; + 且三语言的 native 扩展(torch / node addon / cjson)都能 segfault,崩溃隔离对 Lua 同样成立。 +- **非目标**: + - `instrument`(实时音源)不进本期——实时回调 + 低延迟音频环是另一道题,明确不承诺。 + - 不承诺 win-x86 的 Python/Node 支持(官方产物就没有);平台矩阵**按 runtime 实际能力声明**(§8.5), + 用户可自带编译版扩格(§8.6)。 + - 宿主代跑 `pip/npm install` 不做(依赖策略 = 作者 vendoring,§12.2 预留字段除外)。 +- 既有事实锚点(本设计全部复用,零改动):注册表不关心实现来源(`ExtensionManager.cs:546 RegisterEntry` + 只验证"接口 + 无参 ctor");路由键 `kind:identity`(`ExtensionRouting.cs:48`);设置按 + `packageId → kind:engineId` 分桶;资源包=不加载代码的 type 注册(`ExtensionManager.cs:240`); + 热安装(`Editor.cs:1229 InstallExtensions`)、卸载延迟交 `ExtensionInstaller`; + Legacy 单向适配器的挂点先例(`ExtensionManager.cs:68 LegacyLoadHook`)。 + +--- + +## 1. 总览 + +### 1.1 一条脚本插件的解剖 + +``` +%APPDATA%\TuneLab\Extensions\com.example.pyvoice\ ← .tlx 解包后的包目录(与 .NET 插件同级) + manifest.json ← 声明 runtime + entry(§2) + plugin\main.py ← 入口模块(宿主 spawn runtime 后 import/run 它) + vendor\ ← (可选)作者 vendoring 的第三方库 + Introduction.md + │ 宿主侧 + ▼ +ScriptedLoadHook ──探测 runtime──▶ SidecarHost(package 粒度一个子进程) + │ manifest.json stdio: JSON-RPC 控制面 + │ 映射区: 共享内存音频/曲线面 + ▼ +RemoteFormat / RemoteEffectEngine / RemoteVoiceEngine : SDK A 类接口的 RPC 转发代理 + ▼ +现有 FormatsManager / EffectManager / VoicesManager / ExtensionSettingsManager / 路由 / 侧栏 —— 零改动 +``` + +### 1.2 三条版本轴(各管各的,勿对齐) + +| 轴 | 位置 | 管什么 | 现值 | +|---|---|---|---| +| `AssemblyVersion` | SDK/Foundation csproj | .NET 绑定层,钉死不动 | `2.0.0.0` | +| `SdkVersion` | `ExtensionManager.cs:29` | .NET 插件编译面 vs 宿主(manifest `sdk-version` 门,`ExtensionManager.cs:185`) | `1.1` | +| **`ProtocolVersion`(新)** | `Scripted\WireProtocol.cs` | wire 协议帧/方法表演进,宿主与 façade 双台账 | `1` | + +脚本条目 manifest 仍必填 `sdk-version`:它经握手映射成 façade 支持的 SDK 面区间(façade 声明 +`tunelab_sdk = "1.1"`),校验语义与 `sdk-version` 门同构——"脚本插件用了新面 × 旧宿主/façade"在握手期 +人话拒绝,不进运行时。 + +### 1.3 冻结 ABI 边界 + +新增的一切都是宿主 internal(`TuneLab\Extensions\Scripted\`)或脚本侧包。SDK 的 A 类接口由 +`Remote*` 代理实现(宿主消费方向不变);B 类宿主实现面**不出进程**——sidecar 看到的是它们在协议侧的 +镜像(§5.3),插件永远拿不到活的 .NET 对象。`PublicAPI.*.txt` 全程不动。 + +--- + +## 2. manifest 扩展 + +### 2.1 新字段(宿主侧 DTO:`ExtensionManifest.cs` / `ExtensionInfo.cs`,非 SDK) + +条目级(entry): + +| 字段 | 要求 | 语义 | +|---|---|---| +| `runtime` | 声明即成脚本条目 | 运行时区间串:`"python>=3.10,<4"` / `"node>=18"` / `"lua>=5.4"` / `"luajit>=2.1"`(flavor 前缀参与判定,§8.3) | +| `entry` | 与 `runtime` 成对 | 包内相对路径:`plugin/main.py` / `plugin/index.js` / `plugin/main.lua` | +| `deps` | 选填 | `"vendored"`(默认,隐式)/ `"pip"`(**已声明未实现**:见到即 `Skipped: deps mode not supported`) | +| `protocol-version` | 包级,选填 | 缺省按 1;高于宿主 `WireProtocol.Version` → 握手前 `Skipped`(与 `sdk-version` 门同款文案) | + +规则(全部是加载错误,不静默降级——延续 V1 纪律): + +- 脚本条目**不得**再写 `class`/`assembly`;`runtime` 与 `entry` 必成对;与 `class/assembly` 混写 = 错误。 +- 判别次序:manifest 含 `id` ⇒ V1(不变);V1 条目含 `runtime` ⇒ 分流 `ScriptedLoadHook`,否则走现有 + ALC 路径。含 `runtime` 但 `id` 缺失 = Legacy 时代不存在此字段 ⇒ 仍按 Legacy 处理(其 `runtime` 被忽略), + 文案提示应补 `id`。 +- 脚本条目同样走包级/条目禁用门、`platforms` 门、`sdk-version` 门(`ExtensionManager.cs:140` LoadV1 内、 + ALC 之前分流,位置在 `!IsCodeKind` 与 `ValidateSdkReferences` 之间,`ExtensionManager.cs:240/281`)。 + 连带一处必改:未知 `type` 的"资源包不声明代码"判据(`ExtensionManager.cs:246`,现只认 + `assembly/class`)须扩成也认 `runtime/entry`——否则 `type` 拼错的脚本条目会被当资源包静默登记成 + loaded,正是该处注释点名要防的"比报错更误导"。 +- `type:"runtime"` 是**新资源类型**(§8.4):零代码,走现有资源注册路径;不填 `assembly/class`, + 新增包级字段 `flavor`(`python|node|lua|luajit`)、`runtime-version`(精确串,如 `"3.12.7"`)。 + 老宿主见到未知 `type` 本就当资源登记,新宿主才消费它的 `flavor`——天然向后兼容。 + +### 2.2 脚本插件条目示例(voice,单条目简写形) + +```json +{ + "id": "com.example.pyvoice", "name": "Py Voice", "version": "1.0.0", + "sdk-version": "1.1", "protocol-version": 1, + "runtime": "python>=3.10,<4", "entry": "plugin/main.py", + "type": "voice", "engine": "PyVoice", + "introduction": "plugin/Introduction.md", + "localizations": { "zh-CN": { "name": "示例语音引擎", "introduction": "plugin/Introduction.zh-CN.md" } }, + "platforms": ["win-x64", "win-arm64", "osx-x64", "osx-arm64", "linux-x64", "linux-arm64"] +} +``` + +### 2.3 加载期校验(握手后) + +sidecar 在 `initialize` 应答里回 `provides: [{ "type", "engine"|"suffixes", "settings": bool }]`,宿主逐条 +核对 manifest 承诺:缺席 = `Failed: entry did not declare :`;多余同理想声明才有效。 +`IExtensionSettings` 探测从"静态查类型"(`ExtensionManager.cs:611`)换成握手字段 `settings`。 + +--- + +## 3. 进程与生命周期 + +### 3.1 归属与粒度 + +- **一个 sidecar = 一个包**(不是条目、不是会话)。多 part / 多 effect 实例的 session 在一条 stdio 上 + 多路复用;模型只随进程加载一次。 +- `ScriptedRegistry`(新,宿主 internal 单例)持有 `packageId → SidecarHost`;`Remote*` 代理从它取通道。 +- **懒起**:`Init` 首次被需要时 spawn(对齐 .NET 引擎的懒 Init;App 启动的 eager `InitEngine` 预热对脚本 + 引擎默认关闭,设置可开——避免开机拉一堆 torch)。 + +### 3.2 状态机 + +``` +None → ResolvingRuntime → Spawning → Handshake → Ready + │ (EOF / 退出 / 握手失败) │ IO/心跳超时 + ▼ ▼ + Restarting(backoff) ◀───────────────── Dying(收割) + │ 窗口内 ≥5 次重启仍死 + ▼ + Failed(circuit open) ——侧栏可见,含 stderr 尾部摘要 +``` + +- **epoch**:每次 spawn 递增。宿主对每个 session 记录其 epoch;一切 sidecar→宿主 的 publication / + RPC 应答带 epoch,旧 epoch 到达即丢(防"复活后旧进程尾包打进新会话")。崩溃=隐式取消在途请求 + (对齐"取消是正常结局"语义),产物保留上一版。 +- **退避**:1s/2s/5s/10s/30s;窗口(10 min)内重启 ≥5 → 熔断 `Failed`,用户点"重新加载"或重启宿主复位。 +- **正常关机**:`ExtensionManager.Destroy`(`App.axaml.cs` desktop.Exit 路径)→ 先对全部 sidecar 发 + `shutdown`(限时 3s)→ 未退则 Kill → 再走现有 .NET 引擎 Destroy。卸载因此不再被 sidecar 文件锁卡住。 + +### 3.3 防遗留四防线(按优先级;1/2/3 冗余并存) + +| # | 机制 | 平台 | 覆盖 | +|---|---|---|---| +| 1 | **stdio EOF 自杀**:façade 用专用线程阻塞读 stdin,EOF → 先收割自己的子进程(dataloader/child)再退 | 全平台 | 一切进程死亡(含 `Program.cs:79` 的 `Kill()`——该路径不跑托管清理,OS 关句柄仍然成立) | +| 2 | **OS 父死钩子**:Windows Job Object(`KILL_ON_JOB_CLOSE`+禁 breakaway,子进程自动继承);Linux `prctl(PR_SET_PDEATHSIG,SIGTERM)`;macOS kqueue `NOTE_EXIT` 挂父 pid | 分平台 | 子进程阻塞在 native 长调用、看不见 EOF 的死角 | +| 3 | **心跳**:控制面 ping 15s / 超时 60s。超时 ≠ 自杀——宿主挂起(进程活着)时 sidecar **不**退出(丢模型重载是白丢);心跳只用于宿主识别"半死 sidecar"→ 判死重启 | 全平台 | 卡死的 sidecar | +| 4 | **启动清扫**:spawn 时把 `{pid, create-time, packageId, nonce}` 记 `Configs\scripted-pids.json`;下一实例持锁(`TuneLab.lock`)后清扫——按 pid+进程创建时间+可执行路径三重匹配防 PID 复用,只杀路径落在 Extensions 下的残留 | 全平台 | BSOD/断电后的开机残留 | + +### 3.4 spawn 规范 + +- argv:` [语言旗标] `(python:`-u` 禁缓冲;node:`--disable-warning=ExperimentalWarning`;lua/luajit:裸跑)。 +- stdin/stdout **只归协议**;日志一律走控制面 `log.write` 通知(§5.4),由宿主转发进既有 `ILogger` + (按 ALC 归因的等价物 = packageId 归因)。stderr 捕获到包目录 `logs/stderr-.txt`(滚动 2 份), + 尾部 64KB 供 `Failed` 文案。**禁止**插件往 stdout 打裸文本(帧即坏,判死重启——文档写明)。 +- 环境变量注入:`TUNELAB_PROTOCOL=1`、`TUNELAB_EPOCH`、`TUNELAB_NONCE`(shm 命名域,§7);置 + `PYTHONUNBUFFERED`/`NODE_OPTIONS` 白名单式控制,其余继承。 +- Windows:`CreateNoWindow` + 非重叠 stdio 句柄;隐藏控制台差异封装在 `ScriptedLauncher` 一处。 + +### 3.5 数据线程纪律(协议级硬约束) + +宿主数据线程上**永不**做无界同步 RPC。规则: + +- 声明面调用(`get_*_configs`,宿主在参数 commit 时数据线程要结果)→ **宿主侧缓存**:值按 + `(engineId, 参数快照)` 键缓存;miss 时起异步 RPC + 超时(默认 200ms)→ 超时则本拍用旧缓存/返回上值, + 应答到达后按 commit 流程 diff 到 UI。UI 声明位可容忍一拍延迟,正确性不受影响(声明本就是纯函数)。 +- `get_next_pending_synthesis_range`(peek)→ 同上"异步答 + 缓存":sidecar 靠 §5.5 推送的变更事件自维护 + 增量脏表,peek 是它内存状态的本地查询(无回调);宿主对 peek 结果按 `(session, window, 变更序号)` 缓存, + miss 即等下一次调度拍(不阻塞数据线程)。 +- 其余(synthesize/process/声明/init…)全在异步续体上,超时按方法配置(synthesize 不设 RPC 超时, + 靠取消)。 + +--- + +## 4. 自恢复:崩溃 = 会话重建 + +既有语义背书:tempo 变化/part 位移时宿主本就 dispose 旧会话、带新 context 全量重建、插件从零重合成 +(plugin-development §5.9)。**sidecar 崩溃恢复 = 重放该路径**,插件作者不写任何恢复代码: + +| 类型 | 死亡期表现 | 恢复动作 | +|---|---|---| +| format | 当次导入/导出报错(用户可见,人话) | 无状态,下次调用即新进程 | +| effect | **passthrough**(`EffectManager.GetInitedEngine` 失败即降级的现成通路) | 重 Init + 重建全部 processor;宿主保守调度自动重跑 `Process` | +| voice | 状态带保上一版 + `Synthesizing…restarting` 提示 | 重 Init + 重建全部 session(epoch++);调度器自然重合成脏/可见区 | +| settings | 面板暂用缓存 schema | 重拉 `get_settings_config`,桶数据在宿主侧不受影响 | + +## 5. RPC 协议 + +### 5.1 帧与消息 + +- 帧 = LSP 式头段 `Content-Length: \r\n\r\n` + UTF-8 JSON body(单写线程聚批,防管道碎帧)。 +- JSON-RPC 2.0 子集:`request(id,method,params)` / `reply(id,result|error)` / `notification(method,params)`。 +- 方法名 snake_case;错误对象 `{code, message}`,code 枚举稳定:`bad_request, unsupported_protocol, + stale_epoch, stale_handle, runtime_error, cancelled, timeout, io`。 + +### 5.2 系统方法(双向) + +| 方法 | 方向 | 语义 | +|---|---|---| +| `initialize {protocol, sdk_version, package_id, entries[]}` → `{provides[], capabilities{audio_plane,curve_plane}, runtime{exe,version,arch,flavor}, facade_version}` | H→S / 应答 | 握手(§3.4 环境变量核对 epoch/protocol;不符即关管道) | +| `log.write {level, message}` | S→H n | 插件日志 → 宿主 ILogger(packageId 归因) | +| `ping {}/pong {epoch}` | 双向 n | 心跳(§3.3-3) | +| `shutdown {why}` | H→S c | 礼貌退出(§3.2) | +| `capabilities.report {…}` | S→H n | 运行中能力变更(罕见,v2 预留) | + +### 5.3 载荷编解码约定("句柄化宿主实现面") + +B 类接口**不逐项远程化**。判据:sidecar 只需要 (a) 一次性物化的值(快照、属性对象)、(b) 批量求值 +(曲线面)、(c) 就地写(音频面)。因此: + +- 值对象:`PropertyObject` / `ObjectConfig` / 快照族 / `Synthesized*` → JSON(字段名 = SDK 属性名 camelCase; + `NaN` 用字符串 `"nan"`——JSON 不合法 NaN 的机械修)。 +- 句柄:`snapshot_id`(随会话 epoch 失效)、`evaluator` = `(snapshot_id, track_key)` 复合、 + `segment_id`(宿主 `IAudioSegment` 握柄的镜像;`Create/Write/Commit/Resize/Dispose` 一一对应)。 +- 活视图(context/note/automation 的通知流)→ **不镜像**,改为 §5.5 的推送事件流 + 快照拉取。 + 这是全协议最大的一条简化:跨进程没有"随手读活的宿主对象",一切经事件与快照——正与 V1 自己的 + 线程纪律同构(worker 只读快照)。 + +### 5.4 format(Phase 1) + +| 方法 | 参数 → 结果 | +|---|---| +| `format.import {entry_id, stream_ref} → {project_info}` | 宿主把源文件 copy 到暂存 `\TuneLab\Temp\io-\.in` 传路径;façade 以二进制流读,回 ProjectInfo JSON | +| `format.export {entry_id, project_info, stream_ref} → {}` | sidecar 写暂存 out 文件;宿主读进既有的 MemoryStream 原子写流程(`FormatsManager` 语义不变),失败=导出错误 | + +ProjectInfo/TrackInfo/…/NoteInfo 全族 DTO 已是值对象,JSON 映射表进 façade 文档(字段逐一对齐, +宿主不猜)。**字节流不进消息体**(大工程导出可数十 MB——文件交接零拷贝)。 + +### 5.5 引擎与会话方法(effect Phase 2 / voice Phase 3) + +引擎级(H→S):`engine.init {engine_id}`(异步长任务:进度经 `engine.init_progress` n;超时=无, +失败回 error)、`engine.destroy`、`engine.create_session {engine_id, session_id, voice_id|绑定信息}`、 +声明五件套 `engine.get_automation_configs {…}` 等(结果经 §3.5 缓存)。 + +会话推送流(H→S n,voice):`sess.note_added/note_removed/note_modified{note_id, fields[], …}`、 +`sess.properties_changed{keys}`、`sess.automation_range_changed{track, start, end}`、`sess.committed{}`。 +note_id = `IVoiceSynthesisNote.Id`(宿主发号,冻结面已按 Id 键控产物,`VoiceSynthesisNoteSnapshot.cs:17`)。 + +会话级(H→S c):`sess.get_snapshot {session_id, note_ids[]}` → 快照 JSON(automation/pitch 全量冻结、 +求值器以句柄出现——"实现范围只能等音素定时才知道,同步前缀无处确圈窗"的上游结论); +`sess.peek {session_id, start, end}` → `SynthesisRange|null`(§3.5 缓存通道); +`sess.synthesize_next {session_id, start, end}`(异步,取消经 `sess.cancel` n); +产物回传(S→H):`sess.publish_pitch/parameters/phonemes/status {…}` + 对应 changed 信号 +(`StatusChanged` 等四族,`IVoiceSynthesisSession.cs:112-116`);音频面见 §6.2。 + +effect 同族:`effect.create_session {context绑定: 输入段几何+参数快照}`、`effect.process`、 +`effect.input_read {offset,count} → base64`(或 §6.2 shm 读视图,宿主输入段映射为只读区)、 +`Input.RangeModified` → `effect.input_range_changed` n。 + +### 5.6 曲线面(**已定稿:远程句柄 (a) + shm 兜底**) + +- `evaluator.evaluate {snapshot_id, track, positions_b64} → {results_b64}`:小批量 inline + little-endian f64(阈值 64KB)。 +- `evaluator.evaluate {snapshot_id, track, positions_count, out_ref} → {shm_offset}`:大批量走 + `audio_plane: shm` 同族的结果缓冲,façade 以 `numpy`/`Float64Array` 视图取回。 +- **宿主在 RPC 工作线程直接出值**——冻结求值器本就不可变、跨线程安全(V1 契约原文),无需 marshal + 回数据线程;这是 (a) 成本可控的根基。插值/投影算法**唯一实现留在宿主**,façade 零移植、零漂移。 +- `positions` 非降序为硬前提:façade 排序断言,宿主不校验(乱序=未定义值,同 in-proc 语义)。 +- 句柄随 snapshot 换代失效:新 `get_snapshot` 或 `sess.dispose` 即 invalidate 旧 `snapshot_id`, + 命中旧句柄回 `stale_handle`。 + +### 5.7 settings + +`settings.get_config {engine_key}` → `ObjectConfig` JSON;`settings.apply {engine_key, values}`。 +宿主侧桶(`ExtensionSettingsStore`、DPAPI/Keychain 密码字段)**不动**:sidecar 永远收不到密码字段的 +密文之外形态,schema 里 `IsPassword` 字段值由宿主侧解出后才推给 `settings.apply`(与 .NET 插件同权)。 + +--- + +## 6. 传输:三面分道 + +| 面 | 载荷 | 通道 | 预算 | +|---|---|---|---| +| 控制面 | RPC/事件/快照 | stdio JSON,§5.1 | 每块合成几次往返,µs~ms,忽略 | +| 音频面 | PCM f32(≈384KB/s/流) | **共享内存段**(§7),仅当 `audio_plane: shm` | 零序列化;写=内存带宽 | +| 曲线面 | f64 批量数组 | base64 inline <64KB,超出走 shm 结果缓冲 | µs~ms | + +- **音频面镜像 in-proc 语义**:`create_audio_segment {segment_id, sample_offset, count, rate}` → + `{region_ref}`;façade 映射后给 numpy/TypedArray 视图,`write(offset, array)` 直写宿主缓冲; + `commit`/`resize`/`dispose` = 16 字节级控制消息。`IAudioSegment.Resize` 的身份保持语义(下游缓存存活) + 原样映射为 `resize` 消息。 +- 能力协商:`audio_plane: shm | inline`。`inline` 面 = `segment.write_b64`(PUC Lua、异常平台的兜底; + 协议同构,性能自降,文档如实标注)。 +- 明确**不引入** FlatBuffers/CAPNProto/gRPC(schema 工具链在多语言侧的摩擦远大于收益;MessagePack 留作 + benchmark 真要求时的平替位)。TCP/命名管道不做(端口、防火墙、半关语义全是净亏)。 + +## 7. 共享内存布局与清理 + +- 区域命名:`tlx---`。`boot-nonce` = 宿主本次启动随机串(一切资源随它 + 成族;上次崩溃的残留可识别、可清扫)。 +- 宿主分配:`MemoryMappedFile`(.NET 跨平台)。POSIX:façade `shm_open` 后**立即 unlink**(fd 已映射, + 双方进程死即净);Windows:mapping 名进 Global\ 前缀需提权 ⇒ 一律用**文件后备**命名(宿主暂存目录下 + `regions\`),随包卸载/启动清扫删除,进程句柄自动回收。 +- 段区头部:`{magic u32, version u16, flags u16, sample_offset i64, sample_count i32, sample_rate i32}` + 后接交织立体声 f32 体;几何变更 = 新段新区域(`Resize` 原地改 header+可扩体区)。 +- 生命周期与 `IAudioSegment` 一致:Dispose/换代即宿主解除映射、façade 收 `segment.gone` n 解视图; + 进程死全部由 OS/清扫兜底(§3.3)。 +- 曲线面结果缓冲复用同一区域分配器(环形、按 `(epoch, seq)` 命名)。 + +## 8. 运行时交付与探测 + +### 8.1 四级探测(RuntimeLocator,加载期完成,不跑插件代码) + +1. **设置显式路径**(设置→扩展→运行时;每语言一栏,`PathPickerConfig`——SDK 1.1 现成面)。 +2. **环境变量** `TUNELAB_PYTHON` / `TUNELAB_NODE` / `TUNELAB_LUA`(分号多候选;CI/高级用户)。 +3. **runtime 资源包**(§8.4):扫 Extensions 下 `type:"runtime"`、`flavor` 对得上、`platforms` 含当前 RID + 的包,取满足区间的最高版。 +4. **系统 PATH**:Python 先 `py -3` launcher 再 `python`/`python3`;Node `node`;Lua `lua`/`luajit`。 + +### 8.2 真跑核验(每一格都要过) + +候选以固定参数起进程一次,读版本+架构(`sys.version`/`platform.machine()`;`process.arch`; +`_VERSION` + `jit` 全局),超时 2s;结果按 `(exe路径, mtime)` 缓存。**防 spoof 硬要求**: +Windows `WindowsApps\python.exe` 商店跳转桩、nvm/fnm shim——跑不过核验即换下一候选。 +版本/arch 不满足条目区间 ⇒ `Skipped: requires python>=3.10, found 3.8`(人话,同 `sdk-version` 门文案风格)。 + +### 8.3 flavor 语义 + +`runtime` 串 = flavor + 区间。flavor 不匹配(如装了 Lua 5.4 而条目要 `luajit`)视同未找到。 +方言碎片化被协议吸收(字节级 JSON over stdio 与方言无关);façade 声明各方言下限(纯 Lua 无 C 依赖)。 + +### 8.4 runtime 资源包 + +```json +{ "id": "com.tunelab.runtime.win-x64.python", "name": "TuneLab Python Runtime", + "type": "runtime", "flavor": "python", "runtime-version": "3.12.7", + "platforms": ["win-x64"], "version": "1.0.0" } +``` + +内含 `runtime\`(python embeddable 树 / node dist 树 / lua bin)。官方发布经 +`tools/build-runtime-package/`(抓 python.org embeddable zip、nodejs.org dist、Lua/LuaJIT 构建产物 → +组装 → 打 `.tlx` → sha256 随 GitHub release;`upload-release.yml` 加制品)。 + +### 8.5 平台矩阵(首年官方格;⚠=社区构建作者自证,❌=官方无产物) + +| | win-x64 | win-arm64 | osx-x64 | osx-arm64 | linux-x64 | linux-arm64 | win-x86 | +|---|---|---|---|---|---|---|---| +| Python | ✅ 3.12+ | ✅ 3.14+/uv 3.12+ | ✅ | ✅ | ✅ glibc≥2.28 | ✅ | ❌(3.13+ 停发) | +| Node | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌(v13 起无) | +| Lua | ✅ | ⚠ | ✅ | ✅ | ✅ | ✅ | **✅(C89 可便携,唯一填格者)** | + +宿主只按 RID 探测核验;格子由 runtime 官方产物决定,**政策与协议不因语言变**。 + +### 8.6 缺失时(**定稿:半自动**) + +侧栏 `Skipped · 未找到 Python 运行时` + 两按钮:【下载运行时】(走 `AppUpdateManager` 同款 +GitHub release + sha256 + 进度条通道,用户明示同意;落地即 `Editor.InstallExtensions` 热装,**无任何 +新安装路径**)与【选择已装位置】。设置项"自动获取脚本运行时"默认**关**(开了=免提示,其余同)。 +镜像/离线:源 URL 可覆写(与 §8.1-① 的自带编译版是同一条灵活政策的两个面)。 +macOS:runtime 资源包安装钩子负责剥 `com.apple.quarantine`;下载的包不进 .app,公证面不变。 + +### 8.7 并存与回收 + +多版本 runtime 包并存(目录名带 flavor+版本,探测按区间择优;两插件钉不同版本各得其所)。 +被引用中的 runtime 包禁卸(侧栏点名使用方);提供"清理未被引用的运行时"。它就是普通扩展包—— +`ExtensionActivation`/`Installer` 管线零改动。 + +## 9. façade 与宿主适配层 + +### 9.1 宿主侧(`TuneLab\Extensions\Scripted\`,全 internal) + +`ScriptedLoadHook`(挂 `LoadV1`,模式对齐 `LegacyLoadHook`)→ `RuntimeLocator` → `SidecarHost` +(进程/帧/句柄表/epoch/重启)→ `WireRpc`(§5)→ `ShmChannel`(§7)→ +`RemoteFormat(:IImportFormat,IExportFormat)` / `RemoteEffectEngine` / `RemoteVoiceEngine` / +`RemoteScriptedSettings` —— 注册进现有 manager。**这些代理是 B 类的"假象制造者"**:对 manager 而言 +它们与 in-proc 引擎不可分辨。 + +### 9.2 插件侧(新顶层目录 `scripting/`) + +| 包 | 交付 | 依赖底线 | 关键点 | +|---|---|---|---| +| `tunelab` (PyPI) | `scripting/python/tunelab/` | **纯 stdlib**(json/threading/`multiprocessing.shared_memory`/ctypes 设 PDEATHSIG) | 装饰器注册 `@tunelab.voice_engine`;`synthesize_next` 是协程,宿主发布经内部 marshal 线程;numpy 数组直写 shm 视图 | +| `@tunelab/plugin` (npm) | `scripting/node/tunelab-plugin/` | TS-first;mmap 走可选 peerDep `koffi`,无则自报 `inline` | 事件循环单线程 ↔ 协议天然契合;CPU 密集 offload 到 `worker_threads`(façade 提供,禁阻塞 loop 饿死心跳) | +| `tunelab` (luarocks / 单文件) | `scripting/lua/tunelab/` | **纯 Lua 单文件**(内嵌 json.lua + 协程 RPC 主循环);LuaJIT+FFI 才开 `shm` 能力 | PUC 自报 `inline`;曲线经打包 double 串 + `string.unpack` | + +façade 内嵌 `wire=1` + `tunelab_sdk` 区间;版本随宿主台账发布,离线 wheel/tar 附在 runtime 资源包内 +(vendoring 场景作者本地取用)。SDK 面成员映射表(snake_case ↔ `IVoiceSynthesis*` 逐成员)是 façade +文档的核心附录,漂移即测试红(§12.1 M2 的映射封条)。 + +## 10. 失败 / 降级矩阵 + +| 事件 | format | effect | voice | 设置面板 | 用户所见 | +|---|---|---|---|---|---| +| runtime 未找到 | — | — | — | — | `Skipped`+下载按钮(§8.6) | +| 握手协议不符 | 同左 | 同左 | 同左 | 同左 | `Skipped: wire 2 > host 1` | +| sidecar 崩溃 | 当次报错 | passthrough → 恢复重跑 | 上一版产物+重建重合成 | 缓存 schema | `Synthesizing…restarting` / stderr 摘要 | +| sidecar 挂起 | 超时→报错 | 心跳判死→重 spawn | peek/声明走缓存;synthesize 取消→重排 | — | 状态带提示 | +| 宿主猝死 | —(sidecar EOF 自杀,§3.3) | 同左 | 同左 | 同左 | 下次启动清扫 | +| init 抛错 | n/a | 引擎 `Failed` | 引擎 `Failed` | — | 侧栏 `Failed`+原因 | +| stale_handle/尾包 | — | 丢弃 | 丢弃 | — | 无感(epoch 兜底) | + +## 11. 安全与信任 + +- 信任模型与 .NET 插件**完全同级**:扩展即全权代码(vendored 依赖同理),sidecar 不带来新权限面, + 也**不承诺沙箱**。区别只在崩溃隔离与可卸载性。 +- 密码字段:密文/明文都不出宿主进程;`settings.apply` 推的是解出的终值(与 .NET 插件同权,§5.7)。 +- 下载链:GitHub release 固定 + **sha256 钉死** + 镜像覆写要求同样校验;`tools/build-runtime-package` + 的产物清单进发布检查单。 +- `ValidateSdkReferences`(`ExtensionManager.cs:337`)对脚本源不适用——补偿 = 握手期 `sdk-version` + 区间 + façade 映射封条测试(§12)。 + +## 12. 实施分期与验收 + +### 12.0 里程碑 + +| M | 交付 | 验收 | +|---|---|---| +| M0 | 设计文档(本文)+ `runtime/entry/deps` manifest 字段 + `RuntimeLocator` 四级探测(含核验缓存)+ runtime 资源包格式 + `tools/build-runtime-package` + `Skipped` 文案链 | 新用例:字段校验矩阵/探测顺序/spoof 候选跳过;无 runtime 时插件停在 Skipped;编译面不碰任何解释器真链 | +| M1 | `SidecarHost`+`WireRpc`+帧编解码+§3.3 防线 1/2/4+epoch/退避 + **.NET 假 sidecar**(测试工程内子进程,CI 免装解释器)+ `RemoteFormat` + Python `tunelab` + `tests/plugins/PyFormat` 样例 + 【下载运行时】按钮 | 假 sidecar 协议单测全绿;`HeadlessHost`(`TuneLab\Headless\HeadlessHost.cs`)E2E:装 .tlx → 导入导出 → 卸载;杀宿主 → 子进程 ≤2s 自清 | +| M2 | Node/Lua façade + effect(passthrough、心跳、能力协商:PUC Lua 走 inline 验证兜底路)+ stderr 日志收割 + `[重新加载]` | 三语言 effect 样例全跑;崩溃→恢复→重合成用例;映射封条测试 | +| M3 | `ShmChannel` + voice(推送流/会话重建恢复/曲线面 (a)+shm 兜底)+ Python voice 样例(附录 A)+ CI 三平台矩阵(setup-python/setup-node/lua 预编译) | 附录 A 剧本端到端;挂起注入用例(数据线程不冻结的看门狗断言);合成产物与 .NET 样例逐字段对拍 | + +### 12.1 测试资产 + +`tests/plugins/{PyFormat,NodeFormat,LuaFormat}/` = manifest+源码,无 csproj,直接进 `tests/packages` +被 `pack-tlx.ps1` 收走(绕开三件套的 build 步);`manifest-variants` 补脚本条目变异体; +`tests/TuneLab.Tests/Scripted/*.cs` 用假 sidecar;E2E 标 `[Trait("runtime","needs-interpreter")]` +CI 专属。协议封条:方法表 JSON schema 一份(`scripting/wire/v1.json`),三语言 façade 各自跑同一回放。 + +### 12.2 配置项清单(宿主设置,`SettingsRegistry`) + +``` +scripted.autoFetchRuntime bool = false # §8.6 全自动档开关 +scripted.runtimes.python string = "" # ①级路径(PathPicker) +scripted.runtimes.node string = "" +scripted.runtimes.lua string = "" +scripted.runtimeSource string = "" # 镜像 URL 覆写(sha256 仍强制) +scripted.eagerWarmup bool = false # App 启动预热脚本引擎 +scripted.initTimeoutSec int = 0 # 0 = 无上限(§5.5 长任务语义) +``` + +开发工效(M1 附带):`PathManager` 的 `TuneLab.lock`/Extensions 根目录接受环境变量覆盖 +(`TUNELAB_DATAROOT`),让 fork 联调实例与日常实例的 `%APPDATA%\TuneLab` 物理隔离——两检出共享 +用户目录是已知互踩点(单实例锁 + dll 锁)。 + +### 12.3 文档与术语 + +- 用户向:`docs/plugin-development-scripting.md`(英文源 + zh-CN,M1 起随能力增补); + `plugin-development.md` 增一节"脚本插件"指针。 +- `naming-glossary.md` 已随行增补(sidecar / runtime 资源包 / epoch / 三面 / 会话重建)。 + +--- + +## 附录 A:PyVoice 端到端样例(M3 验收剧本) + +### A.1 包与 manifest + +见 §2.2。布局:`src/{manifest.json, plugin/{main.py, engine.py, g2p.py, acoustic.py}, +vendor/(pip install --target 产物), Introduction*.md}`;模型走独立资源包或设置指路径(plugin-development +§5.10 原约定直接沿用);`platforms` 逐格声明。 + +### A.2 代码骨架(façade 草样 API) + +```python +# main.py +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "vendor")) +import tunelab +from .engine import PyVoiceEngine + +@tunelab.voice_engine # 注册;握手时随 provides 上报 engine id +class Engine(PyVoiceEngine): + def init(self): # 懒调;失败 raise → 侧栏 Failed + self.catalog = scan_voicebanks() # VoiceSourceInfos 契约:同步、永不阻塞,Init 期缓存 + def destroy(self): self.catalog = None +``` + +```python +# engine.py —— A 类成员逐一镜像(映射表 = scripting/wire/v1.json) +class PyVoiceEngine: + def get_automation_configs(self, ctx): # 声明面纯函数;ctx.parts[i].voice_id 分流多声库 + return { "Breathiness": tunelab.AutomationConfig(0, 1, display_text=tr("气声")) } + def get_note_property_config(self, ctx): + return tunelab.ObjectConfig(properties={ + "tension": tunelab.Slider.linear(0, -1, 1), + "mode": tunelab.ComboBox(["normal", "whisper"]), + }) + def get_phoneme_property_configs(self, ctx): return {} + def create_session(self, context): return PySession(self, context) + +class PySession: + default_lyric = "a" + def is_continuation(self, note): return note.lyric in ("-", "ー") + + def get_next_pending_synthesis_range(self, start, end): # peek:只查本地增量表(§3.5 有宿主缓存兜底) + return self.dirty.next_in(start, end) + + async def synthesize_next(self, start, end): + piece = self.dirty.next_in(start, end) # commit:同窗口确定性重导出 + snap = await self.ctx.get_snapshot(piece.notes) # 全量冻结快照(§5.5;求值器=句柄) + self.dirty.clear(piece) + out = await self.ctx.run_worker(self.render, snap) # offload;发布自动 marshal 回控制线程 + # render 内:snap.pitch.evaluate(times)(曲线面);phonemes = pinned or g2p(n.lyric); + # audio = acoustic(...) → float32 + seg = self.ctx.create_audio_segment(int(piece.start*48000), len(out.audio), 48000) + seg.write(0, out.audio) # numpy 视图直写 shm(§6.2) + seg.commit() + self.set_products(pitch=out.pitch, parameters={}, + phonemes={n.id: syll for n, syll in zip(snap.notes, out.sylls)}) +``` + +崩溃恢复、取消、进度、状态带均由 façade 与宿主包办(§4);作者代码里没有任何跨进程概念—— +协议藏在 `tunelab` 之内,这正是 façade 存在的意义。 + +### A.3 开发 → 打包 → 安装 → 使用 + +1. `pip install tunelab`;`pytest` 直接喂构造快照(façade 可脱离宿主单测)。 +2. `python -m tunelab.serve --replay fixtures/part01.json`:假宿主回放变更流/窗口请求,本地断点调 render。 +3. `python -m tunelab.build src/ dist/com.example.pyvoice.tlx` → 关 TuneLab → `install-tlx.ps1` + (或应用内拖入/双击 .tlx 热装)。改代码 → 侧栏"重新加载"(kill+respawn+会话重建,免重启宿主)。 +4. 探不到 runtime → Skipped + 【下载运行时】(§8.6)。 +5. 使用 = 与 .NET 引擎不可分辨:侧栏条目/Introduction、设置桶、声库选择器、状态带、路由页(与其他 + `voice:PyVoice` 候选竞争同规则)、工程存 `engine` id。 From 256349411500b0d26a678b1abd58fc671e7062ea Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Mon, 14 Sep 2026 14:34:58 +0800 Subject: [PATCH 02/12] =?UTF-8?q?feat(scripted):=20M0=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E8=84=9A=E6=9C=AC=E6=9D=A1=E7=9B=AE=20manifest=20=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E3=80=81wire=20=E5=8D=8F=E8=AE=AE=E9=97=A8=E3=80=81ru?= =?UTF-8?q?ntime=20=E5=9B=9B=E7=BA=A7=E6=8E=A2=E6=B5=8B=E4=B8=8E=E6=89=93?= =?UTF-8?q?=E5=8C=85=E5=B7=A5=E5=85=B7=E9=93=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计文档 docs/scripted-plugin-sidecar-design.md §2/§8/§12-M0 的首个落地片;SDK 冻结面零改动 (新增全在宿主 internal + 协议侧)。 - manifest:条目级 runtime/entry/deps(与 class/assembly 互斥,混写=Failed),包级 protocol-version(第三版本轴 WireProtocol.Version=1,高于宿主=条目循环前置拒 Skipped,非法值 Failed)与 flavor/runtime-version(type:runtime 资源包专用)。 - LoadV1 分流:脚本条目走 ScriptedEntryGate(结构校验→deps 模式→RuntimeLocator 四级探测),M0 结局恒为如实 Skipped(sidecar 注册随 M1 替换);连带把「声明了代码」资源判据(未知 type 不吞) 扩成也认 runtime/entry——type 拼错的脚本包不再被静默登记成 loaded。 - RuntimeLocator:settings→env(TUNELAB_*)→runtime 资源包(高版本优先)→PATH(win 先 py -3);每 候选真跑核验(2s 超时、(exe,mtime) 缓存,商店跳转桩/shim 自然出局);探测轨迹含被拒原因,成功 也回填。arch 只记录不门禁(跨进程可借模拟层跑异架构,承诺由作者 platforms 声明)——此定案同步回 设计文档 §8.2。 - ScriptedEntryGate / SettingsRegistry:deps:"pip" 已声明未实现(Skipped 非 Failed);五条 scripted.* 设置键(M0 仅存储,运行时页 UI 随 M1)。 - tools/build-runtime-package:pack-runtime.ps1(下载 / 自带树 -FromDir、布局归一、.tlx+sha256)+ runtime-sources.json(3.12.7 / 20.17.0 各格,缺官方产物的格子明写绕行)+ README。 - 测试:31 项(spec 解析/区间、四级优先级、spoof 落空、包序、缓存、门矩阵、管线协议门/未知 type/互斥声明);全程 FakeProber,绝不 spawn 真解释器。与 ExtensionManager.LoadResults 静态表相关 的类圈进 ExtensionLoadResults 串行 collection 并逐条自清理(修 ExtensionList 空安装断言的跨类污染)。 - manifest-variants 三 QA 包:v1-scripted-py-format / v1-scripted-unknown-type / v1-scripted-protocol-high(拖 .tlx 即可肉眼验三条文案)。 --- TuneLab/Configs/SettingsRegistry.cs | 24 ++ TuneLab/Extensions/ExtensionInfo.cs | 22 +- TuneLab/Extensions/ExtensionManager.cs | 54 ++- TuneLab/Extensions/ExtensionManifest.cs | 15 + TuneLab/Extensions/Scripted/RuntimeLocator.cs | 377 ++++++++++++++++++ TuneLab/Extensions/Scripted/RuntimeSpec.cs | 112 ++++++ .../Extensions/Scripted/ScriptedEntryGate.cs | 56 +++ TuneLab/Extensions/Scripted/WireProtocol.cs | 12 + docs/scripted-plugin-sidecar-design.md | 10 +- .../ExtensionListCommandTests.cs | 2 + tests/TuneLab.Tests/ScriptedEntryGateTests.cs | 106 +++++ .../ScriptedLoadPipelineTests.cs | 102 +++++ .../ScriptedRuntimeLocatorTests.cs | 113 ++++++ .../TuneLab.Tests/ScriptedRuntimeSpecTests.cs | 64 +++ tests/TuneLab.Tests/ScriptedTestSupport.cs | 34 ++ .../v1-scripted-protocol-high/manifest.json | 11 + .../v1-scripted-py-format/manifest.json | 13 + .../v1-scripted-py-format/plugin/main.py | 3 + .../v1-scripted-unknown-type/manifest.json | 9 + tools/build-runtime-package/README.md | 29 ++ tools/build-runtime-package/pack-runtime.ps1 | 94 +++++ .../runtime-sources.json | 36 ++ 22 files changed, 1289 insertions(+), 9 deletions(-) create mode 100644 TuneLab/Extensions/Scripted/RuntimeLocator.cs create mode 100644 TuneLab/Extensions/Scripted/RuntimeSpec.cs create mode 100644 TuneLab/Extensions/Scripted/ScriptedEntryGate.cs create mode 100644 TuneLab/Extensions/Scripted/WireProtocol.cs create mode 100644 tests/TuneLab.Tests/ScriptedEntryGateTests.cs create mode 100644 tests/TuneLab.Tests/ScriptedLoadPipelineTests.cs create mode 100644 tests/TuneLab.Tests/ScriptedRuntimeLocatorTests.cs create mode 100644 tests/TuneLab.Tests/ScriptedRuntimeSpecTests.cs create mode 100644 tests/TuneLab.Tests/ScriptedTestSupport.cs create mode 100644 tests/manifest-variants/v1-scripted-protocol-high/manifest.json create mode 100644 tests/manifest-variants/v1-scripted-py-format/manifest.json create mode 100644 tests/manifest-variants/v1-scripted-py-format/plugin/main.py create mode 100644 tests/manifest-variants/v1-scripted-unknown-type/manifest.json create mode 100644 tools/build-runtime-package/README.md create mode 100644 tools/build-runtime-package/pack-runtime.ps1 create mode 100644 tools/build-runtime-package/runtime-sources.json diff --git a/TuneLab/Configs/SettingsRegistry.cs b/TuneLab/Configs/SettingsRegistry.cs index 34f73978..cf21a814 100644 --- a/TuneLab/Configs/SettingsRegistry.cs +++ b/TuneLab/Configs/SettingsRegistry.cs @@ -175,6 +175,29 @@ internal static class SettingsRegistry // 而不是让人去反复开关命令桥(那个开关只管连不连得上)。 + "It is also the ceiling for the command bridge: an external tool (the tunelab CLI, an MCP server) never gets more than this, whatever it asks for."); + // ── 脚本运行时(sidecar;docs/scripted-plugin-sidecar-design.md §8)——M0 起仅存储, + // "设置 → 扩展 → 运行时"页与下载按钮随 M1 挂 UI;现在可经 agent set_setting / 手改 settings.json / + // 环境变量(TUNELAB_PYTHON 等)设定。值是解释器可执行文件路径,分号分隔多候选(按序核验,首个真跑通过的赢)。 + public static readonly SettingItem ScriptedRuntimePython = Str("ScriptedRuntimePython", null, + "Scripted Runtime: Python", TextBoxConfig.Create(), string.Empty, + description: "Absolute path(s) of the Python interpreter for scripted plugins (semicolon-separated candidates). " + + "Probing order: this setting, then TUNELAB_PYTHON, then installed runtime packages, then PATH."); + public static readonly SettingItem ScriptedRuntimeNode = Str("ScriptedRuntimeNode", null, + "Scripted Runtime: Node.js", TextBoxConfig.Create(), string.Empty, + description: "Absolute path(s) of the Node.js executable for scripted plugins (semicolon-separated candidates). " + + "Probing order: this setting, then TUNELAB_NODE, then installed runtime packages, then PATH."); + public static readonly SettingItem ScriptedRuntimeLua = Str("ScriptedRuntimeLua", null, + "Scripted Runtime: Lua", TextBoxConfig.Create(), string.Empty, + description: "Absolute path(s) of the Lua interpreter for scripted plugins (semicolon-separated candidates; use TUNELAB_LUAJIT for the luajit flavor). " + + "Probing order: this setting, then TUNELAB_LUA / TUNELAB_LUAJIT, then installed runtime packages, then PATH."); + public static readonly SettingItem ScriptedAutoFetchRuntime = Bool("ScriptedAutoFetchRuntime", null, + "Auto Fetch Scripted Runtimes", CheckBoxConfig.Create(false), false, + description: "When on, a missing interpreter for a scripted plugin is downloaded automatically (checksum-pinned runtime package); " + + "when off, the sidebar offers a one-click download that asks first. Offline / air-gapped: keep it off and provide the interpreter yourself."); + public static readonly SettingItem ScriptedRuntimeSource = Str("ScriptedRuntimeSource", null, + "Scripted Runtime Download Source", TextBoxConfig.Create(), string.Empty, + description: "Override base URL for runtime package downloads (mirrors / intranet). Checksum verification still applies; empty means the official release feed."); + // 全部条目——顺序 = 【设置窗行序】(tab 分组、组内重要项在前),是单一受控顺序源: // 设置窗 All.Where(Tab==tab) 渲染、agent list_settings 同序。末尾是仅存储的孤儿设置(无 tab、不渲染)。 // 磁盘 JSON 键随此顺序写出(分组排列)——键顺序无语义、按键名加载,不影响兼容。 @@ -190,6 +213,7 @@ internal static class SettingsRegistry ParameterBoundaryExtension, ParameterSyncMode, AutoGeneratePronunciation, // 仅存储(无设置窗行) AutoScrollTarget, AgentModelProvider, AgentAuthorization, + ScriptedRuntimePython, ScriptedRuntimeNode, ScriptedRuntimeLua, ScriptedAutoFetchRuntime, ScriptedRuntimeSource, ]; // ── 工厂 + 转换器 ── diff --git a/TuneLab/Extensions/ExtensionInfo.cs b/TuneLab/Extensions/ExtensionInfo.cs index 8420cc75..4e3adaae 100644 --- a/TuneLab/Extensions/ExtensionInfo.cs +++ b/TuneLab/Extensions/ExtensionInfo.cs @@ -65,9 +65,12 @@ namespace TuneLab.Extensions; // 曾经是候选类【数组】、由宿主扫描认领,那是为了让一个 format 条目容纳导入类 + 导出类; // 但"宿主替作者猜哪个类"与"方向是声明不是推断"相悖,且两个类共用一个设置桶必然牺牲其中 // 一份 schema。故收敛成:两份实现就写两个条目,各自一份设置。 -// assembly —— 含该实现类的程序集(相对包文件夹的路径);资源类省略。 -// assemblies —— 仅 Legacy 老 schema 顶层使用(盲扫候选 dll);V1 条目改用单数 assembly。 -// platforms —— 平台过滤(同一包内不同插件可各自声明)。 + // assembly —— 含该实现类的程序集(相对包文件夹的路径);资源类省略。 + // assemblies —— 仅 Legacy 老 schema 顶层使用(盲扫候选 dll);V1 条目改用单数 assembly。 + // platforms —— 平台过滤(同一包内不同插件可各自声明)。 + // runtime/entry/deps —— 脚本条目(sidecar,见 scripted-plugin-sidecar-design.md §2):runtime 非空即 + // 声明"进程外实现"(flavor+版本区间,如 "python>=3.10,<4"),entry 指包内入口脚本; + // 与 class/assembly【互斥】(同"声明即事实"纪律),deps 缺省 vendored、"pip" 已声明未实现。 internal class ExtensionInfo { public string type { get; set; } = string.Empty; @@ -159,6 +162,19 @@ public string[] EffectiveIdentitySuffixes [JsonIgnore] public string EffectiveClass => (entryClass ?? string.Empty).Trim(); + // —— 脚本条目(sidecar;docs/scripted-plugin-sidecar-design.md §2)—— + // 判别只看 runtime(entry 缺失由校验报 Failed,不在判别里吞错)。 + public string? runtime { get; set; } + + // 包内相对路径的入口脚本(main.py / index.js / main.lua);与 runtime 成对出现。 + public string? entry { get; set; } + + // 依赖策略:"vendored"(缺省)| "pip"(已声明未实现 → Skipped)。 + public string? deps { get; set; } + + [JsonIgnore] + public bool IsScripted => !string.IsNullOrEmpty(runtime); + // —— 显示名(可翻译,独立于身份 id)—— public string? name { get; set; } diff --git a/TuneLab/Extensions/ExtensionManager.cs b/TuneLab/Extensions/ExtensionManager.cs index 2affbaa3..d1475d38 100644 --- a/TuneLab/Extensions/ExtensionManager.cs +++ b/TuneLab/Extensions/ExtensionManager.cs @@ -17,6 +17,7 @@ using TuneLab.Extensions.Instruments; using TuneLab.Extensions.Effect; using TuneLab.Extensions.Agent; +using TuneLab.Extensions.Scripted; namespace TuneLab.Extensions; // 扩展统一加载管线:发现 → 读 manifest 判代际 → 校验 → V1 per-folder ALC 加载 / Legacy fallback → 实例化。 @@ -191,6 +192,27 @@ static void LoadV1(string path, ExtensionManifest description) } } + // ── 校验:wire 协议兼容门(脚本 sidecar 专用第三轴,见 scripted-plugin-sidecar-design.md §1.2)。 + // 资源包/纯 .NET 包不写此字段,零影响;写了就必须落在宿主支持面内——高于即握手前拒, + // 与 sdk-version 门同款语义("用了新协议 × 老宿主"是唯一合法不兼容方向)。── + if (description.protocolVersion.HasValue) + { + if (description.protocolVersion.Value < 1) + { + result.Status = ExtensionLoadStatus.Failed; + result.Error = string.Format("Invalid protocol-version '{0}'", description.protocolVersion.Value); + Log.Error(string.Format("Extension {0}: invalid protocol-version '{1}'", description.name, description.protocolVersion.Value)); + return; + } + if (description.protocolVersion.Value > WireProtocol.Version) + { + result.Status = ExtensionLoadStatus.Skipped; + result.Error = string.Format("Requires extension protocol {0}, host provides {1}", description.protocolVersion.Value, WireProtocol.Version); + Log.Warning(string.Format("Extension {0} skipped: requires extension protocol {1}, host provides {2}", description.name, description.protocolVersion.Value, WireProtocol.Version)); + return; + } + } + // ── 加载:per-folder ALC,遍历归一化后的各 extension ── PluginLoadContext? alc = null; int loaded = 0, failed = 0, skipped = 0, disabled = 0; @@ -241,13 +263,14 @@ static void LoadV1(string path, ExtensionManifest description) { // 但【声明了代码】的未知 type 不是资源包,而是本宿主还不支持的插件类型(如只在别的分支/ // 更高版本存在的 kind)。资源类 type 是开放集,宿主无从区分二者——除了这个判据:资源包 - // 不写 assembly/classes。缺了它就会把这种包静默登记成 loaded,侧栏显示"已加载"而其代码 + // 不写 assembly/classes,也不写 runtime/entry(脚本声明同样是"有代码",M0 起)。 + // 缺了它就会把这种包静默登记成 loaded,侧栏显示"已加载"而其代码 // 一行没跑,比报错更误导。故如实报跳过。 - if (!string.IsNullOrEmpty(ext.assembly) || ext.EffectiveClass.Length > 0) + if (!string.IsNullOrEmpty(ext.assembly) || ext.EffectiveClass.Length > 0 || ext.IsScripted) { skipped++; var unsupported = string.Format( - "unsupported extension type '{0}': the entry declares code (assembly/class) but this host has no such plugin kind" + "unsupported extension type '{0}': the entry declares code (assembly/class or runtime/entry) but this host has no such plugin kind" + " (supported: format / voice / instrument / effect; see docs/plugin-development.md)", string.IsNullOrEmpty(type) ? "(empty)" : type); entry.Status = ExtensionEntryStatus.Skipped; @@ -261,6 +284,31 @@ static void LoadV1(string path, ExtensionManifest description) continue; } + // ── 脚本条目(sidecar;docs/scripted-plugin-sidecar-design.md):走加载门而非 ALC ── + // M0:结构校验 + runtime 探测,结局恒为如实 Skipped(sidecar 注册随 M1 替换本支的"RuntimeReady")。 + // 与程序集路径平行、互斥(runtime 声明即排除 class/assembly),注册表/路由/设置零感知。 + if (ext.IsScripted) + { + var gate = ScriptedEntryGate.Evaluate(ext, path); + if (gate.Status == ScriptedGateStatus.Skipped) + { + skipped++; + entry.Status = ExtensionEntryStatus.Skipped; + entry.Error = gate.Message; + reasons.Add(string.Format("{0}: {1}", IdentityLabel(ext, kind), gate.Message)); + Log.Warning(string.Format("Extension {0}: {1}: {2}", description.name, IdentityLabel(ext, kind), gate.Message)); + } + else + { + failed++; + entry.Status = ExtensionEntryStatus.Failed; + entry.Error = gate.Message; + reasons.Add(string.Format("{0}: {1}", IdentityLabel(ext, kind), gate.Message)); + Log.Error(string.Format("Extension {0}: {1}: {2}", description.name, IdentityLabel(ext, kind), gate.Message)); + } + continue; + } + try { // manifest 内联身份:直接定位条目声明的单个程序集(不再盲扫目录)。 diff --git a/TuneLab/Extensions/ExtensionManifest.cs b/TuneLab/Extensions/ExtensionManifest.cs index a463b25f..44336ffb 100644 --- a/TuneLab/Extensions/ExtensionManifest.cs +++ b/TuneLab/Extensions/ExtensionManifest.cs @@ -29,6 +29,21 @@ internal class ExtensionManifest : ExtensionInfo [JsonPropertyName("sdk-version")] public string? sdkVersion { get; set; } + // —— 脚本条目(sidecar;docs/scripted-plugin-sidecar-design.md)—— + // wire 协议版本(独立于 sdk-version 的第三轴,§1.2)。高于宿主 WireProtocol.Version ⇒ 握手前 + // Skipped(与 sdk-version 门同款语义)。缺省 = 本宿主最新轴,向后兼容(既有包不可能声明不存在的轴)。 + [JsonPropertyName("protocol-version")] + public int? protocolVersion { get; set; } + + // —— type:"runtime" 资源包(§8.4):解释器分发单元 —— + // 零代码(无 assembly/class/runtime/entry),宿主只登记;RuntimeLocator 扫描消费。 + // 老宿主对未知 type 一律当资源包,天然向后兼容。 + public string? flavor { get; set; } + + // runtime 包的精确解释器版本(如 "3.12.7")。与基类 version(包自身发布版本)是两根轴,勿混。 + [JsonPropertyName("runtime-version")] + public string? runtimeVersion { get; set; } + public ExtensionInfo[] extensions { get; set; } = []; // 缺当前语言或缺字段则回退基础 description。 diff --git a/TuneLab/Extensions/Scripted/RuntimeLocator.cs b/TuneLab/Extensions/Scripted/RuntimeLocator.cs new file mode 100644 index 00000000..53a24756 --- /dev/null +++ b/TuneLab/Extensions/Scripted/RuntimeLocator.cs @@ -0,0 +1,377 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using TuneLab.Configs; + +namespace TuneLab.Extensions.Scripted; + +// 一次真跑核验的结果(成功时带 RuntimeInfo;失败时 reason 是人话,进探测轨迹)。 +internal readonly record struct ProbeOutcome(RuntimeInfo? Info, string? Reason); + +internal interface IRuntimeProber +{ + // exe + 额外参数(py launcher 用 ["-3"],普通候选为 null)。超时/坏输出 = 核验不过(防商店跳转桩与 shim)。 + ProbeOutcome Probe(string exe, string[]? extraArgs, string flavor); +} + +// 解释器定位(设计文档 §8.1 四级探测 + §8.2 真跑核验)。加载期纯发现,不 spawn sidecar。 +// 测试注入假 prober;生产用 ProcessProber(真跑、2s 超时、结果按 (exe,mtime) 缓存)。 +internal sealed class RuntimeLocator +{ + public static readonly RuntimeLocator Default = new(new ProcessProber()); + + readonly IRuntimeProber mProber; + readonly Func mSettingsPython; + readonly Func mSettingsNode; + readonly Func mSettingsLua; + readonly Func mEnv; + readonly Func mExtensionsRoot; + readonly Dictionary mCache = new(); + readonly object mLock = new(); + + public RuntimeLocator(IRuntimeProber prober, + Func? settingsPython = null, Func? settingsNode = null, Func? settingsLua = null, + Func? env = null, Func? extensionsRoot = null) + { + mProber = prober; + mSettingsPython = settingsPython ?? (() => SettingsRegistry.ScriptedRuntimePython.Property.Value); + mSettingsNode = settingsNode ?? (() => SettingsRegistry.ScriptedRuntimeNode.Property.Value); + mSettingsLua = settingsLua ?? (() => SettingsRegistry.ScriptedRuntimeLua.Property.Value); + mEnv = env ?? Environment.GetEnvironmentVariable; + mExtensionsRoot = extensionsRoot ?? (() => PathManager.ExtensionsFolder); + } + + public const string EnvPython = "TUNELAB_PYTHON"; + public const string EnvNode = "TUNELAB_NODE"; + public const string EnvLua = "TUNELAB_LUA"; + public const string EnvLuaJit = "TUNELAB_LUAJIT"; + + // 探测 env/settings 串的候选分隔符(多候选按序尝试)。 + static readonly char[] sSemicolon = [';']; + + // 返回 true 时 info 有效;false 时 trail = 多行轨迹(每级一行,含"为什么不算"),直接进 Skipped 文案。 + public bool TryResolve(RuntimeSpec spec, out RuntimeInfo info, out string trail) + { + var sb = new StringBuilder(); + info = default; + trail = string.Empty; + + // ① 设置显式路径 + var settingsValue = spec.Flavor switch + { + "python" => mSettingsPython(), + "node" => mSettingsNode(), + _ => mSettingsLua(), + }; + var candidates = Split(settingsValue); + if (candidates.Count == 0) + sb.Append(string.Format(CultureInfo.InvariantCulture, "settings ({0}: empty); ", SettingsKeyFor(spec.Flavor))); + else if (TryEach(candidates, null, spec, out info, sb, "settings")) + return Accept(sb, out trail); + + // ② 环境变量(多候选) + var envName = spec.Flavor switch { "python" => EnvPython, "node" => EnvNode, "luajit" => EnvLuaJit, _ => EnvLua }; + candidates = Split(mEnv(envName)); + if (candidates.Count == 0) + sb.Append(string.Format(CultureInfo.InvariantCulture, "env {0} (unset); ", envName)); + else if (TryEach(candidates, null, spec, out info, sb, "env")) + return Accept(sb, out trail); + + // ③ runtime 资源包(type:"runtime",平台过滤 + 区间匹配;§8.4) + var packageCandidates = EnumerateRuntimePackageExes(spec.Flavor, mExtensionsRoot()); + if (packageCandidates.Count == 0) + sb.Append("installed runtime packages (none matching); "); + else if (TryEach(packageCandidates, null, spec, out info, sb, "runtime package")) + return Accept(sb, out trail); + + // ④ 系统 PATH 候选名(Windows python 先试 py launcher)。net8 无 Environment.SearchPathForExecutable, + // 自备 ResolveFromPath(PATH 目录 × 名字,Windows 再走 PATHEXT)。 + if (spec.Flavor == "python" && OperatingSystem.IsWindows()) + { + if (TryProbeResolved("py", ["-3"], spec, out info, sb, "PATH 'py -3'")) + return Accept(sb, out trail); + } + if (TryEach(PathCandidates(spec.Flavor), null, spec, out info, sb, "PATH")) + return Accept(sb, out trail); + + trail = sb.ToString().TrimEnd(' ', ';'); + return false; + } + + // 成功出口也回填 trail(含"被拒候选 + 最终接受者",供日志/侧栏 tooltip 完整还原因果)。 + static bool Accept(StringBuilder sb, out string trail) + { + trail = sb.ToString().TrimEnd(' ', ';'); + return true; + } + + bool TryEach(List candidates, string[]? extraArgs, RuntimeSpec spec, out RuntimeInfo info, StringBuilder sb, string source) + { + foreach (var c in candidates) + { + if (TryProbeResolved(c, extraArgs, spec, out info, sb, source)) + return true; + } + info = default; + return false; + } + + bool TryProbeResolved(string exe, string[]? extraArgs, RuntimeSpec spec, out RuntimeInfo info, StringBuilder sb, string source) + { + var resolved = Path.IsPathRooted(exe) ? exe : ResolveFromPath(exe); + if (resolved == null || !File.Exists(resolved)) + { + info = default; + sb.Append(string.Format(CultureInfo.InvariantCulture, "{0} '{1}' rejected: not found; ", source, exe)); + return false; + } + if (!ProbeCached(resolved, extraArgs, spec.Flavor, out var outcome)) + { + info = default; + sb.Append(string.Format(CultureInfo.InvariantCulture, "{0} '{1}' rejected: {2}; ", source, Path.GetFileName(resolved), outcome.Reason)); + return false; + } + info = outcome.Info!.Value; + if (!spec.SatisfiedBy(info.Version)) + { + sb.Append(string.Format(CultureInfo.InvariantCulture, "{0} '{1}' rejected: version {2} does not satisfy {3}; ", source, Path.GetFileName(resolved), info.Version, spec.Describe())); + return false; + } + sb.Append(string.Format(CultureInfo.InvariantCulture, "{0} '{1}' accepted ({2}).", source, Path.GetFileName(resolved), info.Version)); + return true; + } + + // 核验缓存:键 = exe 完整路径 + extraArgs + 文件 mtime(换版即失效重跑)。 + bool ProbeCached(string exe, string[]? extraArgs, string flavor, out ProbeOutcome outcome) + { + long mtime; + try { mtime = File.GetLastWriteTimeUtc(exe).Ticks; } + catch { mtime = 0; } + var key = string.Concat(exe, "|", flavor, "|", string.Join(",", extraArgs ?? []), "|", mtime.ToString(CultureInfo.InvariantCulture)); + lock (mLock) + { + if (mCache.TryGetValue(key, out outcome)) + return outcome.Info != null; + } + outcome = mProber.Probe(exe, extraArgs, flavor); + lock (mLock) + mCache[key] = outcome; + return outcome.Info != null; + } + + static List Split(string? value) + => string.IsNullOrEmpty(value) ? [] : value.Split(sSemicolon, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + + internal static string SettingsKeyFor(string flavor) + => flavor switch { "python" => nameof(SettingsRegistry.ScriptedRuntimePython), "node" => nameof(SettingsRegistry.ScriptedRuntimeNode), _ => nameof(SettingsRegistry.ScriptedRuntimeLua) }; + + static string? ResolveFromPath(string name) + { + if (Path.IsPathRooted(name)) + return File.Exists(name) ? name : null; + var pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var exts = OperatingSystem.IsWindows() + ? (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.BAT;.CMD").Split(';', StringSplitOptions.RemoveEmptyEntries) + : new[] { "" }; + foreach (var dirRaw in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + string dir; + try { dir = Path.GetFullPath(dirRaw.Trim()); } catch { continue; } + if (!Directory.Exists(dir)) + continue; + foreach (var ext in exts) + { + var candidate = Path.Combine(dir, name + ext); + if (File.Exists(candidate)) + return candidate; + } + } + return null; + } + + static List PathCandidates(string flavor) => flavor switch + { + "python" => ["python", "python3"], + "node" => ["node"], + "lua" => OperatingSystem.IsWindows() ? ["lua", "lua54"] : ["lua5.4", "lua"], + "luajit" => ["luajit", "luajit-5.1"], + _ => [], + }; + + // ③ 级:扫已装 runtime 资源包(type:"runtime" + flavor 对 + 平台含当前 RID + 区间满足),高版本优先。 + // 宽容解析:坏 manifest / 缺字段的包跳过并记 warning(不影响其他包)。public 供 M1 下载按钮查重。 + public static List EnumerateRuntimePackageExes(string flavor, string extensionsRoot) + { + var list = new List<(string Exe, Version V)>(); + foreach (var pkg in EnumerateRuntimePackages(extensionsRoot)) + { + if (!string.Equals(pkg.Flavor, flavor, StringComparison.OrdinalIgnoreCase)) + continue; + var exe = ResolvePackageExe(pkg.Directory, flavor); + if (exe == null) + continue; + list.Add((exe, pkg.Version)); + } + return list.OrderByDescending(x => x.V).Select(x => x.Exe).ToList(); + } + + internal readonly record struct InstalledRuntimePackage(string Directory, string Id, string Flavor, Version Version); + + public static List EnumerateRuntimePackages(string extensionsRoot) + { + var result = new List(); + if (!Directory.Exists(extensionsRoot)) + return result; + foreach (var dir in Directory.GetDirectories(extensionsRoot)) + { + var manifestPath = Path.Combine(dir, "manifest.json"); + if (!File.Exists(manifestPath)) + continue; + try + { + using var stream = File.OpenRead(manifestPath); + var m = JsonSerializer.Deserialize(stream); + if (m == null || !string.Equals(m.type, "runtime", StringComparison.OrdinalIgnoreCase)) + continue; + if (!m.IsPlatformAvailable()) + continue; + var flavor = (m.flavor ?? string.Empty).Trim().ToLowerInvariant(); + if (flavor is not ("python" or "node" or "lua" or "luajit")) + continue; + // runtime-version 缺失/畸形 = 不是可用的 runtime 包(勿把空串喂成 "0.0" 版本参与竞争)。 + var versionText = (m.runtimeVersion ?? string.Empty).Trim(); + if (versionText.Length == 0 || !Version.TryParse(NormalizeVersion(versionText), out var v)) + continue; + result.Add(new InstalledRuntimePackage(dir, m.id ?? Path.GetFileName(dir), flavor, v)); + } + catch + { + // 坏包:本视图宽容跳过(LoadV1 主循环会如实报它的加载错误,两处判据不冲突)。 + } + } + return result; + } + + // runtime 包 exe 布局(pack-runtime 工具产出,见 tools/build-runtime-package/README.md): + // win:runtime\;unix:runtime\bin\。 + static string? ResolvePackageExe(string packageDir, string flavor) + { + string[] names = flavor switch + { + "python" => ["python.exe", "python3", "python"], + "node" => ["node.exe", "node"], + "lua" => ["lua.exe", "lua54", "lua", "lua5.4"], + "luajit" => ["luajit.exe", "luajit"], + _ => [], + }; + foreach (var name in names) + { + var root = Path.Combine(packageDir, "runtime", name); + if (File.Exists(root)) + return root; + var bin = Path.Combine(packageDir, "runtime", "bin", name); + if (File.Exists(bin)) + return bin; + } + return null; + } + + internal static string NormalizeVersion(string? s) + { + if (string.IsNullOrEmpty(s)) + return "0.0"; + var cut = s.IndexOfAny(['-', '+', ' ']); + return cut < 0 ? s : s[..cut]; + } +} + +// 生产 prober:真跑核验(§8.2:固定参数、2s 超时、stdout 两要素)。跑不过即"不算"—— +// 商店跳转桩(exit 9009 无输出)、nvm/fnm shim(坏输出)在这层自然出局。 +internal sealed class ProcessProber : IRuntimeProber +{ + const int TimeoutMs = 2000; + + public ProbeOutcome Probe(string exe, string[]? extraArgs, string flavor) + { + (string[]? Args, Func? Parser) probe = flavor switch + { + "python" => (new[] { "-c", "import sys,platform;sys.stdout.write('.'.join(map(str,sys.version_info[:3]))+chr(10)+platform.machine())" }, (Func)ParsePython), + "node" => (new[] { "-p", "process.version.slice(1)+'\\n'+process.arch" }, (Func)ParseNode), + "lua" or "luajit" => (new[] { "-e", "local v=_VERSION:gsub('^Lua%s+','') local j=jit and jit.version and jit.version:gsub('^[^%d]-','') or '' if j~='' then io.write(j..'|luajit') else io.write(v..'|lua') end" }, (Func)ParseLua), + _ => (null, null), + }; + var args = probe.Args; + var parser = probe.Parser; + if (args == null || parser == null) + return new(null, "unknown flavor"); + try + { + var psi = new ProcessStartInfo(exe) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + }; + if (extraArgs != null) + foreach (var a in extraArgs) + psi.ArgumentList.Add(a); + foreach (var a in args) + psi.ArgumentList.Add(a); + + using var proc = Process.Start(psi); + if (proc == null) + return new(null, "failed to start"); + var stdout = proc.StandardOutput.ReadToEnd(); + if (!proc.WaitForExit(TimeoutMs)) + { + try { proc.Kill(true); } catch { } + return new(null, "probe timed out (2s)"); + } + if (proc.ExitCode != 0) + return new(null, string.Format(CultureInfo.InvariantCulture, "probe exited with code {0}", proc.ExitCode)); + var (version, detectedFlavor) = parser(stdout.Trim()); + if (version.Length == 0) + return new(null, "unrecognized probe output"); + // luajit 冒充 lua(_VERSION=5.1)时按 flavor 区间自然裁决;flavor=="luajit" 必须是 luajit。 + if (flavor == "luajit" && detectedFlavor != "luajit") + return new(null, string.Format(CultureInfo.InvariantCulture, "'{0}' is {1}, not luajit", Path.GetFileName(exe), detectedFlavor)); + var arch = flavor is "python" or "node" ? ArchOf(stdout) : ""; + return new(new RuntimeInfo(exe, detectedFlavor.Length > 0 ? detectedFlavor : flavor, version, arch), null); + } + catch (Exception ex) + { + return new(null, ex.Message); + } + } + + static (string, string) ParsePython(string output) + { + var lines = output.Split('\n'); + return lines.Length >= 1 ? (RuntimeLocator.NormalizeVersion(lines[0].Trim()), "python") : ("", ""); + } + + static (string, string) ParseNode(string output) + { + var lines = output.Split('\n'); + return lines.Length >= 1 ? (RuntimeLocator.NormalizeVersion(lines[0].Trim()), "node") : ("", ""); + } + + static (string, string) ParseLua(string output) + { + var parts = output.Split('|'); + return parts.Length == 2 ? (RuntimeLocator.NormalizeVersion(parts[0].Trim()), parts[1].Trim()) : ("", ""); + } + + static string ArchOf(string output) + { + var lines = output.Split('\n'); + return lines.Length >= 2 ? lines[1].Trim() : ""; + } +} diff --git a/TuneLab/Extensions/Scripted/RuntimeSpec.cs b/TuneLab/Extensions/Scripted/RuntimeSpec.cs new file mode 100644 index 00000000..b4badfca --- /dev/null +++ b/TuneLab/Extensions/Scripted/RuntimeSpec.cs @@ -0,0 +1,112 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace TuneLab.Extensions.Scripted; + +// 脚本插件已发现的运行时信息(探测结果;设计文档 §8.2)。 +// Arch 仅作呈现/诊断记录(跨进程子进程可借模拟层跑异架构,如 win-arm64 跑 x64 python), +// 门禁看 flavor + 版本区间与 manifest `platforms`,不按 arch 拒载。 +internal readonly record struct RuntimeInfo(string Exe, string Flavor, string Version, string Arch); + +// manifest `runtime` 字段的结构:flavor(python|node|lua|luajit)+ 逗号分隔的版本约束 +// (">=3.10,<4";操作符 >= > <= < == =,版本 1–3 段,缺段补 0)。解析失败回错误串,供 Failed 文案。 +internal readonly record struct RuntimeSpec(string Flavor, Version Min, bool MinInclusive, Version Max, bool MaxInclusive) +{ + // 裸 flavor("python",无约束)合法;约束段整体可空。交替序讲究:luajit 必须排在 lua 之前, + // 否则正则前缀会吞成 "lua" + 残留 "jit>=…"(交错缺陷,测试 NormalizesDeclaration 钉住)。 + static readonly Regex sFlavorPrefix = new(@"^(python|node|luajit|lua)\s*(.*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + static readonly Regex sConstraint = new(@"^(>=|<=|==|>|<|=)\s*(\d+(?:\.\d+){0,2})$", RegexOptions.Compiled); + + public bool HasMin { get; init; } + public bool HasMax { get; init; } + + public bool SatisfiedBy(string versionString) + { + if (!TryParseVersion(versionString, out var v)) + return false; + if (HasMin && (v < Min || (!MinInclusive && v == Min))) + return false; + if (HasMax && (v > Max || (!MaxInclusive && v == Max))) + return false; + return true; + } + + // 人话描述("python>=3.10,<4"),供 Skipped/Failed 文案回显声明。 + public string Describe() + { + var s = Flavor.ToLowerInvariant(); + if (HasMin) + s += (MinInclusive ? ">=" : ">") + Min.ToString(3); + if (HasMax) + s += (HasMin ? "," : "") + (MaxInclusive ? "<=" : "<") + Max.ToString(3); + return s; + } + + // 解析 "python>=3.10,<4" 之类。失败:ok=false + error(作者可见的人话)。 + public static bool TryParse(string? text, out RuntimeSpec spec, out string? error) + { + spec = default; + error = null; + if (string.IsNullOrWhiteSpace(text)) + { + error = "'runtime' must not be empty (expected e.g. \"python>=3.10\")"; + return false; + } + var m = sFlavorPrefix.Match(text.Trim()); + if (!m.Success) + { + error = string.Format(CultureInfo.InvariantCulture, + "malformed 'runtime' '{0}': expected with optional comma-separated constraints; " + + "flavors = python | node | lua | luajit, operators = >= > <= < == = (e.g. \"python>=3.10,<4\")", text); + return false; + } + var flavor = m.Groups[1].Value.ToLowerInvariant(); + var min = new Version(0, 0); var minInc = true; var hasMin = false; + var max = new Version(int.MaxValue, 0); var maxInc = true; var hasMax = false; + var rest = m.Groups[2].Value.Trim(); + if (rest.Length > 0) + { + foreach (var token in rest.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var c = sConstraint.Match(token); + if (!c.Success || !TryParseVersion(c.Groups[2].Value, out var v)) + { + error = string.Format(CultureInfo.InvariantCulture, "bad constraint '{0}' in 'runtime' (expected , op one of >= > <= < == =)", token); + return false; + } + switch (c.Groups[1].Value) + { + case ">=": if (!hasMin || v > min) { min = v; minInc = true; } hasMin = true; break; + case ">": if (!hasMin || v > min) { min = v; minInc = false; } hasMin = true; break; + case "<=": if (!hasMax || v < max) { max = v; maxInc = true; } hasMax = true; break; + case "<": if (!hasMax || v < max) { max = v; maxInc = false; } hasMax = true; break; + case "=": + case "==": + min = v; minInc = true; hasMin = true; + max = v; maxInc = true; hasMax = true; + break; + } + } + } + spec = new RuntimeSpec(flavor, min, minInc, max, maxInc) { HasMin = hasMin, HasMax = hasMax }; + return true; + } + + static bool TryParseVersion(string s, out Version v) + { + var parts = s.Trim().Split('.'); + var nums = new int[3]; + for (int i = 0; i < parts.Length && i < 3; i++) + { + if (!int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out nums[i])) + { + v = new Version(0, 0); + return false; + } + } + v = new Version(nums[0], nums[1], nums[2]); + return true; + } +} diff --git a/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs b/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs new file mode 100644 index 00000000..bb21bf74 --- /dev/null +++ b/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; + +namespace TuneLab.Extensions.Scripted; + +internal enum ScriptedGateStatus { Skipped, Failed } + +// 脚本条目加载门(M0):结构校验 + deps 模式 + runtime 四级探测;M1 把"RuntimeReady"一支换成 sidecar 注册。 +// 纯函数式判给(不 mutate 结果对象),ExtensionManager 据 Status 记账(skipped/failed)并回填 entry.Error。 +// 错误文案 = 作者可见的人话(英文,日志/异常约定)。 +internal static class ScriptedEntryGate +{ + public readonly record struct Result(ScriptedGateStatus Status, string Message); + + // locator 参数为测试注入点(假 prober/假扩展根,绝不在单测里跑真解释器);生产走 Default。 + public static Result Evaluate(ExtensionInfo ext, string packageDir, RuntimeLocator? locator = null) + { + locator ??= RuntimeLocator.Default; + // —— 结构:runtime 与 entry 成对;与 class/assembly 互斥("声明即事实",不静默择一)—— + var entry = (ext.entry ?? string.Empty).Trim(); + if (entry.Length == 0) + return new(ScriptedGateStatus.Failed, "'runtime' declares a scripted entry but 'entry' (the in-package script path) is missing"); + if (!string.IsNullOrEmpty(ext.assembly) || ext.EffectiveClass.Length > 0) + return new(ScriptedGateStatus.Failed, "an entry declares both 'runtime'/'entry' (scripted, out-of-process) and 'class'/'assembly' (in-process ALC); pick one"); + + // entry 必须是包内相对路径(防 .. 逃逸)且文件存在——缺了就是包打错了,如实报。 + var fullPath = Path.GetFullPath(Path.Combine(packageDir, entry)); + var root = Path.GetFullPath(packageDir) + Path.DirectorySeparatorChar; + if (!fullPath.StartsWith(root, StringComparison.Ordinal)) + return new(ScriptedGateStatus.Failed, string.Format("'entry' must be a path inside the package folder, got '{0}'", entry)); + if (!File.Exists(fullPath)) + return new(ScriptedGateStatus.Failed, string.Format("'entry' script not found in the package: '{0}'", entry)); + + // —— deps 模式(§12.2):vendored 隐式;pip 已声明未实现(Skipped,非错误)—— + var deps = (ext.deps ?? string.Empty).Trim().ToLowerInvariant(); + if (deps.Length > 0 && deps != "vendored") + { + if (deps == "pip") + return new(ScriptedGateStatus.Skipped, "'deps': \"pip\" mode is declared but not implemented by this host build (author-vendored dependencies only for now)"); + return new(ScriptedGateStatus.Failed, string.Format("'deps': unknown mode '{0}' (supported: vendored)", deps)); + } + + // —— runtime 声明与四级探测(§8.1/§8.2)—— + if (!RuntimeSpec.TryParse(ext.runtime, out var spec, out var specError)) + return new(ScriptedGateStatus.Failed, specError!); + if (!locator.TryResolve(spec, out var info, out var trail)) + return new(ScriptedGateStatus.Skipped, string.Format( + "no matching runtime: {0}. To provide one, install a 'runtime' package (type \"runtime\"), set '{1}' in settings, or put a matching interpreter on PATH.", + trail, RuntimeLocator.SettingsKeyFor(spec.Flavor))); + + // M0 终点:runtime 就位也不加载(sidecar 加载器随 M1 落地)——如实 Skipped,不谎报 Loaded。 + return new(ScriptedGateStatus.Skipped, string.Format( + "scripted sidecar loading is not implemented by this host build yet (wire protocol up to {0}); runtime resolved: {1} {2} at '{3}'.", + WireProtocol.Version, info.Flavor, info.Version, info.Exe)); + } +} diff --git a/TuneLab/Extensions/Scripted/WireProtocol.cs b/TuneLab/Extensions/Scripted/WireProtocol.cs new file mode 100644 index 00000000..253aeba4 --- /dev/null +++ b/TuneLab/Extensions/Scripted/WireProtocol.cs @@ -0,0 +1,12 @@ +namespace TuneLab.Extensions.Scripted; + +// 脚本 sidecar 的 wire 协议版本轴(设计文档 docs/scripted-plugin-sidecar-design.md §1.2)。 +// 三条版本轴各管各的:AssemblyVersion(.NET 绑定,钉死 2.0.0.0)、SdkVersion(.NET 插件编译面)、 +// ProtocolVersion(本轴:manifest `protocol-version` × 宿主 + sidecar 握手)。互不对齐、互不换算。 +// +// 提档纪律(仿 sdk-api-evolution §0 的"一个动作、两处台账"):帧/方法表有不兼容改动时,此常量 +// 与 scripting/wire/v1.json(发布 M1 起)同批提档;加性新方法不提档。 +internal static class WireProtocol +{ + public const int Version = 1; +} diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md index 7c7354a0..42a11c57 100644 --- a/docs/scripted-plugin-sidecar-design.md +++ b/docs/scripted-plugin-sidecar-design.md @@ -1,6 +1,7 @@ # 脚本插件 Sidecar 系统设计(Python / Node.js / Lua) -> 状态:**定稿待评审**。基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 +> 状态:**定稿;M0 已落地**(manifest 脚本字段、`WireProtocol` 门、`RuntimeLocator` 四级探测+核验、 +> `ScriptedEntryGate`、runtime 资源包工具链、31 项单测)。基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 > 配套阅读:[plugin-development.md](plugin-development.md)(V1 插件契约)、[sdk-api-evolution.md](sdk-api-evolution.md)(冻结 ABI 纪律)、[naming-glossary.md](naming-glossary.md)。 > 本文只设计**宿主侧与协议侧**;`TuneLab.SDK` / `TuneLab.Foundation` 一行不动(§1.3)。 @@ -318,10 +319,13 @@ effect 同族:`effect.create_session {context绑定: 输入段几何+参数快 ### 8.2 真跑核验(每一格都要过) -候选以固定参数起进程一次,读版本+架构(`sys.version`/`platform.machine()`;`process.arch`; +候选以固定参数起进程一次,读版本与 flavor(`sys.version`/`platform.machine()`;`process.arch`; `_VERSION` + `jit` 全局),超时 2s;结果按 `(exe路径, mtime)` 缓存。**防 spoof 硬要求**: Windows `WindowsApps\python.exe` 商店跳转桩、nvm/fnm shim——跑不过核验即换下一候选。 -版本/arch 不满足条目区间 ⇒ `Skipped: requires python>=3.10, found 3.8`(人话,同 `sdk-version` 门文案风格)。 +**版本**不满足条目 `runtime` 区间 ⇒ `Skipped: requires python>=3.10, found 3.8`(人话,同 `sdk-version` 门文案风格)。 +**arch 只记录、不作门禁**(实现定案):跨进程子进程可借 OS 模拟层跑异架构(win-arm64 跑 x64 python、 +Rosetta 跑 x64 node),拒载反而挡死了合法组合;架构承诺由作者侧 `platforms` 字段声明、宿主 platforms 门执行。 +探测轨迹(每级候选 + 被拒原因 + 最终接受者)随成功/失败都回填,进侧栏 tooltip。 ### 8.3 flavor 语义 diff --git a/tests/TuneLab.Tests/ExtensionListCommandTests.cs b/tests/TuneLab.Tests/ExtensionListCommandTests.cs index 89cb3e08..96385956 100644 --- a/tests/TuneLab.Tests/ExtensionListCommandTests.cs +++ b/tests/TuneLab.Tests/ExtensionListCommandTests.cs @@ -12,6 +12,8 @@ namespace TuneLab.Tests; // 这份清单的价值几乎全在那些【否定性事实】上:装了但被关掉、加载了但被别的包顶替、有摘要但那是 // 转述不是作者原话。它们一旦被"顺手统一"掉,调用方就会理直气壮地向用户保证一个本次运行根本不存在 // 的能力,故逐条钉住。 +// 读进程级静态表 ExtensionManager.LoadResults,与写它的 ScriptedLoadPipelineTests 串行(同 ActionRegistry 范式)。 +[Collection("ExtensionLoadResults")] public class ExtensionListCommandTests { static readonly ExtensionListCommand Command = new(); diff --git a/tests/TuneLab.Tests/ScriptedEntryGateTests.cs b/tests/TuneLab.Tests/ScriptedEntryGateTests.cs new file mode 100644 index 00000000..2358d473 --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedEntryGateTests.cs @@ -0,0 +1,106 @@ +using System.Collections.Generic; +using System.IO; +using TuneLab.Extensions; +using TuneLab.Extensions.Scripted; +using Xunit; + +namespace TuneLab.Tests; + +// 脚本条目加载门(M0)的裁决矩阵(设计文档 §2/§8):结构性错误 Failed、能力缺失/未实现 Skipped, +// 一切文案必须是作者看得懂的人话。 +public class ScriptedEntryGateTests +{ + readonly string mPkg = Path.Combine(Path.GetTempPath(), "tl-scripted-gate-" + Path.GetRandomFileName()); + + ExtensionInfo Entry(string runtime, string? entry = "plugin/main.py", string? deps = null, + string? cls = null, string? assembly = null) + { + Directory.CreateDirectory(Path.Combine(mPkg, "plugin")); + if (entry != null) + File.WriteAllText(Path.Combine(mPkg, entry.Replace('/', Path.DirectorySeparatorChar)), ""); + return new ExtensionInfo { type = "format", runtime = runtime, entry = entry, deps = deps, entryClass = cls, assembly = assembly }; + } + + static RuntimeLocator EmptyLocator() + => new(new FakeProber(), () => "", () => "", () => "", _ => null, () => Path.GetTempPath()); + + [Fact] + public void MissingEntryFileIsFailed() + { + var ext = Entry("python>=3.10"); + ext.entry = null; + Assert.Equal(ScriptedGateStatus.Failed, ScriptedEntryGate.Evaluate(ext, mPkg, EmptyLocator()).Status); + Assert.Contains("'entry'", ScriptedEntryGate.Evaluate(ext, mPkg, EmptyLocator()).Message); + } + + [Fact] + public void MixingAlcAndScriptedDeclarationsIsFailed() + { + var r = ScriptedEntryGate.Evaluate(Entry("python>=3.10", cls: "X.Y", assembly: "a.dll"), mPkg, EmptyLocator()); + Assert.Equal(ScriptedGateStatus.Failed, r.Status); + Assert.Contains("pick one", r.Message); + } + + [Fact] + public void EntryEscapingPackageIsFailed() + { + var r = ScriptedEntryGate.Evaluate(Entry("python>=3.10", entry: "../outside.py"), mPkg, EmptyLocator()); + Assert.Equal(ScriptedGateStatus.Failed, r.Status); + Assert.Contains("inside the package folder", r.Message); + } + + [Fact] + public void EntryFileAbsentOnDiskIsFailed() + { + var ext = Entry("python>=3.10"); + File.Delete(Path.Combine(mPkg, "plugin", "main.py")); + var r = ScriptedEntryGate.Evaluate(ext, mPkg, EmptyLocator()); + Assert.Equal(ScriptedGateStatus.Failed, r.Status); + Assert.Contains("not found", r.Message); + } + + [Fact] + public void PipDepsModeIsSkippedNotFailed() + { + var r = ScriptedEntryGate.Evaluate(Entry("python>=3.10", deps: "pip"), mPkg, EmptyLocator()); + Assert.Equal(ScriptedGateStatus.Skipped, r.Status); + Assert.Contains("not implemented", r.Message); + } + + [Fact] + public void UnknownDepsModeIsFailed() + { + var r = ScriptedEntryGate.Evaluate(Entry("python>=3.10", deps: "apt"), mPkg, EmptyLocator()); + Assert.Equal(ScriptedGateStatus.Failed, r.Status); + } + + [Fact] + public void MalformedRuntimeSpecIsFailed() + { + var r = ScriptedEntryGate.Evaluate(Entry("ruby>=3"), mPkg, EmptyLocator()); + Assert.Equal(ScriptedGateStatus.Failed, r.Status); + Assert.Contains("malformed 'runtime'", r.Message); + } + + [Fact] + public void RuntimeNotFoundIsSkippedWithActionableTrail() + { + var r = ScriptedEntryGate.Evaluate(Entry("python>=99.0"), mPkg, EmptyLocator()); + Assert.Equal(ScriptedGateStatus.Skipped, r.Status); + Assert.Contains("no matching runtime", r.Message); + Assert.Contains("install a 'runtime' package", r.Message.ToLowerInvariant()); + } + + [Fact] + public void ResolvedRuntimeStillSkippedInM0ButReportsResolution() + { + Directory.CreateDirectory(mPkg); + var prober = new FakeProber(); + var exe = FakeProber.FakeExe(mPkg, "python.exe", prober, "python", "3.12.7"); + var locator = new RuntimeLocator(prober, () => exe.Exe, () => "", () => "", _ => null, () => mPkg); + var r = ScriptedEntryGate.Evaluate(Entry("python>=3.10"), mPkg, locator); + Assert.Equal(ScriptedGateStatus.Skipped, r.Status); // M0 的诚实:runtime 就位也不谎报 Loaded + Assert.Contains("not implemented by this host build yet", r.Message); + Assert.Contains("3.12.7", r.Message); + } +} diff --git a/tests/TuneLab.Tests/ScriptedLoadPipelineTests.cs b/tests/TuneLab.Tests/ScriptedLoadPipelineTests.cs new file mode 100644 index 00000000..511271f1 --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedLoadPipelineTests.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using TuneLab.Extensions; +using Xunit; + +namespace TuneLab.Tests; + +// 脚本字段进完整加载管线(ExtensionManager.Load)后的结局(设计文档 §2.1 校验次序、§1.2 协议门)。 +// 只用结构错误一支——它在 runtime 探测【之前】落判,故与机器上的解释器实况无关(确定性)。 +// 与一切读 ExtensionManager.LoadResults(进程级静态表)的测试串行,且逐条自清理—— +// ExtensionListCommandTests 的"空安装"断言依赖这张表,脏了就是跨类串扰(同 ActionRegistry 范式)。 +[Collection("ExtensionLoadResults")] +public class ScriptedLoadPipelineTests : IDisposable +{ + // 进程级静态表 ExtensionManager.LoadResults 的串行域(定义在此,成员:本类 + ExtensionListCommandTests)。 + [CollectionDefinition("ExtensionLoadResults")] + public sealed class ExtensionLoadResultsCollection { } + + readonly List mAdded = []; + + ExtensionLoadResult LoadManifest(string id, object manifest) + => LoadRaw("pipe-" + id.Replace('.', '_'), JsonSerializer.Serialize(manifest)); + + public void Dispose() + { + var list = (List)ExtensionManager.LoadResults; + foreach (var r in mAdded) + list.Remove(r); + } + + [Fact] + public void ScriptedEntryMixingAlcFieldsFailsThroughThePipeline() + { + var result = LoadManifest("com.test.scriptmix", new + { + id = "com.test.scriptmix", name = "mix", version = "1.0.0", type = "voice", engine = "MIX", + @class = "X.Y", assembly = "missing.dll", runtime = "python>=3.10", entry = "main.py", + }); + Assert.Equal(ExtensionLoadStatus.Failed, result.Status); + Assert.Contains("'runtime'/'entry'", result.Error); + Assert.Single(result.Entries); + Assert.Equal(ExtensionEntryStatus.Failed, result.Entries[0].Status); + } + + ExtensionLoadResult LoadRaw(string tag, string json) + { + var dir = Path.Combine(Path.GetTempPath(), "tl-scripted-" + tag + "-" + Path.GetRandomFileName()); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "manifest.json"), json); + var before = ExtensionManager.LoadResults.Count; + ExtensionManager.Load(dir); + var result = ExtensionManager.LoadResults.Last(r => r.DirectoryPath == dir); + for (int i = before; i < ExtensionManager.LoadResults.Count; i++) + mAdded.Add(ExtensionManager.LoadResults[i]); + return result; + } + + [Fact] + public void ProtocolVersionGateUsesExactJsonPropertyName() + { + // manifest 键是连字符的 "protocol-version",匿名类型写不出来 → 手写 JSON。 + var result = LoadRaw("protocol", """ + { + "id": "com.test.proto99", "name": "proto99", "version": "1.0.0", + "protocol-version": 99, + "type": "format", "suffixes": ["p99"], "class": "X.Y", "assembly": "missing.dll" + } + """); + Assert.Equal(ExtensionLoadStatus.Skipped, result.Status); + Assert.Contains("Requires extension protocol 99, host provides 1", result.Error); + } + + [Fact] + public void InvalidProtocolVersionFails() + { + var result = LoadRaw("protocolbad", """ + { + "id": "com.test.protozero", "name": "protozero", "version": "1.0.0", + "protocol-version": 0, + "type": "format", "suffixes": ["pz0"], "class": "X.Y", "assembly": "missing.dll" + } + """); + Assert.Equal(ExtensionLoadStatus.Failed, result.Status); + Assert.Contains("Invalid protocol-version", result.Error); + } + + [Fact] + public void UnknownTypeDeclaringRuntimeIsNotSwallowedAsResource() + { + var result = LoadRaw("unknownscripted", """ + { + "id": "com.test.weirdscripted", "name": "weird", "version": "1.0.0", + "type": "sfx-scripted", "runtime": "node>=18", "entry": "main.js" + } + """); + Assert.Equal(ExtensionLoadStatus.Skipped, result.Status); + Assert.Contains("declares code (assembly/class or runtime/entry)", result.Error); + } +} diff --git a/tests/TuneLab.Tests/ScriptedRuntimeLocatorTests.cs b/tests/TuneLab.Tests/ScriptedRuntimeLocatorTests.cs new file mode 100644 index 00000000..9ad8ddd3 --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedRuntimeLocatorTests.cs @@ -0,0 +1,113 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using TuneLab.Extensions.Scripted; +using Xunit; + +namespace TuneLab.Tests; + +// 四级探测(settings → env → runtime 资源包 → PATH)+ 真跑核验 + 缓存(设计文档 §8.1/§8.2)。 +// 全程 FakeProber / 假扩展根;不碰真解释器与真 %APPDATA%。 +public class ScriptedRuntimeLocatorTests +{ + readonly string mRoot = Path.Combine(Path.GetTempPath(), "tl-scripted-locator-" + Path.GetRandomFileName()); + + static RuntimeLocator Make(FakeProber prober, string settingsPython = "", string settingsNode = "", string settingsLua = "", + IReadOnlyDictionary? env = null, string extensionsRoot = "") + => new(prober, () => settingsPython, () => settingsNode, () => settingsLua, + name => env != null && env.TryGetValue(name, out var v) ? v : null, + () => extensionsRoot); + + [Fact] + public void SettingsBeatsEnv() + { + Directory.CreateDirectory(mRoot); + var prober = new FakeProber(); + var fromSettings = FakeProber.FakeExe(mRoot, "pyA.exe", prober, "python", "3.11.0"); + var fromEnv = FakeProber.FakeExe(mRoot, "pyB.exe", prober, "python", "3.12.7"); + Assert.True(RuntimeSpec.TryParse("python>=3.10", out var spec, out _)); + var locator = Make(prober, settingsPython: fromSettings.Exe, + env: new Dictionary { [RuntimeLocator.EnvPython] = fromEnv.Exe }); + + Assert.True(locator.TryResolve(spec, out var info, out _)); + Assert.Equal(fromSettings.Exe, info.Exe); + Assert.Equal(1, prober.ProbeCount); + } + + [Fact] + public void SpoofedCandidateFallsThroughToNext() + { + Directory.CreateDirectory(mRoot); + var prober = new FakeProber(); + var good = FakeProber.FakeExe(mRoot, "node_real.exe", prober, "node", "20.11.0"); + var stub = Path.Combine(mRoot, "node_stub.exe"); + File.WriteAllText(stub, "microsoft store redirect stub"); // 在盘上,但表里没有 → 真跑核验 9009 + var locator = Make(prober, settingsNode: stub, + env: new Dictionary { [RuntimeLocator.EnvNode] = good.Exe }); + Assert.True(locator.TryResolve(TryParseOrThrow("node>=18"), out var info, out var trail)); + Assert.Equal(good.Exe, info.Exe); + Assert.Contains("rejected", trail); + Assert.Contains("9009", trail); + } + + [Fact] + public void PackagesAreOrderedNewestFirst() + { + Directory.CreateDirectory(mRoot); + var exts = Path.Combine(mRoot, "exts"); + WriteRuntimePackage(Path.Combine(exts, "py-3112"), "a.py3112", "python", "3.11.2"); + WriteRuntimePackage(Path.Combine(exts, "py-3127"), "a.py3127", "python", "3.12.7"); + WriteRuntimePackage(Path.Combine(exts, "broken"), "a.broken", "python", null); + var exes = RuntimeLocator.EnumerateRuntimePackageExes("python", exts); + Assert.Collection(exes, + e => Assert.Contains("py-3127", e), + e => Assert.Contains("py-3112", e)); + } + + [Fact] + public void ProbeResultsAreCachedByExePath() + { + Directory.CreateDirectory(mRoot); + var prober = new FakeProber(); + var exe = FakeProber.FakeExe(mRoot, "lua54.exe", prober, "lua", "5.4.6"); + var locator = Make(prober, settingsLua: exe.Exe); + var spec = TryParseOrThrow("lua>=5.4"); + Assert.True(locator.TryResolve(spec, out _, out _)); + Assert.True(locator.TryResolve(spec, out _, out _)); + Assert.Equal(1, prober.ProbeCount); + } + + [Fact] + public void UnsatisfiableRangeRejectsEveryCandidateAndTrailsWhy() + { + Directory.CreateDirectory(mRoot); + var prober = new FakeProber(); + var exe = FakeProber.FakeExe(mRoot, "py.exe", prober, "python", "3.8.10"); + var locator = Make(prober, settingsPython: exe.Exe); + Assert.False(locator.TryResolve(TryParseOrThrow("python>=3.10"), out _, out var trail)); + Assert.Contains("does not satisfy", trail); + } + + static RuntimeSpec TryParseOrThrow(string s) + { + Assert.True(RuntimeSpec.TryParse(s, out var spec, out _)); + return spec; + } + + static string WriteRuntimePackage(string dir, string id, string flavor, string? version) + { + Directory.CreateDirectory(dir); + var manifest = new Dictionary + { + ["id"] = id, ["name"] = id, ["type"] = "runtime", ["flavor"] = flavor, ["version"] = "1.0.0", + }; + if (version != null) + manifest["runtime-version"] = version; + File.WriteAllText(Path.Combine(dir, "manifest.json"), JsonSerializer.Serialize(manifest)); + // 有 exe 才算可用包(ResolvePackageExe 探 runtime/ 或 runtime/bin/)。 + var exeDir = Path.Combine(dir, "runtime"); + Directory.CreateDirectory(exeDir); + File.WriteAllText(Path.Combine(exeDir, OperatingSystem.IsWindows() ? "python.exe" : "python"), "fake"); + return dir; + } +} diff --git a/tests/TuneLab.Tests/ScriptedRuntimeSpecTests.cs b/tests/TuneLab.Tests/ScriptedRuntimeSpecTests.cs new file mode 100644 index 00000000..4324ad6e --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedRuntimeSpecTests.cs @@ -0,0 +1,64 @@ +using TuneLab.Extensions.Scripted; +using Xunit; + +namespace TuneLab.Tests; + +// manifest `runtime` 串的解析与区间判定(设计文档 §8.1)。 +public class ScriptedRuntimeSpecTests +{ + [Fact] + public void ParsesFlavorWithConstraintList() + { + Assert.True(RuntimeSpec.TryParse("python>=3.10,<4", out var spec, out var error)); + Assert.Null(error); + Assert.Equal("python", spec.Flavor); + Assert.True(spec.HasMin); + Assert.True(spec.HasMax); + Assert.Equal("python>=3.10.0,<4.0.0", spec.Describe()); + } + + [Theory] + [InlineData("3.12.7", true)] + [InlineData("3.10", true)] + [InlineData("4.0", false)] // 上界开 + [InlineData("3.9.9", false)] + public void RangeMembership(string version, bool expected) + { + Assert.True(RuntimeSpec.TryParse("python>=3.10,<4", out var spec, out _)); + Assert.Equal(expected, spec.SatisfiedBy(version)); + } + + [Fact] + public void BareFlavorMeansUnconstrained() + { + Assert.True(RuntimeSpec.TryParse("node", out var spec, out _)); + Assert.False(spec.HasMin); + Assert.False(spec.HasMax); + Assert.True(spec.SatisfiedBy("20.11.0")); + } + + [Fact] + public void EqualityConstraintIsExactOnThreeParts() + { + Assert.True(RuntimeSpec.TryParse("lua==5.4", out var spec, out _)); + Assert.True(spec.SatisfiedBy("5.4")); + Assert.False(spec.SatisfiedBy("5.5")); + } + + [Theory] + [InlineData("")] + [InlineData("ruby>=3")] // 未知 flavor + [InlineData("python~3")] // 未知操作符 + [InlineData("python>=x.y")] // 坏版本 + public void MalformedIsRejectedWithString(string text) + { + Assert.False(RuntimeSpec.TryParse(text, out _, out var error)); + Assert.False(string.IsNullOrEmpty(error)); + } + + [Theory] + [InlineData("PYTHON >= 3.10", true)] // 大小写与空白宽容 + [InlineData("luajit>=2.1,<2.2", true)] + public void NormalizesDeclaration(string text, bool expected) + => Assert.Equal(expected, RuntimeSpec.TryParse(text, out _, out _)); +} diff --git a/tests/TuneLab.Tests/ScriptedTestSupport.cs b/tests/TuneLab.Tests/ScriptedTestSupport.cs new file mode 100644 index 00000000..88693bcf --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedTestSupport.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections.Generic; +using System.IO; +using TuneLab.Extensions.Scripted; + +namespace TuneLab.Tests; + +// 假 prober:按 exe 完整路径查表出"版本/flavor/arch",表外一律核验失败(模拟商店桩/shim 出局)。 +// 单测绝不 spawn 真解释器(设计文档 §12.0 M0 验收:编译面不碰解释器真链)。 +internal sealed class FakeProber : IRuntimeProber +{ + public Dictionary Table { get; } = new(StringComparer.OrdinalIgnoreCase); + public int ProbeCount { get; private set; } + + public void Add(string exe, string flavor, string version) + => Table[exe] = new RuntimeInfo(exe, flavor, version, "x64"); + + public ProbeOutcome Probe(string exe, string[]? extraArgs, string flavor) + { + ProbeCount++; + return Table.TryGetValue(exe, out var info) + ? new ProbeOutcome(info, null) + : new ProbeOutcome(null, "probe exited with code 9009 (stub or shim)"); + } + + public static RuntimeInfo FakeExe(string dir, string name, FakeProber prober, string flavor, string version) + { + Directory.CreateDirectory(dir); + var path = Path.Combine(dir, name); + File.WriteAllText(path, "fake-interpreter"); + prober.Add(path, flavor, version); + return new RuntimeInfo(path, flavor, version, "x64"); + } +} diff --git a/tests/manifest-variants/v1-scripted-protocol-high/manifest.json b/tests/manifest-variants/v1-scripted-protocol-high/manifest.json new file mode 100644 index 00000000..9bcf709f --- /dev/null +++ b/tests/manifest-variants/v1-scripted-protocol-high/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "com.tunelab.test.scriptedproto", + "name": "V1 Scripted Protocol Too High", + "version": "1.0.0", + "description": "protocol-version 高于宿主(1) → 预期包级 Skipped: Requires extension protocol 99, host provides 1(条目循前置,runtime 探测根本不跑)。", + "protocol-version": 99, + "type": "format", + "suffixes": ["tlproto"], + "runtime": "python>=3.10", + "entry": "plugin/main.py" +} diff --git a/tests/manifest-variants/v1-scripted-py-format/manifest.json b/tests/manifest-variants/v1-scripted-py-format/manifest.json new file mode 100644 index 00000000..707cb9bb --- /dev/null +++ b/tests/manifest-variants/v1-scripted-py-format/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "com.tunelab.test.scriptedpy", + "name": "V1 Scripted Python Format", + "version": "1.0.0", + "author": "TuneLab Tests", + "description": "脚本 format 条目(runtime+entry)。预期(M0):真跑核验 Python——探不到则 Skipped: no matching runtime(探测轨迹全文);探得到则 Skipped: sidecar loading not implemented yet。绝不谎报 Loaded。", + "sdk-version": "1.1", + "type": "format", + "suffixes": ["tlscripthi"], + "runtime": "python>=3.10,<4", + "entry": "plugin/main.py", + "platforms": ["win", "osx", "linux"] +} diff --git a/tests/manifest-variants/v1-scripted-py-format/plugin/main.py b/tests/manifest-variants/v1-scripted-py-format/plugin/main.py new file mode 100644 index 00000000..9f85f60c --- /dev/null +++ b/tests/manifest-variants/v1-scripted-py-format/plugin/main.py @@ -0,0 +1,3 @@ +# Placeholder: the sidecar loader lands in M1 (docs/scripted-plugin-sidecar-design.md §12). +# Until then this file only has to EXIST — the gate checks the manifest structure, not this code. +print("tunelab scripted test entry: not runnable before M1") diff --git a/tests/manifest-variants/v1-scripted-unknown-type/manifest.json b/tests/manifest-variants/v1-scripted-unknown-type/manifest.json new file mode 100644 index 00000000..504e54f0 --- /dev/null +++ b/tests/manifest-variants/v1-scripted-unknown-type/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "com.tunelab.test.scriptedunknown", + "name": "V1 Scripted Unknown Type", + "version": "1.0.0", + "description": "未知 type + runtime 声明 → 预期 Skipped: unsupported extension type(声明了代码即非资源包,不静默吞)。", + "type": "sfx-scripted", + "runtime": "node>=18", + "entry": "main.js" +} diff --git a/tools/build-runtime-package/README.md b/tools/build-runtime-package/README.md new file mode 100644 index 00000000..b79aa7d4 --- /dev/null +++ b/tools/build-runtime-package/README.md @@ -0,0 +1,29 @@ +# build-runtime-package + +Assembles TuneLab "runtime" resource packages (`.tlx`, manifest `type: "runtime"` + `flavor` ++ `runtime-version` + `platforms`) that scripted extensions (Python / Node.js / Lua sidecar — +see `docs/scripted-plugin-sidecar-design.md` §8.4) probe against. + + pwsh tools/build-runtime-package/pack-runtime.ps1 -Flavor python -Version 3.12.7 -Rid win-x64 + +- Downloads the archive named in `runtime-sources.json`, flattens it under `runtime/`, writes + the manifest, zips to `dist/runtime---.tlx` and emits a `.sha256` sidecar. +- The sha256 must be pinned into the release notes / the host's download map (semi-auto fetch + verifies it; §8.6). Release checklist: pack per cell of the matrix, upload the `.tlx` + hash + to the GitHub release, bump the host map. + +## Layout contract + +`RuntimeLocator.ResolvePackageExe` looks for `runtime/` (win) or `runtime/bin/` (unix). +`runtime-sources.json` entries carry `layout` (`root` | `prefix`) to normalize; verify the warning +about a missing exe before publishing. + +## Cells we do NOT ship from python.org / nodejs.org + +- `python win-arm64`: no official embeddable before 3.14 → use python-build-standalone (uv) builds. +- `python win-x86` / `node win-x86`: no official binaries at all — the cell stays open for + user-supplied trees (`-FromDir`) and other flavors (Lua is C89-portable and can fill it). +- `lua` / `luajit`: no official zip anywhere; build from source and pack with `-FromDir`. + +`-FromDir ` is exactly the "user-supplied compiled runtime" mount (§8.6): same layout +contract, same sha256-on-publish discipline if you distribute it. diff --git a/tools/build-runtime-package/pack-runtime.ps1 b/tools/build-runtime-package/pack-runtime.ps1 new file mode 100644 index 00000000..26d2944f --- /dev/null +++ b/tools/build-runtime-package/pack-runtime.ps1 @@ -0,0 +1,94 @@ +# Assemble an official TuneLab "runtime" resource package (.tlx + sha256). +# +# pwsh tools/build-runtime-package/pack-runtime.ps1 -Flavor python -Version 3.12.7 -Rid win-x64 +# -> downloads from runtime-sources.json, extracts into runtime/, zips to dist/runtime-win-x64-python-3.12.7.tlx +# +# pwsh tools/build-runtime-package/pack-runtime.ps1 -Flavor lua -Version 5.4.6 -Rid win-x86 -FromDir C:\built\lua-5.4.6 +# -FromDir skips the network: point it at a prepared tree (a self-compiled interpreter counts — +# design doc 8.6; the tree must expose runtime/ or runtime/bin/ after packing). +# +# Layout contract (RuntimeLocator.ResolvePackageExe): win: runtime\ unix: runtime/bin/ +# -layout root : archive already has python.exe / node.exe at its root -> copy straight in +# -layout prefix : archive wraps one top dir (python/, node-vX/) -> flatten it +param( + [Parameter(Mandatory)][ValidateSet('python','node','lua','luajit')][string]$Flavor, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$Rid, + [string]$FromDir = '', + [string]$MapPath = (Join-Path $PSScriptRoot 'runtime-sources.json'), + [string]$OutDir = (Join-Path $PSScriptRoot 'dist') +) +$ErrorActionPreference = 'Stop' + +$pkgName = "runtime-$Rid-$Flavor-$Version" +$stage = Join-Path ([IO.Path]::GetTempPath()) "tunelab-runtime-$pkgName-$([Guid]::NewGuid().ToString('N'))" +$runtime = Join-Path $stage 'runtime' +$manifest = Join-Path $stage 'manifest.json' +New-Item -ItemType Directory -Force -Path $runtime | Out-Null + +$srcExe = $FromDir +if (-not $srcExe) { + $map = Get-Content $MapPath -Raw | ConvertFrom-Json + $entry = $map.$Flavor.versions.$Version.$Rid + if (-not $entry -or -not $entry.url) { throw "no download for $Flavor $Version on $Rid; pass -FromDir with a prepared tree" } + $archive = Join-Path ([IO.Path]::GetTempPath()) "tunelab-dl-$pkgName" + Write-Host "downloading $($entry.url)" + Invoke-WebRequest -Uri $entry.url -OutFile $archive + $extracted = Join-Path ([IO.Path]::GetTempPath()) "tunelab-x-$pkgName-$([Guid]::NewGuid().ToString('N'))" + if ($archive -match '\.zip$') { + Expand-Archive -LiteralPath $archive -DestinationPath $extracted + } elseif ($archive -match '\.tar\.(gz|xz)$') { + New-Item -ItemType Directory -Force -Path $extracted | Out-Null + tar -xf $archive -C $extracted + if ($LASTEXITCODE) { throw "tar failed: $LASTEXITCODE" } + } else { throw "unsupported archive type" } + if ($entry.layout -eq 'prefix') { + $top = Get-ChildItem -LiteralPath $extracted + if (@($top).Count -ne 1 -or $top.PSIsContainer -eq $false) { throw "expected a single top-level dir in archive" } + $srcExe = $top.FullName + } else { + $srcExe = $extracted + } +} + +Copy-Item -Path (Join-Path $srcExe '*') -Destination $runtime -Recurse -Force + +# exe visibility per platform (RuntimeLocator tries runtime/ then runtime/bin/) +$plat = $Rid.Split('-')[0] +$exeName = switch ($Flavor) { + 'python' { if ($plat -eq 'win') { 'python.exe' } else { 'python3' } } + 'node' { if ($plat -eq 'win') { 'node.exe' } else { 'node' } } + 'lua' { if ($plat -eq 'win') { 'lua.exe' } else { 'lua' } } + 'luajit' { if ($plat -eq 'win') { 'luajit.exe' } else { 'luajit' } } +} +$found = Get-ChildItem -Path $runtime -Recurse | Where-Object { $_.Name -in @($exeName, [IO.Path]::GetFileNameWithoutExtension($exeName), 'python', 'python3') } | Select-Object -First 1 +if (-not $found) { Write-Warning "no '$exeName' under runtime/ — verify layout before publishing" } +if ($plat -ne 'win') { + # keep unix exec bits (Copy-Item on Windows may drop them; tar path preserves) + if ($found -and $found.FullName -match 'bin') { chmod +x $found.FullName 2>$null } +} + +$manifestObj = [ordered]@{ + id = "com.tunelab.runtime.$Rid.$Flavor" + name = "TuneLab $Flavor Runtime ($Version, $Rid)" + version = "1.0.0" + author = "TuneLab" + description = "Interpreter distribution for scripted extensions ($Flavor $Version, $Rid)." + type = 'runtime' + flavor = $Flavor + 'runtime-version' = $Version + platforms = @($Rid) +} +$manifestObj | ConvertTo-Json -Depth 4 | Set-Content -Path $manifest -Encoding utf8 + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +$outTlx = Join-Path $OutDir "$pkgName.tlx" +if (Test-Path $outTlx) { Remove-Item $outTlx -Force } +Compress-Archive -Path (Join-Path $stage '*') -DestinationPath ([IO.Path]::ChangeExtension($outTlx, '.zip')) +Move-Item ([IO.Path]::ChangeExtension($outTlx, '.zip')) $outTlx +$hash = (Get-FileHash $outTlx -Algorithm SHA256).Hash.ToLowerInvariant() +Set-Content -Path "$outTlx.sha256" -Value "$hash $pkgName.tlx" -Encoding ascii + +Remove-Item -Recurse -Force $stage +Write-Host "packed $outTlx" +Write-Host "sha256 $hash" # pin this into the release notes / host download map diff --git a/tools/build-runtime-package/runtime-sources.json b/tools/build-runtime-package/runtime-sources.json new file mode 100644 index 00000000..32c7f697 --- /dev/null +++ b/tools/build-runtime-package/runtime-sources.json @@ -0,0 +1,36 @@ +{ + "python": { + "source": "python.org embeddable (win) / python-build-standalone (osx, linux)", + "versions": { + "3.12.7": { + "win-x64": { "url": "https://www.python.org/ftp/python/3.12.7/python-3.12.7-embed-amd64.zip", "layout": "root" }, + "win-arm64": { "note": "python.org ships no ARM64 embeddable before 3.14; use python-build-standalone (uv) builds for this cell", "url": "", "layout": "prefix", "exe": "python.exe" }, + "osx-x64": { "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-x86_64-apple-darwin-install_only.tar.gz", "layout": "prefix", "exe": "python3" }, + "osx-arm64": { "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-aarch64-apple-darwin-install_only.tar.gz", "layout": "prefix", "exe": "python3" }, + "linux-x64": { "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-x86_64-unknown-linux-gnu-install_only.tar.gz", "layout": "prefix", "exe": "python3" }, + "linux-arm64": { "url": "https://github.com/astral-sh/python-build-standalone/releases/download/20241016/cpython-3.12.7+20241016-aarch64-unknown-linux-gnu-install_only.tar.gz", "layout": "prefix", "exe": "python3" } + } + } + }, + "node": { + "source": "nodejs.org official distributions", + "versions": { + "20.17.0": { + "win-x64": { "url": "https://nodejs.org/dist/v20.17.0/node-v20.17.0-win-x64.zip", "layout": "prefix", "exe": "node.exe" }, + "win-arm64": { "url": "https://nodejs.org/dist/v20.17.0/node-v20.17.0-win-arm64.zip", "layout": "prefix", "exe": "node.exe" }, + "osx-x64": { "url": "https://nodejs.org/dist/v20.17.0/node-v20.17.0-darwin-x64.tar.gz", "layout": "prefix", "exe": "bin/node" }, + "osx-arm64": { "url": "https://nodejs.org/dist/v20.17.0/node-v20.17.0-darwin-arm64.tar.gz", "layout": "prefix", "exe": "bin/node" }, + "linux-x64": { "url": "https://nodejs.org/dist/v20.17.0/node-v20.17.0-linux-x64.tar.xz", "layout": "prefix", "exe": "bin/node" }, + "linux-arm64": { "url": "https://nodejs.org/dist/v20.17.0/node-v20.17.0-linux-arm64.tar.xz", "layout": "prefix", "exe": "bin/node" } + } + } + }, + "lua": { + "source": "no official binaries - build from source (C89) or wrap a distro build; see README", + "versions": {} + }, + "luajit": { + "source": "luajit/OpenResty build trees; no official zip", + "versions": {} + } +} From 35a8e590df3d5a5013375a72568107c4d8ac57c2 Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Mon, 14 Sep 2026 17:49:48 +0800 Subject: [PATCH 03/12] =?UTF-8?q?feat(scripted):=20M1=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20sidecar=20=E5=B8=A7=E5=8D=8F=E8=AE=AE=E3=80=81=E8=BF=9B?= =?UTF-8?q?=E7=A8=8B=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E3=80=81RemoteFor?= =?UTF-8?q?mat=20=E4=B8=8E=20Python=20fa=C3=A7ade=20=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计文档 §12.0-M1 落地;SDK 冻结面零改动。实现差异与推迟项已记 §12.4。 宿主侧(TuneLab\Extensions\Scripted\): - ScriptedWire:wire JSON 规范实现(tagged PropertyValue、NaN/±Infinity 一律字符串字面量 ——net8 JsonDocument 不吃裸 token——、DoubleWireConverter、PartInfo kind 判别器、Map 保序、 Multiple 拒写=封条);MapConverter 写侧属性名 bug 修(WriteStringValue→WritePropertyName)。 - WireFraming:Content-Length 帧(512MB 上限、CRLF 行、UTF-8)。 - SidecarHost:懒 spawn(python -u / node --disable-warning / dotnet exec)、initialize 握手 (entries↔provides 核对,缺位即杀且真因先落 tcs)、epoch 防尾包、pending 表、调用超时、 读者线程 LastReaderFault 诊断、stderr 64KB 尾、退避计数+5 次熔断(ResetCircuit 复位)、 优雅 shutdown→3s→KillTree;Windows Job Object(KILL_ON_JOB_CLOSE,防线 2 宿主侧)。 - ScriptedRuntimeLedger + SidecarRegistry:spawn 记台账/退出销账、启动清扫(pid+创建时间+ 路径三重匹配防 PID 复用,防线 4)挂 LoadExtensions;ShutdownAll 挂 Destroy(先脚本后 .NET)。 - RemoteFormat:IImportFormat/IExportFormat 代理;字节流走暂存文件交接(Temp\io-\), 工程数据走消息体;导出经宿主 MemoryStream 原子写约定不变。 - LoadV1 脚本支升级:format 条目 runtime 就位 → RegisterScriptedFormatEntry(镜像 RegisterFormatEntry 校验序:后缀声明、FindConflictingEntry 以 entry 路径为实现身份); voice/effect Ready 仍如实 Skipped(注册支随 M2/M3)。 协议另一侧: - scripting/wire/v1.json:规范真源(帧、方法表、值编码、生命周期自卫条款)。 - scripting/python/tunelab:纯 stdlib façade(framing、单线程 RPC 环、tagged↔native 属性双向 往返、裸 NaN/inf 出口前转字符串、stdin EOF 自杀、Linux PR_SET_PDEATHSIG、macOS ppid 轮询; @tunelab.format 装饰器,单 handler 自动绑定 manifest entry)。 - tests/ScriptedFakeSidecar:.NET 假 sidecar(多模式:normal/no-capability/no-provides/hang/ crash-on-import/echo-log),CI 免装解释器的协议封条执行体;进 .sln 与测试工程引用(只管构建序)。 测试(46 项 C# + 7 项 Python 全绿;全量 646/682——36 红为基线既有的 Setup 环境问题零回归): - 生命周期:握手/epoch=1、无 capabilities 判死、provides 缺位判死(真因优先于泛化消息)、 hang→timeout、crash→respawn epoch≥2、Shutdown 幂等+Disposed 后拒启。 - RemoteFormat:导入合成工程→ProjectInfo(含 tagged property 解回原生)、导出文件读回输出流。 - codec:全形态往返、线上文对照规范(tagged/枚举 camelCase/判别器/键名策略)、Map 保序、 NaN/Infinity 字符串字面量、Multiple 拒写、裸值宽容读。 - 真 Python E2E(tests/plugins/PyFormat,.pyt 文本格式):spawn 3.11→握手→导入→导出往返 (640ms 实测,计时下限断言钉住 vacuous-pass 风险;缺解释器默认放行,TUNELAB_REQUIRE_PYTHON=1 变红)。 样例与工具: - tests/plugins/PyFormat(源即发行物);pack-tlx.ps1 收编 tests/plugins 下无 csproj 目录。 - AGENTS.md 脚本节填实(构建/测试命令、ProtocolVersion 三轴告诫、样例免 build 规则)。 --- AGENTS.md | 26 +- TuneLab.sln | 15 + TuneLab/Extensions/ExtensionManager.cs | 71 ++- TuneLab/Extensions/Scripted/RemoteFormat.cs | 89 ++++ .../Extensions/Scripted/ScriptedEntryGate.cs | 36 +- TuneLab/Extensions/Scripted/ScriptedWire.cs | 344 +++++++++++++ TuneLab/Extensions/Scripted/SidecarHost.cs | 479 ++++++++++++++++++ .../Extensions/Scripted/SidecarRegistry.cs | 149 ++++++ TuneLab/Extensions/Scripted/WireFraming.cs | 76 +++ TuneLab/Extensions/Scripted/WireProtocol.cs | 4 + docs/scripted-plugin-sidecar-design.md | 30 +- scripting/README.md | 24 + scripting/python/pyproject.toml | 21 + scripting/python/tests/test_facade.py | 81 +++ scripting/python/tunelab/__init__.py | 22 + scripting/python/tunelab/_framing.py | 43 ++ scripting/python/tunelab/_props.py | 108 ++++ scripting/python/tunelab/_service.py | 203 ++++++++ scripting/wire/v1.json | 64 +++ tests/ScriptedFakeSidecar/Program.cs | 195 +++++++ .../TuneLab.ScriptedFakeSidecar.csproj | 14 + tests/TuneLab.Tests/ScriptedEntryGateTests.cs | 7 +- tests/TuneLab.Tests/ScriptedPythonE2ETests.cs | 85 ++++ .../ScriptedRemoteFormatTests.cs | 54 ++ .../TuneLab.Tests/ScriptedSidecarHostTests.cs | 158 ++++++ tests/TuneLab.Tests/ScriptedWireCodecTests.cs | 141 ++++++ tests/TuneLab.Tests/TuneLab.Tests.csproj | 2 + tests/pack-tlx.ps1 | 4 + tests/plugins/PyFormat/Introduction.md | 10 + tests/plugins/PyFormat/manifest.json | 14 + tests/plugins/PyFormat/plugin/main.py | 51 ++ 31 files changed, 2582 insertions(+), 38 deletions(-) create mode 100644 TuneLab/Extensions/Scripted/RemoteFormat.cs create mode 100644 TuneLab/Extensions/Scripted/ScriptedWire.cs create mode 100644 TuneLab/Extensions/Scripted/SidecarHost.cs create mode 100644 TuneLab/Extensions/Scripted/SidecarRegistry.cs create mode 100644 TuneLab/Extensions/Scripted/WireFraming.cs create mode 100644 scripting/README.md create mode 100644 scripting/python/pyproject.toml create mode 100644 scripting/python/tests/test_facade.py create mode 100644 scripting/python/tunelab/__init__.py create mode 100644 scripting/python/tunelab/_framing.py create mode 100644 scripting/python/tunelab/_props.py create mode 100644 scripting/python/tunelab/_service.py create mode 100644 scripting/wire/v1.json create mode 100644 tests/ScriptedFakeSidecar/Program.cs create mode 100644 tests/ScriptedFakeSidecar/TuneLab.ScriptedFakeSidecar.csproj create mode 100644 tests/TuneLab.Tests/ScriptedPythonE2ETests.cs create mode 100644 tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs create mode 100644 tests/TuneLab.Tests/ScriptedSidecarHostTests.cs create mode 100644 tests/TuneLab.Tests/ScriptedWireCodecTests.cs create mode 100644 tests/plugins/PyFormat/Introduction.md create mode 100644 tests/plugins/PyFormat/manifest.json create mode 100644 tests/plugins/PyFormat/plugin/main.py diff --git a/AGENTS.md b/AGENTS.md index 7551b1d7..db5ea626 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,15 +15,23 @@ `%APPDATA%\TuneLab\Extensions\\`, which is where the app actually loads from. **TuneLab must be closed** first — a running instance locks the extension dlls. -## Scripted plugins (sidecar) — planned, see docs/scripted-plugin-sidecar-design.md - -- Python/Node/Lua plugins run **out of process** (JSON-RPC over stdio); the design doc is the source of - truth until sections of it land. Façade libraries will live under `scripting/{python,node,lua}/` - (not in the .sln; per-façade test commands will be listed here when M1 lands). -- Sample scripted test plugins under `tests/plugins/` are manifest + sources only — **no csproj, no - build step**: they go straight into `tests/packages//` and are covered by pack/install steps 2–3 - above. `ProtocolVersion` (wire protocol) is a third, independent version axis — see design doc §1.2; - do not align it with `SdkVersion` or `AssemblyVersion`. +## Scripted plugins (sidecar) — see docs/scripted-plugin-sidecar-design.md + +- Python/Node/Lua plugins run **out of process** (JSON-RPC over stdio); M1 landed (format only). + Façades live under `scripting/` (not in the .sln); wire spec: `scripting/wire/v1.json`. +- Build & test (parallel to the dotnet pipeline): + - fake sidecar (protocol seal, no interpreter needed): built with the solution; the .NET test + project launches it via `dotnet exec`. Manual: `dotnet build tests/ScriptedFakeSidecar -c Debug`. + - Python façade: `python -m unittest discover -s scripting/python/tests` (pure stdlib). + - Real-interpreter E2E: `dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj --filter "runtime=needs-interpreter"` + (set `TUNELAB_REQUIRE_PYTHON=1` to fail-fast when no Python>=3.10 is discoverable, else it + passes vacuously; `TUNELAB_PYTHON=` pins the interpreter — probing order: settings → env → + runtime packages → PATH). +- Scripted test plugins (e.g. `tests/plugins/PyFormat/`) are manifest + sources only — **no csproj, + no build step**; `pack-tlx.ps1` picks csproj-less folders under `tests/plugins/` up directly. +- `ProtocolVersion` (wire protocol, `Scripted/WireProtocol.cs`) is a third, independent version axis — + do not align it with `SdkVersion` or `AssemblyVersion`. Bump it together with a new + `scripting/wire/v.json` only on incompatible frame/method changes (design doc §1.2). ## ⚠️ Frozen public ABI: TuneLab.SDK & TuneLab.Foundation diff --git a/TuneLab.sln b/TuneLab.sln index 2144c26e..29412f03 100644 --- a/TuneLab.sln +++ b/TuneLab.sln @@ -27,6 +27,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TuneLab.Setup.Stub", "TuneL EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TuneLab.Cli", "TuneLab.Cli\TuneLab.Cli.csproj", "{4E729D7F-E690-4E80-9D67-9B4C5D75C962}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TuneLab.ScriptedFakeSidecar", "tests\ScriptedFakeSidecar\TuneLab.ScriptedFakeSidecar.csproj", "{5A860E05-D48C-4A56-8C52-CBECECBC040C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -169,12 +171,25 @@ Global {4E729D7F-E690-4E80-9D67-9B4C5D75C962}.Release|x64.Build.0 = Release|Any CPU {4E729D7F-E690-4E80-9D67-9B4C5D75C962}.Release|x86.ActiveCfg = Release|Any CPU {4E729D7F-E690-4E80-9D67-9B4C5D75C962}.Release|x86.Build.0 = Release|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|x64.Build.0 = Debug|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Debug|x86.Build.0 = Debug|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|Any CPU.Build.0 = Release|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|x64.ActiveCfg = Release|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|x64.Build.0 = Release|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|x86.ActiveCfg = Release|Any CPU + {5A860E05-D48C-4A56-8C52-CBECECBC040C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {4F18DBBE-C75D-4CF2-BD32-254D31F4FD4E} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {5A860E05-D48C-4A56-8C52-CBECECBC040C} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {96245EFE-B915-471B-AC1A-8290E843A5BE} diff --git a/TuneLab/Extensions/ExtensionManager.cs b/TuneLab/Extensions/ExtensionManager.cs index d1475d38..14a31802 100644 --- a/TuneLab/Extensions/ExtensionManager.cs +++ b/TuneLab/Extensions/ExtensionManager.cs @@ -74,6 +74,8 @@ public static string GetPackageName(string packageId) public static void LoadExtensions() { PathManager.MakeSureExist(PathManager.ExtensionsFolder); + // 防遗留第 4 防线:先于一切加载,清掉上次宿主猝死留下的 sidecar 孤儿(持着包目录的文件锁)。 + SidecarRegistry.SweepStaleProcesses(); FormatsManager.LoadBuiltIn(); VoicesManager.LoadBuiltIn(); InstrumentsManager.LoadBuiltIn(); @@ -94,6 +96,9 @@ public static void LoadExtensions() public static void Destroy() { + // 脚本 sidecar 先退(§3.2:礼貌 shutdown → 限时 → Kill),再走 .NET 引擎 Destroy, + // 卸载器随后删目录时不会被活进程锁住。 + SidecarRegistry.ShutdownAll(); VoicesManager.Destroy(); InstrumentsManager.Destroy(); EffectManager.Destroy(); @@ -285,12 +290,36 @@ static void LoadV1(string path, ExtensionManifest description) } // ── 脚本条目(sidecar;docs/scripted-plugin-sidecar-design.md):走加载门而非 ALC ── - // M0:结构校验 + runtime 探测,结局恒为如实 Skipped(sidecar 注册随 M1 替换本支的"RuntimeReady")。 - // 与程序集路径平行、互斥(runtime 声明即排除 class/assembly),注册表/路由/设置零感知。 + // M1:format 条目 runtime 就位即注册 RemoteFormat(懒 spawn 的 RPC 代理,注册表零感知); + // voice/effect 的注册支随 M2/M3,此前 runtime 就位也如实 Skipped。 if (ext.IsScripted) { var gate = ScriptedEntryGate.Evaluate(ext, path); - if (gate.Status == ScriptedGateStatus.Skipped) + if (gate.Status == ScriptedGateStatus.Ready && FormatsManager.IsFormatKind(kind)) + { + if (RegisterScriptedFormatEntry(description.id ?? string.Empty, path, ext, gate.Runtime, displayName: ext.LocalizedName(lang), out var scriptedError)) + { + loaded++; + } + else + { + failed++; + entry.Status = ExtensionEntryStatus.Failed; + entry.Error = scriptedError; + reasons.Add(string.Format("{0}: {1}", IdentityLabel(ext, kind), scriptedError)); + Log.Error(string.Format("Extension {0}: {1}: {2}", description.name, IdentityLabel(ext, kind), scriptedError)); + } + } + else if (gate.Status == ScriptedGateStatus.Ready) + { + // 引擎类注册支未落地(effect=M2 / voice=M3)——如实 Skipped,不谎报。 + skipped++; + var pending = string.Format("scripted {0} registration is not implemented by this host build yet ({1})", kind, gate.Message); + entry.Status = ExtensionEntryStatus.Skipped; + entry.Error = pending; + reasons.Add(string.Format("{0}: {1}", IdentityLabel(ext, kind), pending)); + } + else if (gate.Status == ScriptedGateStatus.Skipped) { skipped++; entry.Status = ExtensionEntryStatus.Skipped; @@ -671,6 +700,42 @@ static bool RegisterFormatEntry(string packageId, ExtensionInfo ext, Assembly as return true; } + // 脚本 format 条目的注册(M1):镜像 RegisterFormatEntry 的校验次序——后缀声明、身份集、 + // 同实现禁止拆条目(脚本侧"实现"的身份键 = entry 路径)、然后交 FormatsManager。设置声明在 + // 脚本侧来自握手字段(M2 接 IExtensionSettings 代理),M1 恒 false。工厂返回包级共享的 + // RemoteFormat(无状态代理;spawn 与重启都藏在 SidecarHost 里)。 + static bool RegisterScriptedFormatEntry(string packageId, string packageDir, ExtensionInfo ext, RuntimeInfo runtime, string displayName, out string? error) + { + if (!ext.ValidateSuffixDeclaration(out error)) + return false; + + var importSuffixes = ext.EffectiveImportSuffixes; + var exportSuffixes = ext.EffectiveExportSuffixes; + bool hasImport = importSuffixes.Length > 0; + bool hasExport = exportSuffixes.Length > 0; + var identity = ext.EffectiveIdentitySuffixes; + + var entryPath = (ext.entry ?? string.Empty).Trim().Replace('\\', '/'); + var conflict = FormatsManager.FindConflictingEntry(packageId, entryPath, identity); + if (conflict != null) + { + error = string.Format( + "'entry' '{0}' is already declared by this package's format entry '{1}' with overlapping suffixes; one implementation must be one entry", + entryPath, conflict); + return false; + } + + var host = SidecarRegistry.For(packageId, packageDir, runtime, entryPath); + host.ExpectFormat(entryPath, importSuffixes, exportSuffixes); + var proxy = new RemoteFormat(host, entryPath); + FormatsManager.RegisterFormat(packageId, FormatsManager.DeriveKind(hasImport, hasExport), identity, displayName, + hasImport ? (importSuffixes, () => (IImportFormat)proxy) : null, + hasExport ? (exportSuffixes, () => (IExportFormat)proxy) : null, + declaresSettings: false, className: entryPath); + error = null; + return true; + } + // 解析条目声明的那一个入口类,并校验它实现 T 且有无参构造。任何不满足都是加载错误—— // 宿主不再"扫一遍候选挑一个",因为那等于替作者决定哪个类是这个条目的实现(见 ExtensionInfo 头注释)。 static bool TryResolveCtor(Assembly assembly, string className, out ConstructorInfo? ctor, out string? error) diff --git a/TuneLab/Extensions/Scripted/RemoteFormat.cs b/TuneLab/Extensions/Scripted/RemoteFormat.cs new file mode 100644 index 00000000..8afa8632 --- /dev/null +++ b/TuneLab/Extensions/Scripted/RemoteFormat.cs @@ -0,0 +1,89 @@ +using System; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using TuneLab.SDK; + +namespace TuneLab.Extensions.Scripted; + +// 脚本 format 条目的进程内代理(设计文档 §5.4):注册表眼里它与 in-proc IImportFormat/IExportFormat +// 不可分辨;每个方法体 = 一次 RPC(懒 spawn 由 SidecarHost 管)。 +// 字节流不进消息体:导入把源字节落到暂存文件再传路径,导出让 sidecar 写暂存文件、宿主读回 +// (FormatsManager 对输出流的"宿主拥有生命周期"约定因此原样成立)。 +internal sealed class RemoteFormat : IImportFormat, IExportFormat +{ + readonly SidecarHost mHost; + readonly string mEntryId; // = sidecar 侧注册键(握手 provides 的 id;M1 = 包内 entry 路径) + + public RemoteFormat(SidecarHost host, string entryId) + { + mHost = host; mEntryId = entryId; + } + + public ProjectInfo Deserialize(Stream stream) + { + var file = WriteTempPayload(stream); + try + { + var reply = mHost.CallAsync("format.import", new JsonObject + { + ["entry"] = mEntryId, + ["stream_ref"] = file, + }).GetAwaiter().GetResult(); + var project = reply?["project"]?.Deserialize(ScriptedWire.Options) + ?? throw new InvalidDataException("sidecar format.import reply carries no 'project'"); + return project; + } + finally + { + TryDelete(file); + } + } + + public void Serialize(Stream output, ProjectInfo info) + { + var file = NextTempPath(".out"); + try + { + var projectNode = JsonSerializer.SerializeToNode(info, ScriptedWire.Options); + mHost.CallAsync("format.export", new JsonObject + { + ["entry"] = mEntryId, + ["stream_ref"] = file, + ["project"] = projectNode, + }).GetAwaiter().GetResult(); + using var input = File.OpenRead(file); + input.CopyTo(output); + } + finally + { + TryDelete(file); + } + } + + string WriteTempPayload(Stream stream) + { + var file = NextTempPath(".in"); + using (var fs = File.Create(file)) + stream.CopyTo(fs); + return file; + } + + static string NextTempPath(string suffix) + { + var dir = Path.Combine(PathManager.TuneLabFolder, "Temp", "io-" + SidecarIo.Nonce); + Directory.CreateDirectory(dir); + return Path.Combine(dir, Guid.NewGuid().ToString("N") + suffix); + } + + static void TryDelete(string path) + { + try { File.Delete(path); } catch { } + } +} + +internal static class SidecarIo +{ + // 本次宿主启动的随机串:暂存目录/(M3 起的)shm 区域名都嵌它,上次崩溃的残留可整体识别。 + public static readonly string Nonce = Guid.NewGuid().ToString("N")[..12]; +} diff --git a/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs b/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs index bb21bf74..2e3fcbe6 100644 --- a/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs +++ b/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs @@ -3,54 +3,54 @@ namespace TuneLab.Extensions.Scripted; -internal enum ScriptedGateStatus { Skipped, Failed } +internal enum ScriptedGateStatus { Ready, Skipped, Failed } -// 脚本条目加载门(M0):结构校验 + deps 模式 + runtime 四级探测;M1 把"RuntimeReady"一支换成 sidecar 注册。 -// 纯函数式判给(不 mutate 结果对象),ExtensionManager 据 Status 记账(skipped/failed)并回填 entry.Error。 -// 错误文案 = 作者可见的人话(英文,日志/异常约定)。 +// 脚本条目加载门:结构校验 + deps 模式 + runtime 四级探测。Ready 之后由各 type 的注册支处理 +// (M1: format 注册 RemoteFormat;voice/effect 的注册支随 M2/M3 落地,此前如实 Skipped)。 +// 纯函数式判给(不 mutate 结果对象),ExtensionManager 据 Status 记账(skipped/failed/registered) +// 并回填 entry.Error。错误文案 = 作者看得懂的人话(英文,日志/异常约定)。 internal static class ScriptedEntryGate { - public readonly record struct Result(ScriptedGateStatus Status, string Message); + public readonly record struct Result(ScriptedGateStatus Status, string Message, RuntimeInfo Runtime); // locator 参数为测试注入点(假 prober/假扩展根,绝不在单测里跑真解释器);生产走 Default。 public static Result Evaluate(ExtensionInfo ext, string packageDir, RuntimeLocator? locator = null) { locator ??= RuntimeLocator.Default; + // —— 结构:runtime 与 entry 成对;与 class/assembly 互斥("声明即事实",不静默择一)—— var entry = (ext.entry ?? string.Empty).Trim(); if (entry.Length == 0) - return new(ScriptedGateStatus.Failed, "'runtime' declares a scripted entry but 'entry' (the in-package script path) is missing"); + return new(ScriptedGateStatus.Failed, "'runtime' declares a scripted entry but 'entry' (the in-package script path) is missing", default); if (!string.IsNullOrEmpty(ext.assembly) || ext.EffectiveClass.Length > 0) - return new(ScriptedGateStatus.Failed, "an entry declares both 'runtime'/'entry' (scripted, out-of-process) and 'class'/'assembly' (in-process ALC); pick one"); + return new(ScriptedGateStatus.Failed, "an entry declares both 'runtime'/'entry' (scripted, out-of-process) and 'class'/'assembly' (in-process ALC); pick one", default); // entry 必须是包内相对路径(防 .. 逃逸)且文件存在——缺了就是包打错了,如实报。 - var fullPath = Path.GetFullPath(Path.Combine(packageDir, entry)); + var fullPath = Path.GetFullPath(Path.Combine(packageDir, entry.Replace('/', Path.DirectorySeparatorChar))); var root = Path.GetFullPath(packageDir) + Path.DirectorySeparatorChar; if (!fullPath.StartsWith(root, StringComparison.Ordinal)) - return new(ScriptedGateStatus.Failed, string.Format("'entry' must be a path inside the package folder, got '{0}'", entry)); + return new(ScriptedGateStatus.Failed, string.Format("'entry' must be a path inside the package folder, got '{0}'", entry), default); if (!File.Exists(fullPath)) - return new(ScriptedGateStatus.Failed, string.Format("'entry' script not found in the package: '{0}'", entry)); + return new(ScriptedGateStatus.Failed, string.Format("'entry' script not found in the package: '{0}'", entry), default); // —— deps 模式(§12.2):vendored 隐式;pip 已声明未实现(Skipped,非错误)—— var deps = (ext.deps ?? string.Empty).Trim().ToLowerInvariant(); if (deps.Length > 0 && deps != "vendored") { if (deps == "pip") - return new(ScriptedGateStatus.Skipped, "'deps': \"pip\" mode is declared but not implemented by this host build (author-vendored dependencies only for now)"); - return new(ScriptedGateStatus.Failed, string.Format("'deps': unknown mode '{0}' (supported: vendored)", deps)); + return new(ScriptedGateStatus.Skipped, "'deps': \"pip\" mode is declared but not implemented by this host build (author-vendored dependencies only for now)", default); + return new(ScriptedGateStatus.Failed, string.Format("'deps': unknown mode '{0}' (supported: vendored)", deps), default); } // —— runtime 声明与四级探测(§8.1/§8.2)—— if (!RuntimeSpec.TryParse(ext.runtime, out var spec, out var specError)) - return new(ScriptedGateStatus.Failed, specError!); + return new(ScriptedGateStatus.Failed, specError!, default); if (!locator.TryResolve(spec, out var info, out var trail)) return new(ScriptedGateStatus.Skipped, string.Format( "no matching runtime: {0}. To provide one, install a 'runtime' package (type \"runtime\"), set '{1}' in settings, or put a matching interpreter on PATH.", - trail, RuntimeLocator.SettingsKeyFor(spec.Flavor))); + trail, RuntimeLocator.SettingsKeyFor(spec.Flavor)), default); - // M0 终点:runtime 就位也不加载(sidecar 加载器随 M1 落地)——如实 Skipped,不谎报 Loaded。 - return new(ScriptedGateStatus.Skipped, string.Format( - "scripted sidecar loading is not implemented by this host build yet (wire protocol up to {0}); runtime resolved: {1} {2} at '{3}'.", - WireProtocol.Version, info.Flavor, info.Version, info.Exe)); + // runtime 就位:交给调用方按 kind 注册(format=M1;voice/effect 随 M2/M3 的注册支,此前如实 Skipped)。 + return new(ScriptedGateStatus.Ready, string.Format("runtime resolved: {0} {1} at '{2}'", info.Flavor, info.Version, info.Exe), info); } } diff --git a/TuneLab/Extensions/Scripted/ScriptedWire.cs b/TuneLab/Extensions/Scripted/ScriptedWire.cs new file mode 100644 index 00000000..944acc75 --- /dev/null +++ b/TuneLab/Extensions/Scripted/ScriptedWire.cs @@ -0,0 +1,344 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using TuneLab.Foundation; +using TuneLab.SDK; + +namespace TuneLab.Extensions.Scripted; + +// 脚本 sidecar 的 JSON 编解码(设计文档 §5.3):DataInfo 全族 + PropertyObject/PropertyValue/ +// PropertyArray/Map 是宿主↔插件的唯一序列化真源;façade 依同一规范(wire 规范文件 scripting/wire/ +// v1.json 随 M1 提交,映射封条测试钉住)。 +// +// PropertyValue 线上形态(写侧恒带标签、读侧宽容收裸值;Multiple 是 UI 三态、永不跨线,见下): +// bool → {"b":true} number → {"n":1.5}(NaN/±Infinity 用 JSON 具名字面量) +// string → {"s":"x"} object → {"o":{...}} array → {"a":[...]} Null → null +// 读侧裸值推断:JSON 原生 bool/number/string/array/null 直落对应型;对象若"恰含一个保留标签键 +// 且值型相符"按 tagged 解,否则视为嵌套 PropertyObject(宿主写侧永不产生该歧义形,宽容只兜第三方手搓)。 +// Map ↔ JSON 对象(键序按 Map 的插入序写出);非 string 键的 Map 不支持(抛 NotSupportedException)。 +// 属性名 camelCase、公有字段(Point.X/Y)参与序列化、枚举写小写名("voice")读宽容(名或数)。 +internal static class ScriptedWire +{ + public static readonly JsonSerializerOptions Options = CreateOptions(); + + static JsonSerializerOptions CreateOptions() + { + var o = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + IncludeFields = true, + NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals, + }; + o.Converters.Add(new DoubleWireConverter()); + o.Converters.Add(new PropertyValueConverter()); + o.Converters.Add(new PropertyObjectConverter()); + o.Converters.Add(new PropertyArrayConverter()); + o.Converters.Add(new PartInfoConverter()); + o.Converters.Add(new MapConverterFactory()); + o.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase)); + return o; + } + + public static string Serialize(T value) => JsonSerializer.Serialize(value, Options); + + public static T? Deserialize(string json) => JsonSerializer.Deserialize(json, Options); + + public static JsonNode SerializeNode(T value) => JsonSerializer.SerializeToNode(value, Options)!; + + public static T? DeserializeNode(JsonNode? node) => node == null ? default : node.Deserialize(Options); + + // ── 转换器 ── + + // wire 规范形态:NaN/±Infinity 一律 JSON 字符串 "NaN"|"Infinity"|"-Infinity"(合法 JSON,且 + // net8 的 JsonDocument——转换器共用它的严格解析——不接受裸具名 token)。读侧宽容:数字、 + // 字符串、(经 NumberHandling 的)裸 token 都能收。 + sealed class DoubleWireConverter : JsonConverter + { + public override double Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType switch + { + JsonTokenType.Number => reader.GetDouble(), + JsonTokenType.String => Parse(reader.GetString()!), + JsonTokenType.True or JsonTokenType.False => throw new JsonException("bool is not a number"), + _ => throw new JsonException("unexpected token for double"), + }; + + static double Parse(string s) => s switch + { + "NaN" => double.NaN, + "Infinity" => double.PositiveInfinity, + "-Infinity" => double.NegativeInfinity, + _ => double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) ? d : throw new JsonException("bad number '" + s + "'"), + }; + + public override void Write(Utf8JsonWriter writer, double value, JsonSerializerOptions options) + { + if (double.IsNaN(value)) writer.WriteStringValue("NaN"); + else if (double.IsPositiveInfinity(value)) writer.WriteStringValue("Infinity"); + else if (double.IsNegativeInfinity(value)) writer.WriteStringValue("-Infinity"); + else writer.WriteNumberValue(value); + } + } + + // PartInfo 是抽象类(midi/audio 两 concrete 子型):线上形态 = 子型字段 + 判别器 "kind": "midi"|"audio"。 + sealed class PartInfoConverter : JsonConverter + { + public override PartInfo? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return null; + using var doc = JsonDocument.ParseValue(ref reader); + var root = doc.RootElement; + var kind = root.TryGetProperty("kind", out var k) ? k.GetString() : null; + switch (kind) + { + case "audio": return root.Deserialize(options); + case "midi": + case null: return root.Deserialize(options); + default: throw new JsonException(string.Format(CultureInfo.InvariantCulture, "unknown part kind '{0}' (expected midi|audio)", kind)); + } + } + + public override void Write(Utf8JsonWriter writer, PartInfo value, JsonSerializerOptions options) + { + var concreteType = value is AudioPartInfo ? typeof(AudioPartInfo) : typeof(MidiPartInfo); + var node = JsonSerializer.SerializeToNode(value, concreteType, options)!.AsObject(); + node["kind"] = value is AudioPartInfo ? "audio" : "midi"; + node.WriteTo(writer); + } + } + + sealed class PropertyValueConverter : JsonConverter + { + public override PropertyValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null or JsonTokenType.True or JsonTokenType.False + or JsonTokenType.Number or JsonTokenType.String or JsonTokenType.StartArray or JsonTokenType.StartObject) + { + using var doc = JsonDocument.ParseValue(ref reader); + return ReadValue(doc.RootElement); + } + throw new JsonException(string.Format(CultureInfo.InvariantCulture, "unexpected token {0} for PropertyValue", reader.TokenType)); + } + + // 统一递归:tagged 优先(宿主写侧恒 tagged),裸值宽容(第三方手搓)。 + internal static PropertyValue ReadValue(JsonElement el) + { + switch (el.ValueKind) + { + case JsonValueKind.Null: + return default; + case JsonValueKind.True: + return PropertyValue.Create(true); + case JsonValueKind.False: + return PropertyValue.Create(false); + case JsonValueKind.Number: + return PropertyValue.Create(el.GetDouble()); + case JsonValueKind.String: + return PropertyValue.Create(el.GetString()!); + case JsonValueKind.Array: + return PropertyValue.Create(new PropertyArray(el.EnumerateArray().Select(ReadValue).ToList())); + case JsonValueKind.Object: + { + var props = el.EnumerateObject().ToArray(); + if (props.Length == 1) + { + var p = props[0]; + switch (p.Name) + { + case "b" when p.Value.ValueKind is JsonValueKind.True or JsonValueKind.False: + return PropertyValue.Create(p.Value.GetBoolean()); + case "n" when p.Value.ValueKind is JsonValueKind.Number: + return PropertyValue.Create(p.Value.GetDouble()); + case "n" when p.Value.ValueKind == JsonValueKind.String: + return PropertyValue.Create(ParseNumberLiteral(p.Value.GetString()!)); + case "s" when p.Value.ValueKind == JsonValueKind.String: + return PropertyValue.Create(p.Value.GetString()!); + case "o" when p.Value.ValueKind == JsonValueKind.Object: + return PropertyValue.Create(ReadObjectMap(p.Value)); + case "a" when p.Value.ValueKind == JsonValueKind.Array: + return PropertyValue.Create(new PropertyArray(p.Value.EnumerateArray().Select(ReadValue).ToList())); + } + } + return PropertyValue.Create(ReadObjectMap(el)); // 非 tagged 形 = 原生属性对象(键撞保留字的裸 map 见规范注记) + } + default: + return default; + } + } + + internal static PropertyObject ReadObjectMap(JsonElement el) + { + var map = new Map(); + foreach (var p in el.EnumerateObject()) + map[p.Name] = ReadValue(p.Value); + return new PropertyObject(map); + } + + internal static double ParseNumberLiteral(string s) => s switch + { + "NaN" => double.NaN, + "Infinity" => double.PositiveInfinity, + "-Infinity" => double.NegativeInfinity, + _ => double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) + ? d + : throw new JsonException("bad number literal '" + s + "'"), + }; + + public override void Write(Utf8JsonWriter writer, PropertyValue value, JsonSerializerOptions options) + { + if (value.IsMultiple()) + throw new NotSupportedException("PropertyValue.Multiple is a transient tri-state and must never cross the wire"); + if (value.IsNull()) + { + writer.WriteNullValue(); + return; + } + if (value.ToBoolean(out var b)) + { + writer.WriteStartObject(); + writer.WriteBoolean("b", b); + writer.WriteEndObject(); + return; + } + if (value.ToDouble(out var d)) + { + writer.WriteStartObject(); + if (double.IsNaN(d)) + writer.WriteString("n", "NaN"); + else if (double.IsPositiveInfinity(d)) + writer.WriteString("n", "Infinity"); + else if (double.IsNegativeInfinity(d)) + writer.WriteString("n", "-Infinity"); + else + writer.WriteNumber("n", d); + writer.WriteEndObject(); + return; + } + if (value.ToString(out var s)) + { + writer.WriteStartObject(); + writer.WriteString("s", s); + writer.WriteEndObject(); + return; + } + if (value.ToObject(out var o)) + { + writer.WriteStartObject(); + writer.WritePropertyName("o"); + new PropertyObjectConverter().Write(writer, o, options); + writer.WriteEndObject(); + return; + } + if (value.ToArray(out var a)) + { + writer.WriteStartObject(); + writer.WritePropertyName("a"); + new PropertyArrayConverter().Write(writer, a, options); + writer.WriteEndObject(); + return; + } + writer.WriteNullValue(); + } + } + + sealed class PropertyObjectConverter : JsonConverter + { + public override PropertyObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return PropertyObject.Empty; + using var doc = JsonDocument.ParseValue(ref reader); + var map = new Map(); + foreach (var p in doc.RootElement.EnumerateObject()) + map[p.Name] = JsonSerializer.Deserialize(p.Value.GetRawText(), options); + return new PropertyObject(map); + } + + public override void Write(Utf8JsonWriter writer, PropertyObject value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach (var kv in value.Map) + { + writer.WritePropertyName(kv.Key); + new PropertyValueConverter().Write(writer, kv.Value, options); + } + writer.WriteEndObject(); + } + } + + sealed class PropertyArrayConverter : JsonConverter + { + public override PropertyArray Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + return PropertyArray.Empty; + var list = new List(); + reader.Read(); // StartArray + while (reader.TokenType != JsonTokenType.EndArray) + { + list.Add(JsonSerializer.Deserialize(ref reader, options)); + reader.Read(); + } + return new PropertyArray(list); + } + + public override void Write(Utf8JsonWriter writer, PropertyArray value, JsonSerializerOptions options) + { + writer.WriteStartArray(); + var pv = new PropertyValueConverter(); + foreach (var item in value) + pv.Write(writer, item, options); + writer.WriteEndArray(); + } + } + + sealed class MapConverterFactory : JsonConverterFactory + { + public override bool CanConvert(Type typeToConvert) + => typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(Map<,>) + && typeToConvert.GetGenericArguments()[0] == typeof(string); + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + var valueType = typeToConvert.GetGenericArguments()[1]; + var convType = typeof(MapConverter<>).MakeGenericType(valueType); + return (JsonConverter)Activator.CreateInstance(convType)!; + } + + sealed class MapConverter : JsonConverter> + { + public override Map Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + var map = new Map(); + if (reader.TokenType == JsonTokenType.Null) + return map; + reader.Read(); // StartObject + while (reader.TokenType == JsonTokenType.PropertyName) + { + var key = reader.GetString()!; + reader.Read(); + map[key] = JsonSerializer.Deserialize(ref reader, options)!; + reader.Read(); + } + return map; + } + + public override void Write(Utf8JsonWriter writer, Map value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach (var kv in value) + { + writer.WritePropertyName(kv.Key); + JsonSerializer.Serialize(writer, kv.Value, options); + } + writer.WriteEndObject(); + } + } + } +} diff --git a/TuneLab/Extensions/Scripted/SidecarHost.cs b/TuneLab/Extensions/Scripted/SidecarHost.cs new file mode 100644 index 00000000..2a930b7a --- /dev/null +++ b/TuneLab/Extensions/Scripted/SidecarHost.cs @@ -0,0 +1,479 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; + +namespace TuneLab.Extensions.Scripted; + +// 一个包一个 sidecar 的宿主(设计文档 §3)。懒 spawn(首次 Call 前 EnsureRunning); +// 死亡 = 读者线程 EOF / 进程退出;重启带退避与熔断;epoch 防尾包。 +// M1 消费方 = RemoteFormat;M2/M3 接 effect/voice(引擎注册改常驻后仍复用本类)。 +internal sealed class SidecarHost +{ + readonly string mPackageId; + readonly string mPackageDir; + readonly string mEntry; // 包内相对路径 + readonly RuntimeInfo mRuntime; + readonly int mBackoffSecondsMax = 30; + readonly TimeSpan mCallTimeout; + + // 测试注入点:每次 spawn 叠加的环境变量(生产不触碰)。 + public Dictionary? ExtraEnvironment { get; set; } + + public SidecarHost(string packageId, string packageDir, RuntimeInfo runtime, string entry, TimeSpan? callTimeout = null) + { + mPackageId = packageId; mPackageDir = packageDir; mRuntime = runtime; mEntry = entry; + mCallTimeout = callTimeout ?? TimeSpan.FromMinutes(2); + } + + readonly object mLock = new(); + readonly ConcurrentDictionary> mPending = new(); + int mEpoch; + Process? mProcess; + Stream? mStdin; + TaskCompletionSource? mStartedTcs; // 握手进行中(单飞) + DateTime mFirstStartUtc = DateTime.MinValue; + int mDeathsInWindow; + bool mCircuitOpen; + bool mDisposed; + + public int Epoch { get { lock (mLock) return mEpoch; } } + public bool CircuitOpen { get { lock (mLock) return mCircuitOpen; } } + public string StderrTail { get; private set; } = string.Empty; + internal Exception? LastReaderFault { get; private set; } // 诊断(测试与失败文案用) + + // 熔断复位(侧栏"重新加载"的语义;测试亦用)。不复活进行中的调用,只放行下一次 spawn。 + public void ResetCircuit() + { + lock (mLock) + { + mCircuitOpen = false; + mDeathsInWindow = 0; + mFirstStartUtc = DateTime.MinValue; + } + } + + // sidecar 侧被动事件(宿主拉模式,M1 只消费 log 与 diagnostics)。 + public event Action? LogReceived; // level, message + public event Action? Died; // 每次死亡(含即将重启) + + // 握手后必须兑现在这些期望(§2.3:manifest 承诺 ↔ sidecar 声明逐条核对,缺席 = 协议违例)。 + // 加载期注册(懒 spawn 之前),线程安全:只在注册后、spawn 前的窗口写。 + sealed class ExpectedFormat { public string Entry = ""; public string[] Import = []; public string[] Export = []; } + readonly List mExpectedFormats = []; + + public void ExpectFormat(string entryId, string[] importSuffixes, string[] exportSuffixes) + { + lock (mExpectedFormats) + mExpectedFormats.Add(new ExpectedFormat { Entry = entryId, Import = importSuffixes, Export = exportSuffixes }); + } + + public async Task EnsureRunningAsync() + { + TaskCompletionSource start; + lock (mLock) + { + if (mDisposed) + throw new ObjectDisposedException(nameof(SidecarHost)); + if (mCircuitOpen) + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "sidecar for {0} is stopped after repeated crashes", mPackageId)); + if (mProcess != null) + return; + if (mStartedTcs == null) + { + mStartedTcs = start = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ThreadPool.QueueUserWorkItem(_ => StartOnce(start)); + } + else + start = mStartedTcs; + } + await start.Task.ConfigureAwait(false); + } + + void StartOnce(TaskCompletionSource tcs) + { + try + { + lock (mLock) + { + if (mProcess != null) { tcs.TrySetResult(true); return; } // 单飞竞态兜底 + } + StartAndHandshake(); + lock (mLock) + { + if (mFirstStartUtc == DateTime.MinValue) mFirstStartUtc = DateTime.UtcNow; + mStartedTcs = null; + } + tcs.TrySetResult(true); + } + catch (Exception ex) + { + lock (mLock) mStartedTcs = null; + tcs.TrySetException(ex); + } + } + + void StartAndHandshake() + { + var entryPath = Path.GetFullPath(Path.Combine(mPackageDir, mEntry.Replace('/', Path.DirectorySeparatorChar))); + var psi = new ProcessStartInfo(mRuntime.Exe) + { + WorkingDirectory = mPackageDir, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var a in FlavorArgs(mRuntime.Flavor)) + psi.ArgumentList.Add(a); + psi.ArgumentList.Add(entryPath); + psi.Environment["TUNELAB_PROTOCOL"] = WireProtocol.Version.ToString(CultureInfo.InvariantCulture); + psi.Environment["TUNELAB_EPOCH"] = (mEpoch + 1).ToString(CultureInfo.InvariantCulture); + psi.Environment["TUNELAB_PACKAGE_ID"] = mPackageId; + if (ExtraEnvironment != null) + foreach (var (k, v) in ExtraEnvironment) + { + if (v == null) psi.Environment.Remove(k); + else psi.Environment[k] = v; + } + + var proc = Process.Start(psi) ?? throw new InvalidOperationException("failed to start sidecar process"); + int epoch; + lock (mLock) + { + mEpoch++; epoch = mEpoch; + mProcess = proc; + mStdin = proc.StandardInput.BaseStream; + } + ScriptedRuntimeLedger.Record(mPackageId, proc.Id, mRuntime.Exe); + if (OperatingSystem.IsWindows()) + JobObjects.AssignToKillOnHostClose(proc); + + proc.ErrorDataReceived += (_, e) => { if (e.Data != null) AppendStderr(e.Data); }; + try { proc.BeginErrorReadLine(); } catch { } + + var reader = new Thread(() => ReadLoop(proc, epoch)) { IsBackground = true, Name = "sidecar-reader-" + mPackageId }; + reader.Start(); + + // 握手(同步在本线程:EnsureRunning 已在池线程上)。失败即杀半死进程并计一次死亡。 + // params.entries 携带宿主期望(entry 键 = manifest 的相对路径)——sidecar 按同一键兑现 provides。 + var handshake = new JsonObject + { + ["protocol"] = WireProtocol.Version, + ["package_id"] = mPackageId, + }; + var entriesNode = new JsonArray(); + lock (mExpectedFormats) + foreach (var ef in mExpectedFormats) + entriesNode.Add((JsonNode)new JsonObject + { + ["type"] = "format", + ["entry"] = ef.Entry, + ["import_suffixes"] = JsonSerializer.SerializeToNode(ef.Import), + ["export_suffixes"] = JsonSerializer.SerializeToNode(ef.Export), + }); + handshake["entries"] = entriesNode; + try + { + var reply = CallInternal("initialize", handshake, TimeSpan.FromSeconds(30), mEpoch); + if (reply == null || reply["capabilities"] is not JsonObject) + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "sidecar for {0} completed handshake without capabilities", mPackageId)); + ValidateHandshake(reply); + } + catch (Exception ex) + { + // 真因先落 started tcs(Kill 会触发读者线程 OnDeath 的泛化"exited"消息——first-set-wins, + // 不抢先就是把 "does not declare entry" 说成 "exited during startup")。 + TaskCompletionSource? st; + lock (mLock) + { + st = mStartedTcs; + if (mProcess == proc) { mProcess = null; mStdin = null; } + } + st?.TrySetException(ex); + try { proc.Kill(true); } catch { } + throw; + } + } + + static IEnumerable FlavorArgs(string flavor) => flavor switch + { + "python" => ["-u"], + "node" => ["--disable-warning=ExperimentalWarning"], + "dotnet-fake" => ["exec"], // 协议封条测试执行体:dotnet exec (设计 §12.1,CI 免装解释器) + _ => [], + }; + + // 握手后核对 provides(§2.3):manifest 承诺的每个脚本条目,sidecar 必须自报支持,否则握手期 + // 就判死("作者写了、sidecar 不认"是最容易静默失联的一类不匹配,拦在最前)。 + void ValidateHandshake(JsonNode? reply) + { + var provided = new HashSet(StringComparer.Ordinal); + if (reply!["provides"] is JsonArray arr) + foreach (var item in arr) + if (item is JsonObject o && o["entry"]?.GetValue() is { } e) + provided.Add(e); + lock (mExpectedFormats) + { + foreach (var ef in mExpectedFormats) + if (!provided.Contains(ef.Entry)) + throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, + "sidecar for {0} handshake does not declare entry '{1}' that the manifest promised", mPackageId, ef.Entry)); + } + } + + void AppendStderr(string line) + { + const int Cap = 64 * 1024; + var tail = StderrTail + line + "\n"; + if (tail.Length > Cap) + tail = tail.Substring(tail.Length - Cap); + StderrTail = tail; + } + + void ReadLoop(Process proc, int epoch) + { + string? json = null; + try + { + while (WireFraming.TryRead(proc.StandardOutput.BaseStream, out json)) + { + try { Dispatch(json, epoch); } + catch (Exception ex) { LastReaderFault = ex; break; } + } + } + catch (Exception ex) + { + LastReaderFault = new InvalidOperationException("frame read failed near: " + (json == null ? "" : json[..Math.Min(160, json.Length)]), ex); + } + try { proc.WaitForExit(2000); } catch { } + int exitCode = -1; + try { if (proc.HasExited) exitCode = proc.ExitCode; } catch { } + OnDeath(epoch, exitCode); + try { proc.Dispose(); } catch { } + } + + void Dispatch(string json, int epoch) + { + var node = JsonNode.Parse(json)?.AsObject(); + if (node == null) return; + if (node.TryGetPropertyValue("method", out var m) && m != null) + { + // sidecar → 宿主:M1 只有 log.write;其余通知忽略(前向兼容)。 + if (m.GetValue() == "log.write") + { + var p = node["params"]?.AsObject(); + LogReceived?.Invoke(p?["level"]?.GetValue() ?? "info", p?["message"]?.GetValue() ?? ""); + } + return; + } + if (node.TryGetPropertyValue("id", out var idNode) && idNode != null) + { + // 键与发出时一致:字符串 id 取裸值(ToJsonString 会带引号 → 永不命中 pending)。 + var key = idNode.GetValueKind() == System.Text.Json.JsonValueKind.String ? idNode.GetValue() : idNode.ToJsonString(); + if (mPending.TryRemove(key, out var tcs)) + { + if (node["error"] is JsonObject err) + tcs.TrySetException(new SidecarRpcException(err["code"]?.ToString() ?? "runtime_error", err["message"]?.ToString() ?? "")); + else + tcs.TrySetResult(node["result"]); + } + } + } + + void OnDeath(int epoch, int exitCode) + { + TaskCompletionSource? started; + bool fatal; + int backoffSeconds; + lock (mLock) + { + if (epoch != mEpoch || mDisposed) // 旧世代的读者线程:尾包/尾死信号,忽略 + return; + foreach (var kv in mPending) + if (mPending.TryRemove(kv.Key, out var tcs)) + tcs.TrySetException(new SidecarRpcException("runtime_error", string.Format(CultureInfo.InvariantCulture, "sidecar exited (code {0})", exitCode))); + mProcess = null; mStdin = null; + started = mStartedTcs; + + var now = DateTime.UtcNow; + if ((now - mFirstStartUtc) > TimeSpan.FromMinutes(10)) + { mFirstStartUtc = now; mDeathsInWindow = 0; } + mDeathsInWindow++; + fatal = mDeathsInWindow >= 5; + if (fatal) mCircuitOpen = true; + backoffSeconds = fatal ? 0 : Math.Min(mBackoffSecondsMax, 1 << Math.Min(5, mDeathsInWindow)); // 2/4/8/16/30s + } + started?.TrySetException(new SidecarRpcException("runtime_error", string.Format(CultureInfo.InvariantCulture, "sidecar exited during startup (code {0})", exitCode))); + Died?.Invoke(); + if (!fatal) + { + // 不自动复活进行中的调用(M1 语义):失败如实抛给调用方;下一次调用触发 EnsureRunning 重开。 + // 熔断窗口计数只随时间复位——下次调用若在窗口内仍死,很快 Open。 + } + } + + public async Task CallAsync(string method, JsonNode? args) + { + await EnsureRunningAsync().ConfigureAwait(false); + int epoch; + lock (mLock) epoch = mEpoch; + return await Task.Run(() => CallInternal(method, args, mCallTimeout, epoch)).ConfigureAwait(false); + } + + JsonNode? CallInternal(string method, JsonNode? args, TimeSpan timeout, int epoch) + { + Stream stdin; + lock (mLock) + { + if (mProcess == null || mStdin == null || epoch != mEpoch) + throw new SidecarRpcException("runtime_error", "sidecar not running"); + stdin = mStdin; + } + var id = Guid.NewGuid().ToString("N"); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + mPending[id] = tcs; + var request = new JsonObject + { + ["jsonrpc"] = "2.0", + ["id"] = JsonNode.Parse("\"" + id + "\""), + ["method"] = method, + ["params"] = args?.DeepClone(), + }; + try + { + lock (mLock) + { + if (mStdin != stdin) throw new SidecarRpcException("runtime_error", "sidecar restarted mid-call"); + WireFraming.Write(stdin, request.ToJsonString()); + } + if (!tcs.Task.Wait(timeout)) + { + mPending.TryRemove(id, out _); + throw new SidecarRpcException("timeout", string.Format(CultureInfo.InvariantCulture, "'{0}' timed out after {1}s", method, timeout.TotalSeconds)); + } + return tcs.Task.Result; + } + catch + { + mPending.TryRemove(id, out _); + throw; + } + } + + public void Shutdown() + { + Process? proc; + Stream? stdin; + lock (mLock) + { + mDisposed = true; + proc = mProcess; stdin = mStdin; + } + if (stdin != null) + { + try + { + WireFraming.Write(stdin, new JsonObject { ["jsonrpc"] = "2.0", ["method"] = "shutdown", ["params"] = new JsonObject { ["reason"] = "host-stop" } }.ToJsonString()); + } + catch { } + } + int pid = -1; + if (proc != null) + { + try { pid = proc.Id; } catch { } + try { if (!proc.WaitForExit(3000) && pid > 0) KillTree(pid); } catch { } + } + if (pid > 0) + ScriptedRuntimeLedger.Complete(mPackageId, pid); + } + + static void KillTree(int pid) + { + try + { + using var p = Process.GetProcessById(pid); + p.Kill(true); + } + catch { } + } +} + +internal sealed class SidecarRpcException(string code, string message) : Exception(code + ": " + message) +{ + public string Code { get; } = code; +} + +// Windows 防遗留第 2 防线(设计文档 §3.3):所有 sidecar 挂进同一个 kill-on-close 的 Job Object, +// 宿主进程无论怎么死(含 Kill),OS 关作业句柄 → 作业内全部进程连坐。子进程继承作业 ⇒ 整棵树被罩住。 +internal static class JobObjects +{ + static readonly Lazy sJob = new(() => + { + var h = CreateJobObjectW(IntPtr.Zero, null); + if (h == IntPtr.Zero) return IntPtr.Zero; + var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION { BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION { LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE } }; + int size = System.Runtime.InteropServices.Marshal.SizeOf(); + if (!SetInformationJobObject(h, JobObjectExtendedLimitInformation, ref info, (uint)size)) + { } // 设置失败仍保留作业:至少 Assign 还能生效或静默失败,都不致命 + return h; + }); + + public static void AssignToKillOnHostClose(Process process) + { + var h = sJob.Value; + if (h == IntPtr.Zero) return; + _ = AssignProcessToJobObject(h, process.Handle); + } + + const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000; + const int JobObjectExtendedLimitInformation = 9; + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + struct JOBOBJECT_BASIC_LIMIT_INFORMATION + { + public long PerProcessUserTimeLimit; + public long PerJobUserTimeLimit; + public uint LimitFlags; + public UIntPtr MinimumWorkingSetSize; + public UIntPtr MaximumWorkingSetSize; + public uint ActiveProcessLimit; + public long Affinity; + public uint PriorityClass; + public uint SchedulingClass; + } + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + struct IO_COUNTERS + { + public ulong ReadOperationCount; public ulong WriteOperationCount; public ulong OtherOperationCount; + public ulong ReadTransferCount; public ulong WriteTransferCount; public ulong OtherTransferCount; + } + + [System.Runtime.InteropServices.StructLayout(System.Runtime.InteropServices.LayoutKind.Sequential)] + struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION + { + public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; + public IO_COUNTERS IoInfo; + public UIntPtr ProcessMemoryLimit; + public UIntPtr JobMemoryLimit; + public UIntPtr PeakProcessMemoryUsed; + public UIntPtr PeakJobMemoryUsed; + } + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr CreateJobObjectW(IntPtr jobAttributes, string? name); + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr job, int infoType, ref JOBOBJECT_EXTENDED_LIMIT_INFORMATION info, uint infoSize); + + [System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); +} diff --git a/TuneLab/Extensions/Scripted/SidecarRegistry.cs b/TuneLab/Extensions/Scripted/SidecarRegistry.cs new file mode 100644 index 00000000..4a75eb7a --- /dev/null +++ b/TuneLab/Extensions/Scripted/SidecarRegistry.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using TuneLab.Foundation; + +namespace TuneLab.Extensions.Scripted; + +// 防遗留第 4 防线(设计文档 §3.3):spawn 记台账、退出销账;宿主猝死留下的记录由下一个实例 +// (拿到 TuneLab.lock 之后)清扫——按 pid + 进程创建时间 + 可执行路径三重匹配防 PID 复用, +// 且只杀"路径落在 Extensions 运行时/包目录之下"的进程。 +internal static class ScriptedRuntimeLedger +{ + sealed class Entry + { + public string package_id { get; set; } = string.Empty; + public int pid { get; set; } + public string exe { get; set; } = string.Empty; + public long start_ticks { get; set; } // Process.StartTime 的 UTC ticks;匹配不上 = 同 pid 新进程(复用) + } + + static readonly object sLock = new(); + static List sEntries = []; + static string FilePath => Path.Combine(PathManager.ConfigsFolder, "scripted-pids.json"); + + public static void Record(string packageId, int pid, string exe) + { + long startTicks; + try { using var p = Process.GetProcessById(pid); startTicks = p.StartTime.ToUniversalTime().Ticks; } + catch { return; } + lock (sLock) + { + sEntries.RemoveAll(e => e.package_id == packageId && e.pid == pid); + sEntries.Add(new Entry { package_id = packageId, pid = pid, exe = exe, start_ticks = startTicks }); + Save(); + } + } + + public static void Complete(string packageId, int pid) + { + lock (sLock) + { + if (sEntries.RemoveAll(e => e.package_id == packageId && e.pid == pid) > 0) + Save(); + } + } + + // 清扫残留:返回杀掉的条数。任何一步失败只跳过该条,绝不因残留清单坏掉宿主启动。 + public static int SweepOrphans() + { + List stale; + lock (sLock) + { + if (!File.Exists(FilePath)) + return 0; + try + { + sEntries = JsonSerializer.Deserialize>(File.ReadAllText(FilePath)) ?? []; + } + catch + { + sEntries = []; + return 0; + } + stale = sEntries.ToList(); + sEntries.Clear(); + Save(); + } + int killed = 0; + foreach (var e in stale) + { + try + { + using var p = Process.GetProcessById(e.pid); + if (p.StartTime.ToUniversalTime().Ticks != e.start_ticks) + continue; // pid 已被复用:这不是我们留的人 + var imageName = TryGetExePath(p); + if (imageName == null || !PathUnderExtensions(imageName)) + continue; // 三重匹配任何一环对不上都不动它 + p.Kill(true); + killed++; + } + catch { /* 已自行退出:本就该从台账消失 */ } + } + return killed; + } + + static string? TryGetExePath(Process p) + { + // MainModule 是 net8 最可靠的路径来源;拿不到(32/64 位差异、权限)就宁可放过——三重匹配宁可漏杀不误杀。 + try { return p.MainModule?.FileName; } catch { return null; } + } + + static bool PathUnderExtensions(string candidate) + { + var root = Path.GetFullPath(PathManager.ExtensionsFolder) + Path.DirectorySeparatorChar; + try { return Path.GetFullPath(candidate).StartsWith(root, StringComparison.OrdinalIgnoreCase); } + catch { return false; } + } + + static void Save() + { + try + { + PathManager.MakeSureExist(PathManager.ConfigsFolder); + File.WriteAllText(FilePath, JsonSerializer.Serialize(sEntries, new JsonSerializerOptions { WriteIndented = true })); + } + catch { /* 台账尽力而为:丢了下一轮清扫兜底 */ } + } +} + +// packageId → SidecarHost 的进程级表(设计文档 §3.1)。M1 只被 format 工厂触碰。 +internal static class SidecarRegistry +{ + static readonly Dictionary sHosts = new(); + static readonly object sLock = new(); + + public static SidecarHost For(string packageId, string packageDir, RuntimeInfo runtime, string entry) + { + lock (sLock) + { + if (!sHosts.TryGetValue(packageId, out var host)) + sHosts[packageId] = host = new SidecarHost(packageId, packageDir, runtime, entry); + return host; + } + } + + public static void ShutdownAll() + { + SidecarHost[] hosts; + lock (sLock) + { + hosts = sHosts.Values.ToArray(); + sHosts.Clear(); + } + foreach (var h in hosts) + h.Shutdown(); + } + + public static void SweepStaleProcesses() + { + var n = ScriptedRuntimeLedger.SweepOrphans(); + if (n > 0) + Log.Warning(string.Format(CultureInfo.InvariantCulture, "cleaned {0} orphaned sidecar process(es) from a previous run", n)); + } +} diff --git a/TuneLab/Extensions/Scripted/WireFraming.cs b/TuneLab/Extensions/Scripted/WireFraming.cs new file mode 100644 index 00000000..154e0255 --- /dev/null +++ b/TuneLab/Extensions/Scripted/WireFraming.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Text; + +namespace TuneLab.Extensions.Scripted; + +// LSP 式帧:`Content-Length: \r\n\r\n` + UTF-8 JSON 体(设计文档 §5.1)。 +// 单读线程用 TryRead;写侧套锁(SendNotification 与 request 应答可并发)。 +internal static class WireFraming +{ + public const long MaxFrameBytes = 512L * 1024 * 1024; + + public static bool TryRead(Stream input, out string json) + { + json = string.Empty; + long length = -1; + // 头段:逐行读到空行为止;只认 Content-Length,其余头忽略(前向兼容)。 + while (true) + { + var line = ReadLine(input); + if (line == null) + return false; // EOF(宿主死/管道关) + if (line.Length == 0) + break; // 空行 = 头结束 + var idx = line.IndexOf(':'); + if (idx <= 0) + continue; + if (line[..idx].Trim().Equals("Content-Length", StringComparison.OrdinalIgnoreCase) + && long.TryParse(line[(idx + 1)..].Trim(), out var parsed) && parsed >= 0) + length = parsed; + } + if (length < 0) + return false; + if (length > MaxFrameBytes) + throw new InvalidDataException(string.Format("frame too large: {0} bytes", length)); + var body = new byte[(int)length]; + var done = 0; + while (done < body.Length) + { + var read = input.Read(body, done, body.Length - done); + if (read <= 0) + return false; + done += read; + } + json = Encoding.UTF8.GetString(body); + return true; + } + + public static void Write(Stream output, string json) + { + var body = Encoding.UTF8.GetBytes(json); + var header = Encoding.ASCII.GetBytes(string.Format("Content-Length: {0}\r\n\r\n", body.Length)); + output.Write(header, 0, header.Length); + output.Write(body, 0, body.Length); + output.Flush(); + } + + static string? ReadLine(Stream input) + { + var sb = new StringBuilder(64); + while (true) + { + var c = input.ReadByte(); + if (c < 0) + return sb.Length == 0 ? null : StripCr(sb); + if (c == '\n') + return StripCr(sb); + sb.Append((char)c); + if (sb.Length > 64 * 1024) + throw new InvalidDataException("header line too long"); + } + } + + static string StripCr(StringBuilder sb) + => sb.Length > 0 && sb[sb.Length - 1] == '\r' ? sb.ToString(0, sb.Length - 1) : sb.ToString(); +} diff --git a/TuneLab/Extensions/Scripted/WireProtocol.cs b/TuneLab/Extensions/Scripted/WireProtocol.cs index 253aeba4..0287ff50 100644 --- a/TuneLab/Extensions/Scripted/WireProtocol.cs +++ b/TuneLab/Extensions/Scripted/WireProtocol.cs @@ -9,4 +9,8 @@ namespace TuneLab.Extensions.Scripted; internal static class WireProtocol { public const int Version = 1; + + // wire JSON 规范里 Multiple(三态哨兵)不占形态——PropertyType 注释原文"瞬态、永不序列化", + // 它是 UI 多选合并层的概念,跨边界值恒为定值或 Null。 + public const string PropertyTags = "b n s o a"; } diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md index 42a11c57..855f5ea4 100644 --- a/docs/scripted-plugin-sidecar-design.md +++ b/docs/scripted-plugin-sidecar-design.md @@ -1,7 +1,8 @@ # 脚本插件 Sidecar 系统设计(Python / Node.js / Lua) -> 状态:**定稿;M0 已落地**(manifest 脚本字段、`WireProtocol` 门、`RuntimeLocator` 四级探测+核验、 -> `ScriptedEntryGate`、runtime 资源包工具链、31 项单测)。基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 +> 状态:**定稿;M1 已落地**(帧协议 + SidecarHost 生命周期 + RemoteFormat + Python façade + 假 sidecar 封条 + +> 真解释器 E2E;实现偏差见 §12.4)。此前:M0 已落地(manifest 字段、协议门、四级探测)。 +> 基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 > 配套阅读:[plugin-development.md](plugin-development.md)(V1 插件契约)、[sdk-api-evolution.md](sdk-api-evolution.md)(冻结 ABI 纪律)、[naming-glossary.md](naming-glossary.md)。 > 本文只设计**宿主侧与协议侧**;`TuneLab.SDK` / `TuneLab.Foundation` 一行不动(§1.3)。 @@ -219,7 +220,8 @@ B 类接口**不逐项远程化**。判据:sidecar 只需要 (a) 一次性物 (曲线面)、(c) 就地写(音频面)。因此: - 值对象:`PropertyObject` / `ObjectConfig` / 快照族 / `Synthesized*` → JSON(字段名 = SDK 属性名 camelCase; - `NaN` 用字符串 `"nan"`——JSON 不合法 NaN 的机械修)。 + NaN/±Infinity **一律字符串字面量** `"NaN"|"Infinity"|"-Infinity"`——net8 的 JsonDocument 不接受裸具名 + token,宿主读侧对两种形态都宽容;规范真源 `scripting/wire/v1.json`)。 - 句柄:`snapshot_id`(随会话 epoch 失效)、`evaluator` = `(snapshot_id, track_key)` 复合、 `segment_id`(宿主 `IAudioSegment` 握柄的镜像;`Create/Write/Commit/Resize/Dispose` 一一对应)。 - 活视图(context/note/automation 的通知流)→ **不镜像**,改为 §5.5 的推送事件流 + 快照拉取。 @@ -425,8 +427,9 @@ façade 内嵌 `wire=1` + `tunelab_sdk` 区间;版本随宿主台账发布, ### 12.1 测试资产 -`tests/plugins/{PyFormat,NodeFormat,LuaFormat}/` = manifest+源码,无 csproj,直接进 `tests/packages` -被 `pack-tlx.ps1` 收走(绕开三件套的 build 步);`manifest-variants` 补脚本条目变异体; +`tests/plugins/{PyFormat,NodeFormat,LuaFormat}/` = manifest+源码,无 csproj;`pack-tlx.ps1` 直接收编 +`tests/plugins` 下的无 csproj 目录(源即发行物,免三件套的 build 步); +`manifest-variants` 补脚本条目变异体; `tests/TuneLab.Tests/Scripted/*.cs` 用假 sidecar;E2E 标 `[Trait("runtime","needs-interpreter")]` CI 专属。协议封条:方法表 JSON schema 一份(`scripting/wire/v1.json`),三语言 façade 各自跑同一回放。 @@ -452,6 +455,23 @@ scripted.initTimeoutSec int = 0 # 0 = 无上限(§5.5 长任 `plugin-development.md` 增一节"脚本插件"指针。 - `naming-glossary.md` 已随行增补(sidecar / runtime 资源包 / epoch / 三面 / 会话重建)。 +### 12.4 M1 落地注记(与设计差异/推迟项) + +- **落地**:`ScriptedWire`(值编码全族,含 `DoubleWireConverter` 字符串字面量、PartInfo `kind` 判别器)、 + `WireFraming`、`SidecarHost`(握手/epoch/退避熔断/stderr 尾部/`ResetCircuit`)、 + `ScriptedRuntimeLedger`+`SidecarRegistry`(防线 4 与关停编排,挂在 `LoadExtensions`/`Destroy`)、 + Windows Job Object(防线 2 宿主侧半边)、`RemoteFormat`(文件交接)、`scripting/python/tunelab`、 + `scripting/wire/v1.json`(规范真源)、`tests/ScriptedFakeSidecar`(协议封条执行体,CI 免解释器)、 + 真 Python E2E(`TUNELAB_REQUIRE_PYTHON=1` 强制,否则缺解释器 vacuous pass)。 +- **推迟到 M2**:【下载运行时】按钮(runtime 官方制品尚不存在,下载映射要随 release 起效); + `IExtensionSettings` 远程代理;stderr 落盘滚动(M1 只在内存留 64KB 尾部)。 +- **实现差异**: + - 崩溃后不自动定时复活,**下一次调用驱动重 spawn**(backoff 计数仍生效、熔断照常)——M1 消费方 + format 是拉模式,自动重启只有缓存收益;effect 的 push 调度落地时(M2)再补后台重启环。 + - 心跳(防线 3)未实现——M1 所有调用带超时(RPC 默认 2min、握手 30s、声明面缓存支随 M2), + 挂起的 sidecar 在 format 面上被超时等价覆盖;M2 的 effect/voice 调度环接入时补真心跳。 + - 开发隔离 `TUNELAB_DATA_DIR` **仓内已有**(`PathManager.cs:16`),§12.2 设想的 env 覆盖钩子无需新增。 + --- ## 附录 A:PyVoice 端到端样例(M3 验收剧本) diff --git a/scripting/README.md b/scripting/README.md new file mode 100644 index 00000000..d3a769b9 --- /dev/null +++ b/scripting/README.md @@ -0,0 +1,24 @@ +# scripting — TuneLab 脚本插件 façade 与线协议规范 + +脚本插件(Python / Node.js / Lua)以 **跨进程 sidecar** 接入 V1 扩展系统; +宿主侧设计见 [docs/scripted-plugin-sidecar-design.md](../docs/scripted-plugin-sidecar-design.md)。 +本目录放各语言的 façade 包与**语言无关**的线协议规范(一份规范、三薄层实现)。 + +## 目录 + +| 路径 | 内容 | 状态 | +|---|---|---| +| `wire/v1.json` | 线协议规范(帧、方法表、值编码) | M1 ✅(format 面) | +| `python/tunelab/` | Python façade(纯 stdlib,PyPI 名 `tunelab`) | M1 ✅ | +| `node/` | Node façade `@tunelab/plugin` | M2 | +| `lua/` | Lua façade(纯 Lua 单文件 + LuaJIT FFI 能力探测) | M2 | + +## 构建与测试(不进 .sln;与 dotnet 管线平行) + +``` +python -m unittest discover -s scripting/python/tests -v # façade 单测(无第三方依赖) +# pip install -e scripting/python # 本地联调时给样例插件供包 +``` + +线协议改动纪律(设计文档 §1.2):不兼容改动 = `WireProtocol.Version` + 本目录规范文件**同批提档**; +加性新方法不提档。宿主 `tests/TuneLab.Tests/Scripted*` 与 `tests/ScriptedFakeSidecar` 是封条的另一端。 diff --git a/scripting/python/pyproject.toml b/scripting/python/pyproject.toml new file mode 100644 index 00000000..09c3144e --- /dev/null +++ b/scripting/python/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "tunelab" +version = "0.1.0" +description = "TuneLab scripted-plugin facade (Python sidecar protocol, wire v1)" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "TuneLab" }] +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", +] + +[project.urls] +Homepage = "https://github.com/mhbalthasar/TuneLab" + +[tool.setuptools] +packages = ["tunelab"] diff --git a/scripting/python/tests/test_facade.py b/scripting/python/tests/test_facade.py new file mode 100644 index 00000000..dc39fceb --- /dev/null +++ b/scripting/python/tests/test_facade.py @@ -0,0 +1,81 @@ +"""tunelab facade unit tests: python -m unittest discover -s scripting/python/tests""" +import io +import json +import math +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from tunelab import _framing, _props # noqa: E402 + + +class FramingTests(unittest.TestCase): + def test_roundtrip_utf8(self): + buf = io.BytesIO() + _framing.write_message(buf, {"m": "你好, NaN", "v": float("nan")}) + buf.seek(0) + msg = _framing.read_message(buf) + self.assertEqual(msg["m"], "你好, NaN") + self.assertTrue(math.isnan(msg["v"])) + + def test_content_length_is_bytes_not_chars(self): + buf = io.BytesIO() + _framing.write_message(buf, {"s": "汉字" * 50}) + raw = buf.getvalue() + header, _, body = raw.partition(b"\r\n\r\n") + self.assertEqual(int(header.split(b":")[1]), len(body)) + + def test_eof_returns_none(self): + self.assertIsNone(_framing.read_message(io.BytesIO(b""))) + + +class PropertyCodecTests(unittest.TestCase): + def test_tagged_roundtrip(self): + native = {"tension": 0.5, "mode": "normal", "on": True, "off": None, + "list": [1, "a"], "obj": {"inner": 2.0}} + tagged = {k: _props.encode_value(v) for k, v in native.items()} + self.assertEqual(tagged["tension"], {"n": 0.5}) + self.assertEqual(tagged["mode"], {"s": "normal"}) + self.assertEqual(tagged["on"], {"b": True}) + self.assertIsNone(tagged["off"]) + self.assertEqual(tagged["list"], {"a": [{"n": 1}, {"s": "a"}]}) + self.assertEqual(tagged["obj"], {"o": {"inner": {"n": 2.0}}}) + back = _props.decode_map(tagged) + self.assertEqual(back["tension"], 0.5) + self.assertEqual(back["mode"], "normal") + self.assertIs(back["on"], True) + self.assertIsNone(back["off"]) + self.assertEqual(back["list"], [1.0, "a"]) + self.assertEqual(back["obj"], {"inner": 2.0}) + + def test_nan_literal(self): + raw = json.dumps(_props.encode_value(float("nan"))) + self.assertIn("NaN", raw) # bare JSON literal, host AllowNamedFloatingPointLiterals reads it + self.assertTrue(math.isnan(_props.decode_value(json.loads(raw, parse_constant=lambda c: float(c))))) + + def test_project_walk_encodes_only_properties_maps(self): + project = {"tracks": [{"parts": [{"notes": [ + {"pos": 960, "dur": 480, "pitch": 60, "properties": {"tension": 0.5}}, + ]}]}]} + out = _props.walk_encode(project) + note = out["tracks"][0]["parts"][0]["notes"][0] + self.assertEqual(note["properties"], {"tension": {"n": 0.5}}) + self.assertEqual(note["pitch"], 60) # untouched + self.assertEqual(note["pos"], 960) # untouched (numbers ride plain in DataInfo) + again = _props.walk_decode(out) + n2 = again["tracks"][0]["parts"][0]["notes"][0] + self.assertEqual(n2["properties"], {"tension": 0.5}) + self.assertEqual(n2["pitch"], 60) + self.assertEqual(n2["pos"], 960) + + def test_plain_values_tolerated_on_read(self): + # host always writes tagged; readers MAY accept plain natives (third-party payloads) + self.assertEqual(_props.decode_value(1.5), 1.5) + self.assertEqual(_props.decode_value("x"), "x") + self.assertIs(_props.decode_value(True), True) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripting/python/tunelab/__init__.py b/scripting/python/tunelab/__init__.py new file mode 100644 index 00000000..deab2ec4 --- /dev/null +++ b/scripting/python/tunelab/__init__.py @@ -0,0 +1,22 @@ +"""TuneLab scripted-plugin facade (pure stdlib). + +Speaks the sidecar wire protocol (docs/scripted-plugin-sidecar-design.md §5): LSP-style +Content-Length frames of JSON-RPC 2.0 over stdio. M1 covers `format` entries; voice/effect +decorators land with M2/M3 and will keep this public surface additive. + +Usage (in the package's entry script, e.g. plugin/main.py): + + import tunelab + + @tunelab.format + class Plugin: + def import_project(self, stream_ref): ... # return a project dict + def export_project(self, stream_ref, project): ... + + tunelab.serve() +""" + +from ._service import format, serve, info, warn, error, capabilities + +__all__ = ["format", "serve", "info", "warn", "error", "capabilities"] +__version__ = "0.1.0" diff --git a/scripting/python/tunelab/_framing.py b/scripting/python/tunelab/_framing.py new file mode 100644 index 00000000..1b79d1ec --- /dev/null +++ b/scripting/python/tunelab/_framing.py @@ -0,0 +1,43 @@ +"""Content-Length framed JSON over stdio (wire §5.1).""" +import json +import sys + +_MAX_FRAME = 512 * 1024 * 1024 + + +def read_message(stream): + """Read one frame; returns the decoded object, or None on EOF (host died — caller must exit).""" + length = None + while True: + line = stream.readline() + if not line: + return None + line = line.rstrip(b"\r\n") + if line == b"": + break + name, _, value = line.partition(b":") + if name.strip().lower() == b"content-length": + length = int(value.strip()) + if length is None or length < 0 or length > _MAX_FRAME: + return None + body = b"" + while len(body) < length: + chunk = stream.read(length - len(body)) + if not chunk: + return None + body += chunk + return json.loads(body.decode("utf-8")) + + +def write_message(stream, obj): + payload = json.dumps(obj, ensure_ascii=False, allow_nan=True).encode("utf-8") + stream.write(b"Content-Length: %d\r\n\r\n" % len(payload)) + stream.write(payload) + stream.flush() + + +def open_stdio(): + # Binary pipes with explicit UTF-8; text-mode newline translation would corrupt framing. + stdin = getattr(sys.stdin, "buffer", sys.stdin) + stdout = getattr(sys.stdout, "buffer", sys.stdout) + return stdin, stdout diff --git a/scripting/python/tunelab/_props.py b/scripting/python/tunelab/_props.py new file mode 100644 index 00000000..93e114c3 --- /dev/null +++ b/scripting/python/tunelab/_props.py @@ -0,0 +1,108 @@ +"""Wire tagged values for PropertyObject maps (scripting/wire spec, §5.3). + +Host↔plugin property maps ride as JSON objects whose VALUES are tagged: + {"b": bool} | {"n": number} | {"s": string} | {"o": {...}} | {"a": [...]} | null(=Null) +The walker finds property maps by the key "properties" (camelCase, host naming policy) and +converts to/from plain Python natives so plugin authors never see the tags. +""" +import math + + +def encode_value(v): + if isinstance(v, bool): + return {"b": v} + if v is None: + return None + if isinstance(v, (int, float)): + if isinstance(v, float) and math.isnan(v): + return {"n": "NaN"} # 规范形态:字符串字面量(宿主 JsonDocument 不接受裸 NaN token) + if isinstance(v, float) and math.isinf(v): + return {"n": "Infinity" if v > 0 else "-Infinity"} + return {"n": float(v)} + if isinstance(v, str): + return {"s": v} + if isinstance(v, dict): + return {"o": {k: encode_value(x) for k, x in v.items()}} + if isinstance(v, (list, tuple)): + return {"a": [encode_value(x) for x in v]} + raise TypeError("cannot encode property value of type " + type(v).__name__) + + +def decode_value(v): + if v is None: + return None + if isinstance(v, bool): + return v + if isinstance(v, (int, float)): + return float(v) + if isinstance(v, str): + return v + if isinstance(v, list): + return [decode_value(x) for x in v] + if isinstance(v, dict): + if len(v) == 1: + (tag, payload), = v.items() + if tag in ("b", "n", "s", "o", "a"): + if tag == "o": + return decode_map(payload) + if tag == "a": + return [decode_value(x) for x in payload] + if tag == "n" and isinstance(payload, str): + return {"NaN": float("nan"), "Infinity": float("inf"), "-Infinity": float("-inf")}[payload] + return payload + # single-key dicts with a non-reserved key, or multi-key dicts: raw nested property map + return decode_map(v) + raise TypeError("cannot decode property value") + + +def decode_map(m): + return {k: decode_value(x) for k, x in m.items()} + + +def _special(v): + return isinstance(v, float) and (math.isnan(v) or math.isinf(v)) + + +def _special_str(v): + if math.isnan(v): + return "NaN" + return "Infinity" if v > 0 else "-Infinity" + + +def walk_encode(node): + """Encode a project tree on the way OUT to the host. + + - key \"properties\" (dict) → every value becomes a tagged property value + - any bare NaN/±Infinity float anywhere → its string literal (wire spec: NaN/±Infinity + are ALWAYS JSON strings — illegal bare tokens must never hit the pipe) + """ + if isinstance(node, list): + return [walk_encode(x) for x in node] + if isinstance(node, tuple): + return [walk_encode(x) for x in node] + if isinstance(node, dict): + out = {} + for k, val in node.items(): + if k == "properties" and isinstance(val, dict): + out[k] = {pk: encode_value(pv) for pk, pv in val.items()} + else: + out[k] = walk_encode(val) + return out + if _special(node): + return _special_str(node) + return node + + +def walk_decode(node): + """Decode property maps inside a project tree coming FROM the host into plugin-plain form.""" + if isinstance(node, list): + return [walk_decode(x) for x in node] + if isinstance(node, dict): + out = {} + for k, val in node.items(): + if k == "properties" and isinstance(val, dict): + out[k] = decode_map(val) + else: + out[k] = walk_decode(val) + return out + return node diff --git a/scripting/python/tunelab/_service.py b/scripting/python/tunelab/_service.py new file mode 100644 index 00000000..bb205aae --- /dev/null +++ b/scripting/python/tunelab/_service.py @@ -0,0 +1,203 @@ +"""The serve loop: initialize handshake + format.import/export dispatch (wire §5.2/§5.4).""" +import json +import os +import platform +import sys +import threading + +from . import _framing +from ._props import walk_encode, walk_decode + +WIRE_PROTOCOL = 1 + +_entries = {} # entry id (manifest-relative path) -> {"import": fn|None, "export": fn|None} +_lock = threading.Lock() +_stdout_stream = None + + +def format(cls=None, *, import_fn=None, export_fn=None, entry=None): + """Register the format implementation for one scripted entry. + + Decorator on a class exposing `import_project(stream_ref)` and/or + `export_project(stream_ref, project)`; or pass plain functions directly. + `entry` must match the manifest "entry" value when a package declares several. + """ + def register(importer, exporter, key): + _entries[key] = {"import": importer, "export": exporter} + + if cls is not None: + inst = cls() + importer = getattr(inst, "import_project", None) + exporter = getattr(inst, "export_project", None) + if importer is None and exporter is None: + raise ValueError("tunelab.format: neither import_project nor export_project defined") + register(importer, exporter, entry or _default_entry_id()) + return cls + + if import_fn is None and export_fn is None: + raise ValueError("tunelab.format: nothing to register") + register(import_fn, export_fn, entry or _default_entry_id()) + return import_fn if export_fn is None else export_fn + + +def _default_entry_id(): + # Manifest entries arrive keyed by the package-relative path; the host also passed us that + # very script as argv[1] (cwd = package dir), so derive the same key. + if len(sys.argv) > 1: + try: + return os.path.relpath(os.path.abspath(sys.argv[1]), os.getcwd()).replace(os.sep, "/") + except ValueError: + pass + return "" + + +def _send(obj): + with _lock: + _framing.write_message(_stdout_stream, obj) + + +def log(level, message): + try: + _send({"jsonrpc": "2.0", "method": "log.write", "params": {"level": level, "message": str(message)}}) + except Exception: + pass + + +def info(message): + log("info", message) + + +def warn(message): + log("warning", message) + + +def error(message): + log("error", message) + + +def capabilities(): + """What this facade declares at handshake (inline planes only until shm lands in M3).""" + return {"audio_plane": "inline", "curve_plane": "inline"} + + +def _reply(req_id, result=None, err=None): + msg = {"jsonrpc": "2.0", "id": req_id} + if err is not None: + msg["error"] = err + else: + msg["result"] = result + _send(msg) + + +def _handle_initialize(req): + params = req.get("params") or {} + declared = params.get("entries") or [] + provides = [] + for e in declared: + entry_id = e.get("entry") + handler = _entries.get(entry_id) + if handler is None and len(_entries) == 1: + # single-handler package: bind it to whatever the manifest declared (1 条目 = 1 实现) + handler = next(iter(_entries.values())) + _entries[entry_id] = handler + if handler is None: + continue + provides.append({ + "type": e.get("type", "format"), + "entry": entry_id, + "can_import": handler.get("import") is not None, + "can_export": handler.get("export") is not None, + }) + return { + "provides": provides, + "capabilities": capabilities(), + "runtime": { + "exe": sys.executable, + "version": platform.python_version(), + "arch": platform.machine(), + "flavor": "python", + }, + "facade_version": __import__("tunelab").__version__, + } + + +def _dispatch(req): + method = req.get("method") + params = req.get("params") or {} + req_id = req.get("id") + + if method == "initialize": + _reply(req_id, _handle_initialize(req)) + elif method == "shutdown": + _reply(req_id, {}) if req_id is not None else None + os._exit(0) + elif method == "format.import": + entry = _entries.get(params.get("entry")) or _single() + fn = entry and entry.get("import") + if fn is None: + _reply(req_id, err={"code": "bad_request", "message": "no importer for entry " + str(params.get("entry"))}) + return + try: + project = fn(params["stream_ref"]) + _reply(req_id, {"project": walk_encode(project)}) + except Exception as ex: + _reply(req_id, err={"code": "runtime_error", "message": str(ex)}) + elif method == "format.export": + entry = _entries.get(params.get("entry")) or _single() + fn = entry and entry.get("export") + if fn is None: + _reply(req_id, err={"code": "bad_request", "message": "no exporter for entry " + str(params.get("entry"))}) + return + try: + fn(params["stream_ref"], walk_decode(params.get("project") or {})) + _reply(req_id, {}) + except Exception as ex: + _reply(req_id, err={"code": "runtime_error", "message": str(ex)}) + elif req_id is not None: + _reply(req_id, err={"code": "bad_request", "message": "unknown method " + str(method)}) + # notifications without id: ignore (forward-compatible) + + +def _single(): + return next(iter(_entries.values())) if len(_entries) == 1 else None + + +def _arm_parent_death_guard(): + """Defense line 2's plugin-side half: exit when the host goes away, per platform.""" + import signal as _signal + if sys.platform.startswith("linux"): + try: + import ctypes + ctypes.CDLL("libc.so.6", use_errno=True).prctl(1, int(_signal.SIGTERM), 0, 0, 0) # PR_SET_PDEATHSIG + return + except Exception: + pass + # macOS (no PDEATHSIG) & fallbacks: poll our parent pid; reparenting to init/launchd = host gone. + parent = os.getppid() + + def watch(): + while True: + try: + threading.Event().wait(2.0) + except KeyboardInterrupt: + return + if os.getppid() != parent: + os._exit(0) + + threading.Thread(target=watch, daemon=True).start() + + +def serve(): + """Run the protocol loop until the host's pipe closes. Never returns before that.""" + global _stdout_stream + _stdout_stream = _framing.open_stdio()[1] + stdin, _ = _framing.open_stdio() + _arm_parent_death_guard() + while True: + try: + msg = _framing.read_message(stdin) + except Exception: + os._exit(1) # broken frame = protocol violation; let the host respawn us + if msg is None: + os._exit(0) # stdin EOF = host died (defense line 1) + _dispatch(msg) diff --git a/scripting/wire/v1.json b/scripting/wire/v1.json new file mode 100644 index 00000000..c62cc1e7 --- /dev/null +++ b/scripting/wire/v1.json @@ -0,0 +1,64 @@ +{ + "protocol-version": 1, + "status": "M1: control plane + format methods; effect/voice/audio-plane/shm sections land M2/M3", + "framing": { + "transport": "stdio (binary), one message per frame", + "frame": "Content-Length: \\r\\n\\r\\n", + "max-frame-bytes": 536870912, + "stdout-discipline": "child stdout is protocol-only; all logging goes through the log.write notification, stderr is captured by the host for diagnostics" + }, + "jsonrpc": { + "version": "2.0", + "ids": "opaque strings (host uses GUID-n); notifications carry no id", + "error-codes": ["bad_request", "unsupported_protocol", "stale_epoch", "stale_handle", "runtime_error", "cancelled", "timeout", "io"] + }, + "environment": { + "TUNELAB_PROTOCOL": "wire version the host speaks; child must refuse handshake otherwise", + "TUNELAB_EPOCH": "monotonic process-generation counter, 1 per (re)spawn", + "TUNELAB_PACKAGE_ID": "manifest package id" + }, + "methods": { + "initialize": { + "direction": "host->sidecar", + "params": { "protocol": "int", "package_id": "string", "entries": [{ "type": "format", "entry": "manifest-relative path", "import_suffixes": ["..."], "export_suffixes": ["..."] }] }, + "result": { "provides": [{ "type": "string", "entry": "string", "can_import": "bool", "can_export": "bool" }], "capabilities": { "audio_plane": "inline|shm", "curve_plane": "inline|shm" }, "runtime": { "exe": "string", "version": "string", "arch": "string", "flavor": "python|node|lua|luajit" }, "facade_version": "string" }, + "notes": "every host-declared format entry MUST be answered in provides; a missing one is a protocol violation and the host kills the child" + }, + "format.import": { + "direction": "host->sidecar", + "params": { "entry": "string", "stream_ref": "absolute path of a temp file holding the source bytes" }, + "result": { "project": "ProjectInfo, see value-encoding" }, + "temp-files": "under /Temp/io-/; host deletes after the call; the child must not outlive them" + }, + "format.export": { + "direction": "host->sidecar", + "params": { "entry": "string", "stream_ref": "absolute path the sidecar must write the exported bytes to", "project": "ProjectInfo, see value-encoding" }, + "result": {}, + "notes": "host owns the final output stream and copies stream_ref into it atomically" + }, + "log.write": { "direction": "sidecar->host", "notification": true, "params": { "level": "info|warning|error", "message": "string" } }, + "shutdown": { "direction": "host->sidecar", "params": { "reason": "string" }, "result": {} } + }, + "value-encoding": { + "naming": "property names camelCase; public fields included (Point.X/Y); enums as camelCase strings, numbers accepted on read", + "property-value": { + "bool": { "b": true }, + "number": { "n": 1.5 }, + "string": { "s": "x" }, + "object": { "o": { "key": "" } }, + "array": { "a": [""] }, + "null": null, + "nan-or-infinity": "NEVER a bare JSON token — always the strings \"NaN\"|\"Infinity\"|\"-Infinity\" (inside n payloads and for any plain double field alike; legal JSON, host reads all forms, writers MUST write strings)", + "multiple": "never crosses the wire (UI tri-state)", + "reading-plain-values": "readers MAY accept untagged JSON natives (bool/number/string/array/object); bare objects under a properties map with exactly one reserved key b|n|s|o|a of matching value kind are treated as tagged — authors who need such single-underscore-free literal maps as property objects must nest them under another key" + }, + "property-object": "JSON object of tagged values; appears at key \"properties\" inside notes/parts/effects/projects trees", + "foundation-map": "Map ↔ JSON object (insertion order preserved)", + "partinfo-discriminator": "PartInfo (abstract) ↔ concrete fields plus \"kind\": \"midi\" | \"audio\"" + }, + "lifecycle": { + "parent-death": "child MUST exit on stdin EOF (all platforms) and additionally arm OS-level guards: Windows host-side Job Object, Linux PR_SET_PDEATHSIG, macOS ppid poll — all inside the facade/host", + "crash": "host discards the process generation (epoch++), fails the in-flight calls with runtime_error, respawns on the next call with backoff; 5 deaths in 10 minutes open the circuit", + "progress-events-settings": "reserved for M2/M3 method groups; unknown notifications MUST be ignored by both sides (forward compatibility)" + } +} diff --git a/tests/ScriptedFakeSidecar/Program.cs b/tests/ScriptedFakeSidecar/Program.cs new file mode 100644 index 00000000..d1acfe2a --- /dev/null +++ b/tests/ScriptedFakeSidecar/Program.cs @@ -0,0 +1,195 @@ +using System.Text.Json; +using System.Text.Json.Nodes; + +// 假 sidecar:与宿主 SidecarHost 说同一套 LSP 帧 + JSON-RPC(wire v1)。 +// 供 M1 协议封条 / 生命周期测试用(CI 无需任何真解释器)。行为由 TUNELAB_FAKE_MODE 控制: +// normal 正常应答 initialize / format.import / format.export / log.write / shutdown +// no-capability initialize 应答缺 capabilities → 宿主应判握手失败 +// no-provides initialize 应答不兑现在 entries 里承诺的条目 → 宿主 provides 核对应判死 +// hang 握手后收到调用即挂起不答 → 宿主 RPC 超时 +// echo-log format.import 前发一条 log.write 通知 +// 数据面用固定合成工程(不依赖真解析),把 stream_ref 原样回读以验证文件交接往返。 +internal static class Program +{ + static int Main() + { + var mode = Environment.GetEnvironmentVariable("TUNELAB_FAKE_MODE") ?? "normal"; + Console.Error.WriteLine($"fake-up mode={mode} pid={Environment.ProcessId}"); + AppDomain.CurrentDomain.UnhandledException += (_, e) => + Console.Error.WriteLine("fake-unhandled: " + e.ExceptionObject); + using var stdin = Console.OpenStandardInput(); + using var stdout = Console.OpenStandardOutput(); + + while (TryRead(stdin, out var json)) + { + var req = JsonNode.Parse(json)!.AsObject(); + var method = req["method"]?.GetValue(); + + if (method == "shutdown") + return 0; + + var id = req["id"]?.DeepClone(); + if (method == "format.import" && mode == "crash-on-import") + Environment.Exit(3); // 模拟插件 native 崩溃:不留尾包,直接暴毙 + + if (method == "initialize") + { + var result = HandleInitialize(req["params"]?.AsObject() ?? new JsonObject(), mode); + if (mode == "no-capability") + result.AsObject().Remove("capabilities"); + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = result }); + continue; + } + + if (mode == "hang") + continue; // 不回任何后续调用 + + var p = req["params"]?.AsObject(); + switch (method) + { + case "log.write": + break; + case "format.import": + { + if (mode == "echo-log") + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["method"] = "log.write", ["params"] = new JsonObject { ["level"] = "info", ["message"] = "fake import" } }); + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject { ["project"] = SyntheticProject() } }); + break; + } + case "format.export": + { + var streamRef = p?["stream_ref"]?.GetValue(); + if (streamRef != null) + File.WriteAllText(streamRef, "FAKE-EXPORT"); + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); + break; + } + default: + Write(stdout, new JsonObject + { + ["jsonrpc"] = "2.0", ["id"] = id, + ["error"] = new JsonObject { ["code"] = "bad_request", ["message"] = "unknown method " + method }, + }); + break; + } + } + return 0; // stdin EOF(宿主死)→ 自卫退出(防线 1 的另一半:真 façade 也照此) + } + + static JsonNode HandleInitialize(JsonObject req, string mode) + { + // req = initialize 的 params(宿主发 {protocol, package_id, entries[]}) + var provides = new JsonArray(); + if (mode != "no-provides") + { + if (req["entries"] is JsonArray entries) + foreach (var e in entries) + provides.Add((JsonNode)new JsonObject { ["type"] = (string?)e?["type"] ?? "format", ["entry"] = e?["entry"]?.DeepClone() }); + } + return new JsonObject + { + ["provides"] = provides, + ["capabilities"] = new JsonObject { ["audio_plane"] = "inline", ["curve_plane"] = "inline" }, + ["runtime"] = new JsonObject { ["exe"] = Environment.ProcessPath ?? "fake", ["version"] = "0.0", ["arch"] = "x64", ["flavor"] = "dotnet-fake" }, + ["facade_version"] = "test", + }; + } + + // 与宿主 wire JSON 规范同形的合成工程:一个 track、一个 midi part、一个 note(带 properties, + // 钉住 PropertyObject / Map / 具名浮点的往返)。数值用 1e18 之类以避开 camelCase 大小写敏感键。 + static JsonNode SyntheticProject() => new JsonObject + { + ["tempos"] = new JsonArray { new JsonObject { ["pos"] = 0, ["bpm"] = 120 } }, + ["timeSignatures"] = new JsonArray { new JsonObject { ["barIndex"] = 0, ["numerator"] = 4, ["denominator"] = 4 } }, + ["tracks"] = new JsonArray + { + new JsonObject + { + ["name"] = "Fake Track", + ["gain"] = 0.0, + ["pan"] = 0.0, + ["mute"] = false, + ["solo"] = false, + ["asRefer"] = true, + ["color"] = "#RRGGBB", + ["parts"] = new JsonArray + { + new JsonObject + { + ["kind"] = "midi", + ["name"] = "Part", + ["pos"] = 960, + ["startOffset"] = 0, + ["endOffset"] = 0, + ["gain"] = 0.0, + ["soundSource"] = new JsonObject { ["kind"] = "voice", ["type"] = "fake", ["id"] = "bank" }, + ["effects"] = new JsonArray(), + ["notes"] = new JsonArray + { + new JsonObject { ["pos"] = 960, ["dur"] = 480, ["pitch"] = 60, ["lyric"] = "la", ["pronunciation"] = "la", + ["properties"] = new JsonObject { ["tension"] = new JsonObject { ["n"] = 0.5 }, ["mode"] = new JsonObject { ["s"] = "normal" } }, + ["leadingPhonemes"] = new JsonArray(), ["bodyPhonemes"] = new JsonArray(), ["bodyOffset"] = 0.0 }, + }, + ["automations"] = new JsonObject(), + ["piecewiseAutomations"] = new JsonObject(), + ["pitch"] = new JsonObject { ["segments"] = new JsonArray() }, + ["vibratos"] = new JsonArray(), + ["properties"] = new JsonObject(), + }, + }, + }, + }, + }; + + const long MaxFrame = 512L * 1024 * 1024; + + static bool TryRead(Stream input, out string json) + { + json = ""; + long length = -1; + while (true) + { + var line = ReadLine(input); + if (line == null) return false; + if (line.Length == 0) break; + var idx = line.IndexOf(':'); + if (idx > 0 && line[..idx].Trim().Equals("Content-Length", StringComparison.OrdinalIgnoreCase) + && long.TryParse(line[(idx + 1)..].Trim(), out var n) && n >= 0) + length = n; + } + if (length < 0 || length > MaxFrame) return false; + var body = new byte[length]; + int done = 0; + while (done < length) + { + int r = input.Read(body, done, (int)length - done); + if (r <= 0) return false; + done += r; + } + json = System.Text.Encoding.UTF8.GetString(body); + return true; + } + + static string? ReadLine(Stream s) + { + var sb = new System.Text.StringBuilder(); + while (true) + { + int c = s.ReadByte(); + if (c < 0) return sb.Length == 0 ? null : Trim(sb); + if (c == '\n') return Trim(sb); + sb.Append((char)c); + } + static string Trim(System.Text.StringBuilder b) + => b.Length > 0 && b[^1] == '\r' ? b.ToString(0, b.Length - 1) : b.ToString(); + } + + static void Write(Stream output, JsonNode node) + { + var body = System.Text.Encoding.UTF8.GetBytes(node.ToJsonString(new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping })); + var header = System.Text.Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n"); + output.Write(header); + output.Write(body); + output.Flush(); + } +} diff --git a/tests/ScriptedFakeSidecar/TuneLab.ScriptedFakeSidecar.csproj b/tests/ScriptedFakeSidecar/TuneLab.ScriptedFakeSidecar.csproj new file mode 100644 index 00000000..f8bd93c1 --- /dev/null +++ b/tests/ScriptedFakeSidecar/TuneLab.ScriptedFakeSidecar.csproj @@ -0,0 +1,14 @@ + + + + + Exe + net8.0 + disable + enable + TuneLab.ScriptedFakeSidecar + latest + + + diff --git a/tests/TuneLab.Tests/ScriptedEntryGateTests.cs b/tests/TuneLab.Tests/ScriptedEntryGateTests.cs index 2358d473..9f7e8e77 100644 --- a/tests/TuneLab.Tests/ScriptedEntryGateTests.cs +++ b/tests/TuneLab.Tests/ScriptedEntryGateTests.cs @@ -92,15 +92,16 @@ public void RuntimeNotFoundIsSkippedWithActionableTrail() } [Fact] - public void ResolvedRuntimeStillSkippedInM0ButReportsResolution() + public void ResolvedRuntimeYieldsReadyWithResolutionInMessage() { Directory.CreateDirectory(mPkg); var prober = new FakeProber(); var exe = FakeProber.FakeExe(mPkg, "python.exe", prober, "python", "3.12.7"); var locator = new RuntimeLocator(prober, () => exe.Exe, () => "", () => "", _ => null, () => mPkg); var r = ScriptedEntryGate.Evaluate(Entry("python>=3.10"), mPkg, locator); - Assert.Equal(ScriptedGateStatus.Skipped, r.Status); // M0 的诚实:runtime 就位也不谎报 Loaded - Assert.Contains("not implemented by this host build yet", r.Message); + Assert.Equal(ScriptedGateStatus.Ready, r.Status); + Assert.Equal("python", r.Runtime.Flavor); + Assert.Equal("3.12.7", r.Runtime.Version); Assert.Contains("3.12.7", r.Message); } } diff --git a/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs b/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs new file mode 100644 index 00000000..761304dd --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs @@ -0,0 +1,85 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using TuneLab.Extensions; +using TuneLab.Extensions.Formats; +using TuneLab.Extensions.Scripted; +using TuneLab.SDK; +using Xunit; + +namespace TuneLab.Tests; + +// 端到端真解释器集成(设计 §12.0 M1 验收:HeadlessHost E2E 装→导入→导出)。 +// 用 SidecarHost/RemoteFormat 而非完整 FormatsManager,是为了不碰 %APPDATA% 的已装扩展目录。 +// python 不在场即 vacuously pass(CI 由 needs-interpreter 任务真跑)。trait 标记, +// 单独运行: dotnet test --filter "runtime=needs-interpreter" +[Trait("runtime", "needs-interpreter")] +public class ScriptedPythonE2ETests +{ + static readonly string sPackageDir = LocateUp("tests", "plugins", "PyFormat"); + + static string LocateUp(params string[] tail) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(new[] { dir.FullName }.Concat(tail).ToArray()); + if (Directory.Exists(candidate)) + return candidate; + dir = dir.Parent; + } + throw new DirectoryNotFoundException("PyFormat package not found: " + Path.Combine(tail)); + } + + [Fact] + public void RealPythonImportExportRoundTrip() + { + if (!RuntimeLocator.Default.TryResolve(ParseSpec("python>=3.10"), out var python, out var trail)) + { + if (Environment.GetEnvironmentVariable("TUNELAB_REQUIRE_PYTHON") == "1") + Assert.Fail("python not resolved: " + trail); + return; // 无解释器环境:vacuous pass;真 CI 装了 python 才验 + } + + var facadeDir = LocateUp("scripting", "python"); + Environment.SetEnvironmentVariable("PYTHONPATH", facadeDir); // 子进程继承 + + var entry = "plugin/main.py"; + var host = SidecarRegistry.For("com.tunelab.test.pyformat", sPackageDir, python, entry); + host.ExpectFormat(entry, ["pyt"], ["pyt"]); + var proxy = new RemoteFormat(host, entry); + var sw = System.Diagnostics.Stopwatch.StartNew(); + try + { + // 导入:一行一音符的文本 → ProjectInfo + using var input = new MemoryStream(Encoding.UTF8.GetBytes("60 0 480 la\n62 480 240 du\n")); + var project = ((IImportFormat)proxy).Deserialize(input); + Assert.Single(project.Tracks); + var part = Assert.IsType(project.Tracks[0].Parts[0]); + Assert.Equal(2, part.Notes.Count); + Assert.Equal(60, part.Notes[0].Pitch); + Assert.Equal("la", part.Notes[0].Lyric); + Assert.Equal("pytxt", part.Notes[0].Properties.GetString("source")); // 跨语言 tagged property 往返 + + // 导出:ProjectInfo → 文本 + using var output = new MemoryStream(); + ((IExportFormat)proxy).Serialize(output, project); + var text = Encoding.UTF8.GetString(output.ToArray()); + Assert.Contains("60 0 480 la", text); + Assert.Contains("62 480 240 du", text); + } + finally + { + host.Shutdown(); + } + // 真子进程往返(spawn + 两次 RPC)不可能亚百毫秒——这条把"vacuous 早退"钉死。 + Assert.True(sw.ElapsedMilliseconds > 50, $"round-trip suspiciously fast: {sw.ElapsedMilliseconds}ms"); + } + + static RuntimeSpec ParseSpec(string s) + { + Assert.True(RuntimeSpec.TryParse(s, out var spec, out _)); + return spec; + } +} diff --git a/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs b/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs new file mode 100644 index 00000000..8ff2cebc --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs @@ -0,0 +1,54 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using TuneLab.Extensions.Scripted; +using TuneLab.SDK; +using Xunit; + +namespace TuneLab.Tests; + +// RemoteFormat 的宿主侧全链:假 sidecar 走真管道,验文件交接(导入落 stream_ref、导出读回输出流) +// 与 ProjectInfo wire 编解码在 RPC 两端的往返(设计 §5.4)。解释器缺席也覆盖。 +public class ScriptedRemoteFormatTests +{ + static RemoteFormat MakeProxy(string pkgId) + { + var dll = ScriptedSidecarHostTests.FakeDllPath; + var host = SidecarRegistry.For(pkgId, Path.GetDirectoryName(dll)!, new RuntimeInfo(ScriptedSidecarHostTests.Dotnet, "dotnet-fake", "0.0", "x64"), Path.GetFileName(dll)); + host.ExpectFormat(Path.GetFileName(dll), ["tst"], []); + return new RemoteFormat(host, Path.GetFileName(dll)); + } + + [Fact] + public void ImportMaterializesProjectFromSyntheticReply() + { + var proxy = MakeProxy("com.test.rf-import"); + using var input = new MemoryStream(Encoding.UTF8.GetBytes("ignored by fake; content passes as stream_ref")); + var project = ((IImportFormat)proxy).Deserialize(input); + + Assert.Single(project.Tracks); + var part = Assert.IsType(project.Tracks[0].Parts[0]); + var note = part.Notes[0]; + Assert.Equal(60, note.Pitch); + Assert.Equal("la", note.Lyric); + Assert.Equal(0.5, note.Properties.GetDouble("tension")); // tagged {"n":0.5} 解回原生 + Assert.Equal("normal", note.Properties.GetString("mode")); + Assert.Equal(SourceKind.Voice, part.SoundSource.Kind); + SidecarRegistry.ShutdownAll(); + } + + [Fact] + public void ExportRoundTripsProjectAndWritesBytes() + { + var proxy = MakeProxy("com.test.rf-export"); + var project = new ProjectInfo(); + project.Tracks.Add(new TrackInfo { Name = "T", Parts = { new MidiPartInfo { Name = "P" } } }); + + using var output = new MemoryStream(); + ((IExportFormat)proxy).Serialize(output, project); + // 假 sidecar 写死 FAKE-EXPORT;宿主把它读进自己拥有的输出流(stream 生命周期归宿主,见 IExportFormat 约定)。 + Assert.Equal("FAKE-EXPORT", Encoding.UTF8.GetString(output.ToArray())); + SidecarRegistry.ShutdownAll(); + } +} diff --git a/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs b/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs new file mode 100644 index 00000000..040480c8 --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using TuneLab.Extensions.Scripted; +using Xunit; + +namespace TuneLab.Tests; + +// 真进程 + 真 stdio 帧协议的生命周期封条(设计文档 §12.0 M1)。对端是 .NET 假 sidecar +// (tests/ScriptedFakeSidecar),CI 无需任何解释器;测的是 SidecarHost 的握手/请求应答/ +// provides 核对/崩溃重生 epoch/退避熔断/优雅关停。 +public class ScriptedSidecarHostTests +{ + static readonly string sFakeDir = ResolveFakeDir(); + static readonly string sFakeDll = Path.Combine(sFakeDir, "TuneLab.ScriptedFakeSidecar.dll"); + internal static string FakeDllPath => sFakeDll; + internal static string Dotnet => sDotnet; + static readonly string sDotnet = Process.GetCurrentProcess().ProcessName.Equals("dotnet", StringComparison.OrdinalIgnoreCase) + ? "dotnet" + : FindDotnet(); + + static string ResolveFakeDir() + { + // 从测试程序集目录向上找仓库根下的构建产物(tests/ 下两级)。 + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var hit = Path.Combine(dir.FullName, "tests", "ScriptedFakeSidecar", "bin", "Debug", "net8.0", "TuneLab.ScriptedFakeSidecar.dll"); + if (File.Exists(hit)) + return Path.GetDirectoryName(hit)!; + dir = dir.Parent; + } + throw new FileNotFoundException("fake sidecar dll not built; run: dotnet build tests/ScriptedFakeSidecar -c Debug"); + } + + static string FindDotnet() + { + var onPath = Environment.GetEnvironmentVariable("PATH")? + .Split(Path.PathSeparator).Select(d => Path.Combine(d, OperatingSystem.IsWindows() ? "dotnet.exe" : "dotnet")) + .FirstOrDefault(File.Exists); + return onPath ?? "dotnet"; + } + + static SidecarHost MakeHost(string mode, TimeSpan? callTimeout = null) + { + var runtime = new RuntimeInfo(Dotnet, "dotnet-fake", "0.0", "x64"); + var host = new SidecarHost("com.test.fake", sFakeDir, runtime, "TuneLab.ScriptedFakeSidecar.dll", callTimeout) + { + ExtraEnvironment = new() { ["TUNELAB_FAKE_MODE"] = mode }, + }; + host.ExpectFormat("TuneLab.ScriptedFakeSidecar.dll", ["tst"], []); + return host; + } + + static ScriptedSidecarHostTests() + { + Assert.True(File.Exists(sFakeDll), "fake sidecar must be built before these tests run"); + } + + [Fact] + public async Task HandshakeSucceedsAndEpochIsOne() + { + var host = MakeHost("normal"); + try + { + await host.EnsureRunningAsync(); + Assert.Equal(1, host.Epoch); + } + catch (Exception ex) + { + Assert.Fail("handshake failed: " + ex + " || reader: " + host.LastReaderFault + " || stderr: " + host.StderrTail); + } + finally { host.Shutdown(); } + } + + [Fact] + public async Task FormatImportReturnsSyntheticProject() + { + var host = MakeHost("normal"); + try + { + var result = await host.CallAsync("format.import", new System.Text.Json.Nodes.JsonObject + { + ["entry"] = "TuneLab.ScriptedFakeSidecar.dll", + ["stream_ref"] = Path.Combine(Path.GetTempPath(), "does-not-exist-" + Path.GetRandomFileName()), + }); + Assert.NotNull(result); + Assert.NotNull(result!["project"]); + Assert.NotNull(result["project"]!["tracks"]); + } + finally { host.Shutdown(); } + } + + [Fact] + public async Task HandshakeWithoutCapabilitiesKillsProcess() + { + var host = MakeHost("no-capability"); + try + { + await Assert.ThrowsAnyAsync(() => host.EnsureRunningAsync()); + // 握手失败:不留活口(进程被杀),epoch 仍推进 + Assert.True(host.Epoch >= 1); + } + finally { host.Shutdown(); } + } + + [Fact] + public async Task CallTimesOutWhenPeerHangs() + { + var host = MakeHost("hang", TimeSpan.FromSeconds(3)); + try + { + // 握手用同一超时?不——握手固定 30s。hang 只挂后续调用,握手仍成功。 + await host.EnsureRunningAsync(); + var ex = await Assert.ThrowsAsync(() => + host.CallAsync("format.import", new System.Text.Json.Nodes.JsonObject { ["entry"] = "x", ["stream_ref"] = "y" })); + Assert.Equal("timeout", ex.Code); + } + finally { host.Shutdown(); } + } + + [Fact] + public async Task CrashThenNextCallRespawnsWithBumpedEpoch() + { + var host = MakeHost("crash-on-import"); + try + { + var ex = await Assert.ThrowsAnyAsync(() => + host.CallAsync("format.import", new System.Text.Json.Nodes.JsonObject { ["entry"] = "x", ["stream_ref"] = "y" })); + Assert.True(host.Epoch >= 1); + + // 重开:换回 normal 模式,下一次调用触发 EnsureRunning 重 spawn,epoch 递增。 + host.ExtraEnvironment!["TUNELAB_FAKE_MODE"] = "normal"; + host.ResetCircuit(); + var ok = await host.CallAsync("format.import", new System.Text.Json.Nodes.JsonObject + { + ["entry"] = "TuneLab.ScriptedFakeSidecar.dll", + ["stream_ref"] = Path.GetTempPath(), + }); + Assert.NotNull(ok?["project"]); + Assert.True(host.Epoch >= 2); + } + finally { host.Shutdown(); } + } + + [Fact] + public async Task ShutdownIsIdempotentAndStopsProcess() + { + var host = MakeHost("normal"); + await host.EnsureRunningAsync(); + host.Shutdown(); + host.Shutdown(); // 幂等,不抛 + await Assert.ThrowsAsync(() => host.EnsureRunningAsync()); + } +} diff --git a/tests/TuneLab.Tests/ScriptedWireCodecTests.cs b/tests/TuneLab.Tests/ScriptedWireCodecTests.cs new file mode 100644 index 00000000..c8517af9 --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedWireCodecTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Linq; +using System.Text.Json; +using TuneLab.Foundation; +using TuneLab.SDK; +using Xunit; + +namespace TuneLab.Tests; + +// scripting/wire/v1.json 的宿主侧封条:值编码规范(tagged property、具名浮点、Map 保序、 +// PartInfo 判别器、枚举 camelCase)逐条钉住。假 sidecar / 三语言 façade 都按这份规范实现, +// 宿主偏离规范 = 本类红。 +public class ScriptedWireCodecTests +{ + static ProjectInfo SampleProject() + { + var props = new Map(); + props["s"] = PropertyValue.Create(0.5); + props["b"] = PropertyValue.Create(true); + props["t"] = PropertyValue.Create("str"); + props["arr"] = PropertyValue.Create(new PropertyArray(new PropertyValue[] { PropertyValue.Create(1.0), PropertyValue.Create("x") })); + var inner = new Map(); + inner["inner"] = PropertyValue.Create(-2.25); + props["obj"] = PropertyValue.Create(new PropertyObject(inner)); + var midi = new MidiPartInfo + { + Name = "P", + Pos = 960, + SoundSource = new SoundSourceInfo { Kind = SourceKind.Voice, Type = "Eng", Id = "bank" }, + Notes = + { + new NoteInfo + { + Pos = 960, Dur = 480, Pitch = 60, Lyric = "la", Pronunciation = "la", + Properties = new PropertyObject(props), + }, + }, + }; + midi.Automations["Growl"] = new AutomationInfo { DefaultValue = 0.3, Points = { new Point(960, 0.5), new Point(1440, 0.7) } }; + midi.Vibratos.Add(new VibratoInfo { Pos = 100, Dur = 200, Frequency = 5.5, AffectedAutomations = { ["Volume"] = 1.0 } }); + return new ProjectInfo + { + Tempos = { new TempoInfo { Pos = 0, Bpm = 120 } }, + TimeSignatures = { new TimeSignatureInfo { BarIndex = 0, Numerator = 4, Denominator = 4 } }, + Tracks = { new TrackInfo { Name = "T", Parts = { midi } } }, + }; + } + + [Fact] + public void RoundTripsEveryShapeTheWireCarries() + { + var json = Extensions.Scripted.ScriptedWire.Serialize(SampleProject()); + var back = Extensions.Scripted.ScriptedWire.Deserialize(json)!; + + var note = ((MidiPartInfo)back.Tracks[0].Parts[0]).Notes[0]; + Assert.Equal("la", note.Lyric); + Assert.Equal(60, note.Pitch); // required int 成员 + Assert.Equal(0.5, note.Properties.GetDouble("s")); + Assert.True(note.Properties.GetBoolean("b")); + Assert.Equal("str", note.Properties.GetString("t")); + var arr = note.Properties.Map["arr"].ToArray(out var a) ? a : null; + Assert.NotNull(arr); + Assert.Equal(2, arr!.Count); + Assert.Equal("x", arr[1].ToString(out var s) ? s : null); + var obj = note.Properties.Map["obj"].ToObject(out var o) ? o : null; + Assert.NotNull(obj); + Assert.Equal(-2.25, obj!.GetDouble("inner")); + + var automations = ((MidiPartInfo)back.Tracks[0].Parts[0]).Automations; + Assert.Equal(0.3, automations["Growl"].DefaultValue); + Assert.Equal(new Point(1440, 0.7), automations["Growl"].Points[1]); // 公有字段 X/Y + Assert.Equal(SourceKind.Voice, ((MidiPartInfo)back.Tracks[0].Parts[0]).SoundSource.Kind); + Assert.Equal(1.0, ((MidiPartInfo)back.Tracks[0].Parts[0]).Vibratos[0].AffectedAutomations["Volume"]); + } + + [Fact] + public void WireFormMatchesSpec() + { + var json = Extensions.Scripted.ScriptedWire.Serialize(SampleProject()); + using var doc = JsonDocument.Parse(json); + var track = doc.RootElement.GetProperty("tracks")[0]; + var part = track.GetProperty("parts")[0]; + Assert.Equal("midi", part.GetProperty("kind").GetString()); // PartInfo 判别器 + var props = part.GetProperty("notes")[0].GetProperty("properties"); + Assert.Equal(JsonValueKind.True, props.GetProperty("b").GetProperty("b").ValueKind); // tagged {"b":true} + Assert.Equal(0.5, props.GetProperty("s").GetProperty("n").GetDouble()); + Assert.Equal("voice", part.GetProperty("soundSource").GetProperty("kind").GetString()); // 枚举 camelCase 名 + Assert.False(part.GetProperty("notes")[0].TryGetProperty("Properties", out _)); // 一律 camelCase + } + + [Fact] + public void MapsPreserveInsertionOrderOnTheWire() + { + var map = new Map(); + map["zulu"] = 1; map["alpha"] = 2; map["mike"] = 3; + var json = JsonSerializer.Serialize(map, Extensions.Scripted.ScriptedWire.Options); + using var doc = JsonDocument.Parse(json); + Assert.Equal(new[] { "zulu", "alpha", "mike" }, doc.RootElement.EnumerateObject().Select(p => p.Name)); + } + + [Fact] + public void MultipleNeverCrossesTheWire() + { + var props = new Map(); + props["x"] = PropertyValue.Multiple; + var part = new MidiPartInfo { Properties = new PropertyObject(props) }; + var project = new ProjectInfo(); + project.Tracks.Add(new TrackInfo { Parts = { part } }); + Assert.Throws(() => Extensions.Scripted.ScriptedWire.Serialize(project)); + } + + [Fact] + public void NaNAndInfinityRideStringLiterals() + { + // 写侧形态 = 字符串字面量 {"n":"NaN"}(合法 JSON,STJ AllowNamedFloatingPointLiterals 的写出形); + // 读侧两种都收(字符串字面量 / 裸具名 token,后者 python json allow_nan 产)。 + var props = new Map(); + props["nan"] = PropertyValue.Create(double.NaN); + props["inf"] = PropertyValue.Create(double.PositiveInfinity); + var json = Extensions.Scripted.ScriptedWire.Serialize(new PropertyObject(props)); + Assert.Contains("\"n\":\"NaN\"", json); + Assert.Contains("\"n\":\"Infinity\"", json); + var back = Extensions.Scripted.ScriptedWire.Deserialize(json)!; + Assert.True(double.IsNaN(back.Map["nan"].ToDouble(out var n) ? n : 0)); + Assert.True(double.IsPositiveInfinity(back.Map["inf"].ToDouble(out var i) ? i : 0)); + } + + [Fact] + public void TolerantReadAcceptsPlainNativePropertyValues() + { + // 宽容读侧:第三方手写的裸值(未 tagged)按类型推断收——宿主写侧永远带 tag。 + var json = """{"tension":0.5,"on":true,"word":"x","nest":{"deep":1.0},"nothing":null}"""; + var obj = Extensions.Scripted.ScriptedWire.Deserialize(json)!; + Assert.Equal(0.5, obj.GetDouble("tension")); + Assert.True(obj.GetBoolean("on")); + Assert.Equal("x", obj.GetString("word")); + Assert.True(obj.Map["nest"].ToObject(out var nest)); + Assert.Equal(1.0, nest!.GetDouble("deep")); + Assert.True(obj.Map["nothing"].IsNull()); + } +} diff --git a/tests/TuneLab.Tests/TuneLab.Tests.csproj b/tests/TuneLab.Tests/TuneLab.Tests.csproj index 93a189a6..2d2450bb 100644 --- a/tests/TuneLab.Tests/TuneLab.Tests.csproj +++ b/tests/TuneLab.Tests/TuneLab.Tests.csproj @@ -19,6 +19,8 @@ + + diff --git a/tests/pack-tlx.ps1 b/tests/pack-tlx.ps1 index 4531235a..e149d942 100644 --- a/tests/pack-tlx.ps1 +++ b/tests/pack-tlx.ps1 @@ -16,6 +16,10 @@ New-Item -ItemType Directory -Force -Path $out | Out-Null $sources = @() $sources += Get-ChildItem -Directory (Join-Path $root 'packages') -ErrorAction SilentlyContinue $sources += Get-ChildItem -Directory (Join-Path $root 'manifest-variants') -ErrorAction SilentlyContinue +# 脚本插件(Python/Node/Lua):tests/plugins 下【无 csproj】的目录 = 源即发行物,直接收编 +# (有 csproj 的由构建产出进 packages/,此处跳过以免双打)。 +$sources += Get-ChildItem -Directory (Join-Path $root 'plugins') -ErrorAction SilentlyContinue | + Where-Object { -not (Get-ChildItem -Path $_.FullName -Filter *.csproj -File -ErrorAction SilentlyContinue) } if ($sources.Count -eq 0) { Write-Warning "未找到任何包。请先运行: dotnet build tests/TestPlugins.slnx -c Debug" diff --git a/tests/plugins/PyFormat/Introduction.md b/tests/plugins/PyFormat/Introduction.md new file mode 100644 index 00000000..515f998f --- /dev/null +++ b/tests/plugins/PyFormat/Introduction.md @@ -0,0 +1,10 @@ +# Py Format + +脚本 format 插件的测试样例:`.pyt` = 一行一个音符的文本(`pitch pos dur lyric`)。 + +- 联调(宿主测试 `ScriptedPythonE2ETests` 自动做):`PYTHONPATH` 指 `scripting/python`, + 本目录作为包目录直接 `ExtensionManager.Load`。 +- 真实安装体验:`pwsh tests/pack-tlx.ps1; pwsh tests/install-tlx.ps1 PyFormat` → 拖入宿主 + (无 csproj 的 `tests/plugins/*` 目录被 pack 脚本直接收编,免构建)。 +- 发布给自己的用户时,作者应 `pip install --target=plugin/vendor tunelab`(vendoring 纪律, + 设计文档 §12.2),本测试样例免此步靠 PYTHONPATH。 diff --git a/tests/plugins/PyFormat/manifest.json b/tests/plugins/PyFormat/manifest.json new file mode 100644 index 00000000..370413af --- /dev/null +++ b/tests/plugins/PyFormat/manifest.json @@ -0,0 +1,14 @@ +{ + "id": "com.tunelab.test.pyformat", + "name": "Py Format (txt notes)", + "version": "1.0.0", + "author": "TuneLab Tests", + "description": "脚本 format 样例:pitch pos dur lyric 一行一音符的 .pyt 文本格式。需要系统 Python>=3.10(PYTHONPATH 指 scripting/python)与真实 tuneLab 侧载。", + "sdk-version": "1.1", + "type": "format", + "suffixes": ["pyt"], + "introduction": "Introduction.md", + "runtime": "python>=3.10,<4", + "entry": "plugin/main.py", + "platforms": ["win", "osx", "linux"] +} diff --git a/tests/plugins/PyFormat/plugin/main.py b/tests/plugins/PyFormat/plugin/main.py new file mode 100644 index 00000000..e1c35569 --- /dev/null +++ b/tests/plugins/PyFormat/plugin/main.py @@ -0,0 +1,51 @@ +# PyFormat sample — a .pyt file is one note per line: " ". +# +# Real users vendor the facade (pip install --target=plugin/vendor tunelab); this test package +# gets it from PYTHONPATH (ScriptedPythonE2ETests sets it, README in this folder). +import tunelab + + +@tunelab.format +class PyTxtFormat: + def import_project(self, stream_ref): + notes = [] + with open(stream_ref, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + bits = line.split() + notes.append({ + "pos": float(bits[1]), "dur": float(bits[2]), "pitch": int(bits[0]), + "lyric": bits[3] if len(bits) > 3 else "la", + "pronunciation": "", + "properties": {"source": "pytxt"}, + "leadingPhonemes": [], "bodyPhonemes": [], "bodyOffset": 0.0, + }) + return { + "tempos": [{"pos": 0.0, "bpm": 120.0}], + "timeSignatures": [{"barIndex": 0, "numerator": 4, "denominator": 4}], + "tracks": [{ + "name": "Py Track", "gain": 0.0, "pan": 0.0, "mute": False, "solo": False, + "asRefer": True, "color": "", + "parts": [{ + "kind": "midi", "name": "Py Part", "pos": 0.0, "startOffset": 0.0, "endOffset": 0.0, + "gain": 0.0, "soundSource": {"kind": "voice", "type": "", "id": ""}, + "effects": [], "notes": notes, "automations": {}, "piecewiseAutomations": {}, + "pitch": {"segments": []}, "vibratos": [], "properties": {}, + }], + }], + } + + def export_project(self, stream_ref, project): + lines = [] + for track in project.get("tracks", []): + for part in track.get("parts", []): + for note in part.get("notes", []): + lyric = note.get("lyric") or "la" + lines.append("%d %g %g %s" % (int(note["pitch"]), float(note["pos"]), float(note["dur"]), lyric)) + with open(stream_ref, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + ("\n" if lines else "")) + + +tunelab.serve() From 9f967c2b77e1a44073df19650ff06b0319810b77 Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Mon, 14 Sep 2026 20:35:20 +0800 Subject: [PATCH 04/12] =?UTF-8?q?feat(scripted):=20M2a=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20config=20wire=20codec=E3=80=81=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E8=BF=9C=E7=A8=8B=E4=BB=A3=E7=90=86=EF=BC=88=E6=8F=A1=E6=89=8B?= =?UTF-8?q?=E8=A1=A5=E5=8F=91=E6=97=B6=E5=BA=8F=EF=BC=89=E3=80=81=E5=BF=83?= =?UTF-8?q?=E8=B7=B3=E6=9D=80=E5=8D=8A=E6=AD=BB=E3=80=81stderr=20=E8=90=BD?= =?UTF-8?q?=E7=9B=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计 §12.4 的 M2a 切片:把 M1 记为"推迟"的心跳/stderr 滚动/IExtensionSettings 代理补齐, 并为 effect/voice(M2b/M3)立好 config 线编解码地基。SDK 冻结面仍零改动。 - ScriptedConfigWire:sidecar 声明的控件配置按【静态工厂 + With 链】重建(D 类封闭族反射写不进); slider/checkbox/textbox(combo(number/path/automation(分段=defaultValue "NaN")/object 全型; 自定义 scale/format lambda 不可跨线 → NotSupportedException 人话拒(声明数据与进程内代码分界, 规范入 scripting/wire/v1.json "config" 节)。ObjectConfig.Properties 键序/displayText 保真。 - settings 远程代理:manifest 条目级 settings:true(宿主 DTO)→ RegisterScriptedFormatEntry 传 declaresSettings;RemoteFormat 显式实现 IExtensionSettings(GetSettingsConfig→settings.get_config + config wire;ApplySettings→host.SetSettings)。懒 spawn 时序接合:未运行存值、随 initialize 的 entries[].settings 补发(ApplyPersisted 必早于任何调用的既有次序被天然接住),运行中即时通知。 - 心跳(防线 3,§3.3):15s ping/5s 超时/4 连失联 → KillTree → 死亡流程与熔断计数; SidecarHost(pingTimeout) 注入测试加速;OnDeath 停表、握手成功起表。 - stderr 包内 logs/stderr-.txt 追加滚动(§3.4),与 64KB 尾部并存;只读目录尽力而为。 - Python façade 随改:ping 应答;请求逐条工作线程(长导入不饿死心跳,_send 锁保帧完整); format() 增 get_settings_config/apply_settings 注册位(握手补发经 decode_map 解 tagged); sanitize_numbers 把配置声明里任意位置的裸 NaN/±Infinity 转字符串字面量(协议:NaN 一律字符串)。 - 假 sidecar:ping/settings.get_config(返回与 codec 封条共用的 WidgetConfig fixture,双端同形互验) /settings.apply(通知与请求两形)/mute-ping 模式/handshake 值记录;provides 增 settings 字段。 - 测试:+10 C#(config codec 6、心跳杀半死、设置握手补发+运行中通知、设置面解码、未声明零 spawn) +2 Python;全量 656/692——36 红仍为基线既有 Setup 环境问题,零新增回归。 文档:设计 §5.2 方法表(ping/settings 两行 + initialize.entries.settings)、§5.7 重写为落地语义、 §2.1 settings 字段行、§12.4 进度注记(心跳/滚动不再"推迟";effect/Node/Lua 划入 M2b)。 --- TuneLab/Extensions/ExtensionInfo.cs | 4 + TuneLab/Extensions/ExtensionManager.cs | 4 +- TuneLab/Extensions/Scripted/RemoteFormat.cs | 31 ++- .../Extensions/Scripted/ScriptedConfigWire.cs | 210 ++++++++++++++++++ TuneLab/Extensions/Scripted/ScriptedWire.cs | 8 +- TuneLab/Extensions/Scripted/SidecarHost.cs | 110 ++++++++- docs/scripted-plugin-sidecar-design.md | 51 +++-- scripting/python/tests/test_facade.py | 39 ++++ scripting/python/tunelab/_props.py | 12 + scripting/python/tunelab/_service.py | 151 ++++++++----- scripting/wire/v1.json | 30 ++- tests/ScriptedFakeSidecar/Program.cs | 70 +++++- .../TuneLab.Tests/ScriptedConfigWireTests.cs | 119 ++++++++++ .../ScriptedRemoteFormatTests.cs | 35 +++ .../TuneLab.Tests/ScriptedSidecarHostTests.cs | 61 +++++ 15 files changed, 842 insertions(+), 93 deletions(-) create mode 100644 TuneLab/Extensions/Scripted/ScriptedConfigWire.cs create mode 100644 tests/TuneLab.Tests/ScriptedConfigWireTests.cs diff --git a/TuneLab/Extensions/ExtensionInfo.cs b/TuneLab/Extensions/ExtensionInfo.cs index 4e3adaae..cbdbd656 100644 --- a/TuneLab/Extensions/ExtensionInfo.cs +++ b/TuneLab/Extensions/ExtensionInfo.cs @@ -172,6 +172,10 @@ public string[] EffectiveIdentitySuffixes // 依赖策略:"vendored"(缺省)| "pip"(已声明未实现 → Skipped)。 public string? deps { get; set; } + // 脚本条目:本条目携带扩展设置面板(宿主经 settings.get_config/settings.apply 与 sidecar 往返; + // 缺省 false=零负担,与 .NET 侧"探测式接入 IExtensionSettings"同语义——只是脚本侧无法静态探测)。 + public bool settings { get; set; } + [JsonIgnore] public bool IsScripted => !string.IsNullOrEmpty(runtime); diff --git a/TuneLab/Extensions/ExtensionManager.cs b/TuneLab/Extensions/ExtensionManager.cs index 14a31802..c9f06047 100644 --- a/TuneLab/Extensions/ExtensionManager.cs +++ b/TuneLab/Extensions/ExtensionManager.cs @@ -727,11 +727,11 @@ static bool RegisterScriptedFormatEntry(string packageId, string packageDir, Ext var host = SidecarRegistry.For(packageId, packageDir, runtime, entryPath); host.ExpectFormat(entryPath, importSuffixes, exportSuffixes); - var proxy = new RemoteFormat(host, entryPath); + var proxy = new RemoteFormat(host, entryPath, ext.settings); FormatsManager.RegisterFormat(packageId, FormatsManager.DeriveKind(hasImport, hasExport), identity, displayName, hasImport ? (importSuffixes, () => (IImportFormat)proxy) : null, hasExport ? (exportSuffixes, () => (IExportFormat)proxy) : null, - declaresSettings: false, className: entryPath); + declaresSettings: ext.settings, className: entryPath); error = null; return true; } diff --git a/TuneLab/Extensions/Scripted/RemoteFormat.cs b/TuneLab/Extensions/Scripted/RemoteFormat.cs index 8afa8632..e7122bba 100644 --- a/TuneLab/Extensions/Scripted/RemoteFormat.cs +++ b/TuneLab/Extensions/Scripted/RemoteFormat.cs @@ -2,6 +2,7 @@ using System.IO; using System.Text.Json; using System.Text.Json.Nodes; +using TuneLab.Foundation; using TuneLab.SDK; namespace TuneLab.Extensions.Scripted; @@ -10,14 +11,38 @@ namespace TuneLab.Extensions.Scripted; // 不可分辨;每个方法体 = 一次 RPC(懒 spawn 由 SidecarHost 管)。 // 字节流不进消息体:导入把源字节落到暂存文件再传路径,导出让 sidecar 写暂存文件、宿主读回 // (FormatsManager 对输出流的"宿主拥有生命周期"约定因此原样成立)。 -internal sealed class RemoteFormat : IImportFormat, IExportFormat +// 设置面(§5.7):manifest `settings: true` 的条目才接 GetSettingsConfig/ApplySettings RPC; +// 探测式消费链(GetSettingsEntries 用工厂建 probe 实例 + as IExtensionSettings)与 .NET 插件共用。 +internal sealed class RemoteFormat : IImportFormat, IExportFormat, IExtensionSettings { readonly SidecarHost mHost; readonly string mEntryId; // = sidecar 侧注册键(握手 provides 的 id;M1 = 包内 entry 路径) + readonly bool mHasSettings; - public RemoteFormat(SidecarHost host, string entryId) + public RemoteFormat(SidecarHost host, string entryId, bool hasSettings = false) { - mHost = host; mEntryId = entryId; + mHost = host; mEntryId = entryId; mHasSettings = hasSettings; + } + + ObjectConfig IExtensionSettings.GetSettingsConfig(IExtensionSettingsContext context) + { + if (!mHasSettings) + return ObjectConfig.Create(new OrderedMap()); + // schema 必须可先于 Init 调(设计 §5.7):settings.* 方法不触发 spawn 预热之外的语义, + // sidecar 的 initialize 本就先发生——EnsureRunning 天然满足次序。 + var reply = mHost.CallAsync("settings.get_config", new JsonObject { ["entry"] = mEntryId }) + .GetAwaiter().GetResult(); + return (IControllerConfig?)ScriptedConfigWire.ReadConfig(reply?["config"]) as ObjectConfig + ?? ObjectConfig.Create(new OrderedMap()); + } + + void IExtensionSettings.ApplySettings(PropertyObject settings) + { + if (!mHasSettings) + return; + // 未运行 = 值存宿主侧、下次握手随 entries[].settings 补发;运行中 = 即时通知。 + // (两条路径都在 SetSettings 内——ApplyPersisted 早于任何 spawn 的时序被天然接住。) + mHost.SetSettings(mEntryId, ScriptedWire.SerializeNode(settings)); } public ProjectInfo Deserialize(Stream stream) diff --git a/TuneLab/Extensions/Scripted/ScriptedConfigWire.cs b/TuneLab/Extensions/Scripted/ScriptedConfigWire.cs new file mode 100644 index 00000000..25de19e5 --- /dev/null +++ b/TuneLab/Extensions/Scripted/ScriptedConfigWire.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using TuneLab.Foundation; +using TuneLab.SDK; + +namespace TuneLab.Extensions.Scripted; + +// sidecar → 宿主 的控件配置族解码(设计文档 §5.3 值编码 + scripting/wire/v1.json "config")。 +// config 类全部私有构造 + 静态工厂 + With 链(sdk-api-evolution D 类封闭族),反射写不进——按工厂重建。 +// 不可跨线的可调用成员(INormalizedScale.Custom / INumberFormat.Custom / IDragResponse): +// 规范只承载声明数据(linear|integer 标度、decimals 格式);自定义 lambda 是进程内代码, +// façade 不得发送,codec 见之即抛(早失败于人话,胜过渲染期猜)。 +// +// 线上形(type 判别,数值成员 NaN/±Infinity 用字符串字面量,同值编码规范): +// {"type":"slider","defaultValue":d,"scale":{"kind":"linear"|"integer","min":a,"max":b}, +// "format"?:{"kind":"decimals","digits":n},"randomizable"?:b,"minLabel"?:"s","maxLabel"?:"s"} +// {"type":"checkbox","defaultValue":b} +// {"type":"textbox","defaultValue":s,"password"?:b,"multiline"?:b,"maxVisibleLines"?:n} +// {"type":"combo","items":[{"value":,"displayText"?:"s","separator"?:b,"subItems"?:[items]}],"default"?:} +// {"type":"number","defaultValue":d,"min"?:a,"max"?:b,"step"?:s,"sensitivity"?:v,"randomizable"?:b,"format"?...} +// {"type":"path","defaultValue":s,"target":"file"|"folder","fileTypes"?:[{"name":s,"patterns":[s]}],"pickerTitle"?:s} +// {"type":"automation","scale":{...},"defaultValue"?:d(NaN=分段),"minValue"/"maxValue" 亦可在无 scale 时给出, +// "color"?,"format"?, "minLabel"?, "maxLabel"?, "randomizable"?} +// {"type":"object","properties":[{"id":s,"displayText"?:s,"config":}]}(数组保序) +// automation 轨 map 一律 [{id,displayText?,config:}](键随 id、显示名随 displayText)。 +internal static class ScriptedConfigWire +{ + public static IControllerConfig ReadConfig(JsonNode? node) + { + var o = RequireObject(node, "config"); + var type = RequireString(o["type"], "config.type"); + switch (type) + { + case "slider": + { + var cfg = SliderConfig.Create(ReadDouble(o["defaultValue"], "slider.defaultValue"), ReadScale(o["scale"], "slider.scale")); + if (ReadFormat(o["format"]) is { } fmt) cfg = cfg.WithFormat(fmt); + if (ReadBool(o["randomizable"])) cfg = cfg.WithRandomizable(); + if (ReadString(o["minLabel"]) is { } mn) cfg = cfg.WithMinLabel(mn); + if (ReadString(o["maxLabel"]) is { } mx) cfg = cfg.WithMaxLabel(mx); + return cfg; + } + case "checkbox": + return CheckBoxConfig.Create(ReadBool(o["defaultValue"])); + case "textbox": + { + var cfg = TextBoxConfig.Create(ReadString(o["defaultValue"]) ?? string.Empty); + if (ReadBool(o["password"])) cfg = cfg.WithPassword(); + if (ReadBool(o["multiline"])) cfg = cfg.WithMultiline(o["maxVisibleLines"] is JsonValue mv ? (int)ToDouble(mv) : 0); + return cfg; + } + case "combo": + { + var cfg = ComboBoxConfig.Create(); + if (o["items"] is JsonArray items) + foreach (var item in items) + cfg = cfg.Append(ReadComboItem(item)); + if (o["default"] is { } def) + { + var want = ScriptedWire.ReadPropertyValue(def); + foreach (var it in cfg.Items) + if (it.Value.Equals(want)) { cfg = cfg.WithDefault(it); break; } + } + return cfg; + } + case "number": + { + var cfg = DraggableNumberBoxConfig.Create(ReadDouble(o["defaultValue"], "number.defaultValue")); + if (o["min"] is JsonValue minV) cfg = cfg.WithMin(ToDouble(minV)); + if (o["max"] is JsonValue maxV) cfg = cfg.WithMax(ToDouble(maxV)); + if (o["step"] is JsonValue stepV) cfg = cfg.WithStep(ToDouble(stepV)); + if (o["sensitivity"] is JsonValue sensV) cfg = cfg.WithSensitivity(ToDouble(sensV)); + if (ReadBool(o["randomizable"])) cfg = cfg.WithRandomizable(); + if (ReadFormat(o["format"]) is { } fmt2) cfg = cfg.WithFormat(fmt2); + return cfg; + } + case "path": + { + var def = ReadString(o["defaultValue"]) ?? string.Empty; + var cfg = (ReadString(o["target"]) ?? "file") == "folder" + ? PathPickerConfig.CreateFolder(def) + : PathPickerConfig.CreateFile(def); + if (o["fileTypes"] is JsonArray fts) + foreach (var ft in fts) + { + var fo = RequireObject(ft, "path.fileTypes[]"); + cfg = cfg.AppendFileType(new FileTypeFilter( + RequireString(fo["name"], "fileTypes[].name"), + (fo["patterns"] as JsonArray)?.Select(x => x!.GetValue()).ToList() ?? [])); + } + if (ReadString(o["pickerTitle"]) is { } title) cfg = cfg.WithPickerTitle(title); + return cfg; + } + case "automation": + { + var cfg = o["scale"] != null + ? AutomationConfig.Create(ReadScale(o["scale"], "automation.scale")) + : AutomationConfig.Create(ReadDouble(o["minValue"], "automation.minValue"), ReadDouble(o["maxValue"], "automation.maxValue")); + if (o["defaultValue"] != null) cfg = cfg.WithDefault(ReadDouble(o["defaultValue"], "automation.defaultValue")); + if (ReadString(o["color"]) is { } color) cfg = cfg.WithColor(color); + if (ReadFormat(o["format"]) is { } fmt3) cfg = cfg.WithFormat(fmt3); + if (ReadString(o["minLabel"]) is { } mn2) cfg = cfg.WithMinLabel(mn2); + if (ReadString(o["maxLabel"]) is { } mx2) cfg = cfg.WithMaxLabel(mx2); + if (ReadBool(o["randomizable"])) cfg = cfg.WithRandomizable(); + return cfg; + } + case "object": + { + var props = new OrderedMap(); + if (o["properties"] is JsonArray ps) + foreach (var p in ps) + { + var po = RequireObject(p, "object.properties[]"); + props[new PropertyKey(RequireString(po["id"], "properties[].id"), ReadString(po["displayText"]))] + = ReadConfig(po["config"]); + } + return ObjectConfig.Create(props); + } + default: + throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, "config type '{0}' is not constructible from the wire", type)); + } + } + + public static IReadOnlyOrderedMap ReadAutomationMap(JsonNode? node, string what) + { + var map = new OrderedMap(); + if (node is JsonArray arr) + foreach (var item in arr) + { + var o = RequireObject(item, what + "[]"); + var key = new PropertyKey(RequireString(o["id"], what + "[].id"), ReadString(o["displayText"])); + var cfg = o["config"] != null ? ReadConfig(o["config"]) : ReadConfig(item); + map[key] = (AutomationConfig)cfg; + } + return map; + } + + // ── 零件 ── + + static INormalizedScale ReadScale(JsonNode? node, string what) + { + var o = RequireObject(node, what); + var kind = RequireString(o["kind"], what + ".kind"); + var min = ReadDouble(o["min"], what + ".min"); + var max = ReadDouble(o["max"], what + ".max"); + return kind switch + { + "linear" => NormalizedScale.Linear(min, max), + "integer" => NormalizedScale.Integer(min, max), + _ => throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, + "scale kind '{0}' cannot cross the wire (custom scales are in-process code; declare linear|integer)", kind)), + }; + } + + static INumberFormat? ReadFormat(JsonNode? node) + { + if (node is not JsonObject o) return null; + var kind = ReadString(o["kind"]); + if (kind == "decimals") return NumberFormat.Decimals((int)ReadDouble(o["digits"], "format.digits")); + throw new NotSupportedException(string.Format(CultureInfo.InvariantCulture, + "number format kind '{0}' cannot cross the wire (declare decimals)", kind)); + } + + static ComboBoxItem ReadComboItem(JsonNode? node) + { + var o = RequireObject(node, "combo.items[]"); + if (ReadBool(o["separator"])) + return ComboBoxItem.Separator(ReadString(o["displayText"])); // SDK internal 工厂,宿主经 IVT 可用 + var value = ScriptedWire.ReadPropertyValue(o["value"]); + var display = ReadString(o["displayText"]); + if (o["subItems"] is JsonArray subs) + { + var label = display; + if (label == null && value.ToString(out var sv)) + label = sv; + return new ComboBoxItem(label ?? string.Empty, subs.Select(ReadComboItem).ToList()); + } + return new ComboBoxItem(value, display); + } + + // ── 小工具(require 系列:一切坏形都抛带路径的人话)── + + static JsonObject RequireObject(JsonNode? node, string what) + => node as JsonObject ?? throw new JsonException(string.Format(CultureInfo.InvariantCulture, "'{0}' must be a JSON object", what)); + + static string? ReadString(JsonNode? node) + => node is JsonValue v && v.GetValueKind() == JsonValueKind.String ? v.GetValue() : null; + + static string RequireString(JsonNode? node, string what) + => ReadString(node) ?? throw new JsonException(string.Format(CultureInfo.InvariantCulture, "'{0}' is required and must be a string", what)); + + static double ReadDouble(JsonNode? node, string what) + { + if (node is null) + throw new JsonException(string.Format(CultureInfo.InvariantCulture, "'{0}' is required and must be a number", what)); + return ToDouble((JsonValue)node); + } + + static double ToDouble(JsonValue v) + => v.GetValueKind() == JsonValueKind.String + ? ScriptedWire.ParseNumberString(v.GetValue()) // "NaN"|"Infinity"|"-Infinity"|数字串 + : v.GetValue(); + + static bool ReadBool(JsonNode? node, bool def = false) + => node is JsonValue v && v.GetValueKind() is JsonValueKind.True or JsonValueKind.False ? v.GetValue() : def; +} diff --git a/TuneLab/Extensions/Scripted/ScriptedWire.cs b/TuneLab/Extensions/Scripted/ScriptedWire.cs index 944acc75..67e46590 100644 --- a/TuneLab/Extensions/Scripted/ScriptedWire.cs +++ b/TuneLab/Extensions/Scripted/ScriptedWire.cs @@ -52,6 +52,12 @@ static JsonSerializerOptions CreateOptions() public static T? DeserializeNode(JsonNode? node) => node == null ? default : node.Deserialize(Options); + // 供 config wire codec 复用 PropertyValue 的解析核(节点很小,经文本往返换 JsonElement)。 + internal static PropertyValue ReadPropertyValue(JsonNode? node) + => node == null ? default : PropertyValueConverter.ReadValue(JsonDocument.Parse(node.ToJsonString()).RootElement); + + internal static double ParseNumberString(string s) => PropertyValueConverter.ParseNumberLiteral(s); + // ── 转换器 ── // wire 规范形态:NaN/±Infinity 一律 JSON 字符串 "NaN"|"Infinity"|"-Infinity"(合法 JSON,且 @@ -113,7 +119,7 @@ public override void Write(Utf8JsonWriter writer, PartInfo value, JsonSerializer } } - sealed class PropertyValueConverter : JsonConverter + internal sealed class PropertyValueConverter : JsonConverter { public override PropertyValue Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { diff --git a/TuneLab/Extensions/Scripted/SidecarHost.cs b/TuneLab/Extensions/Scripted/SidecarHost.cs index 2a930b7a..1fb3c7f6 100644 --- a/TuneLab/Extensions/Scripted/SidecarHost.cs +++ b/TuneLab/Extensions/Scripted/SidecarHost.cs @@ -26,13 +26,18 @@ internal sealed class SidecarHost // 测试注入点:每次 spawn 叠加的环境变量(生产不触碰)。 public Dictionary? ExtraEnvironment { get; set; } - public SidecarHost(string packageId, string packageDir, RuntimeInfo runtime, string entry, TimeSpan? callTimeout = null) + public SidecarHost(string packageId, string packageDir, RuntimeInfo runtime, string entry, + TimeSpan? callTimeout = null, TimeSpan? heartbeat = null, TimeSpan? pingTimeout = null) { mPackageId = packageId; mPackageDir = packageDir; mRuntime = runtime; mEntry = entry; mCallTimeout = callTimeout ?? TimeSpan.FromMinutes(2); + mHeartbeatInterval = heartbeat ?? TimeSpan.FromSeconds(15); + mPingTimeout = pingTimeout ?? TimeSpan.FromSeconds(5); } readonly object mLock = new(); + readonly TimeSpan mHeartbeatInterval; + readonly TimeSpan mPingTimeout; readonly ConcurrentDictionary> mPending = new(); int mEpoch; Process? mProcess; @@ -42,6 +47,8 @@ public SidecarHost(string packageId, string packageDir, RuntimeInfo runtime, str int mDeathsInWindow; bool mCircuitOpen; bool mDisposed; + Timer? mHeartbeat; + int mHeartbeatMisses; public int Epoch { get { lock (mLock) return mEpoch; } } public bool CircuitOpen { get { lock (mLock) return mCircuitOpen; } } @@ -65,7 +72,13 @@ public void ResetCircuit() // 握手后必须兑现在这些期望(§2.3:manifest 承诺 ↔ sidecar 声明逐条核对,缺席 = 协议违例)。 // 加载期注册(懒 spawn 之前),线程安全:只在注册后、spawn 前的窗口写。 - sealed class ExpectedFormat { public string Entry = ""; public string[] Import = []; public string[] Export = []; } + sealed class ExpectedFormat + { + public string Entry = ""; + public string[] Import = []; + public string[] Export = []; + public JsonNode? Settings; // 持久设置值(spawn 前灌的在这里随握手补发) + } readonly List mExpectedFormats = []; public void ExpectFormat(string entryId, string[] importSuffixes, string[] exportSuffixes) @@ -74,6 +87,40 @@ public void ExpectFormat(string entryId, string[] importSuffixes, string[] expor mExpectedFormats.Add(new ExpectedFormat { Entry = entryId, Import = importSuffixes, Export = exportSuffixes }); } + public bool Running { get { lock (mLock) return mProcess != null && !mDisposed; } } + + // 宿主已解好的设置值(ApplySettings 路径):未运行则存着、下次握手随 entries[].settings 补发; + // 运行中则即时通知。密码解密发生在宿主侧——sidecar 拿到的与 .NET 插件同权。 + public void SetSettings(string entryId, JsonNode? values) + { + bool notify = false; + lock (mExpectedFormats) + foreach (var ef in mExpectedFormats) + if (ef.Entry == entryId) { ef.Settings = values; notify = Running; break; } + if (notify) + { + try { Notify("settings.apply", new JsonObject { ["entry"] = entryId, ["values"] = values?.DeepClone() }); } + catch { /* 通知竞态(刚死):下一次握手补发兜底 */ } + } + } + + // 通知(不等应答)。未运行即抛(调用方按需吞——SetSettings 已包)。 + public void Notify(string method, JsonNode? args) + { + Stream stdin; + lock (mLock) + { + if (mProcess == null || mStdin == null || mDisposed) + throw new SidecarRpcException("runtime_error", "sidecar not running"); + stdin = mStdin; + } + lock (mLock) + { + if (mStdin != stdin) throw new SidecarRpcException("runtime_error", "sidecar restarted"); + WireFraming.Write(stdin, new JsonObject { ["jsonrpc"] = "2.0", ["method"] = method, ["params"] = args }.ToJsonString()); + } + } + public async Task EnsureRunningAsync() { TaskCompletionSource start; @@ -172,13 +219,18 @@ void StartAndHandshake() var entriesNode = new JsonArray(); lock (mExpectedFormats) foreach (var ef in mExpectedFormats) - entriesNode.Add((JsonNode)new JsonObject + { + var e = new JsonObject { ["type"] = "format", ["entry"] = ef.Entry, ["import_suffixes"] = JsonSerializer.SerializeToNode(ef.Import), ["export_suffixes"] = JsonSerializer.SerializeToNode(ef.Export), - }); + }; + if (ef.Settings != null) + e["settings"] = ef.Settings.DeepClone(); // 握手补发(spawn 前灌过的持久值) + entriesNode.Add((JsonNode)e); + } handshake["entries"] = entriesNode; try { @@ -186,6 +238,7 @@ void StartAndHandshake() if (reply == null || reply["capabilities"] is not JsonObject) throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "sidecar for {0} completed handshake without capabilities", mPackageId)); ValidateHandshake(reply); + StartHeartbeat(mEpoch); } catch (Exception ex) { @@ -211,6 +264,46 @@ void StartAndHandshake() _ => [], }; + // 心跳(防线 3):ping 一次带 5s 超时;连续 4 次失联(≈ interval×4+超时 已够判"半死")杀树。 + // 心跳只用于识别挂死的 sidecar——宿主自己挂起不影响这里(Timer 停了也只是少 ping)。 + void StartHeartbeat(int epoch) + { + StopHeartbeat(); + mHeartbeatMisses = 0; + var timer = new Timer(_ => + { + lock (mLock) + { + if (mDisposed || epoch != mEpoch || mProcess == null) return; + } + _ = Task.Run(async () => + { + try { await CallInternalAsync("ping", new JsonObject { ["epoch"] = epoch }, mPingTimeout, epoch).ConfigureAwait(false); Interlocked.Exchange(ref mHeartbeatMisses, 0); } + catch (SidecarRpcException ex) when (ex.Code == "runtime_error") { /* 进程已死:OnDeath 接管 */ } + catch + { + if (Interlocked.Increment(ref mHeartbeatMisses) >= 4) + { + LastReaderFault ??= new TimeoutException("heartbeat: 4 consecutive ping failures (sidecar wedged?)"); + int pid; + lock (mLock) { pid = epoch == mEpoch ? mProcess?.Id ?? -1 : -1; } + if (pid > 0) KillTree(pid); + } + } + }); + }, null, mHeartbeatInterval, mHeartbeatInterval); + mHeartbeat = timer; + } + + void StopHeartbeat() + { + mHeartbeat?.Dispose(); + mHeartbeat = null; + } + + Task CallInternalAsync(string method, JsonNode args, TimeSpan timeout, int epoch) + => Task.Run(() => CallInternal(method, args, timeout, epoch)); + // 握手后核对 provides(§2.3):manifest 承诺的每个脚本条目,sidecar 必须自报支持,否则握手期 // 就判死("作者写了、sidecar 不认"是最容易静默失联的一类不匹配,拦在最前)。 void ValidateHandshake(JsonNode? reply) @@ -236,6 +329,14 @@ void AppendStderr(string line) if (tail.Length > Cap) tail = tail.Substring(tail.Length - Cap); StderrTail = tail; + // 滚动落盘(§3.4):包目录 logs/stderr-.txt,尽力而为(只读目录等失败不致命)。 + try + { + var dir = Path.Combine(mPackageDir, "logs"); + Directory.CreateDirectory(dir); + File.AppendAllText(Path.Combine(dir, "stderr-" + SidecarIo.Nonce + ".txt"), line + "\n"); + } + catch { } } void ReadLoop(Process proc, int epoch) @@ -297,6 +398,7 @@ void OnDeath(int epoch, int exitCode) { if (epoch != mEpoch || mDisposed) // 旧世代的读者线程:尾包/尾死信号,忽略 return; + StopHeartbeat(); foreach (var kv in mPending) if (mPending.TryRemove(kv.Key, out var tcs)) tcs.TrySetException(new SidecarRpcException("runtime_error", string.Format(CultureInfo.InvariantCulture, "sidecar exited (code {0})", exitCode))); diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md index 855f5ea4..ec8d6f62 100644 --- a/docs/scripted-plugin-sidecar-design.md +++ b/docs/scripted-plugin-sidecar-design.md @@ -1,7 +1,8 @@ # 脚本插件 Sidecar 系统设计(Python / Node.js / Lua) -> 状态:**定稿;M1 已落地**(帧协议 + SidecarHost 生命周期 + RemoteFormat + Python façade + 假 sidecar 封条 + -> 真解释器 E2E;实现偏差见 §12.4)。此前:M0 已落地(manifest 字段、协议门、四级探测)。 +> 状态:**定稿;M2a 已落地**(config wire codec + settings 远程代理 + 心跳 + stderr 落盘滚动; +> effect 双向会话与 Node/Lua façade = M2b)。此前:M1(帧协议/SidecarHost/RemoteFormat/Python +> façade/假 sidecar 封条/真解释器 E2E)、M0(manifest 字段、协议门、四级探测)。 > 基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 > 配套阅读:[plugin-development.md](plugin-development.md)(V1 插件契约)、[sdk-api-evolution.md](sdk-api-evolution.md)(冻结 ABI 纪律)、[naming-glossary.md](naming-glossary.md)。 > 本文只设计**宿主侧与协议侧**;`TuneLab.SDK` / `TuneLab.Foundation` 一行不动(§1.3)。 @@ -81,6 +82,7 @@ RemoteFormat / RemoteEffectEngine / RemoteVoiceEngine : SDK A 类接口的 RPC | `runtime` | 声明即成脚本条目 | 运行时区间串:`"python>=3.10,<4"` / `"node>=18"` / `"lua>=5.4"` / `"luajit>=2.1"`(flavor 前缀参与判定,§8.3) | | `entry` | 与 `runtime` 成对 | 包内相对路径:`plugin/main.py` / `plugin/index.js` / `plugin/main.lua` | | `deps` | 选填 | `"vendored"`(默认,隐式)/ `"pip"`(**已声明未实现**:见到即 `Skipped: deps mode not supported`) | +| `settings` | 选填 | `true` = 本条目携带扩展设置面板(宿主经 `settings.get_config`/`settings.apply` 往返,§5.7;.NET 侧可静态探测、脚本侧无法,故显式声明) | | `protocol-version` | 包级,选填 | 缺省按 1;高于宿主 `WireProtocol.Version` → 握手前 `Skipped`(与 `sdk-version` 门同款文案) | 规则(全部是加载错误,不静默降级——延续 V1 纪律): @@ -208,10 +210,12 @@ None → ResolvingRuntime → Spawning → Handshake → Ready | 方法 | 方向 | 语义 | |---|---|---| -| `initialize {protocol, sdk_version, package_id, entries[]}` → `{provides[], capabilities{audio_plane,curve_plane}, runtime{exe,version,arch,flavor}, facade_version}` | H→S / 应答 | 握手(§3.4 环境变量核对 epoch/protocol;不符即关管道) | +| `initialize {protocol, package_id, entries[{type, entry, import_suffixes, export_suffixes, settings?}]}` → `{provides[], capabilities{audio_plane,curve_plane}, runtime{exe,version,arch,flavor}, facade_version}` | H→S / 应答 | 握手(§3.4 环境变量核对 epoch/protocol;不符即关管道)。`entries[].settings` = 宿主已持值(spawn 前灌过 → 握手补发,Python façade 解 tagged 后灌 apply_settings) | | `log.write {level, message}` | S→H n | 插件日志 → 宿主 ILogger(packageId 归因) | -| `ping {}/pong {epoch}` | 双向 n | 心跳(§3.3-3) | -| `shutdown {why}` | H→S c | 礼貌退出(§3.2) | +| `ping {epoch}` → `{}` | H→S c | 心跳(§3.3-3):15s 间隔 / 单 ping 5s 超时 / 连续 4 失联杀树;façade 必须把用户代码放工作线程跑,保 RPC 环应答 | +| `settings.get_config {entry}` → `{config}` | H→S c | 设置 schema(config 族经工厂重建,自定义标度/格式拒之=人话);manifest `settings: true` 的条目才发 | +| `settings.apply {entry, values}` | H→S n/c | 持久值推送(运行中即时通知;未运行走握手补发);密码字段宿主已解密,与 .NET 插件同权 | +| `shutdown {reason}` | H→S c | 礼貌退出(§3.2) | | `capabilities.report {…}` | S→H n | 运行中能力变更(罕见,v2 预留) | ### 5.3 载荷编解码约定("句柄化宿主实现面") @@ -273,9 +277,12 @@ effect 同族:`effect.create_session {context绑定: 输入段几何+参数快 ### 5.7 settings -`settings.get_config {engine_key}` → `ObjectConfig` JSON;`settings.apply {engine_key, values}`。 +`settings.get_config {entry}` → `{config: }`;`settings.apply {entry, values}` +(通知;值 = tagged property map)。**懒 spawn 的时序接合**:宿主在 spawn 前收到 ApplySettings(ApplyPersisted +必早于任何调用)时把值存下、随 `initialize.entries[].settings` 补发——与 .NET"Init 前必须可灌"同语义。 宿主侧桶(`ExtensionSettingsStore`、DPAPI/Keychain 密码字段)**不动**:sidecar 永远收不到密码字段的 -密文之外形态,schema 里 `IsPassword` 字段值由宿主侧解出后才推给 `settings.apply`(与 .NET 插件同权)。 +密文之外形态,schema 的 `IsPassword` 判定由宿主读 config 树完成(config wire 的 textbox.password 字段); +推给 `settings.apply` 的是解出的终值(与 .NET 插件同权,M2a 已落地)。 --- @@ -455,21 +462,27 @@ scripted.initTimeoutSec int = 0 # 0 = 无上限(§5.5 长任 `plugin-development.md` 增一节"脚本插件"指针。 - `naming-glossary.md` 已随行增补(sidecar / runtime 资源包 / epoch / 三面 / 会话重建)。 -### 12.4 M1 落地注记(与设计差异/推迟项) +### 12.4 实现注记(与设计差异/进度) -- **落地**:`ScriptedWire`(值编码全族,含 `DoubleWireConverter` 字符串字面量、PartInfo `kind` 判别器)、 +- **M1 落地**:`ScriptedWire`(值编码全族,含 `DoubleWireConverter` 字符串字面量、PartInfo `kind` 判别器)、 `WireFraming`、`SidecarHost`(握手/epoch/退避熔断/stderr 尾部/`ResetCircuit`)、 - `ScriptedRuntimeLedger`+`SidecarRegistry`(防线 4 与关停编排,挂在 `LoadExtensions`/`Destroy`)、 - Windows Job Object(防线 2 宿主侧半边)、`RemoteFormat`(文件交接)、`scripting/python/tunelab`、 - `scripting/wire/v1.json`(规范真源)、`tests/ScriptedFakeSidecar`(协议封条执行体,CI 免解释器)、 - 真 Python E2E(`TUNELAB_REQUIRE_PYTHON=1` 强制,否则缺解释器 vacuous pass)。 -- **推迟到 M2**:【下载运行时】按钮(runtime 官方制品尚不存在,下载映射要随 release 起效); - `IExtensionSettings` 远程代理;stderr 落盘滚动(M1 只在内存留 64KB 尾部)。 + `ScriptedRuntimeLedger`+`SidecarRegistry`(防线 1/2/4 与关停编排,挂在 `LoadExtensions`/`Destroy`)、 + `RemoteFormat`(文件交接)、`scripting/python/tunelab`、`scripting/wire/v1.json`(规范真源)、 + `tests/ScriptedFakeSidecar`(协议封条执行体,CI 免装解释器)、真 Python E2E + (`TUNELAB_REQUIRE_PYTHON=1` 强制,否则缺解释器 vacuous pass)。 +- **M2a 落地**:`ScriptedConfigWire`(config 族工厂重建;自定义标度/格式 = 人话拒绝)、 + settings 远程代理(manifest `settings: true` 条目;schema 经工厂配置线、值经 tagged map; + **未 spawn 时值存宿主、随 initialize 的 `entries[].settings` 补发**——懒 spawn 与 + "ApplySettings 早于 Init"时序的接合点)、心跳(15s/5s/4 连失联杀树,`SidecarHost(pingTimeout)` + 注入测试加速)、stderr 包内 `logs/stderr-.txt` 滚动追加。façade 随改:请求逐条工作线程 + (长导入不饿死 ping)、`sanitize_numbers` 兜 NaN、`get_settings_config/apply_settings` 注册位。 +- **推迟**:【下载运行时】按钮与 stderr 清理策略(M2b);effect/voice 双向会话(M2b/M3——反向调用 + 派发环是共同前提);Node/Lua façade(M2b,能力协商以 PUC Lua `inline` 面为首个真实用例)。 - **实现差异**: - - 崩溃后不自动定时复活,**下一次调用驱动重 spawn**(backoff 计数仍生效、熔断照常)——M1 消费方 - format 是拉模式,自动重启只有缓存收益;effect 的 push 调度落地时(M2)再补后台重启环。 - - 心跳(防线 3)未实现——M1 所有调用带超时(RPC 默认 2min、握手 30s、声明面缓存支随 M2), - 挂起的 sidecar 在 format 面上被超时等价覆盖;M2 的 effect/voice 调度环接入时补真心跳。 + - 崩溃后不自动定时复活,**下一次调用驱动重 spawn**(backoff/熔断仍生效)——format 拉模式下 + 自动重启只有缓存收益;effect push 调度落地时(M2b)再补后台重启环。 + - NaN/±Infinity 规范形态 = **字符串字面量**(非裸具名 token——net8 JsonDocument 不吃), + 读侧两种都宽容;§5.3 与 `scripting/wire/v1.json` 已按此定稿。 - 开发隔离 `TUNELAB_DATA_DIR` **仓内已有**(`PathManager.cs:16`),§12.2 设想的 env 覆盖钩子无需新增。 --- diff --git a/scripting/python/tests/test_facade.py b/scripting/python/tests/test_facade.py index dc39fceb..eea1af82 100644 --- a/scripting/python/tests/test_facade.py +++ b/scripting/python/tests/test_facade.py @@ -76,6 +76,45 @@ def test_plain_values_tolerated_on_read(self): self.assertEqual(_props.decode_value("x"), "x") self.assertIs(_props.decode_value(True), True) + def test_special_floats_never_ride_bare(self): + # sanitize_numbers applies to whole payloads (config declarations), not just property maps + node = {"defaultValue": float("nan"), "min": float("-inf"), "max": 1.0, + "list": [float("inf")], "untouched": "NaN-as-text-stays"} + out = _props.sanitize_numbers(node) + self.assertEqual(out["defaultValue"], "NaN") + self.assertEqual(out["min"], "-Infinity") + self.assertEqual(out["max"], 1.0) + self.assertEqual(out["list"], ["Infinity"]) + self.assertEqual(out["untouched"], "NaN-as-text-stays") + + +class ServiceTests(unittest.TestCase): + def test_initialize_binds_single_handler_and_resends_settings(self): + import tunelab._service as svc + applied = {} + svc._entries.clear() + svc._entries[""] = {"import": None, "export": None, + "get_settings": lambda: {"type": "object", "properties": []}, + "apply_settings": lambda v: applied.update(v)} + reply = svc._handle_initialize({"entries": [{ + "type": "format", "entry": "plugin/main.py", + "settings": {"model": {"n": 1.0}}, + }]}) + self.assertEqual([p["entry"] for p in reply["provides"]], ["plugin/main.py"]) + self.assertTrue(reply["provides"][0]["settings"]) + self.assertEqual(applied, {"model": 1.0}) # tagged 解回原生后灌给插件 + + def test_ping_replies_with_empty_result(self): + import tunelab._service as svc + sent = [] + original, svc._send = svc._send, sent.append + try: + svc._dispatch({"jsonrpc": "2.0", "id": "p1", "method": "ping", "params": {}}) + finally: + svc._send = original + self.assertEqual(sent[0]["id"], "p1") + self.assertEqual(sent[0]["result"], {}) + if __name__ == "__main__": unittest.main() diff --git a/scripting/python/tunelab/_props.py b/scripting/python/tunelab/_props.py index 93e114c3..ad841089 100644 --- a/scripting/python/tunelab/_props.py +++ b/scripting/python/tunelab/_props.py @@ -69,6 +69,18 @@ def _special_str(v): return "Infinity" if v > 0 else "-Infinity" +def sanitize_numbers(node): + """Any bare NaN/±Infinity float anywhere in a wire payload (config declarations too) + becomes its string literal — bare tokens are illegal JSON and the host reader rejects them.""" + if isinstance(node, (list, tuple)): + return [sanitize_numbers(x) for x in node] + if isinstance(node, dict): + return {k: sanitize_numbers(v) for k, v in node.items()} + if _special(node): + return _special_str(node) + return node + + def walk_encode(node): """Encode a project tree on the way OUT to the host. diff --git a/scripting/python/tunelab/_service.py b/scripting/python/tunelab/_service.py index bb205aae..9527a31f 100644 --- a/scripting/python/tunelab/_service.py +++ b/scripting/python/tunelab/_service.py @@ -1,42 +1,55 @@ -"""The serve loop: initialize handshake + format.import/export dispatch (wire §5.2/§5.4).""" -import json +"""The serve loop: initialize handshake + format/settings dispatch + heartbeat reply. + +Wire spec: scripting/wire/v1.json (design doc §5). Each request runs on its own +worker thread so long user code (imports, synthesis later) never blocks `ping` — +the host kills us after 4 heartbeat misses otherwise. Replies serialize through the +stdout lock in _send; ordering across requests doesn't matter (id-routed). +""" import os import platform import sys import threading from . import _framing -from ._props import walk_encode, walk_decode +from ._props import walk_encode, walk_decode, sanitize_numbers, decode_map WIRE_PROTOCOL = 1 -_entries = {} # entry id (manifest-relative path) -> {"import": fn|None, "export": fn|None} +_entries = {} # entry id -> {"import","export","get_settings","apply_settings"} +_settings_by_entry = {} _lock = threading.Lock() _stdout_stream = None -def format(cls=None, *, import_fn=None, export_fn=None, entry=None): +def format(cls=None, *, import_fn=None, export_fn=None, entry=None, + get_settings_fn=None, apply_settings_fn=None): """Register the format implementation for one scripted entry. - Decorator on a class exposing `import_project(stream_ref)` and/or - `export_project(stream_ref, project)`; or pass plain functions directly. - `entry` must match the manifest "entry" value when a package declares several. + Decorator on a class exposing any of `import_project(stream_ref)`, + `export_project(stream_ref, project)`, `get_settings_config()` / + `apply_settings(values)`; or pass plain functions directly. `entry` must + match the manifest "entry" value when a package declares several entries. + Settings callbacks speak the WIRE config form (plain dicts; see scripting/wire) + and native property values for apply_settings. """ - def register(importer, exporter, key): - _entries[key] = {"import": importer, "export": exporter} + def register(rec, key): + _entries[key] = rec + + def make_rec(imp, exp, gets, applies): + return {"import": imp, "export": exp, "get_settings": gets, "apply_settings": applies} if cls is not None: inst = cls() - importer = getattr(inst, "import_project", None) - exporter = getattr(inst, "export_project", None) - if importer is None and exporter is None: - raise ValueError("tunelab.format: neither import_project nor export_project defined") - register(importer, exporter, entry or _default_entry_id()) + rec = make_rec(getattr(inst, "import_project", None), getattr(inst, "export_project", None), + getattr(inst, "get_settings_config", None), getattr(inst, "apply_settings", None)) + if rec["import"] is None and rec["export"] is None and rec["get_settings"] is None: + raise ValueError("tunelab.format: nothing to register") + register(rec, entry or _default_entry_id()) return cls - if import_fn is None and export_fn is None: + if import_fn is None and export_fn is None and get_settings_fn is None: raise ValueError("tunelab.format: nothing to register") - register(import_fn, export_fn, entry or _default_entry_id()) + register(make_rec(import_fn, export_fn, get_settings_fn, apply_settings_fn), entry or _default_entry_id()) return import_fn if export_fn is None else export_fn @@ -89,11 +102,9 @@ def _reply(req_id, result=None, err=None): _send(msg) -def _handle_initialize(req): - params = req.get("params") or {} - declared = params.get("entries") or [] +def _handle_initialize(params): provides = [] - for e in declared: + for e in params.get("entries") or []: entry_id = e.get("entry") handler = _entries.get(entry_id) if handler is None and len(_entries) == 1: @@ -107,7 +118,14 @@ def _handle_initialize(req): "entry": entry_id, "can_import": handler.get("import") is not None, "can_export": handler.get("export") is not None, + "settings": handler.get("get_settings") is not None, }) + settings = e.get("settings") + if settings is not None and handler.get("apply_settings") is not None: + try: + handler["apply_settings"](decode_map(settings)) # 整个设置包就是一张 property map + except Exception as ex: + error("apply_settings(handshake) failed: %s" % ex) return { "provides": provides, "capabilities": capabilities(), @@ -121,45 +139,64 @@ def _handle_initialize(req): } +def _find_entry(entry_id): + e = _entries.get(entry_id) + if e is None and len(_entries) == 1: + e = next(iter(_entries.values())) + return e + + def _dispatch(req): method = req.get("method") params = req.get("params") or {} req_id = req.get("id") - if method == "initialize": - _reply(req_id, _handle_initialize(req)) - elif method == "shutdown": - _reply(req_id, {}) if req_id is not None else None - os._exit(0) - elif method == "format.import": - entry = _entries.get(params.get("entry")) or _single() - fn = entry and entry.get("import") - if fn is None: - _reply(req_id, err={"code": "bad_request", "message": "no importer for entry " + str(params.get("entry"))}) - return - try: - project = fn(params["stream_ref"]) - _reply(req_id, {"project": walk_encode(project)}) - except Exception as ex: - _reply(req_id, err={"code": "runtime_error", "message": str(ex)}) - elif method == "format.export": - entry = _entries.get(params.get("entry")) or _single() - fn = entry and entry.get("export") - if fn is None: - _reply(req_id, err={"code": "bad_request", "message": "no exporter for entry " + str(params.get("entry"))}) - return - try: + try: + if method == "initialize": + _reply(req_id, _handle_initialize(params)) + elif method == "ping": + _reply(req_id, {}) + elif method == "shutdown": + if req_id is not None: + _reply(req_id, {}) + os._exit(0) + elif method == "format.import": + entry = _find_entry(params.get("entry")) + fn = entry and entry.get("import") + if fn is None: + _reply(req_id, err={"code": "bad_request", "message": "no importer for entry " + str(params.get("entry"))}) + return + _reply(req_id, {"project": walk_encode(fn(params["stream_ref"]))}) + elif method == "format.export": + entry = _find_entry(params.get("entry")) + fn = entry and entry.get("export") + if fn is None: + _reply(req_id, err={"code": "bad_request", "message": "no exporter for entry " + str(params.get("entry"))}) + return fn(params["stream_ref"], walk_decode(params.get("project") or {})) _reply(req_id, {}) - except Exception as ex: + elif method == "settings.get_config": + entry = _find_entry(params.get("entry")) + fn = entry and entry.get("get_settings") + if fn is None: + _reply(req_id, err={"code": "bad_request", "message": "no settings for entry " + str(params.get("entry"))}) + return + _reply(req_id, {"config": sanitize_numbers(fn())}) + elif method == "settings.apply": + entry = _find_entry(params.get("entry")) + fn = entry and entry.get("apply_settings") + if fn is not None: + fn(decode_map(params.get("values") or {})) + if req_id is not None: + _reply(req_id, {}) + elif req_id is not None: + _reply(req_id, err={"code": "bad_request", "message": "unknown method " + str(method)}) + except Exception as ex: + if req_id is not None: _reply(req_id, err={"code": "runtime_error", "message": str(ex)}) - elif req_id is not None: - _reply(req_id, err={"code": "bad_request", "message": "unknown method " + str(method)}) - # notifications without id: ignore (forward-compatible) - - -def _single(): - return next(iter(_entries.values())) if len(_entries) == 1 else None + else: + error("while handling notification %s: %s" % (method, ex)) + return def _arm_parent_death_guard(): @@ -177,10 +214,7 @@ def _arm_parent_death_guard(): def watch(): while True: - try: - threading.Event().wait(2.0) - except KeyboardInterrupt: - return + threading.Event().wait(2.0) if os.getppid() != parent: os._exit(0) @@ -190,8 +224,7 @@ def watch(): def serve(): """Run the protocol loop until the host's pipe closes. Never returns before that.""" global _stdout_stream - _stdout_stream = _framing.open_stdio()[1] - stdin, _ = _framing.open_stdio() + stdin, _stdout_stream = _framing.open_stdio() _arm_parent_death_guard() while True: try: @@ -200,4 +233,4 @@ def serve(): os._exit(1) # broken frame = protocol violation; let the host respawn us if msg is None: os._exit(0) # stdin EOF = host died (defense line 1) - _dispatch(msg) + threading.Thread(target=_dispatch, args=(msg,), daemon=True).start() diff --git a/scripting/wire/v1.json b/scripting/wire/v1.json index c62cc1e7..52225f7b 100644 --- a/scripting/wire/v1.json +++ b/scripting/wire/v1.json @@ -1,6 +1,6 @@ { "protocol-version": 1, - "status": "M1: control plane + format methods; effect/voice/audio-plane/shm sections land M2/M3", + "status": "M2a: control plane + format + settings + heartbeat (all additive); effect/voice/audio-plane/shm sections land M2b/M3", "framing": { "transport": "stdio (binary), one message per frame", "frame": "Content-Length: \\r\\n\\r\\n", @@ -37,6 +37,20 @@ "notes": "host owns the final output stream and copies stream_ref into it atomically" }, "log.write": { "direction": "sidecar->host", "notification": true, "params": { "level": "info|warning|error", "message": "string" } }, + "ping": { + "direction": "host->sidecar", "params": { "epoch": "int" }, "result": {}, + "notes": "heartbeat: host pings every 15s with a 5s timeout; 4 consecutive failures = wedged sidecar, host kills the tree (facades MUST keep the RPC loop responsive — run user code on worker threads)" + }, + "settings.get_config": { + "direction": "host->sidecar", "params": { "entry": "string" }, "result": { "config": "" }, + "notes": "schema for the extension settings panel (manifest entry field \"settings\": true); must work BEFORE engine init; declared ONLY by entries that have settings" + }, + "settings.apply": { + "direction": "host->sidecar", + "params": { "entry": "string", "values": "" }, + "shape": "notification while running; also delivered as initialize.params.entries[].settings (key 'settings') when the host holds values before (re)spawn", + "notes": "password fields are decrypted host-side before delivery — same trust level as an in-process plugin's ApplySettings" + }, "shutdown": { "direction": "host->sidecar", "params": { "reason": "string" }, "result": {} } }, "value-encoding": { @@ -54,7 +68,19 @@ }, "property-object": "JSON object of tagged values; appears at key \"properties\" inside notes/parts/effects/projects trees", "foundation-map": "Map ↔ JSON object (insertion order preserved)", - "partinfo-discriminator": "PartInfo (abstract) ↔ concrete fields plus \"kind\": \"midi\" | \"audio\"" + "partinfo-discriminator": "PartInfo (abstract) ↔ concrete fields plus \"kind\": \"midi\" | \"audio\"", + "config": { + "note": "controller configs are factory-built on the host side; the wire carries DECLARATIONS only — custom INormalizedScale/INumberFormat/IDragResponse lambdas cannot cross (host codec throws on unknown kinds)", + "slider": { "type": "slider", "defaultValue": "d", "scale": { "kind": "linear|integer", "min": "d", "max": "d" }, "format": { "kind": "decimals", "digits": "n" }, "randomizable": "b?", "minLabel": "s?", "maxLabel": "s?" }, + "checkbox": { "type": "checkbox", "defaultValue": "b" }, + "textbox": { "type": "textbox", "defaultValue": "s", "password": "b?", "multiline": "b?", "maxVisibleLines": "n?" }, + "combo": { "type": "combo", "items": [{ "value": "", "displayText": "s?", "separator": "b?", "subItems": "[items]" }], "default": "?" }, + "number": { "type": "number", "defaultValue": "d", "min": "d?", "max": "d?", "step": "d?", "sensitivity": "d?", "randomizable": "b?", "format": "decimals?" }, + "path": { "type": "path", "defaultValue": "s", "target": "file|folder", "fileTypes": [{ "name": "s", "patterns": ["s"] }], "pickerTitle": "s?" }, + "automation": { "type": "automation", "scale": "as slider (or minValue/maxValue when absent)", "defaultValue": "d|\"NaN\" (=piecewise)", "color": "s?", "format": "decimals?", "minLabel": "s?", "maxLabel": "s?", "randomizable": "b?" }, + "object": { "type": "object", "properties": [{ "id": "s", "displayText": "s?", "config": "" }] }, + "automation-map": "[{ id, displayText?, config: }] (ordered array)" + } }, "lifecycle": { "parent-death": "child MUST exit on stdin EOF (all platforms) and additionally arm OS-level guards: Windows host-side Job Object, Linux PR_SET_PDEATHSIG, macOS ppid poll — all inside the facade/host", diff --git a/tests/ScriptedFakeSidecar/Program.cs b/tests/ScriptedFakeSidecar/Program.cs index d1acfe2a..69e53909 100644 --- a/tests/ScriptedFakeSidecar/Program.cs +++ b/tests/ScriptedFakeSidecar/Program.cs @@ -14,12 +14,19 @@ internal static class Program static int Main() { var mode = Environment.GetEnvironmentVariable("TUNELAB_FAKE_MODE") ?? "normal"; + var settingsLog = Environment.GetEnvironmentVariable("TUNELAB_FAKE_SETTINGS_LOG"); Console.Error.WriteLine($"fake-up mode={mode} pid={Environment.ProcessId}"); AppDomain.CurrentDomain.UnhandledException += (_, e) => Console.Error.WriteLine("fake-unhandled: " + e.ExceptionObject); using var stdin = Console.OpenStandardInput(); using var stdout = Console.OpenStandardOutput(); + void LogSettings(string origin, JsonNode? values) + { + if (settingsLog != null) + File.AppendAllText(settingsLog, $"{origin}\t{values?.ToJsonString() ?? ""}\n"); + } + while (TryRead(stdin, out var json)) { var req = JsonNode.Parse(json)!.AsObject(); @@ -31,10 +38,17 @@ static int Main() var id = req["id"]?.DeepClone(); if (method == "format.import" && mode == "crash-on-import") Environment.Exit(3); // 模拟插件 native 崩溃:不留尾包,直接暴毙 + if (method == "ping" && mode == "mute-ping") + continue; // 心跳装死:只吞 ping,其余照常(测宿主 4 连失联杀树) + if (id == null && method == "settings.apply") + { + LogSettings("notify", req["params"]?["values"]); + continue; + } if (method == "initialize") { - var result = HandleInitialize(req["params"]?.AsObject() ?? new JsonObject(), mode); + var result = HandleInitialize(req["params"]?.AsObject() ?? new JsonObject(), mode, LogSettings); if (mode == "no-capability") result.AsObject().Remove("capabilities"); Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = result }); @@ -49,6 +63,20 @@ static int Main() { case "log.write": break; + case "ping": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); + break; + case "settings.get_config": + Write(stdout, new JsonObject + { + ["jsonrpc"] = "2.0", ["id"] = id, + ["result"] = new JsonObject { ["config"] = WidgetConfig() }, + }); + break; + case "settings.apply": + LogSettings("request", p?["values"]); + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); + break; case "format.import": { if (mode == "echo-log") @@ -76,7 +104,7 @@ static int Main() return 0; // stdin EOF(宿主死)→ 自卫退出(防线 1 的另一半:真 façade 也照此) } - static JsonNode HandleInitialize(JsonObject req, string mode) + static JsonNode HandleInitialize(JsonObject req, string mode, Action logSettings) { // req = initialize 的 params(宿主发 {protocol, package_id, entries[]}) var provides = new JsonArray(); @@ -84,7 +112,17 @@ static JsonNode HandleInitialize(JsonObject req, string mode) { if (req["entries"] is JsonArray entries) foreach (var e in entries) - provides.Add((JsonNode)new JsonObject { ["type"] = (string?)e?["type"] ?? "format", ["entry"] = e?["entry"]?.DeepClone() }); + { + var hasSettings = e?["settings"] != null; + if (hasSettings) + logSettings("handshake", e!["settings"]); + provides.Add((JsonNode)new JsonObject + { + ["type"] = (string?)e?["type"] ?? "format", + ["entry"] = e?["entry"]?.DeepClone(), + ["settings"] = hasSettings, + }); + } } return new JsonObject { @@ -95,6 +133,32 @@ static JsonNode HandleInitialize(JsonObject req, string mode) }; } + // 一个尽量占满 config wire 各 type 的 ObjectConfig(codec 封条的 fixture 源)。 + static JsonNode WidgetConfig() => new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonArray + { + new JsonObject { ["id"] = "model_dir", ["displayText"] = "模型目录", ["config"] = new JsonObject + { ["type"] = "path", ["defaultValue"] = "", ["target"] = "folder" } }, + new JsonObject { ["id"] = "gain", ["config"] = new JsonObject + { ["type"] = "slider", ["defaultValue"] = 0.0, ["scale"] = new JsonObject { ["kind"] = "linear", ["min"] = -12.0, ["max"] = 12.0 }, + ["format"] = new JsonObject { ["kind"] = "decimals", ["digits"] = 1 }, ["randomizable"] = true, + ["minLabel"] = "Soft", ["maxLabel"] = "Hard" } }, + new JsonObject { ["id"] = "seed_mode", ["config"] = new JsonObject + { ["type"] = "combo", ["items"] = new JsonArray + { new JsonObject { ["value"] = new JsonObject { ["s"] = "fast" }, ["displayText"] = "Fast" }, + new JsonObject { ["separator"] = true, ["displayText"] = "advanced" }, + new JsonObject { ["value"] = new JsonObject { ["s"] = "full" } } }, + ["default"] = new JsonObject { ["s"] = "fast" } } }, + new JsonObject { ["id"] = "denoise", ["config"] = new JsonObject { ["type"] = "checkbox", ["defaultValue"] = true } }, + new JsonObject { ["id"] = "api_key", ["config"] = new JsonObject { ["type"] = "textbox", ["defaultValue"] = "", ["password"] = true } }, + new JsonObject { ["id"] = "note", ["config"] = new JsonObject { ["type"] = "textbox", ["defaultValue"] = "", ["multiline"] = true, ["maxVisibleLines"] = 4 } }, + new JsonObject { ["id"] = "steps", ["config"] = new JsonObject + { ["type"] = "number", ["defaultValue"] = 4.0, ["min"] = 1.0, ["max"] = 10.0, ["step"] = 1.0 } }, + }, + }; + // 与宿主 wire JSON 规范同形的合成工程:一个 track、一个 midi part、一个 note(带 properties, // 钉住 PropertyObject / Map / 具名浮点的往返)。数值用 1e18 之类以避开 camelCase 大小写敏感键。 static JsonNode SyntheticProject() => new JsonObject diff --git a/tests/TuneLab.Tests/ScriptedConfigWireTests.cs b/tests/TuneLab.Tests/ScriptedConfigWireTests.cs new file mode 100644 index 00000000..401d5712 --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedConfigWireTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using TuneLab.Extensions.Scripted; +using TuneLab.Foundation; +using TuneLab.SDK; +using Xunit; + +namespace TuneLab.Tests; + +// ScriptedConfigWire:sidecar 声明的控件配置重建为工厂构造的 config 族(设计 §5.3 / wire "config" 节)。 +internal static class ConfigFixtures +{ + // 与 tests/ScriptedFakeSidecar WidgetConfig() 同形(两处一致由测试交叉钉)。 + public const string Widgets = """ + {"type":"object","properties":[ + {"id":"model_dir","displayText":"模型目录","config":{"type":"path","defaultValue":"","target":"folder"}}, + {"id":"gain","config":{"type":"slider","defaultValue":0.0,"scale":{"kind":"linear","min":-12.0,"max":12.0}, + "format":{"kind":"decimals","digits":1},"randomizable":true,"minLabel":"Soft","maxLabel":"Hard"}}, + {"id":"seed_mode","config":{"type":"combo","items":[ + {"value":{"s":"fast"},"displayText":"Fast"}, + {"separator":true,"displayText":"advanced"}, + {"value":{"s":"full"}}],"default":{"s":"fast"}}}, + {"id":"denoise","config":{"type":"checkbox","defaultValue":true}}, + {"id":"api_key","config":{"type":"textbox","defaultValue":"","password":true}}, + {"id":"note","config":{"type":"textbox","defaultValue":"","multiline":true,"maxVisibleLines":4}}, + {"id":"steps","config":{"type":"number","defaultValue":4.0,"min":1.0,"max":10.0,"step":1.0}} + ]} + """; +} + +public class ScriptedConfigWireTests +{ + [Fact] + public void RebuildsEveryLeafTypeViaFactories() + { + var cfg = Assert.IsAssignableFrom(ScriptedConfigWire.ReadConfig(JsonNode.Parse(ConfigFixtures.Widgets))); + var props = cfg.Properties; + Assert.Equal(7, props.Count); + + var path = Assert.IsType(props["model_dir"]); + Assert.Equal(PathPickerTarget.Folder, path.Target); + + var slider = Assert.IsType(props["gain"]); + Assert.Equal(-12.0, slider.Scale.ToValue(0)); + Assert.Equal(12.0, slider.Scale.ToValue(1)); + Assert.Equal("Soft", slider.MinLabel); + Assert.True(slider.Randomizable); + Assert.Equal("0.0", slider.Format.Format(0.0)); // decimals(1) + Assert.True(slider.Format.Parse("2.5") > 2); + + var combo = Assert.IsType(props["seed_mode"]); + Assert.Equal(3, combo.Items.Count); // fast / separator / full + Assert.Equal(PropertyValue.Create("fast"), combo.DefaultOption.Value); + Assert.Equal("Fast", combo.Items[0].DisplayText); + + var check = Assert.IsType(props["denoise"]); + Assert.True(check.DefaultValue); + + var pw = Assert.IsType(props["api_key"]); + Assert.True(pw.IsPassword); + + var multiline = Assert.IsType(props["note"]); + Assert.True(multiline.IsMultiline); + Assert.Equal(4, multiline.MaxVisibleLines); + + var num = Assert.IsType(props["steps"]); + Assert.Equal(1.0, num.Min); + Assert.Equal(10.0, num.Max); + Assert.Equal(1.0, num.Step); + } + + [Fact] + public void KeyOrderAndDisplayTextsSurvive() + { + var cfg = (ObjectConfig)ScriptedConfigWire.ReadConfig(JsonNode.Parse(ConfigFixtures.Widgets)); + Assert.Equal(new[] { "model_dir", "gain", "seed_mode", "denoise", "api_key", "note", "steps" }, + cfg.Properties.Keys.Select(k => k.Id)); + Assert.Equal("模型目录", cfg.Properties.Keys.First().DisplayText); + } + + [Fact] + public void AutomationMapAndPiecewiseNaN() + { + var map = ScriptedConfigWire.ReadAutomationMap(JsonNode.Parse(""" + [{"id":"growl","displayText":"Growl","config":{"type":"automation","scale":{"kind":"linear","min":0.0,"max":1.0},"defaultValue":0.0,"color":"#E5A573"}}, + {"id":"pitch","config":{"type":"automation","minValue":0.0,"maxValue":1200.0,"defaultValue":"NaN"}}] + """), "automations"); + Assert.Equal(2, map.Count); + Assert.False(map["growl"].IsPiecewise); + Assert.Equal("#E5A573", map["growl"].Color); + Assert.True(map["pitch"].IsPiecewise); // 字符串 "NaN" → 分段轨 + Assert.Equal(1200.0, map["pitch"].MaxValue); + } + + [Fact] + public void CustomScalesAreRefusedWithHumanWords() + { + var ex = Assert.Throws(() => ScriptedConfigWire.ReadConfig(JsonNode.Parse( + """{"type":"slider","defaultValue":0.0,"scale":{"kind":"my-log","min":0.0,"max":1.0}}"""))); + Assert.Contains("cannot cross the wire", ex.Message); + } + + [Fact] + public void UnknownConfigTypeThrows() + { + Assert.Throws(() => ScriptedConfigWire.ReadConfig(JsonNode.Parse("""{"type":"knob"}"""))); + } + + [Fact] + public void MalformedShapeThrowsWithJsonPath() + { + // slider 缺 scale:RequireObject 抛,指到名。 + var ex = Assert.Throws(() => ScriptedConfigWire.ReadConfig(JsonNode.Parse( + """{"type":"slider","defaultValue":0.0}"""))); + Assert.Contains("slider.scale", ex.Message); + } +} diff --git a/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs b/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs index 8ff2cebc..07e1b012 100644 --- a/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs +++ b/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs @@ -51,4 +51,39 @@ public void ExportRoundTripsProjectAndWritesBytes() Assert.Equal("FAKE-EXPORT", Encoding.UTF8.GetString(output.ToArray())); SidecarRegistry.ShutdownAll(); } + + [Fact] + public void SettingsSchemaDecodesThroughConfigWire() + { + var dll = ScriptedSidecarHostTests.FakeDllPath; + var host = SidecarRegistry.For("com.test.rf-settings", Path.GetDirectoryName(dll)!, + new RuntimeInfo(ScriptedSidecarHostTests.Dotnet, "dotnet-fake", "0.0", "x64"), Path.GetFileName(dll)); + host.ExpectFormat(Path.GetFileName(dll), [], ["tst"]); + var proxy = new RemoteFormat(host, Path.GetFileName(dll), hasSettings: true); + + var cfg = ((TuneLab.SDK.IExtensionSettings)proxy).GetSettingsConfig(new NullSettingsContext()); + var obj = Assert.IsType(cfg); + Assert.Equal(7, obj.Properties.Count); // fake 的 WidgetConfig fixture 全量落地 + Assert.IsType(obj.Properties["gain"]); + SidecarRegistry.ShutdownAll(); + } + + [Fact] + public void UndeclaredSettingsYieldEmptyPanelAndNoRpc() + { + var dll = ScriptedSidecarHostTests.FakeDllPath; + var host = SidecarRegistry.For("com.test.rf-nosettings", Path.GetDirectoryName(dll)!, + new RuntimeInfo(ScriptedSidecarHostTests.Dotnet, "dotnet-fake", "0.0", "x64"), Path.GetFileName(dll)); + host.ExpectFormat(Path.GetFileName(dll), [], ["tst"]); + var proxy = new RemoteFormat(host, Path.GetFileName(dll), hasSettings: false); + + var cfg = ((TuneLab.SDK.IExtensionSettings)proxy).GetSettingsConfig(new NullSettingsContext()); + Assert.Empty(((ObjectConfig)cfg).Properties); // 未声明 = 空 schema,且从未 spawn + Assert.False(host.Running); + } + + sealed class NullSettingsContext : TuneLab.SDK.IExtensionSettingsContext + { + public TuneLab.Foundation.PropertyObject Settings => TuneLab.Foundation.PropertyObject.Empty; + } } diff --git a/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs b/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs index 040480c8..00a812dc 100644 --- a/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs +++ b/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs @@ -155,4 +155,65 @@ public async Task ShutdownIsIdempotentAndStopsProcess() host.Shutdown(); // 幂等,不抛 await Assert.ThrowsAsync(() => host.EnsureRunningAsync()); } + + [Fact] + public async Task HeartbeatKillsWedgedPeerAfterConsecutiveMisses() + { + // mute-ping:握手正常、之后吞掉一切 ping —— 正是"半死"形态(防线 3 的靶形)。 + var runtime = new RuntimeInfo(Dotnet, "dotnet-fake", "0.0", "x64"); + var host = new SidecarHost("com.test.wedge", sFakeDir, runtime, "TuneLab.ScriptedFakeSidecar.dll", + heartbeat: TimeSpan.FromMilliseconds(200), pingTimeout: TimeSpan.FromMilliseconds(300)) + { + ExtraEnvironment = new() { ["TUNELAB_FAKE_MODE"] = "mute-ping" }, + }; + host.ExpectFormat("TuneLab.ScriptedFakeSidecar.dll", ["tst"], []); + var died = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + host.Died += () => died.TrySetResult(true); + try + { + await host.EnsureRunningAsync(); + Assert.True(host.Running); + var winner = await Task.WhenAny(died.Task, Task.Delay(TimeSpan.FromSeconds(10))); + Assert.Same(died.Task, winner); // 4 连失联 → 杀树 → Died + Assert.False(host.Running); + } + finally { host.Shutdown(); } + } + + [Fact] + public async Task PendingSettingsRideHandshake_AndLiveSettingsNotify() + { + var settingsLog = Path.Combine(Path.GetTempPath(), "tl-settings-" + Path.GetRandomFileName() + ".log"); + var runtime = new RuntimeInfo(Dotnet, "dotnet-fake", "0.0", "x64"); + var host = new SidecarHost("com.test.settings", sFakeDir, runtime, "TuneLab.ScriptedFakeSidecar.dll") + { + ExtraEnvironment = new() { ["TUNELAB_FAKE_MODE"] = "normal", ["TUNELAB_FAKE_SETTINGS_LOG"] = settingsLog }, + }; + host.ExpectFormat("TuneLab.ScriptedFakeSidecar.dll", ["tst"], []); + // spawn 前灌值 → 必须随 initialize 补发(ApplyPersisted 天然早于任何调用)。 + host.SetSettings("TuneLab.ScriptedFakeSidecar.dll", System.Text.Json.Nodes.JsonNode.Parse("""{"model":{"s":"m1"}}""")); + try + { + await host.EnsureRunningAsync(); + // 握手补发 = 原样转发的 tagged 形(解码发生在 façade 侧,见 Python ServiceTests)。 + Assert.Contains("handshake\t{\"model\":{\"s\":\"m1\"}}", File.ReadAllText(settingsLog)); + + // 运行中灌值 → 即时 settings.apply 通知。 + host.SetSettings("TuneLab.ScriptedFakeSidecar.dll", System.Text.Json.Nodes.JsonNode.Parse("""{"model":{"s":"m2"}}""")); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (DateTime.UtcNow < deadline && !File.ReadAllText(settingsLog).Contains("notify\t{\"model\":{\"s\":\"m2\"}}")) + await Task.Delay(50); + Assert.Contains("notify\t{\"model\":{\"s\":\"m2\"}}", File.ReadAllText(settingsLog)); + } + finally + { + host.Shutdown(); + TryDelete(settingsLog); + } + } + + static void TryDelete(string path) + { + try { File.Delete(path); } catch { } + } } From f9f8540c9bcc6be0eb000205c73b8d3f1514aaaf Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Tue, 15 Sep 2026 07:02:22 +0800 Subject: [PATCH 05/12] =?UTF-8?q?feat(scripted):=20M2b=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20effect=20=E5=9E=82=E7=9B=B4=E5=88=87=E7=89=87?= =?UTF-8?q?=EF=BC=9ASidecarHost=20=E5=8F=8D=E5=90=91=E9=80=9A=E9=81=93?= =?UTF-8?q?=E3=80=81RemoteEffectEngine/Session=E3=80=81Python=20@tunelab.e?= =?UTF-8?q?ffect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计 §12.0-M2(拆为 M2b)落地:effect 与 voice 共用的地基(反向调用 + 数据线程 marshal) 第一次立起来。SDK 冻结面仍零改动。 宿主侧: - SidecarHost 反向通道:RegisterLocalCall/Notify —— sidecar→宿主请求在【池线程】完成, 读者线程零阻塞(否则 sidecar 等在途 process 应答 ↔ 宿主等反向应答即锁环);应答经 SendResult/SendError(epoch 竞态丢弃旧世代回包)。initialize.entries 泛化 (format→entry+suffixes;effect→engine);ValidateHandshake 同步认 engine: 兑现。 - RemoteEffectEngine : IEffectSynthesisEngine(+IExtensionSettings 探测式) —— per-host 引擎表扇出反向调用;声明面走 TryCallRunning(进程没起 = 后台拉起 + 立即回缓存/空, 数据线程零无界等待,§3.5);Init 无超时(模型可加载数分钟,死亡通道兜底)。 - RemoteEffectSession : IEffectSynthesisSession —— Process 无限超时(应答即完成); 取消经 effect.cancel 通知尽力;input_read/properties/evaluate/segment_* 反向调用 marshal 回会话捕获的数据线程(30s 上限);effect.status/parameters 发布 = volatile 换引用 + ActionEvent(管线自 marshal);Dispose 收割段句柄。 - WireAudio:f32/f64 base64-LE(inline 平面;shm 面 M3)。 协议另一侧: - scripting/wire/v1.json 补全 effect 家族 + host.* 反向表(规范=实现,fake/宿主/python 三方对拍同一份)。 - Python façade:@tunelab.effect(engine.init/create_session/声明三族/process 电平+cancel/ dispose 生命周期);反向 _call_host(读者线程结算 pending 帧,120s 自卫超时); _effect.py 的 ctx:input_read/properties/automation_tracks/evaluate/output(write/commit/ resize/dispose)/status/parameters——作者眼里没有协议,只有数组与回调。 - 假 sidecar:effect-gain 靶形(完整反向链 + FakeEval 曲线求值支);应答帧先于通知判定; unknown-method 只对带 id 的请求回错(消灭 id:null 非法应答);settings 兼容 engine 键。 测试:C# +3(effect 双向垂直切片×2 + Python effect E2E 真 3.11 实测 ~0.5s 全链), Python +1(反向 ctx 线格式单测);Scripted 59/59;全量 659/695——36 红仍为基线既有 Setup 环境问题零新增;effect 用例超时对齐 60s(首轮全量并行负载下的 30s 抖动源, 诊断脚手架移除)。样例 tests/plugins/PyEffect(源即发行物,pack-tlx 自动收编)。 教训入档:PowerShell -replace/Set-Content 改含中文源文件必坏编码(本轮第三次), 一律用编辑工具;fake/宿主共享 fixture 的形制差异(segments 嵌套)由规范文件收敛。 --- TuneLab/Extensions/ExtensionManager.cs | 36 +- TuneLab/Extensions/Scripted/RemoteEffect.cs | 469 ++++++++++++++++++ TuneLab/Extensions/Scripted/SidecarHost.cs | 146 +++++- TuneLab/Extensions/Scripted/WireAudio.cs | 44 ++ docs/scripted-plugin-sidecar-design.md | 13 +- scripting/python/tests/test_facade.py | 49 ++ scripting/python/tunelab/__init__.py | 4 +- scripting/python/tunelab/_effect.py | 226 +++++++++ scripting/python/tunelab/_service.py | 146 +++++- scripting/wire/v1.json | 27 + tests/ScriptedFakeSidecar/Program.cs | 162 +++++- tests/TuneLab.Tests/EffectTestDoubles.cs | 86 ++++ tests/TuneLab.Tests/ScriptedEffectTests.cs | 101 ++++ tests/TuneLab.Tests/ScriptedPythonE2ETests.cs | 44 ++ tests/plugins/PyEffect/manifest.json | 13 + tests/plugins/PyEffect/plugin/main.py | 53 ++ 16 files changed, 1570 insertions(+), 49 deletions(-) create mode 100644 TuneLab/Extensions/Scripted/RemoteEffect.cs create mode 100644 TuneLab/Extensions/Scripted/WireAudio.cs create mode 100644 scripting/python/tunelab/_effect.py create mode 100644 tests/TuneLab.Tests/EffectTestDoubles.cs create mode 100644 tests/TuneLab.Tests/ScriptedEffectTests.cs create mode 100644 tests/plugins/PyEffect/manifest.json create mode 100644 tests/plugins/PyEffect/plugin/main.py diff --git a/TuneLab/Extensions/ExtensionManager.cs b/TuneLab/Extensions/ExtensionManager.cs index c9f06047..2c391a8f 100644 --- a/TuneLab/Extensions/ExtensionManager.cs +++ b/TuneLab/Extensions/ExtensionManager.cs @@ -310,9 +310,24 @@ static void LoadV1(string path, ExtensionManifest description) Log.Error(string.Format("Extension {0}: {1}: {2}", description.name, IdentityLabel(ext, kind), scriptedError)); } } + else if (gate.Status == ScriptedGateStatus.Ready && kind == "effect") + { + if (RegisterScriptedEffectEntry(description.id ?? string.Empty, path, ext, gate.Runtime, ext.LocalizedName(lang), out var effectError)) + { + loaded++; + } + else + { + failed++; + entry.Status = ExtensionEntryStatus.Failed; + entry.Error = effectError; + reasons.Add(string.Format("{0}: {1}", IdentityLabel(ext, kind), effectError)); + Log.Error(string.Format("Extension {0}: {1}: {2}", description.name, IdentityLabel(ext, kind), effectError)); + } + } else if (gate.Status == ScriptedGateStatus.Ready) { - // 引擎类注册支未落地(effect=M2 / voice=M3)——如实 Skipped,不谎报。 + // 引擎类注册支未落地(voice=M3;instrument 明确不承诺)——如实 Skipped,不谎报。 skipped++; var pending = string.Format("scripted {0} registration is not implemented by this host build yet ({1})", kind, gate.Message); entry.Status = ExtensionEntryStatus.Skipped; @@ -736,6 +751,25 @@ static bool RegisterScriptedFormatEntry(string packageId, string packageDir, Ext return true; } + // 脚本 effect 条目的注册(M2b):engine id 必填且不可跨包复制拆条目(一个条目 = 一个引擎实例); + // 懒 spawn 语义与 .NET 引擎的"Init 按需"同构——EffectManager.GetInitedEngine 首次用到才调 Init, + // RemoteEffectEngine.Init 承担 spawn + engine.init(长任务无超时,失败上抛 = passthrough 触发点)。 + static bool RegisterScriptedEffectEntry(string packageId, string packageDir, ExtensionInfo ext, RuntimeInfo runtime, string displayName, out string? error) + { + var engineId = (ext.engine ?? string.Empty).Trim(); + if (engineId.Length == 0) + { + error = "scripted effect entries must declare 'engine' (the immutable id projects reference)"; + return false; + } + var entryPath = (ext.entry ?? string.Empty).Trim().Replace('\\', '/'); + var host = SidecarRegistry.For(packageId, packageDir, runtime, entryPath); + host.ExpectEngine("effect", engineId); + EffectManager.RegisterEngine(packageId, engineId, displayName, new RemoteEffectEngine(host, engineId, ext.settings)); + error = null; + return true; + } + // 解析条目声明的那一个入口类,并校验它实现 T 且有无参构造。任何不满足都是加载错误—— // 宿主不再"扫一遍候选挑一个",因为那等于替作者决定哪个类是这个条目的实现(见 ExtensionInfo 头注释)。 static bool TryResolveCtor(Assembly assembly, string className, out ConstructorInfo? ctor, out string? error) diff --git a/TuneLab/Extensions/Scripted/RemoteEffect.cs b/TuneLab/Extensions/Scripted/RemoteEffect.cs new file mode 100644 index 00000000..c4e4974e --- /dev/null +++ b/TuneLab/Extensions/Scripted/RemoteEffect.cs @@ -0,0 +1,469 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; +using System.Text.Json.Nodes; +using TuneLab.Foundation; +using TuneLab.SDK; + +namespace TuneLab.Extensions.Scripted; + +// 脚本 effect 引擎的进程内代理(设计 §5.5 effect 家族 + §3.5 数据线程纪律): +// EffectManager/EffectGraph 眼里它与 in-proc IEffectSynthesisEngine 不可分辨。 +// +// 正向(宿主→sidecar):engine.init/destroy、effect.get_*(声明,经 TryCallRunning 有界 + 缓存)、 +// effect.process(无限超时——长模型推理;死亡即 fault——电平语义下失败=passthrough)。 +// 反向(sidecar→宿主):host.input_read / host.properties / host.evaluate / host.segment_* —— +// 全部经会话捕获的同步上下文 marshal 回数据线程执行(活视图纪律),30s 上界。 +// 发布(sidecar→宿主通知):effect.status / effect.parameters(volatile 引用换 + ActionEvent 触发, +// 宿主管线自己 marshal)。 +internal sealed class RemoteEffectEngine : IEffectSynthesisEngine, IExtensionSettings +{ + // 一个 host 一个包,包可多 effect 条目 → 反向调用按 engine 字段扇出到本包全部脚本 effect 引擎。 + static readonly Dictionary> sByHost = new(); + + readonly SidecarHost mHost; + readonly string mEngineId; + readonly bool mHasSettings; + readonly ConcurrentDictionary mSessions = new(); + int mSessionCounter; + + // 声明面缓存(§3.5):(输入指纹 → 结果)。数据线程只吃 250ms 有界调用;miss 后台补发。 + const int DeclarationTimeoutMs = 250; + (string Fingerprint, JsonNode? Result)? mPropCfgCache; + (string Fingerprint, JsonNode? Result)? mAutoCfgCache; + (string Fingerprint, JsonNode? Result)? mSynthCfgCache; + + public RemoteEffectEngine(SidecarHost host, string engineId, bool hasSettings) + { + mHost = host; mEngineId = engineId; mHasSettings = hasSettings; + bool first; + lock (sByHost) + { + if (!sByHost.TryGetValue(host, out var map)) + { + map = new Dictionary(); + sByHost[host] = map; + first = true; + RegisterReverseChannels(host); + } + else + first = false; + map[engineId] = this; + } + _ = first; + } + + static void RegisterReverseChannels(SidecarHost host) + { + host.RegisterLocalCall("host.input_read", a => Route(a, s => s.InputReadAsync(a))); + host.RegisterLocalCall("host.properties", a => Route(a, s => s.PropertiesAsync(a))); + host.RegisterLocalCall("host.evaluate", a => Route(a, s => s.EvaluateAsync(a))); + host.RegisterLocalCall("host.automation_tracks", a => Route(a, s => s.TracksAsync())); + host.RegisterLocalCall("host.segment_create", a => Route(a, s => s.SegmentCreateAsync(a))); + host.RegisterLocalCall("host.segment_write", a => Route(a, s => s.SegmentWriteAsync(a))); + host.RegisterLocalCall("host.segment_commit", a => Route(a, s => s.SegmentSimpleAsync(a, static seg => seg.Commit()))); + host.RegisterLocalCall("host.segment_resize", a => Route(a, s => s.SegmentResizeAsync(a))); + host.RegisterLocalCall("host.segment_dispose", a => Route(a, s => s.SegmentDisposeAsync(a))); + host.RegisterLocalNotify("effect.status", a => RouteFire(a, (s, args) => s.ApplyStatus(args))); + host.RegisterLocalNotify("effect.parameters", a => RouteFire(a, (s, args) => s.ApplyParameters(args))); + } + + static Task Route(JsonNode? args, Func> body) + => RouteCore(args, body); + + static async Task RouteCore(JsonNode? args, Func> body) + { + var (engine, session) = Resolve(args); + if (engine == null || session == null) + throw new SidecarRpcException("stale_handle", "unknown engine/session for reverse call"); + return await body(session).ConfigureAwait(false); + } + + static void RouteFire(JsonNode? args, Action body) + { + var (engine, session) = Resolve(args); + if (engine != null && session != null) + body(session, args); + } + + static (RemoteEffectEngine?, RemoteEffectSession?) Resolve(JsonNode? args) + { + var o = args as JsonObject; + var engineId = o?["engine"]?.GetValue(); + var sessionId = o?["session"]?.GetValue(); + if (engineId == null || sessionId == null) return (null, null); + lock (sByHost) + { + foreach (var map in sByHost.Values) + if (map.TryGetValue(engineId, out var eng) && eng.mSessions.TryGetValue(sessionId, out var sess)) + return (eng, sess); + } + return (null, null); + } + + // ── 引擎生命周期 ── + + public void Init() + { + // 长任务(模型加载可数分钟):无限超时;死亡/错误抛 → EffectManager 边界 catch → passthrough。 + mHost.CallAsync("engine.init", new JsonObject { ["engine"] = mEngineId }, Timeout.InfiniteTimeSpan) + .GetAwaiter().GetResult(); + } + + public void Destroy() + { + try { mHost.Notify("engine.destroy", new JsonObject { ["engine"] = mEngineId }); } catch { } + } + + public IEffectSynthesisSession CreateSession(IEffectSynthesisContext context) + { + var id = mEngineId + "#" + Interlocked.Increment(ref mSessionCounter); + var session = new RemoteEffectSession(this, context, id); + mSessions[id] = session; + _ = mHost.CallAsync("engine.create_session", new JsonObject { ["engine"] = mEngineId, ["session"] = id }) + .ContinueWith(t => { session.CreateFault = t.Exception?.GetBaseException(); }); + return session; + } + + internal void DropSession(string id) => mSessions.TryRemove(id, out _); + + // ── 声明面(缓存 + 有界同步;§3.5)── + + public ObjectConfig GetPropertyConfig(IEffectSynthesisPropertyContext context) + { + var values = DeclarationArgs(context); + return CachedDecl(ref mPropCfgCache, "effect.get_property_config", values) is { } node + ? (IControllerConfig?)ScriptedConfigWire.ReadConfig(node["config"]) as ObjectConfig ?? EmptyObject() + : EmptyObject(); + } + + public IReadOnlyOrderedMap GetAutomationConfigs(IEffectSynthesisPropertyContext context) + { + var values = DeclarationArgs(context); + var node = CachedDecl(ref mAutoCfgCache, "effect.get_automation_configs", values); + return ScriptedConfigWire.ReadAutomationMap(node?["tracks"], "automations"); + } + + public IReadOnlyOrderedMap GetSynthesizedParameterConfigs(IEffectSynthesisPropertyContext context) + { + var values = DeclarationArgs(context); + var node = CachedDecl(ref mSynthCfgCache, "effect.get_synthesized_parameter_configs", values); + return ScriptedConfigWire.ReadAutomationMap(node?["tracks"], "synthesized_parameters"); + } + + static JsonNode DeclarationArgs(IEffectSynthesisPropertyContext context) + { + // 只带声明所需的数据:各 effect 实例的当前参数值(求值器是活视图,跨不了线也不该跨)。 + var effects = new JsonArray(); + foreach (var view in context.Effects) + effects.Add(ScriptedWire.SerializeNode(view.Properties)); + return new JsonObject { ["effects"] = effects }; + } + + JsonNode? CachedDecl(ref (string Fingerprint, JsonNode? Result)? cache, string method, JsonNode extra) + { + var payload = new JsonObject { ["engine"] = mEngineId }; + foreach (var kv in extra.AsObject()) + payload[kv.Key] = kv.Value?.DeepClone(); + var fingerprint = payload.ToJsonString(); + if (mHost.TryCallRunning(method, payload, TimeSpan.FromMilliseconds(DeclarationTimeoutMs), out var result, out _)) + { + cache = (fingerprint, result); + return result; + } + if (cache?.Fingerprint == fingerprint) + return cache.Value.Result; // 进程未起/超时:同输入旧值照用 + return cache?.Result; // 换了输入但拿不到新值:暂用旧值(下拍 commit 再问) + } + + static ObjectConfig EmptyObject() => ObjectConfig.Create(new OrderedMap()); + + // ── 设置面(§5.7;key 用 engine id,与 format 的 entry 同位)── + + ObjectConfig IExtensionSettings.GetSettingsConfig(IExtensionSettingsContext context) + { + if (!mHasSettings) + return EmptyObject(); + var reply = mHost.CallAsync("settings.get_config", new JsonObject { ["entry"] = mEngineId }) + .GetAwaiter().GetResult(); + return (reply?["config"] as JsonObject) is { } cfg ? (ObjectConfig)ScriptedConfigWire.ReadConfig(cfg) : EmptyObject(); + } + + void IExtensionSettings.ApplySettings(PropertyObject settings) + { + if (!mHasSettings) + return; + mHost.SetSettings(mEngineId, ScriptedWire.SerializeNode(settings)); + } + + // ── 会话 ── + + internal sealed class RemoteEffectSession : IEffectSynthesisSession + { + readonly RemoteEffectEngine mEngine; + readonly IEffectSynthesisContext mContext; + readonly string mId; + readonly ConcurrentDictionary mSegments = new(); + int mSegmentCounter; + SynchronizationContext? mSync; // 数据线程投递面(构造=管线数据线程;Process 再捕获兜底) + volatile IReadOnlyList mStatus = Array.Empty(); + volatile IReadOnlyMap mParameters = new Map(); + int mBusy; + bool mDisposed; + + public Exception? CreateFault; + + readonly ActionEvent mStatusChanged = new(); + readonly ActionEvent mParametersChanged = new(); + public IActionEvent StatusChanged => mStatusChanged; + public IActionEvent SynthesizedParametersChanged => mParametersChanged; + + public IReadOnlyMap SynthesizedParameters => mParameters; + public IReadOnlyList Status => mStatus; + + public RemoteEffectSession(RemoteEffectEngine engine, IEffectSynthesisContext context, string id) + { + mEngine = engine; mContext = context; mId = id; + mSync = SynchronizationContext.Current; + } + + public async Task Process(CancellationToken cancellation) + { + if (mDisposed) throw new ObjectDisposedException(nameof(RemoteEffectSession)); + if (Interlocked.CompareExchange(ref mBusy, 1, 0) != 0) + throw new InvalidOperationException("effect session already processing"); + mSync = SynchronizationContext.Current ?? mSync; + try + { + if (CreateFault != null) + throw CreateFault; + var geometry = await OnData(() => + { + var input = mContext.Input; + return new JsonObject { ["sample_offset"] = input.SampleOffset, ["sample_count"] = input.SampleCount, ["sample_rate"] = input.SampleRate }; + }).ConfigureAwait(false); + using var reg = cancellation.CanBeCanceled + ? cancellation.Register(() => { try { mEngine.mHost.Notify("effect.cancel", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = mId }); } catch { } }) + : default; + // 无限超时:完成/死亡/取消(尽力)由 sidecar 定夺;电平语义下取消=正常返回。 + await mEngine.mHost.CallAsync("effect.process", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = mId, ["input"] = geometry }, + Timeout.InfiniteTimeSpan).ConfigureAwait(false); + } + finally + { + Interlocked.Exchange(ref mBusy, 0); + } + } + + public void Dispose() + { + if (mDisposed) return; + mDisposed = true; + mEngine.DropSession(mId); + try { mEngine.mHost.Notify("session.dispose", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = mId }); } catch { } + var segments = mSegments.Values.ToArray(); + mSegments.Clear(); + _ = OnData(() => { foreach (var s in segments) { try { s.Dispose(); } catch { } } return true; }); + } + + // ── 反向调用处理体(在池线程被调,marshal 回数据线程执行,30s 上界)── + + const int MarshalTimeoutMs = 30_000; + + internal async Task InputReadAsync(JsonNode? args) + { + var o = args!.AsObject(); + var offset = (int)(o["offset"]?.GetValue() ?? 0); + var count = (int)(o["count"]?.GetValue() ?? mContext.Input.SampleCount); + var samples = await OnData(() => + { + count = Math.Min(count, Marshal.MaxChunkSamples); + var buf = new float[count]; + mContext.Input.Read(offset, buf); + return buf; + }).ConfigureAwait(false); + return new JsonObject { ["data"] = WireAudio.EncodeF32(samples) }; + } + + internal async Task PropertiesAsync(JsonNode? args) + { + // 活视图按 key 取值(无全键枚举面,纪律即"声明的键自己知道");keys 缺省 = 空集。 + var keys = ((args as JsonObject)?["keys"] as JsonArray)?.Select(x => x!.GetValue()).ToArray() + ?? []; + var values = await OnData(() => + { + var map = new Map(); + foreach (var k in keys) + map[k] = mContext.Properties.GetValue(k, default); + return map; + }).ConfigureAwait(false); + return new JsonObject { ["values"] = ScriptedWire.SerializeNode(new PropertyObject(values)) }; + } + + internal async Task TracksAsync() + { + var keys = await OnData(() => mContext.Automations.Keys.ToArray()).ConfigureAwait(false); + return new JsonObject { ["tracks"] = JsonSerializer.SerializeToNode(keys) }; + } + + internal async Task EvaluateAsync(JsonNode? args) + { + var o = args!.AsObject(); + var track = o["track"]!.GetValue(); + var positions = WireAudio.DecodeF64(o["positions"]!.GetValue()); + var values = await OnData(() => + { + if (!mContext.Automations.TryGetValue(track, out var auto)) + throw new SidecarRpcException("stale_handle", "no automation track '" + track + "'"); + var results = new double[positions.Length]; + auto.Evaluate(positions, results); + return results; + }).ConfigureAwait(false); + return new JsonObject { ["values"] = WireAudio.EncodeF64(values) }; + } + + internal async Task SegmentCreateAsync(JsonNode? args) + { + var o = args!.AsObject(); + var offset = o["sample_offset"]!.GetValue(); + var count = (int)o["sample_count"]!.GetValue(); + var rate = (int)o["sample_rate"]!.GetValue(); + return await OnData(() => + { + var seg = mContext.CreateAudioSegment(offset, count, rate); + var id = mEngine.mEngineId + "/seg" + Interlocked.Increment(ref mSegmentCounter); + mSegments[id] = seg; + return new JsonObject { ["segment"] = id }; + }).ConfigureAwait(false); + } + + internal Task SegmentWriteAsync(JsonNode? args) + { + var o = args!.AsObject(); + var offset = (int)o["offset"]!.GetValue(); + var samples = WireAudio.DecodeF32(o["data"]!.GetValue()); + return SegmentCallAsync(o, seg => { seg.Write(offset, samples); return null; }); + } + + internal Task SegmentResizeAsync(JsonNode? args) + { + var o = args!.AsObject(); + var offset = o["sample_offset"]!.GetValue(); + var count = (int)o["sample_count"]!.GetValue(); + return SegmentCallAsync(o, seg => { seg.Resize(offset, count); return null; }); + } + + internal Task SegmentSimpleAsync(JsonNode? args, Action op) + => SegmentCallAsync(args!.AsObject(), seg => { op(seg); return null; }); + + internal Task SegmentDisposeAsync(JsonNode? args) + { + var segId = args!.AsObject()["segment"]!.GetValue(); + if (!mSegments.TryRemove(segId, out var seg)) + throw new SidecarRpcException("stale_handle", "unknown segment " + segId); + return SegmentCallRaw(seg, s => { s.Dispose(); return null; }); + } + + // 段操作统一路径:解析 id -> marshal 数据线程执行 -> 应答 {}。 + internal Task SegmentCallAsync(JsonObject o, Func op) + { + var segId = o["segment"]!.GetValue(); + if (!mSegments.TryGetValue(segId, out var seg)) + throw new SidecarRpcException("stale_handle", "unknown segment " + segId); + return SegmentCallRaw(seg, op); + } + + internal Task SegmentCallRaw(IAudioSegment seg, Func op) + { + var sync = mSync; + if (sync == null) + return Task.FromResult(op(seg) ?? new JsonObject()); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + sync.Post(_ => + { + try { tcs.TrySetResult(op(seg) ?? new JsonObject()); } + catch (Exception ex) { tcs.TrySetException(ex); } + }, null); + return WithMarshalTimeout(tcs.Task); + } + + // ── 发布(sidecar→宿主通知;任意线程到达,管线自己 marshal)── + + internal void ApplyStatus(JsonNode? args) + { + var list = new List(); + if (args is JsonObject o && o["segments"] is JsonArray arr) + foreach (var item in arr) + { + var so = item!.AsObject(); + list.Add(new SynthesisStatusSegment + { + StartTime = so["start"].GetValue(), + EndTime = so["end"].GetValue(), + Status = ParseStatus(so["status"]?.GetValue()), + Message = so["message"]?.GetValue(), + Progress = so["progress"]?.GetValue() ?? 0, + }); + } + mStatus = list; + mStatusChanged.Invoke(); + } + + static SynthesisSegmentStatus ParseStatus(string? s) => s switch + { + "pending" => SynthesisSegmentStatus.Pending, + "synthesizing" => SynthesisSegmentStatus.Synthesizing, + "failed" => SynthesisSegmentStatus.Failed, + _ => SynthesisSegmentStatus.Synthesized, + }; + + internal void ApplyParameters(JsonNode? args) + { + var map = new Map(); + if (args is JsonObject o && o["parameters"] is JsonObject ps) + foreach (var p in ps) + { + var segments = new List>(); + if (p.Value is JsonObject po && po["segments"] is JsonArray segs) + foreach (var seg in segs) + segments.Add(((JsonArray)seg!).Select(pt => new Point(pt![0]!.GetValue(), pt[1]!.GetValue())).ToList()); + map[p.Key] = new SynthesizedParameter { Segments = segments }; + } + mParameters = map; + mParametersChanged.Invoke(); + } + + // ── 数据线程 marshal ── + + internal Task OnData(Func work) + { + var sync = mSync; + if (sync == null) + return Task.Run(work); // 无上下文(headless/竞态早期):池上执行,活视图纪律由宿主自证 + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + sync.Post(_ => + { + try { tcs.TrySetResult(work()); } + catch (Exception ex) { tcs.TrySetException(ex); } + }, null); + return WithMarshalTimeout(tcs.Task); + } + + internal async Task WithMarshalTimeout(Task task) + { + var done = await Task.WhenAny(task, Task.Delay(MarshalTimeoutMs)).ConfigureAwait(false); + if (done != task) + throw new SidecarRpcException("timeout", "data-thread marshal timed out"); + return await task.ConfigureAwait(false); + } + } + + internal static class Marshal + { + public const int MaxChunkSamples = 1 << 20; // 单次反向读上限(1M 样本 ≈ 4MB base64 ≈ 5.3MB 帧) + } +} diff --git a/TuneLab/Extensions/Scripted/SidecarHost.cs b/TuneLab/Extensions/Scripted/SidecarHost.cs index 1fb3c7f6..765c6226 100644 --- a/TuneLab/Extensions/Scripted/SidecarHost.cs +++ b/TuneLab/Extensions/Scripted/SidecarHost.cs @@ -77,6 +77,7 @@ sealed class ExpectedFormat public string Entry = ""; public string[] Import = []; public string[] Export = []; + public string? EngineType; // effect/voice 引擎期望(非 null 时 Entry 即 engine id) public JsonNode? Settings; // 持久设置值(spawn 前灌的在这里随握手补发) } readonly List mExpectedFormats = []; @@ -87,6 +88,28 @@ public void ExpectFormat(string entryId, string[] importSuffixes, string[] expor mExpectedFormats.Add(new ExpectedFormat { Entry = entryId, Import = importSuffixes, Export = exportSuffixes }); } + // effect/voice 引擎期望(§2.3 同款核对:provides 必须含 {type, engine})。 + public void ExpectEngine(string type, string engineId) + { + lock (mExpectedFormats) + mExpectedFormats.Add(new ExpectedFormat { Entry = engineId, Import = [], Export = [], EngineType = type }); + } + + // ── 反向调用(sidecar → 宿主请求):方法 → 处理器表(§5.5,effect/voice 的地基)。 + // 处理器在 RPC 工作线程上跑,绝不占读者线程;回写经单写锁。 + readonly Dictionary>> mLocalCalls = new(); + readonly Dictionary> mLocalNotifies = new(); + + public void RegisterLocalCall(string method, Func> handler) + { + lock (mLocalCalls) mLocalCalls[method] = handler; + } + + public void RegisterLocalNotify(string method, Action handler) + { + lock (mLocalCalls) mLocalNotifies[method] = handler; + } + public bool Running { get { lock (mLock) return mProcess != null && !mDisposed; } } // 宿主已解好的设置值(ApplySettings 路径):未运行则存着、下次握手随 entries[].settings 补发; @@ -121,6 +144,54 @@ public void Notify(string method, JsonNode? args) } } + void SendRaw(JsonObject message) + { + try + { + lock (mLock) + { + if (mProcess == null || mStdin == null || mDisposed) return; // 半死途中丢应答:sidecar 端由超时/EOF 收敛 + WireFraming.Write(mStdin, message.ToJsonString()); + } + } + catch { } + } + + void SendResult(JsonNode? id, JsonNode? result) + => SendRaw(new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id?.DeepClone(), ["result"] = result }); + + void SendError(JsonNode? id, string code, string message) + => SendRaw(new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id?.DeepClone(), ["error"] = new JsonObject { ["code"] = code, ["message"] = message } }); + + // 有界同步调用(声明面 §3.5 的宿主侧原语):进程没起就不等——后台拉起、当前问立即落空 + // (调用方拿缓存/空值);进程活着则最多等 timeout(数据线程上的阻塞有上界)。 + public bool TryCallRunning(string method, JsonNode? args, TimeSpan timeout, out JsonNode? result, out string? error) + { + result = null; + error = null; + int epoch; + lock (mLock) + { + if (mDisposed || mProcess == null) + { + var _ = EnsureRunningAsync(); // fire-and-forget:下一次问就有真值 + error = "not running"; + return false; + } + epoch = mEpoch; + } + try + { + result = CallInternal(method, args, timeout, epoch); + return true; + } + catch (Exception ex) + { + error = ex.Message; + return false; + } + } + public async Task EnsureRunningAsync() { TaskCompletionSource start; @@ -210,7 +281,8 @@ void StartAndHandshake() reader.Start(); // 握手(同步在本线程:EnsureRunning 已在池线程上)。失败即杀半死进程并计一次死亡。 - // params.entries 携带宿主期望(entry 键 = manifest 的相对路径)——sidecar 按同一键兑现 provides。 + // params.entries 携带宿主期望(format 键 = manifest 的相对路径;engine 键 = engine id)—— + // sidecar 按同一键兑现 provides。 var handshake = new JsonObject { ["protocol"] = WireProtocol.Version, @@ -220,13 +292,15 @@ void StartAndHandshake() lock (mExpectedFormats) foreach (var ef in mExpectedFormats) { - var e = new JsonObject + var e = new JsonObject { ["type"] = ef.EngineType ?? "format" }; + if (ef.EngineType == null) { - ["type"] = "format", - ["entry"] = ef.Entry, - ["import_suffixes"] = JsonSerializer.SerializeToNode(ef.Import), - ["export_suffixes"] = JsonSerializer.SerializeToNode(ef.Export), - }; + e["entry"] = ef.Entry; + e["import_suffixes"] = JsonSerializer.SerializeToNode(ef.Import); + e["export_suffixes"] = JsonSerializer.SerializeToNode(ef.Export); + } + else + e["engine"] = ef.Entry; if (ef.Settings != null) e["settings"] = ef.Settings.DeepClone(); // 握手补发(spawn 前灌过的持久值) entriesNode.Add((JsonNode)e); @@ -313,12 +387,17 @@ void ValidateHandshake(JsonNode? reply) foreach (var item in arr) if (item is JsonObject o && o["entry"]?.GetValue() is { } e) provided.Add(e); + else if (item is JsonObject eo && eo["engine"]?.GetValue() is { } eng) + provided.Add("engine:" + eng); lock (mExpectedFormats) { foreach (var ef in mExpectedFormats) - if (!provided.Contains(ef.Entry)) + { + var key = ef.EngineType != null ? "engine:" + ef.Entry : ef.Entry; + if (!provided.Contains(key)) throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, - "sidecar for {0} handshake does not declare entry '{1}' that the manifest promised", mPackageId, ef.Entry)); + "sidecar for {0} handshake does not declare {1} '{2}' that the manifest promised", mPackageId, ef.EngineType ?? "entry", ef.Entry)); + } } } @@ -367,12 +446,50 @@ void Dispatch(string json, int epoch) if (node == null) return; if (node.TryGetPropertyValue("method", out var m) && m != null) { - // sidecar → 宿主:M1 只有 log.write;其余通知忽略(前向兼容)。 - if (m.GetValue() == "log.write") + var method = m.GetValue(); + if (method == "log.write") { var p = node["params"]?.AsObject(); LogReceived?.Invoke(p?["level"]?.GetValue() ?? "info", p?["message"]?.GetValue() ?? ""); + return; + } + var inId = node["id"]; + var args = node["params"]; + if (inId != null) + { + // 反向请求(sidecar→宿主):必须在池线程完成——读者线程一被处理器卡住, + // sidecar 等反向应答 ↔ 宿主等 sidecar 的在途请求应答,锁环即成。 + Func>? handler; + lock (mLocalCalls) mLocalCalls.TryGetValue(method, out handler); + if (handler == null) + { + SendError(inId, "bad_request", "host has no handler for '" + method + "'"); + return; + } + var id = inId.DeepClone(); + _ = Task.Run(async () => + { + try + { + var result = await handler(args).ConfigureAwait(false); + if (epoch == mEpoch) + SendResult(id, result); + } + catch (SidecarRpcException ex) + { + if (epoch == mEpoch) SendError(id, ex.Code, ex.Message); + } + catch (Exception ex) + { + if (epoch == mEpoch) SendError(id, "runtime_error", ex.Message); + } + }); + return; } + // 无 id 通知:本地表优先,未注册者忽略(前向兼容)。 + Action? notifyHandler; + lock (mLocalCalls) mLocalNotifies.TryGetValue(method, out notifyHandler); + notifyHandler?.Invoke(args); return; } if (node.TryGetPropertyValue("id", out var idNode) && idNode != null) @@ -422,12 +539,15 @@ void OnDeath(int epoch, int exitCode) } } - public async Task CallAsync(string method, JsonNode? args) + public Task CallAsync(string method, JsonNode? args) => CallAsync(method, args, mCallTimeout); + + // timeout = Timeout.InfiniteTimeSpan:等到完成或死亡(engine.init 类长任务;死亡通道兜底)。 + public async Task CallAsync(string method, JsonNode? args, TimeSpan timeout) { await EnsureRunningAsync().ConfigureAwait(false); int epoch; lock (mLock) epoch = mEpoch; - return await Task.Run(() => CallInternal(method, args, mCallTimeout, epoch)).ConfigureAwait(false); + return await Task.Run(() => CallInternal(method, args, timeout, epoch)).ConfigureAwait(false); } JsonNode? CallInternal(string method, JsonNode? args, TimeSpan timeout, int epoch) diff --git a/TuneLab/Extensions/Scripted/WireAudio.cs b/TuneLab/Extensions/Scripted/WireAudio.cs new file mode 100644 index 00000000..38fdf0ad --- /dev/null +++ b/TuneLab/Extensions/Scripted/WireAudio.cs @@ -0,0 +1,44 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; + +namespace TuneLab.Extensions.Scripted; + +// 内联音频/曲线面的编解码(设计 §6:inline plane——小数据走 base64 little-endian,shm 面 M3 上线后 +// 只剩兜底角色)。f32 = PCM 样本,f64 = 曲线求值结果。 +internal static class WireAudio +{ + public static string EncodeF32(ReadOnlySpan samples) + { + var bytes = new byte[samples.Length * 4]; + for (int i = 0; i < samples.Length; i++) + BinaryPrimitives.WriteSingleLittleEndian(bytes.AsSpan(i * 4, 4), samples[i]); + return Convert.ToBase64String(bytes); + } + + public static float[] DecodeF32(string base64) + { + var bytes = Convert.FromBase64String(base64); + var samples = new float[bytes.Length / 4]; + for (int i = 0; i < samples.Length; i++) + samples[i] = BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * 4, 4)); + return samples; + } + + public static string EncodeF64(IReadOnlyList values) + { + var bytes = new byte[values.Count * 8]; + for (int i = 0; i < values.Count; i++) + BinaryPrimitives.WriteDoubleLittleEndian(bytes.AsSpan(i * 8, 8), values[i]); + return Convert.ToBase64String(bytes); + } + + public static double[] DecodeF64(string base64) + { + var bytes = Convert.FromBase64String(base64); + var values = new double[bytes.Length / 8]; + for (int i = 0; i < values.Length; i++) + values[i] = BinaryPrimitives.ReadDoubleLittleEndian(bytes.AsSpan(i * 8, 8)); + return values; + } +} diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md index ec8d6f62..499cce38 100644 --- a/docs/scripted-plugin-sidecar-design.md +++ b/docs/scripted-plugin-sidecar-design.md @@ -476,8 +476,17 @@ scripted.initTimeoutSec int = 0 # 0 = 无上限(§5.5 长任 "ApplySettings 早于 Init"时序的接合点)、心跳(15s/5s/4 连失联杀树,`SidecarHost(pingTimeout)` 注入测试加速)、stderr 包内 `logs/stderr-.txt` 滚动追加。façade 随改:请求逐条工作线程 (长导入不饿死 ping)、`sanitize_numbers` 兜 NaN、`get_settings_config/apply_settings` 注册位。 -- **推迟**:【下载运行时】按钮与 stderr 清理策略(M2b);effect/voice 双向会话(M2b/M3——反向调用 - 派发环是共同前提);Node/Lua façade(M2b,能力协商以 PUC Lua `inline` 面为首个真实用例)。 +- **M2b 落地**:effect 垂直切片——`RemoteEffectEngine`(声明面缓存 RPC + `Destroy`/`Init` 直通、 + Init 无超时=长模型加载)、`RemoteEffectSession`(`Process` 无限超时——应答即完成;取消经通知尽力; + 段句柄表随会话 Dispose)、`SidecarHost` **反向通道**(`RegisterLocalCall`/`RegisterLocalNotify`, + 读者线程零阻塞;`host.input_read/properties/evaluate/segment_*` 全部 marshal 回会话捕获的数据线程, + 30s 上限)+ `effect.status/parameters` 发布(volatile 换引用 + ActionEvent,管线自 marshal); + Python façade `@tunelab.effect`(`_effect.py`:ctx 的 input_read/evaluate/output/status/parameters, + f32/f64 base64-LE)+ 反向 `_call_host`;样例 `tests/plugins/PyEffect`(真 3.11 E2E 实测 467ms 全链)。 + **规范缺口 = 实现漂移的教训已钉**:fake 与宿主对拍同一份 `scripting/wire/v1.json`。 +- **推迟**:【下载运行时】按钮与 stderr 清理策略(M3);Node/Lua façade(M3,能力协商以 PUC Lua + `inline` 面为首个真实用例);voice(M3——依赖同一反向通道 + shm 音频面);心跳在 effect 面已可用 + (façade 每请求独立线程保证环常活)。 - **实现差异**: - 崩溃后不自动定时复活,**下一次调用驱动重 spawn**(backoff/熔断仍生效)——format 拉模式下 自动重启只有缓存收益;effect push 调度落地时(M2b)再补后台重启环。 diff --git a/scripting/python/tests/test_facade.py b/scripting/python/tests/test_facade.py index eea1af82..45d74c51 100644 --- a/scripting/python/tests/test_facade.py +++ b/scripting/python/tests/test_facade.py @@ -116,5 +116,54 @@ def test_ping_replies_with_empty_result(self): self.assertEqual(sent[0]["result"], {}) +class EffectChannelTests(unittest.TestCase): + """Reverse-call formatting: f32/f64 base64-LE payloads & the published shapes.""" + + def setUp(self): + from tunelab import _effect + self.calls = [] + self.notifs = [] + _effect.set_channels(lambda m, p: self._respond(m, p), lambda m, p: self.notifs.append((m, p))) + self._effect = _effect + + def tearDown(self): + from tunelab import _effect + _effect.set_channels(None, None) + + def _respond(self, method, params): + import base64, struct + self.calls.append((method, params)) + if method == "host.input_read": + n = params["count"] + return {"data": base64.b64encode(struct.pack("<%df" % n, *[0.5] * n)).decode()} + if method == "host.evaluate": + raw = base64.b64decode(params["positions"]) + k = len(raw) // 8 + return {"values": base64.b64encode(struct.pack("<%dd" % k, *([0.25] * k))).decode()} + if method == "host.properties": + return {"values": {"amount": {"n": 0.5}}} + if method == "host.segment_create": + return {"segment": "seg1"} + return {} + + def test_roundtrip_through_ctx(self): + import struct + ctx = self._effect._SessionCtx("Eng", "s1", None) + ctx.run_process({"sample_offset": 44100, "sample_count": 4, "sample_rate": 44100}) + self.assertEqual(ctx.input_read(0, 4), [0.5, 0.5, 0.5, 0.5]) + self.assertEqual(ctx.properties(["amount"]), {"amount": 0.5}) + seg = ctx.output(0, 2, 44100) + seg.write(0, [1.0, -1.0]) + write = [c for c in self.calls if c[0] == "host.segment_write"][0][1] + self.assertEqual(struct.unpack("<2f", __import__("base64").b64decode(write["data"])), (1.0, -1.0)) + ctx.status([(0.0, 1.0, "synthesized", 1.0)]) + name, payload = self.notifs[0] + self.assertEqual(name, "effect.status") + self.assertEqual(payload["segments"][0]["status"], "synthesized") + self.assertEqual(payload["engine"], "Eng") + vals = ctx.evaluate("intensity", [0.0, 1.0]) + self.assertEqual(vals, [0.25, 0.25]) + + if __name__ == "__main__": unittest.main() diff --git a/scripting/python/tunelab/__init__.py b/scripting/python/tunelab/__init__.py index deab2ec4..0d8f3564 100644 --- a/scripting/python/tunelab/__init__.py +++ b/scripting/python/tunelab/__init__.py @@ -16,7 +16,7 @@ def export_project(self, stream_ref, project): ... tunelab.serve() """ -from ._service import format, serve, info, warn, error, capabilities +from ._service import format, effect, serve, info, warn, error, capabilities -__all__ = ["format", "serve", "info", "warn", "error", "capabilities"] +__all__ = ["format", "effect", "serve", "info", "warn", "error", "capabilities"] __version__ = "0.1.0" diff --git a/scripting/python/tunelab/_effect.py b/scripting/python/tunelab/_effect.py new file mode 100644 index 00000000..79f527b2 --- /dev/null +++ b/scripting/python/tunelab/_effect.py @@ -0,0 +1,226 @@ +"""Effect engine plumbing: reverse calls to the host + per-session context (wire §5.5). + +The RPC loop lives in _service; this module owns the effect-side method table and the +ProcessContext handed to user code. All byte payloads are base64 little-endian +(f32 PCM, f64 curve values) per scripting/wire/v1.json. +""" +import base64 +import struct +import threading + +_effect_engines = {} # engine id -> user object +_sessions = {} # (engine, session) -> SessionCtx +_call_host = None # injected by _service (reverse request fn) +_notify_host = None # injected by _service (notification fn) + + +def set_channels(call_host, notify_host): + global _call_host, _notify_host + _call_host, _notify_host = call_host, notify_host + + +def register_effect(obj, engine_id): + _effect_engines[engine_id] = obj + + +def declared_engine_ids(): + return list(_effect_engines) + + +def settings_engines(): + """Engine ids that expose a settings panel (get_settings_config present).""" + return {k for k, e in _effect_engines.items() if hasattr(e, "get_settings_config")} + + +def handle(method, params): + """Dispatch an effect-family request; returns the result payload dict.""" + if method == "engine.init": + eng = _need(params) + if hasattr(eng, "init"): + eng.init() + return {} + if method == "engine.destroy": + eng = _effect_engines.get(params.get("engine")) + if eng is not None and hasattr(eng, "destroy"): + eng.destroy() + return {} + if method == "engine.create_session": + eng = _need(params) + ctx = _SessionCtx(params["engine"], params["session"], eng) + _sessions[(params["engine"], params["session"])] = ctx + user = eng.create_session(ctx) if hasattr(eng, "create_session") else eng + ctx.user = user + return {} + if method.startswith("effect.get_"): + eng = _need(params) + key = {"effect.get_property_config": "get_property_config", + "effect.get_automation_configs": "get_automation_configs", + "effect.get_synthesized_parameter_configs": "get_synthesized_parameter_configs"}[method] + fn = getattr(eng, key, None) + if fn is None: + return {"config": {"type": "object", "properties": []}} if key.endswith("property_config") else {"tracks": []} + out = fn(params.get("effects") or []) + if key == "get_property_config": + return {"config": out} + return {"tracks": out} + if method == "effect.process": + ctx = _sessions.get((params["engine"], params["session"])) + if ctx is None: + raise RuntimeError("unknown session " + str(params.get("session"))) + ctx.run_process(params.get("input") or {}) + return {} + raise UnknownMethod(method) + + +class UnknownMethod(Exception): + pass + + +def on_notification(method, params): + if method == "effect.cancel": + ctx = _sessions.get((params.get("engine"), params.get("session"))) + if ctx: + ctx.cancelled.set() + user_cancel = getattr(ctx.user, "cancel", None) + if user_cancel: + try: + user_cancel() + except Exception: + pass + elif method == "session.dispose": + ctx = _sessions.pop((params.get("engine"), params.get("session")), None) + if ctx: + ctx.dispose_segments() + user_dispose = getattr(ctx.user, "dispose", None) + if user_dispose: + try: + user_dispose() + except Exception: + pass + + +class _InputGeom: + def __init__(self, d): + self.sample_offset = d.get("sample_offset", 0) + self.sample_count = d.get("sample_count", 0) + self.sample_rate = d.get("sample_rate", 44100) + + +class _Segment: + """Output audio segment; write takes bytes/bytearray/list[float] (f32 samples).""" + + def __init__(self, engine, session, seg_id, count): + self._k = (engine, session) + self.id = seg_id + self.count = count + self._closed = False + + def write(self, offset, samples): + if isinstance(samples, (bytes, bytearray)) and len(samples) % 4 == 0: + payload = bytes(samples) + else: + payload = struct.pack("<%df" % len(samples), *samples) + _call_host("host.segment_write", {"engine": self._k[0], "session": self._k[1], + "segment": self.id, "offset": offset, + "data": base64.b64encode(payload).decode("ascii")}) + + def commit(self): + _call_host("host.segment_commit", {"engine": self._k[0], "session": self._k[1], "segment": self.id}) + + def resize(self, sample_offset, sample_count): + self.count = sample_count + _call_host("host.segment_resize", {"engine": self._k[0], "session": self._k[1], + "segment": self.id, "sample_offset": sample_offset, + "sample_count": sample_count}) + + def dispose(self): + if self._closed: + return + self._closed = True + _call_host("host.segment_dispose", {"engine": self._k[0], "session": self._k[1], "segment": self.id}) + + +class _SessionCtx: + """The ProcessContext user code receives.""" + + def __init__(self, engine, session, user_engine): + self.engine = engine + self.session = session + self.user = None + self.cancelled = threading.Event() + self._segments = [] + + # -- host pulls --------------------------------------------------------- + def _via(self, extra): + d = {"engine": self.engine, "session": self.session} + d.update(extra) + return d + + def input_read(self, offset=0, count=None): + """Read a window of the (indivisible) input segment; returns a list[float].""" + if count is None: + count = self.input.sample_count - offset + r = _call_host("host.input_read", self._via({"offset": offset, "count": count})) + data = base64.b64decode(r["data"]) + return list(struct.unpack("<%df" % (len(data) // 4), data)) + + def properties(self, keys): + r = _call_host("host.properties", self._via({"keys": list(keys)})) + from ._props import decode_map + return decode_map(r["values"]) + + def automation_tracks(self): + return _call_host("host.automation_tracks", self._via({}))["tracks"] + + def evaluate(self, track, positions): + pos = base64.b64encode(struct.pack("<%dd" % len(positions), *positions)).decode("ascii") + r = _call_host("host.evaluate", self._via({"track": track, "positions": pos})) + data = base64.b64decode(r["values"]) + return list(struct.unpack("<%dd" % (len(data) // 8), data)) + + def output(self, sample_offset, sample_count, sample_rate): + r = _call_host("host.segment_create", self._via({"sample_offset": sample_offset, + "sample_count": sample_count, "sample_rate": sample_rate})) + seg = _Segment(self.engine, self.session, r["segment"], sample_count) + self._segments.append(seg) + return seg + + # -- publishing --------------------------------------------------------- + def status(self, segments): + """segments: list of (start, end, state, progress[, message]) with state in + pending|synthesizing|synthesized|failed.""" + arr = [] + for s in segments: + start, end, state = s[0], s[1], s[2] + progress = s[3] if len(s) > 3 else 0.0 + message = s[4] if len(s) > 4 else None + arr.append({"start": start, "end": end, "status": state, "progress": progress, "message": message}) + _notify_host("effect.status", self._via({"segments": arr})) + + def parameters(self, by_key): + """by_key: {id: {"segments": [[[t,v],...], ...]}} — the SynthesizedParameter wire shape.""" + _notify_host("effect.parameters", self._via({"parameters": by_key})) + + # -- internals ---------------------------------------------------------- + def run_process(self, input_geom): + self.input = _InputGeom(input_geom) + user_process = getattr(self.user, "process", None) + if user_process is not None: + user_process(self) + if self.cancelled.is_set(): + return + + def dispose_segments(self): + for seg in self._segments: + try: + seg.dispose() + except Exception: + pass + self._segments.clear() + + +def _need(params): + eng = _effect_engines.get(params.get("engine")) + if eng is None: + raise RuntimeError("no such engine registered: " + str(params.get("engine"))) + return eng diff --git a/scripting/python/tunelab/_service.py b/scripting/python/tunelab/_service.py index 9527a31f..a5735eea 100644 --- a/scripting/python/tunelab/_service.py +++ b/scripting/python/tunelab/_service.py @@ -21,6 +21,54 @@ _stdout_stream = None +# ── reverse channel (sidecar → host requests; effect pulls input/segments through it) ── +_pending_host_calls = {} +_call_counter = [0] + + +def _call_host(method, params): + """Blocking reverse request; the reader loop resolves the matching response frame.""" + with _lock: + _call_counter[0] += 1 + cid = "s%d" % _call_counter[0] + ev = threading.Event() + box = {} + _pending_host_calls[cid] = (ev, box) + _framing.write_message(_stdout_stream, {"jsonrpc": "2.0", "id": cid, "method": method, "params": params}) + if not ev.wait(120): + _pending_host_calls.pop(cid, None) + raise RuntimeError("reverse call '%s' timed out" % method) + if "error" in box: + raise RuntimeError("host error on %s: %s" % (method, box["error"])) + return box.get("result") or {} + + +def _notify(method, params): + _send({"jsonrpc": "2.0", "method": method, "params": params}) + + +def effect(engine_id=None): + """Decorator registering an effect engine object (class instance or class). + + Recognized members: init()/destroy()/create_session(ctx) -> session; + get_property_config(effects), get_automation_configs(effects), + get_synthesized_parameter_configs(effects) — wire config dicts (see scripting/wire/v1.json). + Session: process(ctx) (level semantics), optional cancel()/dispose(). + """ + from . import _effect + + def deco(obj): + inst = obj() if isinstance(obj, type) else obj + key = engine_id or obj.__name__ + _effect.register_effect(inst, key) + return obj + + if callable(engine_id): # bare @tunelab.effect on a class + obj, engine_id = engine_id, None + return deco(obj) + return deco + + def format(cls=None, *, import_fn=None, export_fn=None, entry=None, get_settings_fn=None, apply_settings_fn=None): """Register the format implementation for one scripted entry. @@ -103,29 +151,37 @@ def _reply(req_id, result=None, err=None): def _handle_initialize(params): + from . import _effect + _effect.set_channels(_call_host, _notify) provides = [] for e in params.get("entries") or []: - entry_id = e.get("entry") - handler = _entries.get(entry_id) - if handler is None and len(_entries) == 1: - # single-handler package: bind it to whatever the manifest declared (1 条目 = 1 实现) - handler = next(iter(_entries.values())) - _entries[entry_id] = handler - if handler is None: - continue - provides.append({ - "type": e.get("type", "format"), - "entry": entry_id, - "can_import": handler.get("import") is not None, - "can_export": handler.get("export") is not None, - "settings": handler.get("get_settings") is not None, - }) - settings = e.get("settings") - if settings is not None and handler.get("apply_settings") is not None: - try: - handler["apply_settings"](decode_map(settings)) # 整个设置包就是一张 property map - except Exception as ex: - error("apply_settings(handshake) failed: %s" % ex) + if e.get("type") == "format": + entry_id = e.get("entry") + handler = _entries.get(entry_id) + if handler is None and len(_entries) == 1: + # single-handler package: bind it to whatever the manifest declared (1 条目 = 1 实现) + handler = next(iter(_entries.values())) + _entries[entry_id] = handler + if handler is None: + continue + provides.append({ + "type": "format", + "entry": entry_id, + "can_import": handler.get("import") is not None, + "can_export": handler.get("export") is not None, + "settings": handler.get("get_settings") is not None, + }) + settings = e.get("settings") + if settings is not None and handler.get("apply_settings") is not None: + try: + handler["apply_settings"](decode_map(settings)) # 整个设置包就是一张 property map + except Exception as ex: + error("apply_settings(handshake) failed: %s" % ex) + else: + engine_id = e.get("engine") + if engine_id in _effect.declared_engine_ids(): + provides.append({"type": e.get("type", "effect"), "engine": engine_id, + "settings": engine_id in _effect.settings_engines()}) return { "provides": provides, "capabilities": capabilities(), @@ -147,6 +203,20 @@ def _find_entry(entry_id): def _dispatch(req): + # 应答帧(宿主对我们反向调用的回复):先结算 pending,再谈其它。 + if "method" not in req and "id" in req: + entry = _pending_host_calls.pop(str(req["id"]), None) + if entry is not None: + ev, box = entry + if req.get("error"): + box["error"] = req["error"] + else: + box["result"] = req.get("result") or {} + ev.set() + return + + from . import _effect + method = req.get("method") params = req.get("params") or {} req_id = req.get("id") @@ -160,6 +230,24 @@ def _dispatch(req): if req_id is not None: _reply(req_id, {}) os._exit(0) + elif method in ("engine.init", "engine.destroy", "engine.create_session", + "effect.get_property_config", "effect.get_automation_configs", + "effect.get_synthesized_parameter_configs", "effect.process"): + if method == "engine.destroy": + try: + _effect.handle(method, params) + except Exception: + pass + if req_id is not None: + _reply(req_id, {}) + return + result = _effect.handle(method, params) + if req_id is not None: + _reply(req_id, result) + elif method in ("effect.cancel", "session.dispose"): + _effect.on_notification(method, params) + if req_id is not None: + _reply(req_id, {}) elif method == "format.import": entry = _find_entry(params.get("entry")) fn = entry and entry.get("import") @@ -176,15 +264,25 @@ def _dispatch(req): fn(params["stream_ref"], walk_decode(params.get("project") or {})) _reply(req_id, {}) elif method == "settings.get_config": - entry = _find_entry(params.get("entry")) + key = params.get("entry") + entry = _find_entry(key) fn = entry and entry.get("get_settings") if fn is None: - _reply(req_id, err={"code": "bad_request", "message": "no settings for entry " + str(params.get("entry"))}) + eng = _effect._effect_engines.get(key) + user_fn = getattr(eng, "get_settings_config", None) + if user_fn is not None: + _reply(req_id, {"config": sanitize_numbers(user_fn())}) + return + _reply(req_id, err={"code": "bad_request", "message": "no settings for entry " + str(key)}) return _reply(req_id, {"config": sanitize_numbers(fn())}) elif method == "settings.apply": - entry = _find_entry(params.get("entry")) + key = params.get("entry") + entry = _find_entry(key) fn = entry and entry.get("apply_settings") + if fn is None: + eng = _effect._effect_engines.get(key) + fn = getattr(eng, "apply_settings", None) if fn is not None: fn(decode_map(params.get("values") or {})) if req_id is not None: diff --git a/scripting/wire/v1.json b/scripting/wire/v1.json index 52225f7b..bce50b5d 100644 --- a/scripting/wire/v1.json +++ b/scripting/wire/v1.json @@ -51,6 +51,33 @@ "shape": "notification while running; also delivered as initialize.params.entries[].settings (key 'settings') when the host holds values before (re)spawn", "notes": "password fields are decrypted host-side before delivery — same trust level as an in-process plugin's ApplySettings" }, + "engine.init": { + "direction": "host->sidecar", "params": { "engine": "string" }, "result": {}, + "notes": "long task allowed (models): host waits without RPC timeout; death/error surfaces as passthrough; init_progress notification reserved" + }, + "engine.destroy": { "direction": "host->sidecar", "params": { "engine": "string" }, "notification": true }, + "engine.create_session": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string" }, "result": {} }, + "session.dispose": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string" }, "notification": true }, + "effect.cancel": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string" }, "notification": true, "notes": "best-effort; a wedged user handler still faces heartbeat" }, + "effect.get_property_config": { "direction": "host->sidecar", "params": { "engine": "string", "effects": [""] }, "result": { "config": "" } }, + "effect.get_automation_configs": { "direction": "host->sidecar", "params": { "engine": "string", "effects": [""] }, "result": { "tracks": "" } }, + "effect.get_synthesized_parameter_configs": { "direction": "host->sidecar", "params": { "engine": "string", "effects": [""] }, "result": { "tracks": "" } }, + "effect.process": { + "direction": "host->sidecar", "params": { "engine": "string", "session": "string", "input": { "sample_offset": "i64", "sample_count": "i32", "sample_rate": "i32" } }, + "result": {}, "timeout": "none (level semantics; the reply IS completion — the host released its data thread meanwhile)", + "notes": "sidecar publishes via host.* reverse calls + effect.status/effect.parameters notifications below" + }, + "effect.status": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "segments": [{ "start": "d", "end": "d", "status": "pending|synthesizing|synthesized|failed", "progress": "d?", "message": "s?" }] } }, + "effect.parameters": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "parameters": { "": { "segments": [[ [t, v], ... ], ...] } } } }, + "host.input_read": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "offset": "i32 (segment-relative)", "count": "i32?" }, "result": { "data": "base64 f32-LE" }, "notes": "host executes on the data thread (live-view discipline) with a 30s marshal cap; chunk limit 1<<20 samples" }, + "host.properties": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "keys": ["string"] }, "result": { "values": "" } }, + "host.automation_tracks": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string" }, "result": { "tracks": ["string"] } }, + "host.evaluate": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "track": "string", "positions": "base64 f64-LE (non-decreasing)" }, "result": { "values": "base64 f64-LE" }, "notes": "live automation evaluated on the host's data thread — interpolation stays host-side (§5.6)" }, + "host.segment_create": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "sample_offset": "i64", "sample_count": "i32", "sample_rate": "i32" }, "result": { "segment": "string" } }, + "host.segment_write": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string", "offset": "i32", "data": "base64 f32-LE" }, "result": {} }, + "host.segment_commit": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string" }, "result": {} }, + "host.segment_resize": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string", "sample_offset": "i64", "sample_count": "i32" }, "result": {} }, + "host.segment_dispose": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string" }, "result": {} }, "shutdown": { "direction": "host->sidecar", "params": { "reason": "string" }, "result": {} } }, "value-encoding": { diff --git a/tests/ScriptedFakeSidecar/Program.cs b/tests/ScriptedFakeSidecar/Program.cs index 69e53909..fcf3dec9 100644 --- a/tests/ScriptedFakeSidecar/Program.cs +++ b/tests/ScriptedFakeSidecar/Program.cs @@ -1,3 +1,4 @@ +using System.Buffers.Binary; using System.Text.Json; using System.Text.Json.Nodes; @@ -27,6 +28,36 @@ void LogSettings(string origin, JsonNode? values) File.AppendAllText(settingsLog, $"{origin}\t{values?.ToJsonString() ?? ""}\n"); } + int reverseId = 0; + // 反向请求(fake→宿主):写请求帧后同步读应答帧——宿主读者线程把处理甩到池线程,不会锁环。 + JsonNode? Reverse(string method, JsonObject args) + { + var rid = "rev-" + ++reverseId; + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = rid, ["method"] = method, ["params"] = args }); + while (TryRead(stdin, out var respJson)) + { + var resp = JsonNode.Parse(respJson)!.AsObject(); + if (resp["method"]?.GetValue() == "ping") + { + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = resp["id"]?.DeepClone(), ["result"] = new JsonObject() }); + continue; // 反向途中回心跳 + } + if (resp["method"] != null) + continue; // 跳过其他通知(不应有) + if (resp["id"]?.ToJsonString() == "\"" + rid + "\"") + { + if (resp["error"] != null) + throw new InvalidOperationException("reverse " + method + " failed: " + resp["error"]!.ToJsonString()); + return resp["result"]; + } + } + Environment.Exit(4); // 宿主管道关了:随它去 + return null; + } + + void Notify(string method, JsonObject args) + => Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["method"] = method, ["params"] = args }); + while (TryRead(stdin, out var json)) { var req = JsonNode.Parse(json)!.AsObject(); @@ -92,7 +123,106 @@ void LogSettings(string origin, JsonNode? values) Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); break; } + case "engine.destroy": + case "session.dispose": + if (id != null) + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); + break; + case "engine.init": + case "engine.create_session": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); + break; + case "effect.get_property_config": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject { ["config"] = GainConfig() } }); + break; + case "effect.get_automation_configs": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, + ["result"] = new JsonObject { ["tracks"] = new JsonArray + { + new JsonObject { ["id"] = "intensity", ["displayText"] = "Intensity", ["config"] = new JsonObject + { ["type"] = "automation", ["minValue"] = 0.0, ["maxValue"] = 1.0, ["defaultValue"] = 0.5 }, }, + } } }); + break; + case "effect.get_synthesized_parameter_configs": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, + ["result"] = new JsonObject { ["tracks"] = new JsonArray + { + new JsonObject { ["id"] = "loudness", ["config"] = new JsonObject + { ["type"] = "automation", ["minValue"] = 0.0, ["maxValue"] = 2.0, ["defaultValue"] = "NaN" }, }, + } } }); + break; + case "effect.process": + { + if (mode != "effect-gain") + break; + var engine = p!["engine"]!.GetValue(); + var session = p["session"]!.GetValue(); + var input = p["input"]!.AsObject(); + long baseOffset = input["sample_offset"]!.GetValue(); + int count = input["sample_count"]!.GetValue(); + int rate = input["sample_rate"]!.GetValue(); + JsonObject Via(JsonObject extra) + { + extra["engine"] = engine; + extra["session"] = session; + return extra; + } + + Notify("effect.status", Via(new JsonObject { ["segments"] = new JsonArray { new JsonObject + { ["start"] = (double)baseOffset / rate, ["end"] = (double)(baseOffset + count) / rate, ["status"] = "synthesizing", ["progress"] = 0.0 } } })); + var samples = new float[count]; + for (int pos = 0; pos < count;) + { + int take = Math.Min(count - pos, 1 << 20); + var r = Reverse("host.input_read", Via(new JsonObject { ["offset"] = pos, ["count"] = take })); + var bytes = Convert.FromBase64String(r!["data"]!.GetValue()); + for (int i = 0; i < take; i++) + samples[pos + i] = BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * 4)); + pos += take; + } + var pr = Reverse("host.properties", Via(new JsonObject { ["keys"] = new JsonArray { "amount" } })); + double amount = pr!["values"]!.AsObject()["amount"] is { } av && av["n"] != null ? av["n"]!.GetValue() : 1.0; + if (engine == "FakeEval") + { + // 反向曲线求值:两个时刻的 f64-LE positions → 断言形如 {"values": b64} + var pos = new byte[16]; + BinaryPrimitives.WriteDoubleLittleEndian(pos.AsSpan(0, 8), (double)baseOffset / rate); + BinaryPrimitives.WriteDoubleLittleEndian(pos.AsSpan(8, 8), (double)(baseOffset + count) / rate); + var ev = Reverse("host.evaluate", Via(new JsonObject { ["track"] = "intensity", ["positions"] = Convert.ToBase64String(pos) })); + var first = BinaryPrimitives.ReadDoubleLittleEndian(Convert.FromBase64String(ev!["values"]!.GetValue()).AsSpan(0, 8)); + if (first != 0.25) + throw new InvalidOperationException("evaluate returned " + first); + } + var segId = Reverse("host.segment_create", Via(new JsonObject + { + ["sample_offset"] = baseOffset, ["sample_count"] = count, ["sample_rate"] = rate, + }))!["segment"]!.GetValue(); + for (int pos = 0; pos < count;) + { + int take = Math.Min(count - pos, 1 << 20); + var bytes = new byte[take * 4]; + for (int i = 0; i < take; i++) + BinaryPrimitives.WriteSingleLittleEndian(bytes.AsSpan(i * 4), samples[pos + i] * (float)amount); + Reverse("host.segment_write", Via(new JsonObject { ["segment"] = segId, ["offset"] = pos, ["data"] = Convert.ToBase64String(bytes) })); + pos += take; + } + Reverse("host.segment_commit", Via(new JsonObject { ["segment"] = segId })); + Notify("effect.parameters", Via(new JsonObject { ["parameters"] = new JsonObject { ["loudness"] = new JsonObject + { + ["segments"] = new JsonArray { new JsonArray + { + new JsonArray { (double)baseOffset / rate, 0.9 }, + new JsonArray { (double)(baseOffset + count) / rate, 0.9 }, + } }, + } } })); + Notify("effect.status", Via(new JsonObject { ["segments"] = new JsonArray { new JsonObject + { ["start"] = (double)baseOffset / rate, ["end"] = (double)(baseOffset + count) / rate, ["status"] = "synthesized", ["progress"] = 1.0 } } })); + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); + break; + } default: + if (id == null) + break; // 未知通知(effect.cancel 等):吞掉,不发 id:null 的非法应答 Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, @@ -116,12 +246,13 @@ static JsonNode HandleInitialize(JsonObject req, string mode, Action new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonArray + { + new JsonObject { ["id"] = "amount", ["displayText"] = "Amount", ["config"] = new JsonObject + { ["type"] = "slider", ["defaultValue"] = 1.0, ["scale"] = new JsonObject { ["kind"] = "linear", ["min"] = 0.0, ["max"] = 2.0 } } }, + }, + }; + // 一个尽量占满 config wire 各 type 的 ObjectConfig(codec 封条的 fixture 源)。 static JsonNode WidgetConfig() => new JsonObject { @@ -248,9 +390,15 @@ static string Trim(System.Text.StringBuilder b) => b.Length > 0 && b[^1] == '\r' ? b.ToString(0, b.Length - 1) : b.ToString(); } + static readonly JsonSerializerOptions WriteOptions = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + TypeInfoResolver = new System.Text.Json.Serialization.Metadata.DefaultJsonTypeInfoResolver(), + }; + static void Write(Stream output, JsonNode node) { - var body = System.Text.Encoding.UTF8.GetBytes(node.ToJsonString(new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping })); + var body = System.Text.Encoding.UTF8.GetBytes(node.ToJsonString(WriteOptions)); var header = System.Text.Encoding.ASCII.GetBytes($"Content-Length: {body.Length}\r\n\r\n"); output.Write(header); output.Write(body); diff --git a/tests/TuneLab.Tests/EffectTestDoubles.cs b/tests/TuneLab.Tests/EffectTestDoubles.cs new file mode 100644 index 00000000..787983be --- /dev/null +++ b/tests/TuneLab.Tests/EffectTestDoubles.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TuneLab.Foundation; +using TuneLab.SDK; + +namespace TuneLab.Tests; + +// 脚本 effect 测试共享替身(宿主实现面):ScriptedEffectTests(假 sidecar)与 +// ScriptedPythonE2ETests(真解释器)同用一份,保证两路对拍的是同一个语义。 + +internal sealed class EffectTestInput : IEffectSynthesisAudio +{ + public readonly float[] Buffer; + public EffectTestInput(int count) + { + Buffer = new float[count]; + for (int i = 0; i < count; i++) + Buffer[i] = i / (float)count; + } + public long SampleOffset => 44100; + public int SampleCount => Buffer.Length; + public int SampleRate => 44100; + public void Read(int offset, Span destination) => Buffer.AsSpan(offset, destination.Length).CopyTo(destination); + public IActionEvent RangeModified { get; } = new ActionEvent(); +} + +internal sealed class EffectTestSegment : IAudioSegment +{ + public readonly List Written = []; + public bool Committed, Disposed; + public void Write(int offset, ReadOnlySpan samples) => Written.AddRange(samples.ToArray()); + public void Commit() => Committed = true; + public void Resize(long sampleOffset, int sampleCount) { } + public void Dispose() => Disposed = true; +} + +internal sealed class EffectTestAutomation : ISynthesisAutomation +{ + public bool Touched; + public void Evaluate(IReadOnlyList positions, Span results) + { + Touched = true; + for (int i = 0; i < results.Length; i++) + results[i] = 0.25; + } + public IActionEvent RangeModified { get; } = new ActionEvent(); +} + +internal sealed class EffectTestProperties : IReadOnlyNotifiablePropertyObject +{ + public IActionEvent WillModify => ActionEvent.Empty; + public IActionEvent Modified => ActionEvent.Empty; + public IReadOnlyNotifiablePropertyObject Object(string key) => this; + public PropertyValue GetValue(string key, PropertyValue defaultValue) + => key == "amount" ? PropertyValue.Create(0.5) : defaultValue; +} + +internal sealed class EffectTestContext : IEffectSynthesisContext +{ + public readonly EffectTestInput Input = new(4410); + public readonly List Segments = []; + public IEffectSynthesisAudio InputLocal => Input; + IEffectSynthesisAudio IEffectSynthesisContext.Input => Input; + public IReadOnlyNotifiablePropertyObject Properties { get; } = new EffectTestProperties(); + public IReadOnlyMap Automations { get; } = + new Map { ["intensity"] = new EffectTestAutomation() }; + public EffectSynthesisSnapshot GetSnapshot(double startTime, double endTime) => throw new NotImplementedException(); + public IAudioSegment CreateAudioSegment(long sampleOffset, int sampleCount, int sampleRate) + { + var s = new EffectTestSegment(); + Segments.Add(s); + return s; + } +} + +internal sealed class EffectTestView : IEffectSynthesisView +{ + public PropertyObject Properties => PropertyObject.Empty; + public IReadOnlyMap Automations { get; } = new Map(); +} + +internal sealed class EffectTestPropContext : IEffectSynthesisPropertyContext +{ + public IReadOnlyList Effects { get; } = [new EffectTestView()]; +} diff --git a/tests/TuneLab.Tests/ScriptedEffectTests.cs b/tests/TuneLab.Tests/ScriptedEffectTests.cs new file mode 100644 index 00000000..9c4f185d --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedEffectTests.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using TuneLab.Extensions.Scripted; +using TuneLab.Foundation; +using TuneLab.SDK; +using Xunit; + +namespace TuneLab.Tests; + +// M2b 垂直切片:脚本 effect 引擎的完整往返——正向声明/生命周期 + 反向数据线程调用 +// (input_read / properties / evaluate / segment 族)+ 发布(status/parameters)。 +// 对端是假 sidecar 的 effect-gain 靶形(读入 → ×amount → 写回 + 回显 + 状态声称)。 +public class ScriptedEffectTests +{ + static SidecarHost MakeHost(string engineId) + { + var dll = ScriptedSidecarHostTests.FakeDllPath; + var host = new SidecarHost("com.test." + engineId, Path.GetDirectoryName(dll)!, + new RuntimeInfo(ScriptedSidecarHostTests.Dotnet, "dotnet-fake", "0.0", "x64"), Path.GetFileName(dll), + callTimeout: TimeSpan.FromSeconds(10), heartbeat: TimeSpan.FromSeconds(60)) + { + ExtraEnvironment = new() { ["TUNELAB_FAKE_MODE"] = "effect-gain" }, + }; + host.ExpectEngine("effect", engineId); + return host; + } + + [Fact(Timeout = 60_000)] + public async Task GainEffectRoundTripThroughReverseCalls() + { + var host = MakeHost("FakeGain"); + var engine = new RemoteEffectEngine(host, "FakeGain", hasSettings: false); + var ctx = new EffectTestContext(); + IEffectSynthesisSession? session = null; + try + { + engine.Init(); + + // 声明面(缓存 RPC + config wire 重建) + var propCfg = Assert.IsType(engine.GetPropertyConfig(new EffectTestPropContext())); + Assert.IsType(propCfg.Properties["amount"]); + var autos = engine.GetAutomationConfigs(new EffectTestPropContext()); + Assert.Equal("Intensity", autos.Keys.First().DisplayText); + Assert.Equal(0.5, autos["intensity".AsKey()].DefaultValue); + var synth = engine.GetSynthesizedParameterConfigs(new EffectTestPropContext()); + Assert.True(synth["loudness".AsKey()].IsPiecewise); + + // 处理(反向链全活:读入→取参数→建段→写→commit→回显→状态) + session = engine.CreateSession(ctx); + await session.Process(CancellationToken.None); + + Assert.Single(ctx.Segments); + var seg = ctx.Segments[0]; + Assert.True(seg.Committed); + Assert.Equal(4410, seg.Written.Count); + for (int i = 0; i < seg.Written.Count; i++) // fake 按 ×0.5(properties 喂回 amount=0.5) + Assert.Equal(ctx.Input.Buffer[i] * 0.5f, seg.Written[i], 6); + var loud = session.SynthesizedParameters["loudness"]; + Assert.Single(loud.Segments); + Assert.Equal(2, loud.Segments[0].Count); + var status = session.Status; + Assert.Single(status); + Assert.Equal(SynthesisSegmentStatus.Synthesized, status[0].Status); + Assert.Equal(1.0, status[0].Progress); + session.Dispose(); + Assert.True(seg.Disposed); // 会话销毁带走段句柄 + } + finally + { + host.Shutdown(); + } + } + + [Fact(Timeout = 60_000)] + public async Task EvaluateReversePullsAutomationValues() + { + var host = MakeHost("FakeEval"); + var engine = new RemoteEffectEngine(host, "FakeEval", hasSettings: false); + var ctx = new EffectTestContext(); + try + { + engine.Init(); + var session = engine.CreateSession(ctx); + await session.Process(CancellationToken.None); + var auto = (EffectTestAutomation)ctx.Automations["intensity"]; + Assert.True(auto.Touched); // 反向曲线求值命中了活视图 + } + finally + { + host.Shutdown(); + } + } +} + +internal static class StringKeyExt +{ + public static PropertyKey AsKey(this string s) => new(s); +} diff --git a/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs b/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs index 761304dd..83a92769 100644 --- a/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs +++ b/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs @@ -82,4 +82,48 @@ static RuntimeSpec ParseSpec(string s) Assert.True(RuntimeSpec.TryParse(s, out var spec, out _)); return spec; } + + [Fact(Timeout = 60_000)] + public void RealPythonEffectGainRoundTrip() + { + if (!RuntimeLocator.Default.TryResolve(ParseSpec("python>=3.10"), out var python, out var trail)) + { + if (Environment.GetEnvironmentVariable("TUNELAB_REQUIRE_PYTHON") == "1") + Assert.Fail("python not resolved: " + trail); + return; // 无解释器环境:vacuous pass + } + Environment.SetEnvironmentVariable("PYTHONPATH", LocateUp("scripting", "python")); + + var pkgDir = LocateUp("tests", "plugins", "PyEffect"); + var entry = "plugin/main.py"; + var host = SidecarRegistry.For("com.tunelab.test.pyeffect", pkgDir, python, entry); + host.ExpectEngine("effect", "PyGain"); + var engine = new RemoteEffectEngine(host, "PyGain", hasSettings: false); + var ctx = new EffectTestContext(); + try + { + engine.Init(); + // 声明面经真 config wire 重建 + var cfg = Assert.IsType(engine.GetPropertyConfig(new EffectTestPropContext())); + Assert.IsType(cfg.Properties["amount".AsKey()]); + + var session = engine.CreateSession(ctx); + session.Process(System.Threading.CancellationToken.None).GetAwaiter().GetResult(); + + var seg = Assert.Single(ctx.Segments); + Assert.True(seg.Committed); + Assert.Equal(4410, seg.Written.Count); + for (int i = 0; i < seg.Written.Count; i += 701) // 跨语言:python 写回 = 输入 × 0.5 + Assert.Equal(ctx.Input.Buffer[i] * 0.5f, seg.Written[i], 6); + var loud = session.SynthesizedParameters["loudness"]; + Assert.Single(loud.Segments); + Assert.Equal(2, loud.Segments[0].Count); + var status = Assert.Single(session.Status); + Assert.Equal(SynthesisSegmentStatus.Synthesized, status.Status); + } + finally + { + host.Shutdown(); + } + } } diff --git a/tests/plugins/PyEffect/manifest.json b/tests/plugins/PyEffect/manifest.json new file mode 100644 index 00000000..110bcd0b --- /dev/null +++ b/tests/plugins/PyEffect/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "com.tunelab.test.pyeffect", + "name": "Py Effect (gain)", + "version": "1.0.0", + "author": "TuneLab Tests", + "description": "脚本 effect 样例:把整段音频乘以固定增益。声明 amount 滑条与 loudness 回显轨。真解释器 E2E 靶。", + "sdk-version": "1.1", + "type": "effect", + "engine": "PyGain", + "runtime": "python>=3.10,<4", + "entry": "plugin/main.py", + "platforms": ["win", "osx", "linux"] +} diff --git a/tests/plugins/PyEffect/plugin/main.py b/tests/plugins/PyEffect/plugin/main.py new file mode 100644 index 00000000..0d85e6db --- /dev/null +++ b/tests/plugins/PyEffect/plugin/main.py @@ -0,0 +1,53 @@ +# PyGain sample effect — multiplies a whole input segment by a fixed gain. +# Real-interpreter E2E target (ScriptedPythonE2ETests); PYTHONPATH supplies the facade. +import tunelab + + +@tunelab.effect("PyGain") +class PyGain: + def init(self): + self.gain = 0.5 + + def destroy(self): + self.gain = 0.0 + + def get_property_config(self, effects): + return {"type": "object", "properties": [{ + "id": "amount", + "config": {"type": "slider", "defaultValue": 0.5, + "scale": {"kind": "linear", "min": 0.0, "max": 1.0}}, + }]} + + def get_automation_configs(self, effects): + return [] + + def get_synthesized_parameter_configs(self, effects): + return [] + + def create_session(self, ctx): + return _Session(self) + + +class _Session: + def __init__(self, engine): + self.engine = engine + + def process(self, ctx): + g = ctx.input + data = ctx.input_read(0, g.sample_count) + seg = ctx.output(g.sample_offset, g.sample_count, g.sample_rate) + seg.write(0, [x * self.engine.gain for x in data]) + seg.commit() + t0 = g.sample_offset / g.sample_rate + t1 = (g.sample_offset + g.sample_count) / g.sample_rate + ctx.status([(t0, t1, "synthesized", 1.0)]) + ctx.parameters({"loudness": {"segments": [[[t0, 0.4], [t1, 0.4]]]}}) + + def cancel(self): + pass + + def dispose(self): + pass + + +tunelab.serve() From 8bb365740b57b79bd7580de72b4ccf2f1f5fcc56 Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Tue, 15 Sep 2026 11:26:05 +0800 Subject: [PATCH 06/12] =?UTF-8?q?feat(scripted):=20M3a=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E8=BF=90=E8=A1=8C=E6=97=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=EF=BC=9A=E5=86=85=E7=BD=AE=E9=95=9C=E5=83=8F?= =?UTF-8?q?=E8=A1=A8(=E5=9B=BD=E5=86=85=E4=BC=98=E5=85=88)=E3=80=81HEAD=20?= =?UTF-8?q?=E7=AB=9E=E9=80=9F=E5=88=A4=E8=B7=AF=E3=80=81=E4=B8=89=E6=A1=A3?= =?UTF-8?q?=E8=A7=A6=E5=8F=91=E3=80=81=E8=AF=A6=E6=83=85=E7=AA=97=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户需求:宿主确定解释器后"自动安装"——镜像表内置、必要时走国内 mirror 提速, Python 与 NodeJS 环境的自动部署过程一体。设计 §8.6/新增 §8.6.1。 - RuntimeFeed(镜像表=数据):python embeddable(3.11.9/3.12.7/3.13.3×amd64、3.13.3×arm64) 与 node dist(20.17.0×x64+arm64、18.20.4)起步格;候选 URL 华为云/npmmirror 在前、 python.org/nodejs.org 兜底。**sha256 已钉两格**——华为云 vs python.org、npmmirror vs nodejs.org 双下比对全等才入表(本机实测:3.13.3 embed amd64 与 node 20.17.0 win-x64); 未钉格照下但记警告,release 工具补钉。Match 的 struct-Nullable 陷阱修复(无格子=真 null)。 - ScriptedRuntimeFetcher:HEAD 并发竞速(3s)择最快可达镜像 → 流式下载(Content-Type 挡 "网页当安装包")→ sha256(有钉必验,不符即弃)→ 解包成 type:"runtime" 资源包布局 (root/prefix 归一,§8.4 同款)→ 主线程登记(Dispatcher 投递,结果确定)。 downloader/extensionsRoot/register/probe/覆写基址全注入——单测喂预制 zip 跑全链不碰网。 - 三档触发:全自动(ScriptedAutoFetchRuntime 开 → gate "no matching runtime" 即 RuntimeInstallQueue 后台一发,按包在途去重、成功自动 Reload+刷新);半自动(详情窗新 「下载运行时」按钮,15 份翻译已补;在途转圈防重按);命令/agent (extension fetch-runtime → fetch_scripted_runtime,ExtensionInstall 授权档, 工具清单封条同步认领)。 - 真链路自证(临时探针,已清理):华为云 4.3s 装好 python 3.13.3 embed,校验通过、 落地 exe 自报 "3.13.3 AMD64",RuntimeLocator ③ 级即命中。 - 稳定性:真 spawn 测试类圈进 SidecarSpawns 串行域(并行把握手超时挤爆 = 上轮全量唯一 抖源,连跑两轮全量一致 671 通过/36 基线红);engine.create_session 与 effect.process 的到达顺序竞态修(会话登记 Task、Process 首步 await——façade 会话表在 create 才建)。 - 探针发现并绕开的环境事实:本机 testhost 为 win-x86(32 位 dotnet 根在 PATH),feed 无 x86 格 → Match 如实 null("没有假安装"),命令与 UI 的拒绝文案覆盖该形态。 测试:+12(fetcher 布局/校验/竞速/覆写 9、queue 去重回执 1、命令拒绝面 2); Scripted 71/71;全量 671/707(36 红=基线 Setup 环境);Legacy 13/13。 文档:§8.6 重写 + §8.6.1 新增(镜像表/钉/判路/实测);§12.4 M3a 条目。 --- TuneLab/Commands/CommandRegistry.cs | 1 + .../Handlers/ExtensionFetchRuntimeCommand.cs | 97 +++++++ TuneLab/Extensions/ExtensionManager.cs | 14 + TuneLab/Extensions/Scripted/RemoteEffect.cs | 8 +- TuneLab/Extensions/Scripted/RuntimeFeed.cs | 89 ++++++ .../Scripted/RuntimeInstallQueue.cs | 61 ++++ .../Extensions/Scripted/ScriptedEntryGate.cs | 11 +- .../Scripted/ScriptedRuntimeFetcher.cs | 264 ++++++++++++++++++ TuneLab/Resources/Translations/de-DE.toml | 1 + TuneLab/Resources/Translations/el-GR.toml | 1 + TuneLab/Resources/Translations/es-US.toml | 1 + TuneLab/Resources/Translations/fr-FR.toml | 1 + TuneLab/Resources/Translations/it-IT.toml | 1 + TuneLab/Resources/Translations/ja-JP.toml | 1 + TuneLab/Resources/Translations/ko-KR.toml | 1 + TuneLab/Resources/Translations/nl-NL.toml | 1 + TuneLab/Resources/Translations/pt-BR.toml | 1 + TuneLab/Resources/Translations/ru-RU.toml | 1 + TuneLab/Resources/Translations/sv-SE.toml | 1 + TuneLab/Resources/Translations/tr-TR.toml | 1 + TuneLab/Resources/Translations/uk-UA.toml | 1 + TuneLab/Resources/Translations/zh-CN.toml | 1 + TuneLab/Resources/Translations/zh-TW.toml | 1 + .../Extensions/ExtensionDetailWindow.cs | 45 +++ .../ExtensionSideBarContentProvider.cs | 47 ++++ docs/scripted-plugin-sidecar-design.md | 49 +++- tests/TuneLab.Tests/CommandRegistryTests.cs | 2 + .../ExtensionFetchRuntimeCommandTests.cs | 71 +++++ tests/TuneLab.Tests/ScriptedEffectTests.cs | 3 +- tests/TuneLab.Tests/ScriptedEntryGateTests.cs | 3 + tests/TuneLab.Tests/ScriptedPythonE2ETests.cs | 3 +- .../ScriptedRemoteFormatTests.cs | 3 +- .../ScriptedRuntimeFetcherTests.cs | 217 ++++++++++++++ .../TuneLab.Tests/ScriptedSidecarHostTests.cs | 3 +- .../TuneLab.Tests/SidecarSpawnsCollection.cs | 9 + 35 files changed, 996 insertions(+), 19 deletions(-) create mode 100644 TuneLab/Commands/Handlers/ExtensionFetchRuntimeCommand.cs create mode 100644 TuneLab/Extensions/Scripted/RuntimeFeed.cs create mode 100644 TuneLab/Extensions/Scripted/RuntimeInstallQueue.cs create mode 100644 TuneLab/Extensions/Scripted/ScriptedRuntimeFetcher.cs create mode 100644 tests/TuneLab.Tests/ExtensionFetchRuntimeCommandTests.cs create mode 100644 tests/TuneLab.Tests/ScriptedRuntimeFetcherTests.cs create mode 100644 tests/TuneLab.Tests/SidecarSpawnsCollection.cs diff --git a/TuneLab/Commands/CommandRegistry.cs b/TuneLab/Commands/CommandRegistry.cs index 23fd8da8..2de44831 100644 --- a/TuneLab/Commands/CommandRegistry.cs +++ b/TuneLab/Commands/CommandRegistry.cs @@ -56,6 +56,7 @@ internal static class CommandRegistry new ExtensionSetSettingCommand(), new ExtensionEnableCommand(), new ExtensionInstallCommand(), + new ExtensionFetchRuntimeCommand(), new ExtensionUninstallCommand(), new ExtensionCancelUninstallCommand(), }; diff --git a/TuneLab/Commands/Handlers/ExtensionFetchRuntimeCommand.cs b/TuneLab/Commands/Handlers/ExtensionFetchRuntimeCommand.cs new file mode 100644 index 00000000..6f59bddb --- /dev/null +++ b/TuneLab/Commands/Handlers/ExtensionFetchRuntimeCommand.cs @@ -0,0 +1,97 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using TuneLab.Extensions; +using TuneLab.Extensions.Scripted; +using TuneLab.Utils; + +namespace TuneLab.Commands.Handlers; + +// `extension fetch-runtime` —— 脚本运行时的自动部署(设计文档 §8.6)。装一个解释器发行(python/node) +// 到扩展目录,成为一个 type:"runtime" 资源包;之后声明它的脚本插件(manifest `runtime`)在 ③ 级探测命中、 +// 懒起。这是「用户已经下载了 .tlx 里的脚本插件、但机器上没有解释器」那条路的自动化替代。 +// +// 与 `extension install` 同一授权档(ExtensionInstall):都往用户机器上放并在之后运行第三方代码。 +// 差别只在来源——install 吃本地 .tlx,fetch-runtime 从镜像表下载(国内优先、竞速择快、可选 sha256 钉)。 +internal sealed class ExtensionFetchRuntimeCommand : ICommand +{ + public string Path => "extension fetch-runtime"; + public CommandKind Kind => CommandKind.Edit; + public string AgentToolName => "fetch_scripted_runtime"; + + public string Brief => "Download and install a scripting runtime (Python / Node.js)"; + + public string Documentation => + "Fetch an interpreter distribution (a 'runtime' resource package) so scripted extensions (manifest entries with a `runtime` field) can load. " + + "Give it a runtime spec exactly like the manifest field: \"python>=3.10\" or \"node>=18\". It picks the fastest reachable mirror (domestic mirrors are tried before the official one), " + + "verifies the download when the built-in feed pins a checksum, then installs it into the user's extensions folder and registers it \u2014 after which list_extensions / the failing scripted plugin will resolve. " + + "\nIt downloads from the network and needs the user's authorization (same as installing a plugin). If nothing in the feed matches the platform, it refuses and the user must supply an interpreter themselves (set the path in settings, or install a 'runtime' .tlx). " + + "\nAlready-satisfied interpreters are left alone: check the plugin's status first (list_extensions shows 'no matching runtime' only when one is genuinely missing)."; + + public string ParametersJsonSchema => """ + { + "type": "object", + "properties": { + "runtime": { "type": "string", "description": "Runtime spec, same grammar as the manifest field, e.g. \"python>=3.10\" or \"node>=18\"." } + }, + "required": ["runtime"], + "additionalProperties": false + } + """; + + // 测试注入点:非空则用它替代真实下载(喂预制归档走完整落盘/登记链)。生产恒 null。 + public ScriptedRuntimeFetcher.DownloadAsyncDelegate? DownloaderOverride; + + public async Task ExecuteAsync(CommandArgs args, CommandContext ctx, CancellationToken cancellationToken) + { + var specText = (args.Json.GetString("runtime") ?? "").Trim().Trim('"'); + if (specText.Length == 0) + return CommandResult.Fail("empty_runtime", "\"runtime\" is required (e.g. \"python>=3.10\")."); + if (!RuntimeSpec.TryParse(specText, out var spec, out var parseError)) + return CommandResult.Fail("bad_runtime", parseError!); + var cell = RuntimeFeed.Match(spec); + if (cell == null) + return CommandResult.Fail("no_feed_cell", string.Format(CultureInfo.InvariantCulture, + "the built-in feed has no auto-installable runtime matching \"{0}\" for this platform ({1}); provide an interpreter yourself.", + spec.Describe(), PlatformHelper.GetPlatform())); + + var label = string.Format(CultureInfo.InvariantCulture, "{0} {1}", cell.Value.Flavor, cell.Value.Version.ToString(3)); + var (proceed, message) = await ctx.Authorize(new AuthorizationRequest(WriteKind.ExtensionInstall, 0, label, "runtime"), cancellationToken); + if (!proceed) + return CommandResult.Ok(new JsonObject { ["runtime"] = spec.Describe(), ["outcome"] = "refused", ["note"] = message }); + + var result = await ScriptedRuntimeFetcher.FetchInstallAsync(spec, null, cancellationToken, DownloaderOverride); + var node = new JsonObject + { + ["runtime"] = spec.Describe(), + ["resolved"] = label, + ["outcome"] = result.Ok ? "applied" : "failed", + ["note"] = result.Ok ? (string.IsNullOrEmpty(message) ? null : message) : result.Message, + }; + if (result.PackageDir != null) + node["path"] = result.PackageDir; + return result.Ok ? CommandResult.Ok(node) : CommandResult.Fail("fetch_failed", result.Message, node); + } + + public string Render(JsonNode? data, CommandArgs args) + { + if (data is not JsonObject obj) + return string.Empty; + var runtime = obj["runtime"]?.GetValue() ?? "?"; + var label = obj["resolved"]?.GetValue() ?? runtime; + switch (obj["outcome"]?.GetValue()) + { + case "refused": + return obj["note"]?.GetValue() ?? string.Empty; + case "failed": + return string.Format("Could not install the {0} runtime: {1}.", label, obj["note"]?.GetValue() ?? "unknown error"); + default: + return string.Format("Installed the {0} runtime into the extensions folder. Scripted plugins that asked for \"{1}\" " + + "will pick it up on their next (re)load — from the Extensions sidebar use Reload, or restart TuneLab.", + label, runtime); + } + } +} diff --git a/TuneLab/Extensions/ExtensionManager.cs b/TuneLab/Extensions/ExtensionManager.cs index 2c391a8f..81cfbc43 100644 --- a/TuneLab/Extensions/ExtensionManager.cs +++ b/TuneLab/Extensions/ExtensionManager.cs @@ -143,6 +143,16 @@ public static void Load(string path) LoadLegacy(path, description, folderName); } + // 重载单包(runtime 自动部署落地后的回执路径):摘旧结果再 Load。同包重复注册由 + // FormatsManager(同后缀忽略)/EffectManager(同包警告+忽略)天然幂等,安全。 + public static void Reload(string path) + { + var stale = mLoadResults.Where(r => string.Equals(r.DirectoryPath, path, StringComparison.Ordinal)).ToList(); + foreach (var r in stale) + mLoadResults.Remove(r); + Load(path); + } + static void LoadV1(string path, ExtensionManifest description) { var lang = TranslationManager.CurrentLanguage.Value; @@ -341,6 +351,10 @@ static void LoadV1(string path, ExtensionManifest description) entry.Error = gate.Message; reasons.Add(string.Format("{0}: {1}", IdentityLabel(ext, kind), gate.Message)); Log.Warning(string.Format("Extension {0}: {1}: {2}", description.name, IdentityLabel(ext, kind), gate.Message)); + // 自动部署档(默认关):仅"探测落空"这一种跳过触发(deps/未知格子不动), + // 后台一次、成功即 Reload——绝不在此加载路径上等网络。 + if (gate.RuntimeMissing && gate.Spec is { } fetchSpec && Configs.SettingsRegistry.ScriptedAutoFetchRuntime.Property.Value) + RuntimeInstallQueue.Schedule(description.id ?? string.Empty, path, fetchSpec); } else { diff --git a/TuneLab/Extensions/Scripted/RemoteEffect.cs b/TuneLab/Extensions/Scripted/RemoteEffect.cs index c4e4974e..b3ddc0b1 100644 --- a/TuneLab/Extensions/Scripted/RemoteEffect.cs +++ b/TuneLab/Extensions/Scripted/RemoteEffect.cs @@ -124,8 +124,10 @@ public IEffectSynthesisSession CreateSession(IEffectSynthesisContext context) var id = mEngineId + "#" + Interlocked.Increment(ref mSessionCounter); var session = new RemoteEffectSession(this, context, id); mSessions[id] = session; - _ = mHost.CallAsync("engine.create_session", new JsonObject { ["engine"] = mEngineId, ["session"] = id }) - .ContinueWith(t => { session.CreateFault = t.Exception?.GetBaseException(); }); + // 顺序要紧:process 请求不得先于 engine.create_session 到达 sidecar(façade 的会话表在 + // create 里建)——故登记 Task,Process 首步 await 它;失败只记 CreateFault(首次使用面报错)。 + session.CreateHandshake = mHost.CallAsync("engine.create_session", new JsonObject { ["engine"] = mEngineId, ["session"] = id }) + .ContinueWith(t => { if (t.Exception != null) session.CreateFault = t.Exception.GetBaseException(); }); return session; } @@ -216,6 +218,7 @@ internal sealed class RemoteEffectSession : IEffectSynthesisSession bool mDisposed; public Exception? CreateFault; + public Task CreateHandshake = Task.CompletedTask; readonly ActionEvent mStatusChanged = new(); readonly ActionEvent mParametersChanged = new(); @@ -239,6 +242,7 @@ public async Task Process(CancellationToken cancellation) mSync = SynchronizationContext.Current ?? mSync; try { + await CreateHandshake.ConfigureAwait(false); // create_session 必先于 process 到达 sidecar if (CreateFault != null) throw CreateFault; var geometry = await OnData(() => diff --git a/TuneLab/Extensions/Scripted/RuntimeFeed.cs b/TuneLab/Extensions/Scripted/RuntimeFeed.cs new file mode 100644 index 00000000..5579dd55 --- /dev/null +++ b/TuneLab/Extensions/Scripted/RuntimeFeed.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace TuneLab.Extensions.Scripted; + +// 一个可下载的运行时发行单元:候选镜像(按首选序,index0 优先)+ 校验和 + 归档布局。 +// urls 是同一逻辑文件的镜像副本;sha256 空 = 未钉(fetcher 会下载但记警告,release 工具负责补钉)。 +internal readonly record struct RuntimeFeedCell( + string Flavor, + Version Version, + string Rid, + IReadOnlyList Urls, + string Sha256, + string Layout); // "root":归档根即 exe;"prefix":归档裹一个顶层目录 + +// 内置镜像表(设计文档 §8.6):脚本运行时的自动部署源。表是数据——release 工程补 sha256、 +// 增版本/格子都不动 fetch 逻辑。镜像按 index0=国内优先排(npmmirror/华为云),官方源兜底; +// 真正用哪个由 RuntimeFetcher 的延迟探测临场裁决(网络环境无关的"自动判路")。 +internal static class RuntimeFeed +{ + // 已知格子(flavor/版本/rid)。sha256 为【已钉】值(境内镜像与官方源双下比对一致,见 §8.6 与 + // tools/build-runtime-package/README);空串 = 未钉,fetcher 会下载但记警告,release 工具补钉。 + // Windows 的 python embeddable 与 node dist 先钉;unix standalone 布局随需要加。 + static readonly RuntimeFeedCell[] sCells = BuildCells(); + + // python-3.13.3-embed-amd64.zip / node-v20.17.0-win-x64.zip(huawei & python.org、npmmirror & nodejs.org 交叉核对) + const string Sha3133Amd64 = "59ff76e16e6597de47474fb22be69e7191a89116910d728ab735079b078e52db"; + const string ShaNode2017WinX64 = "e323fff0aba197090faabd29c4c23f334557ff24454324f0c83faa7e399dbb74"; + + public static IReadOnlyList Cells => sCells; + + // 取满足 spec 的最高版本格子(同 rid)。无则 null。 + public static RuntimeFeedCell? Match(RuntimeSpec spec) + { + // 注意:struct 的 FirstOrDefault 会给出 default 值(HasValue=true 的空格子)——先转可空再取。 + return sCells + .Where(c => string.Equals(c.Flavor, spec.Flavor, StringComparison.OrdinalIgnoreCase) + && RidMatches(c.Rid) + && spec.SatisfiedBy(c.Version.ToString(3))) + .OrderByDescending(c => c.Version) + .Select(c => (RuntimeFeedCell?)c) + .FirstOrDefault(); + } + + static RuntimeFeedCell[] BuildCells() + { + var list = new List(); + + // —— Python embeddable(Windows,无需安装器,解包即用;ARM64 自 3.13 起官方有产物)—— + foreach (var (ver, pyArch, rid, sha) in new (string, string, string, string)[] + { ("3.12.7", "amd64", "win-x64", ""), ("3.11.9", "amd64", "win-x64", ""), + ("3.13.3", "amd64", "win-x64", Sha3133Amd64), ("3.13.3", "arm64", "win-arm64", "") }) + { + var file = $"python-{ver}-embed-{pyArch}.zip"; + list.Add(new RuntimeFeedCell("python", Parse(ver), rid, new[] + { + $"https://mirrors.huaweicloud.com/python/{ver}/{file}", + $"https://registry.npmmirror.com/-/binary/python/{ver}/{file}", + $"https://www.python.org/ftp/python/{ver}/{file}", + }, sha, "root")); + } + + // —— Node.js 发行(Windows zip,解包得 node.exe)—— + foreach (var (ver, nodeArch, rid, sha) in new (string, string, string, string)[] + { ("20.17.0", "win-x64", "win-x64", ShaNode2017WinX64), ("20.17.0", "win-arm64", "win-arm64", ""), ("18.20.4", "win-x64", "win-x64", "") }) + { + var file = $"node-v{ver}-{nodeArch}.zip"; + list.Add(new RuntimeFeedCell("node", Parse(ver), rid, new[] + { + $"https://registry.npmmirror.com/-/binary/node/v{ver}/{file}", + $"https://mirrors.huaweicloud.com/nodejs/v{ver}/{file}", + $"https://npmmirror.com/mirrors/node/v{ver}/{file}", + $"https://nodejs.org/dist/v{ver}/{file}", + }, sha, "prefix")); + } + + return list.ToArray(); + } + + static Version Parse(string s) => Version.Parse(s); + + // 当前进程 rid 归类:feed 里 win-x64/win-arm64 精确匹配;osx/linux 目前无格子。 + static bool RidMatches(string cellRid) + { + var cur = Utils.PlatformHelper.GetPlatform(); // "win-x64" / "osx-arm64" / ... + return string.Equals(cellRid, cur, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/TuneLab/Extensions/Scripted/RuntimeInstallQueue.cs b/TuneLab/Extensions/Scripted/RuntimeInstallQueue.cs new file mode 100644 index 00000000..1fccbbda --- /dev/null +++ b/TuneLab/Extensions/Scripted/RuntimeInstallQueue.cs @@ -0,0 +1,61 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using TuneLab.Foundation; +using TuneLab.Utils; + +namespace TuneLab.Extensions.Scripted; + +// 运行时自动部署的调度环(设计 §8.6 半自动/全自动两档的共同后端): +// 同一包只排一次在途(dedup by packageId);成功后回 UI 线程 Reload 该包——条目循环重跑, +// ③ 级探测随即命中刚落地的 runtime 包。失败只记日志(侧栏仍显示原因 + 手动按钮兜底)。 +internal static class RuntimeInstallQueue +{ + static readonly HashSet sInFlight = new(StringComparer.Ordinal); + + // executor 为测试注入点(单测不碰真网络);生产走 ScriptedRuntimeFetcher。 + public static bool Schedule(string packageId, string packageDir, RuntimeSpec spec, + Func>? executor = null, Action? onDone = null) + { + lock (sInFlight) + { + if (!sInFlight.Add(packageId)) + return false; + } + var run = executor ?? (async s => (await ScriptedRuntimeFetcher.FetchInstallAsync(s, progress: null, CancellationToken.None)).Ok); + var done = onDone ?? DefaultDone; + _ = Task.Run(async () => + { + bool ok = false; + try + { + ok = await run(spec).ConfigureAwait(false); + Log.Info(string.Format(System.Globalization.CultureInfo.InvariantCulture, "scripted runtime auto-fetch for {0}: {1}", packageId, ok ? "installed" : "failed")); + } + catch (Exception ex) + { + Log.Warning(string.Format(System.Globalization.CultureInfo.InvariantCulture, "scripted runtime auto-fetch for {0} threw: {1}", packageId, ex.Message)); + } + finally + { + lock (sInFlight) + sInFlight.Remove(packageId); + } + try { done(packageDir); } catch { } + }); + return true; + } + + static void DefaultDone(string packageDir) + { + try { Dispatcher.UIThread.Post(() => ExtensionManager.Reload(packageDir)); } catch { } + } + + // 在途查询(UI 置灰按钮用)。 + public static bool IsPending(string packageId) + { + lock (sInFlight) return sInFlight.Contains(packageId); + } +} diff --git a/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs b/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs index 2e3fcbe6..1eeb485e 100644 --- a/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs +++ b/TuneLab/Extensions/Scripted/ScriptedEntryGate.cs @@ -11,7 +11,13 @@ internal enum ScriptedGateStatus { Ready, Skipped, Failed } // 并回填 entry.Error。错误文案 = 作者看得懂的人话(英文,日志/异常约定)。 internal static class ScriptedEntryGate { - public readonly record struct Result(ScriptedGateStatus Status, string Message, RuntimeInfo Runtime); + public readonly record struct Result(ScriptedGateStatus Status, string Message, RuntimeInfo Runtime) + { + // runtime 声明合法但探测落空 → 供 ExtensionManager 触发可选的自动部署(与"deps 未实现"等 + // 其它 Skipped 区分开)。Runtime 字段此时为 default。 + public bool RuntimeMissing { get; init; } + public RuntimeSpec? Spec { get; init; } + } // locator 参数为测试注入点(假 prober/假扩展根,绝不在单测里跑真解释器);生产走 Default。 public static Result Evaluate(ExtensionInfo ext, string packageDir, RuntimeLocator? locator = null) @@ -48,7 +54,8 @@ public static Result Evaluate(ExtensionInfo ext, string packageDir, RuntimeLocat if (!locator.TryResolve(spec, out var info, out var trail)) return new(ScriptedGateStatus.Skipped, string.Format( "no matching runtime: {0}. To provide one, install a 'runtime' package (type \"runtime\"), set '{1}' in settings, or put a matching interpreter on PATH.", - trail, RuntimeLocator.SettingsKeyFor(spec.Flavor)), default); + trail, RuntimeLocator.SettingsKeyFor(spec.Flavor)), default) + { RuntimeMissing = true, Spec = spec }; // runtime 就位:交给调用方按 kind 注册(format=M1;voice/effect 随 M2/M3 的注册支,此前如实 Skipped)。 return new(ScriptedGateStatus.Ready, string.Format("runtime resolved: {0} {1} at '{2}'", info.Flavor, info.Version, info.Exe), info); diff --git a/TuneLab/Extensions/Scripted/ScriptedRuntimeFetcher.cs b/TuneLab/Extensions/Scripted/ScriptedRuntimeFetcher.cs new file mode 100644 index 00000000..aa9b5b49 --- /dev/null +++ b/TuneLab/Extensions/Scripted/ScriptedRuntimeFetcher.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Threading; +using TuneLab.Configs; +using TuneLab.Foundation; +using TuneLab.Utils; +using NetHttpClient = System.Net.Http.HttpClient; + +namespace TuneLab.Extensions.Scripted; + +// 脚本运行时自动部署(设计文档 §8.6):镜像表命中 → 竞速择最快可达镜像 → 下载 →(有钉则)验 sha256 +// → 解包成 runtime 资源包布局(§8.4)→ 落进 Extensions 目录并主线程登记。 +// 之后 RuntimeLocator 的 ③ 级(已装 runtime 包)自然命中,脚本条目得以懒起。 +// +// 下载/落盘根/登记全可注入(Downloader/extensionsRoot/register),单测喂预制 zip 即可跑通 +// "落盘→登记→定位命中"全链,不碰真网络(CI 无解释器/无外网)。 +internal static class ScriptedRuntimeFetcher +{ + public delegate Task DownloadAsyncDelegate(string url, string destPath, IProgress? progress, CancellationToken ct); + + public readonly record struct Result(bool Ok, string Message, string? PackageDir); + + public static Task FetchInstallAsync(RuntimeSpec spec, IProgress? progress = null, + CancellationToken ct = default, DownloadAsyncDelegate? downloader = null) + => FetchInstallAsync(spec, progress, ct, downloader, null, null); + + // 完整入口:extensionsRoot/register 供测试注入(生产传 null → 真实 ExtensionsFolder + 主线程登记)。 + public static Task FetchInstallAsync(RuntimeSpec spec, IProgress? progress, + CancellationToken ct, DownloadAsyncDelegate? downloader, string? extensionsRootOverride, Action? registerOverride) + { + var cell = RuntimeFeed.Match(spec); + if (cell == null) + return Task.FromResult(new Result(false, string.Format(CultureInfo.InvariantCulture, + "no auto-installable runtime for {0} on this platform ({1}); install one manually or via a 'runtime' package", + spec.Describe(), PlatformHelper.GetPlatform()), null)); + return InstallAsync(cell.Value, progress, ct, downloader, extensionsRootOverride, registerOverride); + } + + // 供测试直接喂受控 cell(假 sha256 / 预制归档)的底层入口。probe/overrideBase 可注入 + // (单测不碰真网络、不依赖 SettingsRegistry)。 + public static Task InstallAsync(RuntimeFeedCell cell, IProgress? progress, + CancellationToken ct, DownloadAsyncDelegate? downloader, string? extensionsRootOverride = null, Action? registerOverride = null) + => InstallAsync(cell, progress, ct, downloader, extensionsRootOverride, registerOverride, null, null); + + public static async Task InstallAsync(RuntimeFeedCell cell, IProgress? progress, + CancellationToken ct, DownloadAsyncDelegate? downloader, string? extensionsRootOverride, Action? registerOverride, + Func, CancellationToken, Task>? probeOverride, string? overrideBase) + { + var urls = OrderUrls(cell.Urls, overrideBase ?? SettingsRegistry.ScriptedRuntimeSource.Property.Value); + if (urls.Count == 0) + return new Result(false, "runtime feed cell has no URLs", null); + var chosen = await (probeOverride ?? PickFastestAsync)(urls, ct).ConfigureAwait(false); + + var work = Path.Combine(Path.GetTempPath(), "tunelab-runtime", $"{cell.Flavor}-{cell.Version.ToString(3)}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(work); + try + { + var archive = Path.Combine(work, "archive" + ExtOf(chosen)); + Log.Info(string.Format(CultureInfo.InvariantCulture, "fetching runtime {0} {1} from {2}", cell.Flavor, cell.Version.ToString(3), chosen)); + await (downloader ?? DownloadAsync)(chosen, archive, progress, ct).ConfigureAwait(false); + + if (!string.IsNullOrEmpty(cell.Sha256)) + { + var actual = Sha256File(archive); + if (!string.Equals(actual, cell.Sha256, StringComparison.OrdinalIgnoreCase)) + { + TryDelete(archive); + return new Result(false, string.Format(CultureInfo.InvariantCulture, + "downloaded runtime failed checksum (expected {0}, got {1})", cell.Sha256, actual), null); + } + } + else + Log.Warning(string.Format(CultureInfo.InvariantCulture, + "runtime {0} {1} is not sha256-pinned in the feed; trusting the source this once", cell.Flavor, cell.Version.ToString(3))); + + var installDir = LayOut(cell, archive, extensionsRootOverride ?? PathManager.ExtensionsFolder); + if (installDir == null) + return new Result(false, "failed to lay out the downloaded archive into a runtime package", null); + + (registerOverride ?? RegisterOnMainThread)(installDir); // 就地登记;RuntimeLocator ③ 级随即命中 + Log.Info(string.Format(CultureInfo.InvariantCulture, "runtime package installed at {0}", installDir)); + return new Result(true, string.Format(CultureInfo.InvariantCulture, "installed {0} {1}", cell.Flavor, cell.Version.ToString(3)), installDir); + } + catch (OperationCanceledException) + { + return new Result(false, "cancelled", null); + } + catch (Exception ex) + { + Log.Error(string.Format(CultureInfo.InvariantCulture, "runtime fetch failed: {0}", ex)); + return new Result(false, ex.Message, null); + } + finally + { + try { Directory.Delete(work, true); } catch { } + } + } + + // 镜像序:覆写源(显式参数或设置项)排最前(相对路径取自官方源=表内末位), + // 其余按表内首选序(国内镜像在前、官方兜底在后)。 + static List OrderUrls(IReadOnlyList table, string? overrideBase) + { + var urls = new List(); + if (!string.IsNullOrWhiteSpace(overrideBase) && table.Count > 0) + { + var rel = new Uri(table[^1]).AbsolutePath.TrimStart('/'); // 相对路径取自官方源(表内末位) + urls.Add(overrideBase.TrimEnd('/') + "/" + rel); + } + urls.AddRange(table); + return urls; + } + + // 竞速探活:HEAD 各候选(3s 超时),返回最先成功者;全失败回退首选(让真下载去报错/重试)。 + static async Task PickFastestAsync(IReadOnlyList urls, CancellationToken ct) + { + if (urls.Count == 1) + return urls[0]; + using var http = new NetHttpClient { Timeout = TimeSpan.FromSeconds(3) }; + var tasks = urls.Select(async u => + { + try + { + using var _ = await http.SendAsync(new HttpRequestMessage(HttpMethod.Head, u), + HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + return (u, ok: true); + } + catch { return (u, ok: false); } + }).ToList(); + var pending = tasks.ToList(); + while (pending.Count > 0) + { + var finished = await Task.WhenAny(pending).ConfigureAwait(false); + pending.Remove(finished); + var r = await finished.ConfigureAwait(false); + if (r.ok) + return r.u; + } + return urls[0]; + } + + // 默认下载器(流式 + 进度;Content-Type 挡一道"把网页当安装包"的历史事故)。 + static async Task DownloadAsync(string url, string destPath, IProgress? progress, CancellationToken ct) + { + using var http = new NetHttpClient { Timeout = TimeSpan.FromMinutes(10) }; + using var resp = await http.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false); + resp.EnsureSuccessStatusCode(); + var media = resp.Content.Headers.ContentType?.MediaType; + if (media != null && media.StartsWith("text/", StringComparison.OrdinalIgnoreCase)) + throw new InvalidDataException(string.Format(CultureInfo.InvariantCulture, "server returned '{0}' not an archive: {1}", media, url)); + long? total = resp.Content.Headers.ContentLength; + await using (var src = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false)) + await using (var dst = File.Create(destPath)) + { + var buf = new byte[81920]; + long done = 0; + int read; + while ((read = await src.ReadAsync(buf, ct).ConfigureAwait(false)) > 0) + { + await dst.WriteAsync(buf.AsMemory(0, read), ct).ConfigureAwait(false); + done += read; + if (total is > 0) + progress?.Report((double)done / total.Value); + } + } + } + + // 默认登记:Load 写宿主注册表,须在主线程(同 `extension install` 的 OnMainThread 约束); + // 从池线程调用时经 UI 线程投送并等完成——调用方本就在后台,阻塞无害且结果确定。 + static void RegisterOnMainThread(string dir) + { + if (Dispatcher.UIThread.CheckAccess()) + ExtensionManager.Load(dir); + else + Commands.UiThreadDispatcher.Instance.InvokeAsync(() => { ExtensionManager.Load(dir); return true; }) + .GetAwaiter().GetResult(); + } + + // 解包 → `//{manifest.json, runtime/...}`,返回目录;布局对齐 RuntimeLocator.ResolvePackageExe。 + static string? LayOut(RuntimeFeedCell cell, string archive, string extensionsRoot) + { + if (!archive.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + return null; // M3 只覆盖 .zip(python embed / node win dist);unix tar 布局后续扩 + var extract = Path.Combine(Path.GetTempPath(), "tunelab-runtime-x", Guid.NewGuid().ToString("N")); + try + { + ZipFile.ExtractToDirectory(archive, extract); + var contentRoot = cell.Layout == "prefix" ? FlattenSingleTopDir(extract) : extract; + var name = string.Format(CultureInfo.InvariantCulture, "runtime-{0}-{1}-{2}", cell.Flavor, cell.Version.ToString(3), cell.Rid); + var target = Path.Combine(extensionsRoot, name); + if (Directory.Exists(target)) + try { Directory.Delete(target, true); } catch { return null; } + PathManager.MakeSureExist(target); + CopyDir(contentRoot, Path.Combine(target, "runtime")); + File.WriteAllText(Path.Combine(target, "manifest.json"), BuildManifestJson(cell, name)); + return target; + } + finally + { + try { Directory.Delete(extract, true); } catch { } + } + } + + static string FlattenSingleTopDir(string extract) + { + var dirs = Directory.GetDirectories(extract); + if (dirs.Length == 1 && Directory.GetFiles(extract).Length == 0) + return dirs[0]; + return extract; + } + + static void CopyDir(string src, string dst) + { + Directory.CreateDirectory(dst); + foreach (var f in Directory.GetFiles(src)) + File.Copy(f, Path.Combine(dst, Path.GetFileName(f)), true); + foreach (var d in Directory.GetDirectories(src)) + CopyDir(d, Path.Combine(dst, Path.GetFileName(d))); + } + + static string BuildManifestJson(RuntimeFeedCell cell, string name) + { + var m = new Dictionary + { + ["id"] = string.Format(CultureInfo.InvariantCulture, "com.tunelab.runtime.{0}.{1}", cell.Rid, cell.Flavor), + ["name"] = string.Format(CultureInfo.InvariantCulture, "TuneLab {0} Runtime ({1}, {2})", cell.Flavor, cell.Version.ToString(3), cell.Rid), + ["version"] = "1.0.0", + ["author"] = "TuneLab", + ["description"] = "Auto-installed interpreter for scripted extensions.", + ["type"] = "runtime", + ["flavor"] = cell.Flavor, + ["runtime-version"] = cell.Version.ToString(3), + ["platforms"] = new[] { cell.Rid }, + }; + return JsonSerializer.Serialize(m, new JsonSerializerOptions { WriteIndented = true }); + } + + static string Sha256File(string path) + { + using var sha = SHA256.Create(); + using var fs = File.OpenRead(path); + return Convert.ToHexString(sha.ComputeHash(fs)); + } + + static string ExtOf(string url) + { + var p = new Uri(url).AbsolutePath; + var i = p.LastIndexOf('.'); + return i < 0 ? ".zip" : p[i..]; + } + + static void TryDelete(string path) + { + try { File.Delete(path); } catch { } + } +} diff --git a/TuneLab/Resources/Translations/de-DE.toml b/TuneLab/Resources/Translations/de-DE.toml index efab3b2f..cd8d7502 100644 --- a/TuneLab/Resources/Translations/de-DE.toml +++ b/TuneLab/Resources/Translations/de-DE.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Hier gibt es keinen Wert, das Vibrato hat keine Wirkung" [Dialog] +"Fetch Runtime" = "Runtime herunterladen" "OK" = "OK" "Cancel" = "Abbrechen" "Yes" = "Ja" diff --git a/TuneLab/Resources/Translations/el-GR.toml b/TuneLab/Resources/Translations/el-GR.toml index 1b15abc8..4e8f00a5 100644 --- a/TuneLab/Resources/Translations/el-GR.toml +++ b/TuneLab/Resources/Translations/el-GR.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Δεν υπάρχει τιμή εδώ, το vibrato δεν έχει αποτέλεσμα" [Dialog] +"Fetch Runtime" = "Λήψη χρόνου εκτέλεσης" "OK" = "OK" "Cancel" = "Ακύρωση" "Yes" = "Ναί" diff --git a/TuneLab/Resources/Translations/es-US.toml b/TuneLab/Resources/Translations/es-US.toml index 2463e02c..569f5ecb 100644 --- a/TuneLab/Resources/Translations/es-US.toml +++ b/TuneLab/Resources/Translations/es-US.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Aquí no hay valor, el vibrato no tiene efecto" [Dialog] +"Fetch Runtime" = "Descargar el tiempo de ejecución" "OK" = "OK" "Cancel" = "Cancelar" "Yes" = "Sí" diff --git a/TuneLab/Resources/Translations/fr-FR.toml b/TuneLab/Resources/Translations/fr-FR.toml index e11df846..a50ac0f0 100644 --- a/TuneLab/Resources/Translations/fr-FR.toml +++ b/TuneLab/Resources/Translations/fr-FR.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Aucune valeur ici, le vibrato n'a aucun effet" [Dialog] +"Fetch Runtime" = "Télécharger l'environnement d'exécution" "OK" = "OK" "Cancel" = "Annuler" "Yes" = "Oui" diff --git a/TuneLab/Resources/Translations/it-IT.toml b/TuneLab/Resources/Translations/it-IT.toml index c30ac278..3a1f4bd4 100644 --- a/TuneLab/Resources/Translations/it-IT.toml +++ b/TuneLab/Resources/Translations/it-IT.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Nessun valore qui, il vibrato non ha effetto" [Dialog] +"Fetch Runtime" = "Scarica runtime" "OK" = "OK" "Cancel" = "Annulla" "Yes" = "Sì" diff --git a/TuneLab/Resources/Translations/ja-JP.toml b/TuneLab/Resources/Translations/ja-JP.toml index 1cafae51..eb8a1e59 100644 --- a/TuneLab/Resources/Translations/ja-JP.toml +++ b/TuneLab/Resources/Translations/ja-JP.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "ここには値がないため、ビブラートは効果がありません" [Dialog] +"Fetch Runtime" = "ランタイムを取得" "OK" = "OK" "Cancel" = "キャンセル" "Yes" = "はい" diff --git a/TuneLab/Resources/Translations/ko-KR.toml b/TuneLab/Resources/Translations/ko-KR.toml index a56b25c6..963b841a 100644 --- a/TuneLab/Resources/Translations/ko-KR.toml +++ b/TuneLab/Resources/Translations/ko-KR.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "여기에는 값이 없어 비브라토가 적용되지 않습니다" [Dialog] +"Fetch Runtime" = "런타임 다운로드" "OK" = "확인" "Cancel" = "취소" "Yes" = "예" diff --git a/TuneLab/Resources/Translations/nl-NL.toml b/TuneLab/Resources/Translations/nl-NL.toml index 8baab926..9c7ccaa3 100644 --- a/TuneLab/Resources/Translations/nl-NL.toml +++ b/TuneLab/Resources/Translations/nl-NL.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Hier is geen waarde, vibrato heeft geen effect" [Dialog] +"Fetch Runtime" = "Runtime downloaden" "OK" = "OK" "Cancel" = "Annuleren" "Yes" = "Ja" diff --git a/TuneLab/Resources/Translations/pt-BR.toml b/TuneLab/Resources/Translations/pt-BR.toml index 79974bbd..f48f87b6 100644 --- a/TuneLab/Resources/Translations/pt-BR.toml +++ b/TuneLab/Resources/Translations/pt-BR.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Nenhum valor aqui, o vibrato não tem efeito" [Dialog] +"Fetch Runtime" = "Baixar tempo de execução" "OK" = "OK" "Cancel" = "Cancelar" "Yes" = "Sim" diff --git a/TuneLab/Resources/Translations/ru-RU.toml b/TuneLab/Resources/Translations/ru-RU.toml index 3d57a424..076edf42 100644 --- a/TuneLab/Resources/Translations/ru-RU.toml +++ b/TuneLab/Resources/Translations/ru-RU.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Здесь нет значения, вибрато не действует" [Dialog] +"Fetch Runtime" = "Скачать среду выполнения" "OK" = "ОК" "Cancel" = "Отмена" "Yes" = "Да" diff --git a/TuneLab/Resources/Translations/sv-SE.toml b/TuneLab/Resources/Translations/sv-SE.toml index 54a62b23..e8ec24ca 100644 --- a/TuneLab/Resources/Translations/sv-SE.toml +++ b/TuneLab/Resources/Translations/sv-SE.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Inget värde här, vibratot har ingen effekt" [Dialog] +"Fetch Runtime" = "Hämta runtime" "OK" = "OK" "Cancel" = "Avbryt" "Yes" = "Ja" diff --git a/TuneLab/Resources/Translations/tr-TR.toml b/TuneLab/Resources/Translations/tr-TR.toml index f8ae8d02..e01dab88 100644 --- a/TuneLab/Resources/Translations/tr-TR.toml +++ b/TuneLab/Resources/Translations/tr-TR.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Burada değer yok, vibrato etkili olmuyor" [Dialog] +"Fetch Runtime" = "Çalışma zamanını indir" "OK" = "Tamam" "Cancel" = "İptal" "Yes" = "Evet" diff --git a/TuneLab/Resources/Translations/uk-UA.toml b/TuneLab/Resources/Translations/uk-UA.toml index 381c709d..5d31cd63 100644 --- a/TuneLab/Resources/Translations/uk-UA.toml +++ b/TuneLab/Resources/Translations/uk-UA.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "Тут немає значення, вібрато не діє" [Dialog] +"Fetch Runtime" = "Завантажити середовище виконання" "OK" = "OK" "Cancel" = "Скасувати" "Yes" = "Так" diff --git a/TuneLab/Resources/Translations/zh-CN.toml b/TuneLab/Resources/Translations/zh-CN.toml index d1735dab..3074a23d 100644 --- a/TuneLab/Resources/Translations/zh-CN.toml +++ b/TuneLab/Resources/Translations/zh-CN.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "此处无值,颤音不生效" [Dialog] +"Fetch Runtime" = "下载运行时" "OK" = "确认" "Cancel" = "取消" "Yes" = "是" diff --git a/TuneLab/Resources/Translations/zh-TW.toml b/TuneLab/Resources/Translations/zh-TW.toml index 16deea51..47adb959 100644 --- a/TuneLab/Resources/Translations/zh-TW.toml +++ b/TuneLab/Resources/Translations/zh-TW.toml @@ -107,6 +107,7 @@ "No value here, vibrato has no effect" = "此處無值,顫音不生效" [Dialog] +"Fetch Runtime" = "下載執行環境" "OK" = "確定" "Cancel" = "取消" "Yes" = "是" diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionDetailWindow.cs b/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionDetailWindow.cs index f62c1584..858ff7f6 100644 --- a/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionDetailWindow.cs +++ b/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionDetailWindow.cs @@ -61,6 +61,9 @@ internal sealed class ExtensionDetailInfo public IReadOnlyList Pages = []; public bool IsLegacy; // 决定"无文档"占位的措辞:legacy 是机制使然,不是作者没写 public bool IsPendingUninstall; // 打开时该插件是否已处于待卸载态(决定卸载按钮初始态) + // 脚本包探测不到解释器(某条目错误以 "no matching runtime" 开头)→ 底部给「下载运行时」按钮(§8.6 半自动档)。 + public bool CanFetchRuntime; + public string RuntimeSpecText = string.Empty; // manifest 的 runtime 串(点击时投给下载器) } // 扩展详情窗:点侧栏条目弹出,逐条目渲染 introduction(正文完全由作者定义、宿主不解释)。 @@ -74,9 +77,22 @@ internal sealed class ExtensionDetailWindow : Window public event Action? SettingsRequested; public event Action? UninstallRequested; public event Action? CancelUninstallRequested; + // 「下载运行时」按钮被按(脚本包缺解释器,§8.6):provider 投给 RuntimeInstallQueue 并经 SetFetchRuntimeBusy 反馈态。 + public event Action? FetchRuntimeRequested; // 本窗改了启停(包级或条目级,选择已落盘)。provider 据此同步侧栏卡片与「需重启」提示。 public event Action? ActivationChanged; + TextBlock? mFetchRuntimeText; + bool mFetchRuntimeBusy; + + // 下载在途:按钮文案变"…"且不再触发事件(防双击重复排队;queue 本身也按包去重)。 + public void SetFetchRuntimeBusy(bool busy) + { + mFetchRuntimeBusy = busy; + if (mFetchRuntimeText != null) + mFetchRuntimeText.Text = busy ? "…" : "Fetch Runtime".Tr(TC.Dialog); + } + public ExtensionDetailWindow(ExtensionDetailInfo info) { mPackageId = info.PackageId; @@ -338,12 +354,41 @@ Control BuildActionPanel(ExtensionDetailInfo info) HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Bottom, }; + if (info.CanFetchRuntime) + bottomRow.Children.Add(BuildFetchRuntimeButton(info.RuntimeSpecText)); bottomRow.Children.Add(BuildUninstallButton(info.IsPendingUninstall)); col.AddDock(bottomRow, Dock.Bottom); return col; } + // 「下载运行时」:脚本包缺解释器时的半自动入口(§8.6);文案即 manifest 的 runtime 串提示装什么。 + Control BuildFetchRuntimeButton(string runtimeSpec) + { + mFetchRuntimeText = new TextBlock + { + Text = "Fetch Runtime".Tr(TC.Dialog), + FontSize = 12, + Foreground = Style.LIGHT_WHITE.ToBrush(), + VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, + }; + var btn = new Border + { + Background = Style.BUTTON_NORMAL.ToBrush(), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(12, 6), + Cursor = new Cursor(StandardCursorType.Hand), + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, + Child = mFetchRuntimeText, + }; + if (!string.IsNullOrEmpty(runtimeSpec)) + ToolTip.SetTip(btn, runtimeSpec); + btn.PointerEntered += (_, _) => { if (!mFetchRuntimeBusy) btn.Background = Style.BUTTON_NORMAL_HOVER.ToBrush(); }; + btn.PointerExited += (_, _) => { if (!mFetchRuntimeBusy) btn.Background = Style.BUTTON_NORMAL.ToBrush(); }; + btn.PointerPressed += (_, e) => { e.Handled = true; if (!mFetchRuntimeBusy) FetchRuntimeRequested?.Invoke(); }; + return btn; + } + // 统一操作按钮:可选前置图标 + 文本,hover 变色,点击回调。右对齐、宽度贴合内容。 Control TextButton(string label, SvgIcon? icon, Action onClick) { diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionSideBarContentProvider.cs b/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionSideBarContentProvider.cs index ec11220f..0c02735d 100644 --- a/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionSideBarContentProvider.cs +++ b/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionSideBarContentProvider.cs @@ -18,6 +18,7 @@ using TuneLab.Utils; using TuneLab.Extensions.Formats; +using TuneLab.Extensions.Scripted; using TuneLab.Extensions.Voices; namespace TuneLab.UI; @@ -334,6 +335,8 @@ private void OnOpenDetail(ExtensionLoadResult result) Pages = pages, IsLegacy = result.Generation == ExtensionGeneration.Legacy, IsPendingUninstall = ExtensionManager.PendingUninstalls.Contains(result.DirectoryPath), + CanFetchRuntime = TryRuntimeSpecForFetch(result, out var runtimeSpec), + RuntimeSpecText = runtimeSpec, }; mDetailWindow?.Close(); @@ -365,6 +368,8 @@ private void OnOpenDetail(ExtensionLoadResult result) }; mDetailWindow = win; win.SetRestartRequired(NeedsRestart(result)); + if (info.CanFetchRuntime) + win.FetchRuntimeRequested += () => OnFetchRuntime(win, result); if (TopLevel.GetTopLevel(mContentPanel) is Avalonia.Controls.Window owner) win.Show(owner); @@ -374,6 +379,48 @@ private void OnOpenDetail(ExtensionLoadResult result) catch { } } + // 某条脚本条目因"探测不到解释器"被跳过(§8.6)→ 详情窗给「下载运行时」按钮;spec 串回读自 manifest。 + static bool TryRuntimeSpecForFetch(ExtensionLoadResult result, out string specText) + { + specText = string.Empty; + bool missing = result.Entries.Any(e => e.Status == ExtensionEntryStatus.Skipped + && e.Error != null && e.Error.StartsWith("no matching runtime", StringComparison.Ordinal)); + if (!missing) + return false; + try + { + using var doc = JsonDocument.Parse(File.ReadAllText(Path.Combine(result.DirectoryPath, "manifest.json"))); + var root = doc.RootElement; + var candidates = new List { root }; + if (root.TryGetProperty("extensions", out var exts) && exts.ValueKind == JsonValueKind.Array) + candidates.AddRange(exts.EnumerateArray()); + foreach (var c in candidates) + if (c.TryGetProperty("runtime", out var r) && r.ValueKind == JsonValueKind.String + && TuneLab.Extensions.Scripted.RuntimeSpec.TryParse(r.GetString(), out _, out _)) + { + specText = r.GetString()!; + return true; + } + } + catch { } + return false; + } + + // 点「下载运行时」:后台一发(queue 按包去重),完成回 UI 线程重载该包并刷新卡片;按钮在途转圈。 + void OnFetchRuntime(ExtensionDetailWindow win, ExtensionLoadResult result) + { + if (!TryRuntimeSpecForFetch(result, out var specText) || !RuntimeSpec.TryParse(specText, out var spec, out _)) + return; + win.SetFetchRuntimeBusy(true); + var pkgId = result.Id ?? result.DirectoryPath; + RuntimeInstallQueue.Schedule(pkgId, result.DirectoryPath, spec, onDone: dir => Avalonia.Threading.Dispatcher.UIThread.Post(() => + { + win.SetFetchRuntimeBusy(false); + ExtensionManager.Reload(dir); + RefreshExtensions(); + })); + } + private async void OnUninstallExtension(ExtensionItemView itemView) { // We delegate the actual deletion to ExtensionInstaller, which waits diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md index 499cce38..b34bd721 100644 --- a/docs/scripted-plugin-sidecar-design.md +++ b/docs/scripted-plugin-sidecar-design.md @@ -1,8 +1,8 @@ # 脚本插件 Sidecar 系统设计(Python / Node.js / Lua) -> 状态:**定稿;M2a 已落地**(config wire codec + settings 远程代理 + 心跳 + stderr 落盘滚动; -> effect 双向会话与 Node/Lua façade = M2b)。此前:M1(帧协议/SidecarHost/RemoteFormat/Python -> façade/假 sidecar 封条/真解释器 E2E)、M0(manifest 字段、协议门、四级探测)。 +> 状态:**定稿;M3a 已落地**(运行时镜像表 + 竞速自动判路 + 半自动/全自动/命令三档部署 + 详情窗 +> 「下载运行时」按钮;effect= M2b、M2a=声明/设置/心跳,均已落地)。 +> 剩余:voice + shm 音频面(M3b)、Node/Lua façade(M3c)。 > 基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 > 配套阅读:[plugin-development.md](plugin-development.md)(V1 插件契约)、[sdk-api-evolution.md](sdk-api-evolution.md)(冻结 ABI 纪律)、[naming-glossary.md](naming-glossary.md)。 > 本文只设计**宿主侧与协议侧**;`TuneLab.SDK` / `TuneLab.Foundation` 一行不动(§1.3)。 @@ -365,11 +365,30 @@ Rosetta 跑 x64 node),拒载反而挡死了合法组合;架构承诺由作 ### 8.6 缺失时(**定稿:半自动**) -侧栏 `Skipped · 未找到 Python 运行时` + 两按钮:【下载运行时】(走 `AppUpdateManager` 同款 -GitHub release + sha256 + 进度条通道,用户明示同意;落地即 `Editor.InstallExtensions` 热装,**无任何 -新安装路径**)与【选择已装位置】。设置项"自动获取脚本运行时"默认**关**(开了=免提示,其余同)。 -镜像/离线:源 URL 可覆写(与 §8.1-① 的自带编译版是同一条灵活政策的两个面)。 -macOS:runtime 资源包安装钩子负责剥 `com.apple.quarantine`;下载的包不进 .app,公证面不变。 +侧栏 `Skipped · 未找到 Python 运行时` + 动作入口;下载源不再是"我们发的 .tlx"而是 +**内置镜像表直发上游发行件**(M3 定案,见 §8.6.1),落地时本地组装成同款 `type:"runtime"` +资源包 → 走既有登记路径。设置项"自动获取脚本运行时"默认**关**(开了 = 探测落空即后台拉取, +成功后自动 Reload 该包;其余同上)。镜像/离线:`ScriptedRuntimeSource` 覆写基址 +(官方相对路径挂上去,内网镜像整树可用);用户自带编译版走 ① 级路径(§8.1)或 +`tools/build-runtime-package/pack-runtime.ps1 -FromDir` 自制包。 +macOS:下载的二进制在启动前剥 `com.apple.quarantine`(安装钩子)。 + +### 8.6.1 镜像表与竞速(M3a 已实现) + +- **表**:`RuntimeFeed.cs` 内置(flavor×版本×RID → 候选 URL 序 + sha256 + 归档布局)。 + Windows 起步格:python embeddable(3.11.9/3.12.7/3.13.3×amd64、3.13.3×arm64)与 + node dist(20.17.0×x64+arm64、18.20.4)。国内镜像在前(华为云 python、npmmirror/华为云 node), + 官方源在尾兜底。 +- **钉**:已交叉核对钉死——python 3.13.3 embed amd64 与 node 20.17.0 win-x64 的 SHA256 + 由【华为云/npmmirror 与 python.org/nodejs.org 双下比对一致】得出(见常量)。未钉格:照下但 + 记警告(`trusting the source this once`),release 工具(`tools/build-runtime-package`)补钉。 +- **自动判路**:`PickFastestAsync` 对候选并发 HEAD(3s 超时)取最快可达者下载;全灭回退表内 + 首选(下载阶段自然报错,如实回报)。网络环境无关,不需要写死"国内=快"。 +- **三档触发**:全自动(`ScriptedAutoFetchRuntime` 开 → gate 探测落空即 `RuntimeInstallQueue` + 后台一发、在途去重、成功自动 Reload);手动(`extension fetch-runtime` 命令 / agent 工具 + `fetch_scripted_runtime`,授权卡片 = ExtensionInstall 档);纯手动(设置路径 / -FromDir 自制包)。 +- **实测**(真链路自证):win-x64 格经华为云 4.3s 装好 python 3.13.3 embed, + 校验通过后 `python.exe` 自报 `3.13.3 AMD64`;`RuntimeLocator` ③ 级即命中。 ### 8.7 并存与回收 @@ -484,9 +503,17 @@ scripted.initTimeoutSec int = 0 # 0 = 无上限(§5.5 长任 Python façade `@tunelab.effect`(`_effect.py`:ctx 的 input_read/evaluate/output/status/parameters, f32/f64 base64-LE)+ 反向 `_call_host`;样例 `tests/plugins/PyEffect`(真 3.11 E2E 实测 467ms 全链)。 **规范缺口 = 实现漂移的教训已钉**:fake 与宿主对拍同一份 `scripting/wire/v1.json`。 -- **推迟**:【下载运行时】按钮与 stderr 清理策略(M3);Node/Lua façade(M3,能力协商以 PUC Lua - `inline` 面为首个真实用例);voice(M3——依赖同一反向通道 + shm 音频面);心跳在 effect 面已可用 - (façade 每请求独立线程保证环常活)。 +- **M3a 落地**:`RuntimeFeed`(内置镜像表:华为云/npmmirror 在前、官方兜底,python 3.11.9–3.13.3 × + amd64/arm64 与 node 20.17.0/18.20.4 起步;sha256 已钉两格——**镜像与官方双下比对一致**才入表); + `ScriptedRuntimeFetcher`(HEAD 竞速判路 → 流式下载 → 校验(未钉格如实警告)→ 解包布局成 + `type:"runtime"` 资源包 → 主线程登记);`RuntimeInstallQueue`(按包在途去重 + 完成自动 + Reload 该包);`extension fetch-runtime` 命令 / agent 工具 `fetch_scripted_runtime` + (ExtensionInstall 授权档);详情窗【下载运行时】按钮(15 语言已补)。实测:华为云 4.3s + 装好 python 3.13.3 embed,落地 exe 自报版本,③ 级探测即命中。顺手修:create_session 与 + process 的到达顺序竞态(握手 Task 先 await)、feed Match 的 struct-Nullable 陷阱、 + 进程型测试类圈 `SidecarSpawns` 串行域(并行 spawn 风暴是抖动根因)。 +- **推迟**:stderr 保留/清理策略(M3b);Node/Lua façade(M3c,能力协商以 PUC Lua `inline` + 面为首个真实用例);voice + shm(M3b——依赖已落地的反向通道)。 - **实现差异**: - 崩溃后不自动定时复活,**下一次调用驱动重 spawn**(backoff/熔断仍生效)——format 拉模式下 自动重启只有缓存收益;effect push 调度落地时(M2b)再补后台重启环。 diff --git a/tests/TuneLab.Tests/CommandRegistryTests.cs b/tests/TuneLab.Tests/CommandRegistryTests.cs index 4012f37c..26136392 100644 --- a/tests/TuneLab.Tests/CommandRegistryTests.cs +++ b/tests/TuneLab.Tests/CommandRegistryTests.cs @@ -25,6 +25,8 @@ public class CommandRegistryTests "get_app_info", "list_actions", "run_action", "get_editor_status", // issue #150 的后续项:清单上"外部还够不着"的那几个桶各自补的命令。 "open_project", "install_extension", "uninstall_extension", "cancel_extension_uninstall", + // 脚本插件的运行时自动部署(sidecar 设计 §8.6):解释器不是插件,单独一条工具。 + "fetch_scripted_runtime", // part preset 的外部面(覆盖率清单最后一个桶)。 "list_presets", "apply_preset", "save_preset", "delete_preset", "rename_preset", // 保存那一族:走用户口径的保存(改保存路径、清未保存态),与只写副本的 export_project 分开。 diff --git a/tests/TuneLab.Tests/ExtensionFetchRuntimeCommandTests.cs b/tests/TuneLab.Tests/ExtensionFetchRuntimeCommandTests.cs new file mode 100644 index 00000000..2142ef06 --- /dev/null +++ b/tests/TuneLab.Tests/ExtensionFetchRuntimeCommandTests.cs @@ -0,0 +1,71 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using TuneLab.Commands; +using TuneLab.Commands.Handlers; +using TuneLab.Extensions.Scripted; +using TuneLab.Utils; +using Xunit; + +namespace TuneLab.Tests; + +// extension fetch-runtime 的拒绝性一侧(坏 runtime / 无格子 / 用户不授权)——都在触网之前落判, +// 成功下载链由 ScriptedRuntimeFetcherTests 覆盖(不在此重复真网络)。 +public class ExtensionFetchRuntimeCommandTests +{ + sealed class AlwaysAskPolicy : IAuthorizationPolicy + { + public AuthorizationRequest? Asked; + public AuthorizationMode Mode => AuthorizationMode.Confirm; + public bool CanAsk => true; + public Task AskAsync(AuthorizationRequest request, CancellationToken ct) + { + Asked = request; + return Task.FromResult(AuthorizationDecision.Reject); + } + } + + static Task Run(ExtensionFetchRuntimeCommand cmd, string argsJson, CommandContext ctx) + => cmd.ExecuteAsync(CommandArgs.Parse(argsJson), ctx, CancellationToken.None); + + [Fact] + public void MalformedRuntimeSpecIsRefused() + { + var r = Run(new ExtensionFetchRuntimeCommand(), "{\"runtime\":\"ruby>=3\"}", new CommandContext()) + .GetAwaiter().GetResult(); + Assert.True(r.IsError); + Assert.Equal("bad_runtime", r.Error!.Value.Code); + } + + [Fact] + public void UnsatisfiableFeedCellRefusesBeforeAsking() + { + var policy = new AlwaysAskPolicy(); + var r = Run(new ExtensionFetchRuntimeCommand(), "{\"runtime\":\"lua>=5.4\"}", new CommandContext { Authorization = policy }) + .GetAwaiter().GetResult(); + Assert.True(r.IsError); + Assert.Equal("no_feed_cell", r.Error!.Value.Code); // feed 无 lua 格子 → 不弹授权不触网 + Assert.Null(policy.Asked); + } + + [Fact] + public async Task FetchAsksBeforeTouchingNetwork() + { + Assert.True(RuntimeSpec.TryParse("python>=3.10,<4", out var spec, out _)); + var policy = new AlwaysAskPolicy(); + var cmd = new ExtensionFetchRuntimeCommand(); + var r = await Run(cmd, "{\"runtime\":\"python>=3.10,<4\"}", new CommandContext { Authorization = policy }); + if (RuntimeFeed.Match(spec) is null) + { + Assert.True(r.IsError); // 本平台无格子:直接如实拒绝 + Assert.Null(policy.Asked); + } + else + { + Assert.NotNull(policy.Asked); // 有格子:授权卡片先于任何下载 + Assert.Equal(WriteKind.ExtensionInstall, policy.Asked!.Value.Kind); + Assert.False(r.IsError); + Assert.Equal("refused", r.Data!["outcome"]!.GetValue()); + } + } +} diff --git a/tests/TuneLab.Tests/ScriptedEffectTests.cs b/tests/TuneLab.Tests/ScriptedEffectTests.cs index 9c4f185d..5d4d78e1 100644 --- a/tests/TuneLab.Tests/ScriptedEffectTests.cs +++ b/tests/TuneLab.Tests/ScriptedEffectTests.cs @@ -13,7 +13,8 @@ namespace TuneLab.Tests; // M2b 垂直切片:脚本 effect 引擎的完整往返——正向声明/生命周期 + 反向数据线程调用 // (input_read / properties / evaluate / segment 族)+ 发布(status/parameters)。 // 对端是假 sidecar 的 effect-gain 靶形(读入 → ×amount → 写回 + 回显 + 状态声称)。 -public class ScriptedEffectTests +[Collection("SidecarSpawns")] + public class ScriptedEffectTests { static SidecarHost MakeHost(string engineId) { diff --git a/tests/TuneLab.Tests/ScriptedEntryGateTests.cs b/tests/TuneLab.Tests/ScriptedEntryGateTests.cs index 9f7e8e77..faf46868 100644 --- a/tests/TuneLab.Tests/ScriptedEntryGateTests.cs +++ b/tests/TuneLab.Tests/ScriptedEntryGateTests.cs @@ -89,6 +89,8 @@ public void RuntimeNotFoundIsSkippedWithActionableTrail() Assert.Equal(ScriptedGateStatus.Skipped, r.Status); Assert.Contains("no matching runtime", r.Message); Assert.Contains("install a 'runtime' package", r.Message.ToLowerInvariant()); + Assert.True(r.RuntimeMissing); // 自动部署只应答这一种 Skipped + Assert.NotNull(r.Spec); } [Fact] @@ -103,5 +105,6 @@ public void ResolvedRuntimeYieldsReadyWithResolutionInMessage() Assert.Equal("python", r.Runtime.Flavor); Assert.Equal("3.12.7", r.Runtime.Version); Assert.Contains("3.12.7", r.Message); + Assert.False(r.RuntimeMissing); } } diff --git a/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs b/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs index 83a92769..8e6209a5 100644 --- a/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs +++ b/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs @@ -15,7 +15,8 @@ namespace TuneLab.Tests; // python 不在场即 vacuously pass(CI 由 needs-interpreter 任务真跑)。trait 标记, // 单独运行: dotnet test --filter "runtime=needs-interpreter" [Trait("runtime", "needs-interpreter")] -public class ScriptedPythonE2ETests +[Collection("SidecarSpawns")] + public class ScriptedPythonE2ETests { static readonly string sPackageDir = LocateUp("tests", "plugins", "PyFormat"); diff --git a/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs b/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs index 07e1b012..ecf35704 100644 --- a/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs +++ b/tests/TuneLab.Tests/ScriptedRemoteFormatTests.cs @@ -10,7 +10,8 @@ namespace TuneLab.Tests; // RemoteFormat 的宿主侧全链:假 sidecar 走真管道,验文件交接(导入落 stream_ref、导出读回输出流) // 与 ProjectInfo wire 编解码在 RPC 两端的往返(设计 §5.4)。解释器缺席也覆盖。 -public class ScriptedRemoteFormatTests +[Collection("SidecarSpawns")] + public class ScriptedRemoteFormatTests { static RemoteFormat MakeProxy(string pkgId) { diff --git a/tests/TuneLab.Tests/ScriptedRuntimeFetcherTests.cs b/tests/TuneLab.Tests/ScriptedRuntimeFetcherTests.cs new file mode 100644 index 00000000..c92ca87d --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedRuntimeFetcherTests.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TuneLab.Extensions; +using TuneLab.Extensions.Scripted; +using TuneLab.Utils; +using Xunit; + +namespace TuneLab.Tests; + +// 运行时自动部署(设计 §8.6):下载→校验→解包→落成 type:"runtime" 资源包→③ 级探测命中。 +// 全链喂预制 zip + 注入 downloader/extensionsRoot/register/probe,不碰真网络与真 %APPDATA%。 +public class ScriptedRuntimeFetcherTests +{ + readonly string mRoot = Path.Combine(Path.GetTempPath(), "tl-fetch-" + Path.GetRandomFileName()); + + // 格子 rid 跟测试进程实际平台走(runtimes 包 platforms 字段要与 IsPlatformAvailable 对得上)。 + static string RidNow => PlatformHelper.GetPlatform(); + + static RuntimeFeedCell Cell(string layout, string sha256 = "", params string[] urls) + => new("python", new Version(3, 12, 7), RidNow, + urls.Length > 0 ? urls : ["https://example.invalid/python-3.12.7-embed-amd64.zip"], sha256, layout); + + static Task FirstProbe(IReadOnlyList urls, CancellationToken _) => Task.FromResult(urls[0]); + + // 造一个 python-embed 形(根即 python.exe)的 zip。 + string MakeEmbedZip() + { + var zip = Path.Combine(mRoot, "embed.zip"); + Directory.CreateDirectory(mRoot); + using (var fs = File.Create(zip)) + using (var archive = new ZipArchive(fs, ZipArchiveMode.Create)) + { + foreach (var name in new[] { "python.exe", "python313.dll", "python313.zip", "Lib\\site-packages" }) + { + var entry = archive.CreateEntry(name); + using var w = entry.Open(); + w.Write(Encoding.UTF8.GetBytes("payload:" + name)); + } + } + return zip; + } + + string MakeNodeZip() + { + var zip = Path.Combine(mRoot, "node.zip"); + Directory.CreateDirectory(mRoot); + using (var fs = File.Create(zip)) + using (var archive = new ZipArchive(fs, ZipArchiveMode.Create)) + { + foreach (var name in new[] { "node-v20.17.0-win-x64/node.exe", "node-v20.17.0-win-x64/README.md", "node-v20.17.0-win-x64/node_modules/npm/bin" }) + { + var entry = archive.CreateEntry(name); + using var w = entry.Open(); + w.Write(Encoding.UTF8.GetBytes("payload:" + name)); + } + } + return zip; + } + + static ScriptedRuntimeFetcher.DownloadAsyncDelegate CopyDownloader(string source) + => (url, dest, _, _) => { File.Copy(source, dest); return Task.CompletedTask; }; + + [Fact] + public async Task EmbedZipBecomesRuntimePackageThatLocatorResolves() + { + var zip = MakeEmbedZip(); + var exts = Path.Combine(mRoot, "exts"); + var registered = new List(); + var result = await ScriptedRuntimeFetcher.InstallAsync( + Cell("root"), null, CancellationToken.None, CopyDownloader(zip), exts, registered.Add, FirstProbe, null); + Assert.True(result.Ok, result.Message); + + var dir = Path.Combine(exts, $"runtime-python-3.12.7-{RidNow}"); + Assert.Equal(dir, registered.Single()); + var manifest = File.ReadAllText(Path.Combine(dir, "manifest.json")); + Assert.Contains("\"type\": \"runtime\"", manifest); + Assert.Contains("\"flavor\": \"python\"", manifest); + Assert.True(File.Exists(Path.Combine(dir, "runtime", "python.exe"))); + Assert.True(File.Exists(Path.Combine(dir, "runtime", "python313.dll"))); + + // ③ 级命中:假扩展根 + 假 prober(键=落地的 exe 路径)。 + var prober = new FakeProber(); + prober.Add(Path.Combine(dir, "runtime", "python.exe"), "python", "3.12.7"); + var locator = new RuntimeLocator(prober, () => "", () => "", () => "", _ => null, () => exts); + Assert.True(RuntimeSpec.TryParse("python>=3.10", out var spec, out _)); + Assert.True(locator.TryResolve(spec, out var info, out var trail), trail); + Assert.Equal("3.12.7", info.Version); + } + + [Fact] + public async Task PrefixLayoutFlattensSingleTopDir() + { + var zip = MakeNodeZip(); + var exts = Path.Combine(mRoot, "exts2"); + var cell = new RuntimeFeedCell("node", new Version(20, 17, 0), RidNow, ["https://example.invalid/node.zip"], "", "prefix"); + var result = await ScriptedRuntimeFetcher.InstallAsync(cell, null, CancellationToken.None, CopyDownloader(zip), exts, _ => { }, FirstProbe, null); + Assert.True(result.Ok, result.Message); + Assert.True(File.Exists(Path.Combine(exts, $"runtime-node-20.17.0-{RidNow}", "runtime", "node.exe"))); + } + + [Fact] + public async Task PinnedChecksumMismatchRefusesAndKeepsSystemClean() + { + var zip = MakeEmbedZip(); + var exts = Path.Combine(mRoot, "exts3"); + var result = await ScriptedRuntimeFetcher.InstallAsync( + Cell("root", sha256: "DEADBEEF"), null, CancellationToken.None, CopyDownloader(zip), exts, _ => { }, FirstProbe, null); + Assert.False(result.Ok); + Assert.Contains("checksum", result.Message); + Assert.False(Directory.Exists(Path.Combine(exts, $"runtime-python-3.12.7-{RidNow}"))); + } + + [Fact] + public async Task PinnedChecksumMatchInstalls() + { + var zip = MakeEmbedZip(); + var sha = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(zip))); + var exts = Path.Combine(mRoot, "exts4"); + var result = await ScriptedRuntimeFetcher.InstallAsync( + Cell("root", sha256: sha), null, CancellationToken.None, CopyDownloader(zip), exts, _ => { }, FirstProbe, null); + Assert.True(result.Ok, result.Message); + } + + [Fact] + public async Task UnreachableMirrorsFallThroughToFirstAndSurfaceDownloadError() + { + var exts = Path.Combine(mRoot, "exts5"); + var attempts = new List(); + ScriptedRuntimeFetcher.DownloadAsyncDelegate recorder = (url, _, _, _) => { attempts.Add(url); return Task.FromException(new HttpRequestFail()); }; + var result = await ScriptedRuntimeFetcher.InstallAsync( + Cell("root", "", "https://a.example.invalid/f.zip", "https://b.example.invalid/f.zip"), + null, CancellationToken.None, recorder, exts, _ => { }, FirstProbe, null); + Assert.False(result.Ok); + Assert.Single(attempts); // 探活全灭 → 下载只试表内首选一次 + Assert.Contains("a.example.invalid", attempts[0]); + } + + [Fact] + public async Task OverrideBaseIsTriedBeforeTableMirrors() + { + var zip = MakeEmbedZip(); + var seen = new List(); + ScriptedRuntimeFetcher.DownloadAsyncDelegate rec = (url, dest, _, _) => { seen.Add(url); File.Copy(zip, dest); return Task.CompletedTask; }; + var exts = Path.Combine(mRoot, "exts-ovr"); + var result = await ScriptedRuntimeFetcher.InstallAsync( + Cell("root", "", "https://mirror.example/python/3.12.7/python-3.12.7-embed-amd64.zip", + "https://www.python.org/ftp/python/3.12.7/python-3.12.7-embed-amd64.zip"), + null, CancellationToken.None, rec, exts, _ => { }, FirstProbe, "https://intranet.local/mirror/"); + Assert.True(result.Ok, result.Message); + Assert.StartsWith("https://intranet.local/mirror/ftp/python/3.12.7/python-3.12.7-embed-amd64.zip", seen[0]); // 官方相对路径挂覆写基址 + } + + [Fact] + public void FeedMatchIsHonestWhenNoCellExists() + { + // 结构 Nullable 陷阱回归钉:无匹配格子时必须真 null(曾返回 default 空格子)。 + Assert.True(RuntimeSpec.TryParse("lua>=5.4", out var lua, out _)); // feed 无 lua 格子 + Assert.Null(RuntimeFeed.Match(lua)); + } + + [Fact] + public void FeedCellsCoverCurrentPlatformWithCnMirrorsFirstAndOfficialLast() + { + Assert.True(RuntimeSpec.TryParse("python>=3.10,<4", out var py, out _)); + var cell = RuntimeFeed.Match(py); + if (RidNow is "win-x64" or "win-arm64") + { + Assert.NotNull(cell); + Assert.Equal("python", cell!.Value.Flavor); + Assert.All(cell.Value.Urls, u => Assert.StartsWith("https://", u)); + Assert.Contains("huaweicloud", cell.Value.Urls[0]); // 国内镜像在前 + Assert.Contains("python.org", cell.Value.Urls[^1]); // 官方兜底在尾 + } + else + Assert.Null(cell); + } +} + +internal sealed class HttpRequestFail : Exception { } + +// 自动部署调度环:在途去重 + 完成后回执(注入 executor,单测不触网)。 +public class RuntimeInstallQueueTests +{ + [Fact] + public async Task DedupsInFlightAndCallsOnDoneAfterCompletion() + { + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var dones = new List(); + var scheduled = RuntimeInstallQueue.Schedule("com.test.q-a", "C:\\pkg\\a", Parse("python>=3.10"), + executor: _ => gate.Task, onDone: d => dones.Add(d)); + Assert.True(scheduled); + Assert.True(RuntimeInstallQueue.IsPending("com.test.q-a")); + Assert.False(RuntimeInstallQueue.Schedule("com.test.q-a", "C:\\pkg\\a", Parse("python>=3.10"), + executor: _ => Task.FromResult(true), onDone: dones.Add)); // 同包在途去重 + Assert.True(RuntimeInstallQueue.Schedule("com.test.q-b", "C:\\pkg\\b", Parse("python>=3.10"), + executor: _ => Task.FromResult(true), onDone: dones.Add)); // 不同包不受影响 + gate.SetResult(true); + var spin = Task.Run(() => { while (dones.Count < 2) Thread.SpinWait(1000); }); + Assert.Same(spin, await Task.WhenAny(spin, Task.Delay(5000))); + Assert.Contains("C:\\pkg\\a", dones); + Assert.False(RuntimeInstallQueue.IsPending("com.test.q-a")); + } + + static RuntimeSpec Parse(string s) + { + Assert.True(RuntimeSpec.TryParse(s, out var spec, out _)); + return spec; + } +} diff --git a/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs b/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs index 00a812dc..68651581 100644 --- a/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs +++ b/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs @@ -12,7 +12,8 @@ namespace TuneLab.Tests; // 真进程 + 真 stdio 帧协议的生命周期封条(设计文档 §12.0 M1)。对端是 .NET 假 sidecar // (tests/ScriptedFakeSidecar),CI 无需任何解释器;测的是 SidecarHost 的握手/请求应答/ // provides 核对/崩溃重生 epoch/退避熔断/优雅关停。 -public class ScriptedSidecarHostTests +[Collection("SidecarSpawns")] + public class ScriptedSidecarHostTests { static readonly string sFakeDir = ResolveFakeDir(); static readonly string sFakeDll = Path.Combine(sFakeDir, "TuneLab.ScriptedFakeSidecar.dll"); diff --git a/tests/TuneLab.Tests/SidecarSpawnsCollection.cs b/tests/TuneLab.Tests/SidecarSpawnsCollection.cs new file mode 100644 index 00000000..7c63e72e --- /dev/null +++ b/tests/TuneLab.Tests/SidecarSpawnsCollection.cs @@ -0,0 +1,9 @@ +using Xunit; + +namespace TuneLab.Tests; + +// 真起 sidecar 子进程的测试类共用的串行域:每台机器同时只跑一个 spawn 型测试。 +// 并行时十余个 dotnet 子进程同时冷启动会把握手/超时预算挤爆(全量套件里实测抖过)。 +// 协议逻辑本身(framing/codec/gate/queue)不碰进程,照常并行。 +[CollectionDefinition("SidecarSpawns", DisableParallelization = true)] +public class SidecarSpawnsCollection { } From 28934fc4dfd6315fa81cb7280c9a805b2aebe2b2 Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Tue, 15 Sep 2026 12:10:20 +0800 Subject: [PATCH 07/12] =?UTF-8?q?feat(scripted):=20M3b=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=90=8E=E5=8F=B0=E9=87=8D=E5=90=AF=E7=8E=AF(AutoR?= =?UTF-8?q?estart)=20+=20stderr=20=E4=BF=9D=E7=95=99=E7=AD=96=E7=95=A5=20+?= =?UTF-8?q?=20=E4=B8=89=E5=B9=B3=E5=8F=B0=20CI=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=9F=A9=E9=98=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §3.2 状态机的"Restarting(backoff)"从此是真的后台环;§3.4 的滚动落盘补上保留策略; 脚本体系获得持续回归的跨平台门禁。voice + shm 划为专轮(见文末理由)。 - SidecarHost.AutoRestart(默认关):常驻引擎声明后,非熔断死亡 → 按退避(2/4/8/16/30s) 后台 Timer 拉起 EnsureRunning——握手成功即 Running,被 push 调度的 effect/voice 崩溃后 自动 warm 回,不再"等下一次调用才复活";成功起后撤环、优雅关停优先于在途环、ResetCircuit 语义不变。effect 注册支已置 true(voice 随 M3c)。测试:crash-on-import 靶形制造一次死亡, 轮询断言 host 自行回 Running(无第二次调用驱动)。 - stderr 保留(§3.4 "滚动 2 份"兑现):每 boot 首条 stderr 前清一次——只留最近 2 个旧 stderr-.txt;崩溃风暴不反复枚举(一次性 flag + 全 try/catch)。 - .github/workflows/tests.yml:windows/ubuntu/macos 三平台矩阵——setup-dotnet 8 + setup-python 3.12 + setup-node 20;跑①主测试工程(排除 Setup 三类:既有环境问题,TuneLab.Setup 带 RID 输出拷不进测试 bin,注释写明单列跟进)、②needs-interpreter 真解释器 E2E(置 TUNELAB_REQUIRE_PYTHON=1 让"缺解释器"变红而非 vacuous——CI 装了就必须真跑)、③façade 单测 (python -m unittest)、④legacy-compat 工程。至此 sidecar 协议/生命周期/自动部署/编解码 在三平台都有持续门禁,兑现"支持平台与 .NET 宿主一致"的承诺。 - 测试:670/670(排除 Setup 三类的环境红)+ legacy 13/13;Scripted 全组含新重启环用例。 voice + shm 未挤进本轮的理由(设计文档 §12.4 同步):会话面 = get_snapshot 反向拉 + note/automation/committed 事件推送流 + peek 缓存 + 四路产物发布,协议面比 effect 宽一个量级, 值得专轮设计而不是压进收尾轮;反向通道地基(M2b)与串行 spawn 测试域(M3a)已为它铺好。 --- .github/workflows/tests.yml | 66 +++++++++++++++++++ TuneLab/Extensions/ExtensionManager.cs | 1 + TuneLab/Extensions/Scripted/SidecarHost.cs | 52 ++++++++++++++- docs/scripted-plugin-sidecar-design.md | 16 +++-- .../TuneLab.Tests/ScriptedSidecarHostTests.cs | 27 ++++++++ 5 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..e9a98be6 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,66 @@ +name: Tests + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + workflow_call: + +jobs: + # 跨平台测试矩阵:脚本 sidecar 体系(fake sidecar + 真解释器 E2E)必须三平台都绿, + # 才能兑现"平台和 .NET 一致"的承诺。解释器经 setup 装上,needs-interpreter 用例才真跑 + # (否则那条 vacuous pass,见 AGENTS.md 脚本节)。 + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: "windows-latest" + - os: "ubuntu-latest" + - os: "macos-latest" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install .NET 8 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Install Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Node 20 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Restore and build + run: dotnet build TuneLab.sln -c Debug + + # SetupCliOptions/SetupInstallManifest/SetupUninstall 三类走 TuneLab.Setup 程序集, + # 该工程带 RID 输出、测试二进制未必拷得进(本机三平台一致的既有环境问题)——先排除, + # 与脚本插件无关;单列跟进项修复拷盘后再纳入。 + - name: Unit + scripted tests + run: > + dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj -c Debug --no-build + --filter "FullyQualifiedName!~SetupCliOptionsTests&FullyQualifiedName!~SetupInstallManifestTests&FullyQualifiedName!~SetupUninstallTests" + + # 真解释器 E2E 强制断言(缺解释器即红,而不是 vacuous pass)——CI 保证了 setup 装了。 + - name: Real-interpreter scripted E2E + env: + TUNELAB_REQUIRE_PYTHON: "1" + run: > + dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj -c Debug --no-build + --filter "runtime=needs-interpreter" + + - name: Python facade unit tests + run: python -m unittest discover -s scripting/python/tests + + - name: Legacy-compat tests + run: dotnet test legacy/compat/TuneLab.Hosting.Compat.Legacy.Tests/TuneLab.Hosting.Compat.Legacy.Tests.csproj -c Debug diff --git a/TuneLab/Extensions/ExtensionManager.cs b/TuneLab/Extensions/ExtensionManager.cs index 81cfbc43..91f87ad8 100644 --- a/TuneLab/Extensions/ExtensionManager.cs +++ b/TuneLab/Extensions/ExtensionManager.cs @@ -778,6 +778,7 @@ static bool RegisterScriptedEffectEntry(string packageId, string packageDir, Ext } var entryPath = (ext.entry ?? string.Empty).Trim().Replace('\\', '/'); var host = SidecarRegistry.For(packageId, packageDir, runtime, entryPath); + host.AutoRestart = true; // effect 是被 push 调度的常驻引擎:崩溃后自动 warm 回来(§3.2) host.ExpectEngine("effect", engineId); EffectManager.RegisterEngine(packageId, engineId, displayName, new RemoteEffectEngine(host, engineId, ext.settings)); error = null; diff --git a/TuneLab/Extensions/Scripted/SidecarHost.cs b/TuneLab/Extensions/Scripted/SidecarHost.cs index 765c6226..381d86bd 100644 --- a/TuneLab/Extensions/Scripted/SidecarHost.cs +++ b/TuneLab/Extensions/Scripted/SidecarHost.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Globalization; using System.IO; +using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; @@ -48,8 +49,13 @@ public SidecarHost(string packageId, string packageDir, RuntimeInfo runtime, str bool mCircuitOpen; bool mDisposed; Timer? mHeartbeat; + Timer? mRestartTimer; int mHeartbeatMisses; + // 常驻引擎(effect/voice)置 true:意外死亡后按退避自动后台拉起(§3.2 重启环)。 + // format 保持 false(拉模式,下次调用驱动即可,不空转 spawn)。 + public bool AutoRestart { get; set; } + public int Epoch { get { lock (mLock) return mEpoch; } } public bool CircuitOpen { get { lock (mLock) return mCircuitOpen; } } public string StderrTail { get; private set; } = string.Empty; @@ -66,6 +72,12 @@ public void ResetCircuit() } } + // 停掉挂起的后台重启计时器(优雅关停/手动重启后不该再被旧环捞起)。 + void CancelPendingRestart() + { + lock (mLock) { mRestartTimer?.Dispose(); mRestartTimer = null; } + } + // sidecar 侧被动事件(宿主拉模式,M1 只消费 log 与 diagnostics)。 public event Action? LogReceived; // level, message public event Action? Died; // 每次死亡(含即将重启) @@ -223,6 +235,7 @@ void StartOnce(TaskCompletionSource tcs) if (mProcess != null) { tcs.TrySetResult(true); return; } // 单飞竞态兜底 } StartAndHandshake(); + CancelPendingRestart(); // 成功起来:清掉任何排着的旧重启环(若正被调用驱动重开) lock (mLock) { if (mFirstStartUtc == DateTime.MinValue) mFirstStartUtc = DateTime.UtcNow; @@ -411,6 +424,11 @@ void AppendStderr(string line) // 滚动落盘(§3.4):包目录 logs/stderr-.txt,尽力而为(只读目录等失败不致命)。 try { + if (!mStderrPruned) + { + mStderrPruned = true; + PruneStderrLogs(); + } var dir = Path.Combine(mPackageDir, "logs"); Directory.CreateDirectory(dir); File.AppendAllText(Path.Combine(dir, "stderr-" + SidecarIo.Nonce + ".txt"), line + "\n"); @@ -418,6 +436,27 @@ void AppendStderr(string line) catch { } } + bool mStderrPruned; + + // 保留策略(§3.4 滚动 2 份):本次 boot 的文件不算——只留最近 2 个旧 boot,其余删除。 + // 一次 spawn 清一回(mStderrPruned),崩溃风暴也不会反复枚举删表。 + void PruneStderrLogs() + { + try + { + var dir = Path.Combine(mPackageDir, "logs"); + if (!Directory.Exists(dir)) + return; + var stale = new DirectoryInfo(dir).GetFiles("stderr-*.txt") + .Where(f => !f.Name.Contains(SidecarIo.Nonce)) + .OrderByDescending(f => f.LastWriteTimeUtc) + .Skip(2); + foreach (var f in stale) + try { f.Delete(); } catch { } + } + catch { } + } + void ReadLoop(Process proc, int epoch) { string? json = null; @@ -532,10 +571,16 @@ void OnDeath(int epoch, int exitCode) } started?.TrySetException(new SidecarRpcException("runtime_error", string.Format(CultureInfo.InvariantCulture, "sidecar exited during startup (code {0})", exitCode))); Died?.Invoke(); - if (!fatal) + // 后台重启环(§3.2):常驻引擎(effect/voice)声明 AutoRestart——非熔断死亡后按退避自动 + // 拉起,warm 回来即可继续被 push 调度,不必等下一次调用驱动。format 拉模式默认关(省得空转)。 + if (!fatal && AutoRestart) { - // 不自动复活进行中的调用(M1 语义):失败如实抛给调用方;下一次调用触发 EnsureRunning 重开。 - // 熔断窗口计数只随时间复位——下次调用若在窗口内仍死,很快 Open。 + var due = TimeSpan.FromSeconds(backoffSeconds); + var timer = new Timer(_ => + { + try { _ = EnsureRunningAsync(); } catch { } // 失败经 OnDeath 再入环,直到熔断 + }, null, due, Timeout.InfiniteTimeSpan); + lock (mLock) { mRestartTimer?.Dispose(); mRestartTimer = timer; } } } @@ -597,6 +642,7 @@ public void Shutdown() lock (mLock) { mDisposed = true; + mRestartTimer?.Dispose(); mRestartTimer = null; // 关停优先于任何在途重启环 proc = mProcess; stdin = mStdin; } if (stdin != null) diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md index b34bd721..5ced91f6 100644 --- a/docs/scripted-plugin-sidecar-design.md +++ b/docs/scripted-plugin-sidecar-design.md @@ -1,8 +1,8 @@ # 脚本插件 Sidecar 系统设计(Python / Node.js / Lua) -> 状态:**定稿;M3a 已落地**(运行时镜像表 + 竞速自动判路 + 半自动/全自动/命令三档部署 + 详情窗 -> 「下载运行时」按钮;effect= M2b、M2a=声明/设置/心跳,均已落地)。 -> 剩余:voice + shm 音频面(M3b)、Node/Lua façade(M3c)。 +> 状态:**定稿;M3b 已落地**(后台重启环 + stderr 保留策略 + 三平台 CI 测试矩阵;运行时自动部署=M3a、 +> effect=M2b、声明/设置/心跳=M2a、format 全链=M1、manifest/探测=M0)。 +> 剩余:voice + shm(M3c)、Node/Lua façade(M3d)。 > 基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 > 配套阅读:[plugin-development.md](plugin-development.md)(V1 插件契约)、[sdk-api-evolution.md](sdk-api-evolution.md)(冻结 ABI 纪律)、[naming-glossary.md](naming-glossary.md)。 > 本文只设计**宿主侧与协议侧**;`TuneLab.SDK` / `TuneLab.Foundation` 一行不动(§1.3)。 @@ -512,8 +512,14 @@ scripted.initTimeoutSec int = 0 # 0 = 无上限(§5.5 长任 装好 python 3.13.3 embed,落地 exe 自报版本,③ 级探测即命中。顺手修:create_session 与 process 的到达顺序竞态(握手 Task 先 await)、feed Match 的 struct-Nullable 陷阱、 进程型测试类圈 `SidecarSpawns` 串行域(并行 spawn 风暴是抖动根因)。 -- **推迟**:stderr 保留/清理策略(M3b);Node/Lua façade(M3c,能力协商以 PUC Lua `inline` - 面为首个真实用例);voice + shm(M3b——依赖已落地的反向通道)。 +- **M3b(宿主完备化)落地**:后台重启环(`SidecarHost.AutoRestart`——常驻引擎 effect/voice 崩溃后 + 按退避自动 warm 回、握手通过即 Running;优雅关停/成功起后撤环;真重启测试钉住);stderr 保留策略 + (每 boot 一清、只留最近 2 个旧文件,§3.4 兑现);`.github/workflows/tests.yml` 三平台测试矩阵 + (win/ubuntu/macos × setup-python 3.12 + node 20 × 真解释器 E2E 强制档 TUNELAB_REQUIRE_PYTHON=1 × + façade 单测 × legacy 工程;Setup 三类因既有 RID 输出拷盘问题暂除,注释指回本仓)。 +- **仍推迟**:voice + shm 音频面(M3c——反向通道与快照/推送面协议大,需专轮设计:get_snapshot + 反向拉、note/automation 事件推送流、peek 缓存、四路产物发布);Node/Lua façade(M3d, + CI 已备 node——façade 落地即绿)。 - **实现差异**: - 崩溃后不自动定时复活,**下一次调用驱动重 spawn**(backoff/熔断仍生效)——format 拉模式下 自动重启只有缓存收益;effect push 调度落地时(M2b)再补后台重启环。 diff --git a/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs b/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs index 68651581..13349043 100644 --- a/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs +++ b/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs @@ -157,6 +157,33 @@ public async Task ShutdownIsIdempotentAndStopsProcess() await Assert.ThrowsAsync(() => host.EnsureRunningAsync()); } + [Fact(Timeout = 30_000)] + public async Task AutoRestartLoopRewarmsAfterUnexpectedDeath() + { + // §3.2 重启环:常驻引擎声明 AutoRestart → 崩溃后按退避自动 warm 回来, + // 无需下一次调用驱动(这里制造一次 crash-on-import,再等 Running 自己翻回 true)。 + var runtime = new RuntimeInfo(Dotnet, "dotnet-fake", "0.0", "x64"); + var host = new SidecarHost("com.test.rewarm", sFakeDir, runtime, "TuneLab.ScriptedFakeSidecar.dll", + heartbeat: TimeSpan.FromSeconds(30)) + { + AutoRestart = true, + ExtraEnvironment = new() { ["TUNELAB_FAKE_MODE"] = "crash-on-import" }, + }; + host.ExpectFormat("TuneLab.ScriptedFakeSidecar.dll", ["tst"], []); + try + { + await Assert.ThrowsAnyAsync(() => + host.CallAsync("format.import", System.Text.Json.Nodes.JsonNode.Parse("{\"entry\":\"x\",\"stream_ref\":\"y\"}"))); + Assert.False(host.Running); // 刚死 + + var deadline = DateTime.UtcNow.AddSeconds(20); + while (DateTime.UtcNow < deadline && !host.Running) + await Task.Delay(150); + Assert.True(host.Running); // 退避后自己爬回来了(握手已过) + } + finally { host.Shutdown(); } + } + [Fact] public async Task HeartbeatKillsWedgedPeerAfterConsecutiveMisses() { From ff020263747079bc6caa969f45ee548b7bf2798e Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Tue, 15 Sep 2026 18:30:25 +0800 Subject: [PATCH 08/12] =?UTF-8?q?feat(scripted):=20M3c=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20voice=20=E5=9E=82=E7=9B=B4=E5=88=87=E7=89=87?= =?UTF-8?q?=EF=BC=9ARemotePullBus=E3=80=81RemoteVoiceEngine/Session?= =?UTF-8?q?=E3=80=81Python=20@voice=E3=80=81=E7=9C=9F=E8=A7=A3=E9=87=8A?= =?UTF-8?q?=E5=99=A8=E5=90=88=E6=88=90=20E2E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设计 §5(M3 主角)落地:最难最重的 voice 家族经已验证的拉取总线跑通全表面。SDK 冻结面零改动。 - RemotePullBus:常驻会话(effect/voice)共享的反向拉取 + 段登记 + 产物发布总线—— effect 家族原样迁入(M2b 的封条测试全绿作证 wire 语义零变化),voice 复用同一族 host.input_read/properties/evaluate/snapshot/notes/segment_* 处理;会话键带命名空间前缀 (effect:/voice:),create_session/dispose 按前缀分流;段随会话清算(DisposeOwnedSegments 按 sessionKey 前缀收割未归还的段句柄)。 - RemoteVoiceEngine:catalog/layout 在 Init 后拉取、volatile 缓存("VoiceSourceInfos 必须 立即返回"契约);engine.catalog_changed 通知后台重拉;声明五件套(automation/回显轨/ part/note/phoneme 面板)走有界 TryCallRunning(250ms)+指纹缓存——数据线程零无界等待(§3.5)。 - RemoteVoiceSession:context 订阅(WhenAny 全字段 + MembershipModified + PartProperties + 三通道 RangeModified + Committed)→ voice.dirty 粗粒度推送流("廉价标脏"的 remote 映射, sidecar 动作时重拉全量真相);IsContinuation/peek 有界+缓存(note 视图随请求带过去=sidecar 无状态;晚一拍安全性由 synthesize 电平幂等兜底);synthesize 无超时应答即完成、取消尽力; pitch/phonemes/parameters/status 四路发布换引用 + changed 事件;Dispose 全退订。 - 快照跨线形:notes 值全量 + request_id 回指(音素归属键的闭环)+ 求值器=句柄 (pitch/pitch_deviation/ 经 host.evaluate 拉值——§5.6 (a) 决定完整落地,宿主插值 零漂移,Python 侧 snap.pitch.evaluate(times) 透明远程)。 - Python façade @voice:catalog/五声明/create_session(ctx)/session{peek,synthesize, is_continuation,default_lyric,on_dirty?,cancel?,dispose?};VoiceCtx 提供 notes()/snapshot(ids)(Evaluator 句柄)/part_properties/output/status/pitch/phonemes/parameters; 作者眼里没有协议,只有列表、回调与句柄。 - 样例 tests/plugins/PyVoice(440Hz 正弦游标推进)+ 真 3.11 E2E:两段合成、dirty 事件驱动 peek 游标、音素按 request_id 归属、振幅逐点断言。 - scripting/wire/v1.json 补齐 voice 方法表(46 方法)+ 新 ScriptedWireSpecTests 规范封条 (可解析/协议版本一致/方法覆盖)——当轮即抓出 spec 自身两处非法 JSON(裸 or、未引号 数组),兑现"规范真源=机器可读"。 - LoadV1:Ready && voice → RegisterScriptedVoiceEntry(AutoRestart、engine id 必填、 ExpectEngine 兑现);instrument 维持不承诺。 测试:C# +5(voice E2E×3 = fake 靶形全表面 + 真解释器两段合成 + 默认/续延、spec 封条×3 计入) → Scripted 73/73;全量 676/676(排除基线 Setup 环境红);Python 单测 OK;legacy 13/13。 --- TuneLab/Extensions/ExtensionManager.cs | 36 +- TuneLab/Extensions/Scripted/RemoteEffect.cs | 391 +++--------- TuneLab/Extensions/Scripted/RemotePull.cs | 330 ++++++++++ TuneLab/Extensions/Scripted/RemoteVoice.cs | 600 ++++++++++++++++++ docs/scripted-plugin-sidecar-design.md | 23 +- scripting/python/tunelab/__init__.py | 11 +- scripting/python/tunelab/_service.py | 78 ++- scripting/python/tunelab/_voice.py | 271 ++++++++ scripting/wire/v1.json | 20 +- tests/ScriptedFakeSidecar/Program.cs | 107 +++- tests/TuneLab.Tests/ScriptedPythonE2ETests.cs | 59 ++ tests/TuneLab.Tests/ScriptedVoiceTests.cs | 144 +++++ tests/TuneLab.Tests/ScriptedWireSpecTests.cs | 68 ++ tests/TuneLab.Tests/TuneLab.Tests.csproj | 3 + tests/TuneLab.Tests/VoiceTestDoubles.cs | 131 ++++ tests/plugins/PyVoice/manifest.json | 13 + tests/plugins/PyVoice/plugin/main.py | 77 +++ 17 files changed, 2033 insertions(+), 329 deletions(-) create mode 100644 TuneLab/Extensions/Scripted/RemotePull.cs create mode 100644 TuneLab/Extensions/Scripted/RemoteVoice.cs create mode 100644 scripting/python/tunelab/_voice.py create mode 100644 tests/TuneLab.Tests/ScriptedVoiceTests.cs create mode 100644 tests/TuneLab.Tests/ScriptedWireSpecTests.cs create mode 100644 tests/TuneLab.Tests/VoiceTestDoubles.cs create mode 100644 tests/plugins/PyVoice/manifest.json create mode 100644 tests/plugins/PyVoice/plugin/main.py diff --git a/TuneLab/Extensions/ExtensionManager.cs b/TuneLab/Extensions/ExtensionManager.cs index 91f87ad8..8ded39e6 100644 --- a/TuneLab/Extensions/ExtensionManager.cs +++ b/TuneLab/Extensions/ExtensionManager.cs @@ -335,9 +335,24 @@ static void LoadV1(string path, ExtensionManifest description) Log.Error(string.Format("Extension {0}: {1}: {2}", description.name, IdentityLabel(ext, kind), effectError)); } } + else if (gate.Status == ScriptedGateStatus.Ready && kind == "voice") + { + if (RegisterScriptedVoiceEntry(description.id ?? string.Empty, path, ext, gate.Runtime, ext.LocalizedName(lang), out var voiceError)) + { + loaded++; + } + else + { + failed++; + entry.Status = ExtensionEntryStatus.Failed; + entry.Error = voiceError; + reasons.Add(string.Format("{0}: {1}", IdentityLabel(ext, kind), voiceError)); + Log.Error(string.Format("Extension {0}: {1}: {2}", description.name, IdentityLabel(ext, kind), voiceError)); + } + } else if (gate.Status == ScriptedGateStatus.Ready) { - // 引擎类注册支未落地(voice=M3;instrument 明确不承诺)——如实 Skipped,不谎报。 + // 引擎类注册支未落地(instrument 明确不承诺)——如实 Skipped,不谎报。 skipped++; var pending = string.Format("scripted {0} registration is not implemented by this host build yet ({1})", kind, gate.Message); entry.Status = ExtensionEntryStatus.Skipped; @@ -785,6 +800,25 @@ static bool RegisterScriptedEffectEntry(string packageId, string packageDir, Ext return true; } + // 脚本 voice 条目的注册(M3c):同 effect 家族——engine id 必填;懒 spawn 由首次 Init 触发 + // (VoicesManager 按需 Init 的既有语义原样成立)。AutoRestart:合成调度是 push 型,崩了自动 warm 回。 + static bool RegisterScriptedVoiceEntry(string packageId, string packageDir, ExtensionInfo ext, RuntimeInfo runtime, string displayName, out string? error) + { + var engineId = (ext.engine ?? string.Empty).Trim(); + if (engineId.Length == 0) + { + error = "scripted voice entries must declare 'engine' (the immutable id projects reference)"; + return false; + } + var entryPath = (ext.entry ?? string.Empty).Trim().Replace('\\', '/'); + var host = SidecarRegistry.For(packageId, packageDir, runtime, entryPath); + host.AutoRestart = true; + host.ExpectEngine("voice", engineId); + VoicesManager.RegisterEngine(packageId, engineId, displayName, new RemoteVoiceEngine(host, engineId, ext.settings)); + error = null; + return true; + } + // 解析条目声明的那一个入口类,并校验它实现 T 且有无参构造。任何不满足都是加载错误—— // 宿主不再"扫一遍候选挑一个",因为那等于替作者决定哪个类是这个条目的实现(见 ExtensionInfo 头注释)。 static bool TryResolveCtor(Assembly assembly, string className, out ConstructorInfo? ctor, out string? error) diff --git a/TuneLab/Extensions/Scripted/RemoteEffect.cs b/TuneLab/Extensions/Scripted/RemoteEffect.cs index b3ddc0b1..ca9e0b81 100644 --- a/TuneLab/Extensions/Scripted/RemoteEffect.cs +++ b/TuneLab/Extensions/Scripted/RemoteEffect.cs @@ -2,36 +2,30 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; -using System.Threading; -using System.Threading.Tasks; using System.Text.Json; using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; using TuneLab.Foundation; using TuneLab.SDK; namespace TuneLab.Extensions.Scripted; -// 脚本 effect 引擎的进程内代理(设计 §5.5 effect 家族 + §3.5 数据线程纪律): -// EffectManager/EffectGraph 眼里它与 in-proc IEffectSynthesisEngine 不可分辨。 +// 脚本 effect 引擎的进程内代理(设计文档 §5.5 effect 家族):EffectManager/EffectGraph 眼里 +// 它与 in-proc IEffectSynthesisEngine 不可分辨。反向拉取/发布走共享 RemotePullBus(M2b 落地时 +// 自建路由,M3c 起并入总线——对 effect 而言 wire 语义零变化)。 // -// 正向(宿主→sidecar):engine.init/destroy、effect.get_*(声明,经 TryCallRunning 有界 + 缓存)、 -// effect.process(无限超时——长模型推理;死亡即 fault——电平语义下失败=passthrough)。 -// 反向(sidecar→宿主):host.input_read / host.properties / host.evaluate / host.segment_* —— -// 全部经会话捕获的同步上下文 marshal 回数据线程执行(活视图纪律),30s 上界。 -// 发布(sidecar→宿主通知):effect.status / effect.parameters(volatile 引用换 + ActionEvent 触发, -// 宿主管线自己 marshal)。 +// 正向(宿主→sidecar):engine.init/destroy、engine.create_session、effect.get_*(声明,缓存+有界)、 +// effect.process(无超时——应答即完成;取消经通知尽力)。 internal sealed class RemoteEffectEngine : IEffectSynthesisEngine, IExtensionSettings { - // 一个 host 一个包,包可多 effect 条目 → 反向调用按 engine 字段扇出到本包全部脚本 effect 引擎。 - static readonly Dictionary> sByHost = new(); - readonly SidecarHost mHost; readonly string mEngineId; readonly bool mHasSettings; readonly ConcurrentDictionary mSessions = new(); int mSessionCounter; - // 声明面缓存(§3.5):(输入指纹 → 结果)。数据线程只吃 250ms 有界调用;miss 后台补发。 + // 声明面缓存(§3.5):数据线程只吃 250ms 有界调用;miss 后台补发,旧值兜底。 const int DeclarationTimeoutMs = 250; (string Fingerprint, JsonNode? Result)? mPropCfgCache; (string Fingerprint, JsonNode? Result)? mAutoCfgCache; @@ -40,79 +34,11 @@ internal sealed class RemoteEffectEngine : IEffectSynthesisEngine, IExtensionSet public RemoteEffectEngine(SidecarHost host, string engineId, bool hasSettings) { mHost = host; mEngineId = engineId; mHasSettings = hasSettings; - bool first; - lock (sByHost) - { - if (!sByHost.TryGetValue(host, out var map)) - { - map = new Dictionary(); - sByHost[host] = map; - first = true; - RegisterReverseChannels(host); - } - else - first = false; - map[engineId] = this; - } - _ = first; - } - - static void RegisterReverseChannels(SidecarHost host) - { - host.RegisterLocalCall("host.input_read", a => Route(a, s => s.InputReadAsync(a))); - host.RegisterLocalCall("host.properties", a => Route(a, s => s.PropertiesAsync(a))); - host.RegisterLocalCall("host.evaluate", a => Route(a, s => s.EvaluateAsync(a))); - host.RegisterLocalCall("host.automation_tracks", a => Route(a, s => s.TracksAsync())); - host.RegisterLocalCall("host.segment_create", a => Route(a, s => s.SegmentCreateAsync(a))); - host.RegisterLocalCall("host.segment_write", a => Route(a, s => s.SegmentWriteAsync(a))); - host.RegisterLocalCall("host.segment_commit", a => Route(a, s => s.SegmentSimpleAsync(a, static seg => seg.Commit()))); - host.RegisterLocalCall("host.segment_resize", a => Route(a, s => s.SegmentResizeAsync(a))); - host.RegisterLocalCall("host.segment_dispose", a => Route(a, s => s.SegmentDisposeAsync(a))); - host.RegisterLocalNotify("effect.status", a => RouteFire(a, (s, args) => s.ApplyStatus(args))); - host.RegisterLocalNotify("effect.parameters", a => RouteFire(a, (s, args) => s.ApplyParameters(args))); - } - - static Task Route(JsonNode? args, Func> body) - => RouteCore(args, body); - - static async Task RouteCore(JsonNode? args, Func> body) - { - var (engine, session) = Resolve(args); - if (engine == null || session == null) - throw new SidecarRpcException("stale_handle", "unknown engine/session for reverse call"); - return await body(session).ConfigureAwait(false); } - static void RouteFire(JsonNode? args, Action body) - { - var (engine, session) = Resolve(args); - if (engine != null && session != null) - body(session, args); - } - - static (RemoteEffectEngine?, RemoteEffectSession?) Resolve(JsonNode? args) - { - var o = args as JsonObject; - var engineId = o?["engine"]?.GetValue(); - var sessionId = o?["session"]?.GetValue(); - if (engineId == null || sessionId == null) return (null, null); - lock (sByHost) - { - foreach (var map in sByHost.Values) - if (map.TryGetValue(engineId, out var eng) && eng.mSessions.TryGetValue(sessionId, out var sess)) - return (eng, sess); - } - return (null, null); - } - - // ── 引擎生命周期 ── - public void Init() - { - // 长任务(模型加载可数分钟):无限超时;死亡/错误抛 → EffectManager 边界 catch → passthrough。 - mHost.CallAsync("engine.init", new JsonObject { ["engine"] = mEngineId }, Timeout.InfiniteTimeSpan) + => mHost.CallAsync("engine.init", new JsonObject { ["engine"] = mEngineId }, Timeout.InfiniteTimeSpan) .GetAwaiter().GetResult(); - } public void Destroy() { @@ -121,55 +47,57 @@ public void Destroy() public IEffectSynthesisSession CreateSession(IEffectSynthesisContext context) { - var id = mEngineId + "#" + Interlocked.Increment(ref mSessionCounter); - var session = new RemoteEffectSession(this, context, id); - mSessions[id] = session; - // 顺序要紧:process 请求不得先于 engine.create_session 到达 sidecar(façade 的会话表在 - // create 里建)——故登记 Task,Process 首步 await 它;失败只记 CreateFault(首次使用面报错)。 - session.CreateHandshake = mHost.CallAsync("engine.create_session", new JsonObject { ["engine"] = mEngineId, ["session"] = id }) + var key = $"effect:{mEngineId}#{Interlocked.Increment(ref mSessionCounter)}"; + var session = new RemoteEffectSession(this, context, key); + mSessions[key] = session; + RemotePullBus.Register(mHost, session); + session.CreateHandshake = mHost.CallAsync("engine.create_session", + new JsonObject { ["engine"] = mEngineId, ["session"] = key }) .ContinueWith(t => { if (t.Exception != null) session.CreateFault = t.Exception.GetBaseException(); }); return session; } - internal void DropSession(string id) => mSessions.TryRemove(id, out _); + internal void DropSession(string key) + { + mSessions.TryRemove(key, out _); + RemotePullBus.Unregister(mHost, key); + } - // ── 声明面(缓存 + 有界同步;§3.5)── + // ── 声明面 ── public ObjectConfig GetPropertyConfig(IEffectSynthesisPropertyContext context) { var values = DeclarationArgs(context); return CachedDecl(ref mPropCfgCache, "effect.get_property_config", values) is { } node - ? (IControllerConfig?)ScriptedConfigWire.ReadConfig(node["config"]) as ObjectConfig ?? EmptyObject() + ? ScriptedConfigWire.ReadConfig(node["config"]) as ObjectConfig ?? EmptyObject() : EmptyObject(); } public IReadOnlyOrderedMap GetAutomationConfigs(IEffectSynthesisPropertyContext context) { - var values = DeclarationArgs(context); - var node = CachedDecl(ref mAutoCfgCache, "effect.get_automation_configs", values); + var node = CachedDecl(ref mAutoCfgCache, "effect.get_automation_configs", DeclarationArgs(context)); return ScriptedConfigWire.ReadAutomationMap(node?["tracks"], "automations"); } public IReadOnlyOrderedMap GetSynthesizedParameterConfigs(IEffectSynthesisPropertyContext context) { - var values = DeclarationArgs(context); - var node = CachedDecl(ref mSynthCfgCache, "effect.get_synthesized_parameter_configs", values); + var node = CachedDecl(ref mSynthCfgCache, "effect.get_synthesized_parameter_configs", DeclarationArgs(context)); return ScriptedConfigWire.ReadAutomationMap(node?["tracks"], "synthesized_parameters"); } - static JsonNode DeclarationArgs(IEffectSynthesisPropertyContext context) + static JsonObject DeclarationArgs(IEffectSynthesisPropertyContext context) { - // 只带声明所需的数据:各 effect 实例的当前参数值(求值器是活视图,跨不了线也不该跨)。 + // 只带声明所需的数据:各 effect 实例的当前参数值(求值器是活视图,不可跨线也不该跨)。 var effects = new JsonArray(); foreach (var view in context.Effects) effects.Add(ScriptedWire.SerializeNode(view.Properties)); return new JsonObject { ["effects"] = effects }; } - JsonNode? CachedDecl(ref (string Fingerprint, JsonNode? Result)? cache, string method, JsonNode extra) + JsonNode? CachedDecl(ref (string Fingerprint, JsonNode? Result)? cache, string method, JsonObject extra) { var payload = new JsonObject { ["engine"] = mEngineId }; - foreach (var kv in extra.AsObject()) + foreach (var kv in extra) payload[kv.Key] = kv.Value?.DeepClone(); var fingerprint = payload.ToJsonString(); if (mHost.TryCallRunning(method, payload, TimeSpan.FromMilliseconds(DeclarationTimeoutMs), out var result, out _)) @@ -178,13 +106,13 @@ static JsonNode DeclarationArgs(IEffectSynthesisPropertyContext context) return result; } if (cache?.Fingerprint == fingerprint) - return cache.Value.Result; // 进程未起/超时:同输入旧值照用 - return cache?.Result; // 换了输入但拿不到新值:暂用旧值(下拍 commit 再问) + return cache.Value.Result; + return cache?.Result; } static ObjectConfig EmptyObject() => ObjectConfig.Create(new OrderedMap()); - // ── 设置面(§5.7;key 用 engine id,与 format 的 entry 同位)── + // ── 设置面(§5.7;key 用 engine id)── ObjectConfig IExtensionSettings.GetSettingsConfig(IExtensionSettingsContext context) { @@ -192,7 +120,7 @@ ObjectConfig IExtensionSettings.GetSettingsConfig(IExtensionSettingsContext cont return EmptyObject(); var reply = mHost.CallAsync("settings.get_config", new JsonObject { ["entry"] = mEngineId }) .GetAwaiter().GetResult(); - return (reply?["config"] as JsonObject) is { } cfg ? (ObjectConfig)ScriptedConfigWire.ReadConfig(cfg) : EmptyObject(); + return reply?["config"] is { } cfg ? (ObjectConfig)ScriptedConfigWire.ReadConfig(cfg) : EmptyObject(); } void IExtensionSettings.ApplySettings(PropertyObject settings) @@ -204,19 +132,18 @@ void IExtensionSettings.ApplySettings(PropertyObject settings) // ── 会话 ── - internal sealed class RemoteEffectSession : IEffectSynthesisSession + sealed class RemoteEffectSession : IEffectSynthesisSession, IPullSession { readonly RemoteEffectEngine mEngine; readonly IEffectSynthesisContext mContext; - readonly string mId; - readonly ConcurrentDictionary mSegments = new(); - int mSegmentCounter; - SynchronizationContext? mSync; // 数据线程投递面(构造=管线数据线程;Process 再捕获兜底) + readonly SidecarHost mHost; + SynchronizationContext? mSync; volatile IReadOnlyList mStatus = Array.Empty(); volatile IReadOnlyMap mParameters = new Map(); int mBusy; bool mDisposed; + public string SessionKey { get; } public Exception? CreateFault; public Task CreateHandshake = Task.CompletedTask; @@ -228,9 +155,11 @@ internal sealed class RemoteEffectSession : IEffectSynthesisSession public IReadOnlyMap SynthesizedParameters => mParameters; public IReadOnlyList Status => mStatus; - public RemoteEffectSession(RemoteEffectEngine engine, IEffectSynthesisContext context, string id) + SynchronizationContext? IPullSession.Sync => mSync; + + public RemoteEffectSession(RemoteEffectEngine engine, IEffectSynthesisContext context, string key) { - mEngine = engine; mContext = context; mId = id; + mEngine = engine; mContext = context; SessionKey = key; mHost = engine.mHost; mSync = SynchronizationContext.Current; } @@ -242,20 +171,28 @@ public async Task Process(CancellationToken cancellation) mSync = SynchronizationContext.Current ?? mSync; try { - await CreateHandshake.ConfigureAwait(false); // create_session 必先于 process 到达 sidecar + await CreateHandshake.ConfigureAwait(false); if (CreateFault != null) throw CreateFault; - var geometry = await OnData(() => + var geometry = mContext.Input; + var request = new JsonObject { - var input = mContext.Input; - return new JsonObject { ["sample_offset"] = input.SampleOffset, ["sample_count"] = input.SampleCount, ["sample_rate"] = input.SampleRate }; - }).ConfigureAwait(false); + ["engine"] = mEngine.mEngineId, + ["session"] = SessionKey, + ["input"] = new JsonObject + { + ["sample_offset"] = geometry.SampleOffset, + ["sample_count"] = geometry.SampleCount, + ["sample_rate"] = geometry.SampleRate, + }, + }; using var reg = cancellation.CanBeCanceled - ? cancellation.Register(() => { try { mEngine.mHost.Notify("effect.cancel", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = mId }); } catch { } }) + ? cancellation.Register(() => + { + try { mHost.Notify("effect.cancel", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = SessionKey }); } catch { } + }) : default; - // 无限超时:完成/死亡/取消(尽力)由 sidecar 定夺;电平语义下取消=正常返回。 - await mEngine.mHost.CallAsync("effect.process", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = mId, ["input"] = geometry }, - Timeout.InfiniteTimeSpan).ConfigureAwait(false); + await mHost.CallAsync("effect.process", request, Timeout.InfiniteTimeSpan).ConfigureAwait(false); } finally { @@ -267,207 +204,69 @@ public void Dispose() { if (mDisposed) return; mDisposed = true; - mEngine.DropSession(mId); - try { mEngine.mHost.Notify("session.dispose", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = mId }); } catch { } - var segments = mSegments.Values.ToArray(); - mSegments.Clear(); - _ = OnData(() => { foreach (var s in segments) { try { s.Dispose(); } catch { } } return true; }); + RemotePullBus.DisposeOwnedSegments(mHost, SessionKey); // 先于解注册(marshal 需要会话在场) + mEngine.DropSession(SessionKey); + try { mHost.Notify("session.dispose", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = SessionKey }); } catch { } } - // ── 反向调用处理体(在池线程被调,marshal 回数据线程执行,30s 上界)── - - const int MarshalTimeoutMs = 30_000; + // ── IPullSession(总线 handler 已经 marshal 回本会话捕获的数据线程;此处直接触活视图)── - internal async Task InputReadAsync(JsonNode? args) + bool IPullSession.TryInputGeometry(out long offset, out int count, out int rate) { - var o = args!.AsObject(); - var offset = (int)(o["offset"]?.GetValue() ?? 0); - var count = (int)(o["count"]?.GetValue() ?? mContext.Input.SampleCount); - var samples = await OnData(() => - { - count = Math.Min(count, Marshal.MaxChunkSamples); - var buf = new float[count]; - mContext.Input.Read(offset, buf); - return buf; - }).ConfigureAwait(false); - return new JsonObject { ["data"] = WireAudio.EncodeF32(samples) }; + var input = mContext.Input; + offset = input.SampleOffset; count = input.SampleCount; rate = input.SampleRate; + return true; } - internal async Task PropertiesAsync(JsonNode? args) + bool IPullSession.TryReadInput(int offset, int count, out float[] samples) { - // 活视图按 key 取值(无全键枚举面,纪律即"声明的键自己知道");keys 缺省 = 空集。 - var keys = ((args as JsonObject)?["keys"] as JsonArray)?.Select(x => x!.GetValue()).ToArray() - ?? []; - var values = await OnData(() => + var input = mContext.Input; + if (offset < 0 || count < 0 || offset + count > input.SampleCount) { - var map = new Map(); - foreach (var k in keys) - map[k] = mContext.Properties.GetValue(k, default); - return map; - }).ConfigureAwait(false); - return new JsonObject { ["values"] = ScriptedWire.SerializeNode(new PropertyObject(values)) }; - } - - internal async Task TracksAsync() - { - var keys = await OnData(() => mContext.Automations.Keys.ToArray()).ConfigureAwait(false); - return new JsonObject { ["tracks"] = JsonSerializer.SerializeToNode(keys) }; + samples = []; + return false; + } + samples = new float[count]; + input.Read(offset, samples); + return true; } - internal async Task EvaluateAsync(JsonNode? args) + PropertyObject IPullSession.PullProperties(IReadOnlyList? keys) { - var o = args!.AsObject(); - var track = o["track"]!.GetValue(); - var positions = WireAudio.DecodeF64(o["positions"]!.GetValue()); - var values = await OnData(() => - { - if (!mContext.Automations.TryGetValue(track, out var auto)) - throw new SidecarRpcException("stale_handle", "no automation track '" + track + "'"); - var results = new double[positions.Length]; - auto.Evaluate(positions, results); - return results; - }).ConfigureAwait(false); - return new JsonObject { ["values"] = WireAudio.EncodeF64(values) }; + var map = new Map(); + if (keys != null) + foreach (var k in keys) + map[k] = mContext.Properties.GetValue(k, default); + return new PropertyObject(map); } - internal async Task SegmentCreateAsync(JsonNode? args) + bool IPullSession.TryEvaluateTrack(string track, IReadOnlyList positions, out double[] results) { - var o = args!.AsObject(); - var offset = o["sample_offset"]!.GetValue(); - var count = (int)o["sample_count"]!.GetValue(); - var rate = (int)o["sample_rate"]!.GetValue(); - return await OnData(() => + if (!mContext.Automations.TryGetValue(track, out var automation)) { - var seg = mContext.CreateAudioSegment(offset, count, rate); - var id = mEngine.mEngineId + "/seg" + Interlocked.Increment(ref mSegmentCounter); - mSegments[id] = seg; - return new JsonObject { ["segment"] = id }; - }).ConfigureAwait(false); - } - - internal Task SegmentWriteAsync(JsonNode? args) - { - var o = args!.AsObject(); - var offset = (int)o["offset"]!.GetValue(); - var samples = WireAudio.DecodeF32(o["data"]!.GetValue()); - return SegmentCallAsync(o, seg => { seg.Write(offset, samples); return null; }); - } - - internal Task SegmentResizeAsync(JsonNode? args) - { - var o = args!.AsObject(); - var offset = o["sample_offset"]!.GetValue(); - var count = (int)o["sample_count"]!.GetValue(); - return SegmentCallAsync(o, seg => { seg.Resize(offset, count); return null; }); + results = []; + return false; + } + results = new double[positions.Count]; + automation.Evaluate(positions, results); + return true; } - internal Task SegmentSimpleAsync(JsonNode? args, Action op) - => SegmentCallAsync(args!.AsObject(), seg => { op(seg); return null; }); - - internal Task SegmentDisposeAsync(JsonNode? args) - { - var segId = args!.AsObject()["segment"]!.GetValue(); - if (!mSegments.TryRemove(segId, out var seg)) - throw new SidecarRpcException("stale_handle", "unknown segment " + segId); - return SegmentCallRaw(seg, s => { s.Dispose(); return null; }); - } + IReadOnlyList IPullSession.AutomationTracks() => mContext.Automations.Keys.ToList(); - // 段操作统一路径:解析 id -> marshal 数据线程执行 -> 应答 {}。 - internal Task SegmentCallAsync(JsonObject o, Func op) - { - var segId = o["segment"]!.GetValue(); - if (!mSegments.TryGetValue(segId, out var seg)) - throw new SidecarRpcException("stale_handle", "unknown segment " + segId); - return SegmentCallRaw(seg, op); - } + IAudioSegment IPullSession.CreateOutputSegment(long sampleOffset, int sampleCount, int sampleRate) + => mContext.CreateAudioSegment(sampleOffset, sampleCount, sampleRate); - internal Task SegmentCallRaw(IAudioSegment seg, Func op) + void IPullSession.PublishStatus(IReadOnlyList segments) { - var sync = mSync; - if (sync == null) - return Task.FromResult(op(seg) ?? new JsonObject()); - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - sync.Post(_ => - { - try { tcs.TrySetResult(op(seg) ?? new JsonObject()); } - catch (Exception ex) { tcs.TrySetException(ex); } - }, null); - return WithMarshalTimeout(tcs.Task); - } - - // ── 发布(sidecar→宿主通知;任意线程到达,管线自己 marshal)── - - internal void ApplyStatus(JsonNode? args) - { - var list = new List(); - if (args is JsonObject o && o["segments"] is JsonArray arr) - foreach (var item in arr) - { - var so = item!.AsObject(); - list.Add(new SynthesisStatusSegment - { - StartTime = so["start"].GetValue(), - EndTime = so["end"].GetValue(), - Status = ParseStatus(so["status"]?.GetValue()), - Message = so["message"]?.GetValue(), - Progress = so["progress"]?.GetValue() ?? 0, - }); - } - mStatus = list; + mStatus = segments; mStatusChanged.Invoke(); } - static SynthesisSegmentStatus ParseStatus(string? s) => s switch + void IPullSession.PublishParameters(IReadOnlyMap parameters) { - "pending" => SynthesisSegmentStatus.Pending, - "synthesizing" => SynthesisSegmentStatus.Synthesizing, - "failed" => SynthesisSegmentStatus.Failed, - _ => SynthesisSegmentStatus.Synthesized, - }; - - internal void ApplyParameters(JsonNode? args) - { - var map = new Map(); - if (args is JsonObject o && o["parameters"] is JsonObject ps) - foreach (var p in ps) - { - var segments = new List>(); - if (p.Value is JsonObject po && po["segments"] is JsonArray segs) - foreach (var seg in segs) - segments.Add(((JsonArray)seg!).Select(pt => new Point(pt![0]!.GetValue(), pt[1]!.GetValue())).ToList()); - map[p.Key] = new SynthesizedParameter { Segments = segments }; - } - mParameters = map; + mParameters = parameters; mParametersChanged.Invoke(); } - - // ── 数据线程 marshal ── - - internal Task OnData(Func work) - { - var sync = mSync; - if (sync == null) - return Task.Run(work); // 无上下文(headless/竞态早期):池上执行,活视图纪律由宿主自证 - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - sync.Post(_ => - { - try { tcs.TrySetResult(work()); } - catch (Exception ex) { tcs.TrySetException(ex); } - }, null); - return WithMarshalTimeout(tcs.Task); - } - - internal async Task WithMarshalTimeout(Task task) - { - var done = await Task.WhenAny(task, Task.Delay(MarshalTimeoutMs)).ConfigureAwait(false); - if (done != task) - throw new SidecarRpcException("timeout", "data-thread marshal timed out"); - return await task.ConfigureAwait(false); - } - } - - internal static class Marshal - { - public const int MaxChunkSamples = 1 << 20; // 单次反向读上限(1M 样本 ≈ 4MB base64 ≈ 5.3MB 帧) } } diff --git a/TuneLab/Extensions/Scripted/RemotePull.cs b/TuneLab/Extensions/Scripted/RemotePull.cs new file mode 100644 index 00000000..c533a335 --- /dev/null +++ b/TuneLab/Extensions/Scripted/RemotePull.cs @@ -0,0 +1,330 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using TuneLab.Foundation; +using TuneLab.SDK; + +namespace TuneLab.Extensions.Scripted; + +// 常驻会话(effect/voice)共享的「反向拉取 + 产物发布」总线(设计 §5.5/§6 的 inline 面): +// sidecar 经 host.input_read / host.properties / host.evaluate / host.snapshot / host.segment_* +// 反向拉数据线程真值;产物经 effect.*/voice.* 通知回发布。会话键带 kind 前缀(effect:/voice:)。 +internal interface IPullSession +{ + string SessionKey { get; } + // 数据线程投递面:null = 无上下文(headless),反向处理直接池上内联执行。 + SynchronizationContext? Sync { get; } + bool TryInputGeometry(out long offset, out int count, out int rate); + // 读输入段(绝对轴寻址的段内偏移)。越界返回 false。 + bool TryReadInput(int offset, int count, out float[] samples); + // part 级当前参数值(effect = Properties 活视图;voice = PartProperties)。keys null = 空集。 + PropertyObject PullProperties(IReadOnlyList? keys); + bool TryEvaluateTrack(string track, IReadOnlyList positions, out double[] results); + IReadOnlyList AutomationTracks(); + IAudioSegment CreateOutputSegment(long sampleOffset, int sampleCount, int sampleRate); + + void PublishStatus(IReadOnlyList segments); + void PublishParameters(IReadOnlyMap parameters); + // voice-only:effect 会话保持默认 no-op(façade 对 effect 从不发 voice.pitch/phonemes)。 + void PublishPitch(SynthesizedPitch pitch) { } + void PublishPhonemes(IReadOnlyMap phonemes) { } + // voice 反向拉(effect 会话不会收到)。 + JsonNode? PullSnapshot(IReadOnlyList noteIds) => null; + JsonNode? PullNotes() => null; +} + +// 每 host 一张表;反向处理器经 EnsureAttached 每 host 恰好注册一次。 +internal static class RemotePullBus +{ + sealed class Table + { + public readonly ConcurrentDictionary Sessions = new(); + public readonly ConcurrentDictionary Segments = new(); + public int SegCounter; + public int Attached; + } + + static readonly ConcurrentDictionary sTables = new(); + + public static void Register(SidecarHost host, IPullSession session) + { + var table = sTables.GetOrAdd(host, _ => new Table()); + table.Sessions[session.SessionKey] = session; + if (Interlocked.Exchange(ref table.Attached, 1) == 0) + Attach(host, table); + } + + public static void Unregister(SidecarHost host, string sessionKey) + { + if (sTables.TryGetValue(host, out var table)) + table.Sessions.TryRemove(sessionKey, out _); + } + + // 会话终结的段清算:把该会话名下未显式 dispose 的段在数据线程上收割(in-proc 契约里这归插件自己, + // 代理不信任 sidecar 会先清账——它可能已经死了)。 + public static void DisposeOwnedSegments(SidecarHost host, string sessionKey) + { + if (!sTables.TryGetValue(host, out var table)) + return; + var prefix = sessionKey + "/seg"; + foreach (var id in table.Segments.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList()) + if (table.Segments.TryRemove(id, out var seg)) + { + if (table.Sessions.TryGetValue(sessionKey, out var session)) + _ = OnSessionThread(session, () => { try { seg.Dispose(); } catch { } return 0; }); + else + try { seg.Dispose(); } catch { } + } + } + + static (Table?, IPullSession?) Resolve(JsonNode? args) + { + var key = (args as JsonObject)?["session"]?.GetValue(); + if (key != null) + foreach (var table in sTables.Values) + if (table.Sessions.TryGetValue(key, out var session)) + return (table, session); + return (null, null); + } + + static Task Need(JsonNode? args, Func body) + { + var (_, session) = Resolve(args); + if (session == null) + throw new SidecarRpcException("stale_handle", "unknown session for reverse call"); + return Task.FromResult(body(session)); + } + + static Task NeedAsync(JsonNode? args, Func> body) + { + var (_, session) = Resolve(args); + if (session == null) + throw new SidecarRpcException("stale_handle", "unknown session for reverse call"); + return body(session); + } + + static void Attach(SidecarHost host, Table table) + { + // —— 反向拉取(handler 在池线程被调;经会话捕获的同步上下文 marshal 回数据线程)—— + host.RegisterLocalCall("host.input_read", args => NeedAsync(args, async s => + { + var o = args!.AsObject(); + int offset = (int)(o["offset"]?.GetValue() ?? 0); + int count = (int)(o["count"]?.GetValue() ?? 0); + if (!s.TryInputGeometry(out var _, out var total, out var rate)) + throw new SidecarRpcException("stale_handle", "session has no input plane"); + if (count <= 0 || count > RemoteLimits.MaxChunkSamples) + count = Math.Clamp(total - offset, 0, RemoteLimits.MaxChunkSamples); + var samples = await OnSessionThread(s, () => + { + if (!s.TryReadInput(offset, count, out var buf)) + throw new SidecarRpcException("io", "input read out of range"); + return buf; + }).ConfigureAwait(false); + return new JsonObject { ["data"] = WireAudio.EncodeF32(samples), ["sample_rate"] = rate }; + })); + + host.RegisterLocalCall("host.properties", args => NeedAsync(args, async s => + { + var keys = ((args as JsonObject)?["keys"] as JsonArray)?.Select(x => x!.GetValue()).ToList(); + var values = await OnSessionThread(s, () => s.PullProperties(keys is { Count: > 0 } ? keys : null)).ConfigureAwait(false); + return new JsonObject { ["values"] = ScriptedWire.SerializeNode(values) }; + })); + + host.RegisterLocalCall("host.automation_tracks", args => NeedAsync(args, async s => + { + var tracks = await OnSessionThread(s, () => s.AutomationTracks().ToArray()).ConfigureAwait(false); + return new JsonObject { ["tracks"] = JsonSerializer.SerializeToNode(tracks) }; + })); + + host.RegisterLocalCall("host.evaluate", args => NeedAsync(args, async s => + { + var o = args!.AsObject(); + var track = o["track"]!.GetValue(); + var positions = WireAudio.DecodeF64(o["positions"]!.GetValue()); + var results = await OnSessionThread(s, () => + { + if (!s.TryEvaluateTrack(track, positions, out var r)) + throw new SidecarRpcException("stale_handle", "no automation track '" + track + "'"); + return r; + }).ConfigureAwait(false); + return new JsonObject { ["values"] = WireAudio.EncodeF64(results) }; + })); + + host.RegisterLocalCall("host.snapshot", args => NeedAsync(args, async s => + { + var ids = (args!.AsObject()["note_ids"] as JsonArray)?.Select(x => x!.GetValue()).ToList() ?? []; + return await OnSessionThread(s, () => s.PullSnapshot(ids)).ConfigureAwait(false); + })); + host.RegisterLocalCall("host.notes", args => NeedAsync(args, async s => + await OnSessionThread(s, () => s.PullNotes()).ConfigureAwait(false))); + + // —— 段族(id 由本表发号;操作 marshal 回其会话的数据线程)—— + host.RegisterLocalCall("host.segment_create", args => NeedAsync(args, async s => + { + var o = args!.AsObject(); + return await OnSessionThread(s, () => + { + var seg = s.CreateOutputSegment(o["sample_offset"]!.GetValue(), + (int)o["sample_count"]!.GetValue(), (int)o["sample_rate"]!.GetValue()); + var id = s.SessionKey + "/seg" + Interlocked.Increment(ref table.SegCounter); + table.Segments[id] = seg; + return (JsonNode)new JsonObject { ["segment"] = id }; + }).ConfigureAwait(false); + })); + + host.RegisterLocalCall("host.segment_write", args => SegmentCall(args, table, (seg, o) => + { + var offset = (int)o["offset"]!.GetValue(); + seg.Write(offset, WireAudio.DecodeF32(o["data"]!.GetValue())); + })); + host.RegisterLocalCall("host.segment_commit", args => SegmentCall(args, table, (seg, _) => seg.Commit())); + host.RegisterLocalCall("host.segment_resize", args => SegmentCall(args, table, (seg, o) => + seg.Resize(o["sample_offset"]!.GetValue(), (int)o["sample_count"]!.GetValue()))); + host.RegisterLocalCall("host.segment_dispose", args => + { + var o = args!.AsObject(); + var id = o["segment"]!.GetValue(); + if (!table.Segments.TryRemove(id, out var seg)) + throw new SidecarRpcException("stale_handle", "unknown segment " + id); + var (tbl, session) = Resolve(args); + if (session == null) + throw new SidecarRpcException("stale_handle", "session gone"); + return OnSessionThread(session, () => { seg.Dispose(); return (JsonNode?)new JsonObject(); }); + }); + + // —— 发布(sidecar→宿主通知;effect/voice 同形方法共用实现)—— + host.RegisterLocalNotify("effect.status", args => Publish(args, (s, v) => s.PublishStatus(v), ParseStatuses)); + host.RegisterLocalNotify("voice.status", args => Publish(args, (s, v) => s.PublishStatus(v), ParseStatuses)); + host.RegisterLocalNotify("effect.parameters", args => Publish(args, (s, v) => s.PublishParameters(v), ParseParameters)); + host.RegisterLocalNotify("voice.parameters", args => Publish(args, (s, v) => s.PublishParameters(v), ParseParameters)); + host.RegisterLocalNotify("voice.pitch", args => Publish(args, (s, v) => s.PublishPitch(v), ParsePitch)); + host.RegisterLocalNotify("voice.phonemes", args => Publish(args, (s, v) => s.PublishPhonemes(v), ParsePhonemes)); + } + + static Task SegmentCall(JsonNode? args, Table table, Action op) + { + var o = args!.AsObject(); + var id = o["segment"]!.GetValue(); + if (!table.Segments.TryGetValue(id, out var seg)) + throw new SidecarRpcException("stale_handle", "unknown segment " + id); + var (_, session) = Resolve(args); + if (session == null) + throw new SidecarRpcException("stale_handle", "session gone"); + return OnSessionThread(session, () => { op(seg, o); return (JsonNode?)new JsonObject(); }); + } + + static void Publish(JsonNode? args, Action sink, Func parse) + { + var (_, session) = Resolve(args); + if (session != null) + sink(session, parse(args)); + } + + static Task OnSessionThread(IPullSession session, Func work) + { + var sync = session.Sync; + if (sync == null) + return Task.FromResult(work()); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + sync.Post(_ => + { + try { tcs.TrySetResult(work()); } + catch (Exception ex) { tcs.TrySetException(ex); } + }, null); + return WithTimeout(tcs.Task); + } + + static async Task WithTimeout(Task task) + { + var done = await Task.WhenAny(task, Task.Delay(RemoteLimits.MarshalTimeout)).ConfigureAwait(false); + if (done != task) + throw new SidecarRpcException("timeout", "data-thread marshal timed out"); + return await task.ConfigureAwait(false); + } + + // ── wire 解析小件 ── + + internal static IReadOnlyList ParseStatuses(JsonNode? args) + { + var list = new List(); + if (args is JsonObject o && o["segments"] is JsonArray arr) + foreach (var item in arr) + { + var so = item!.AsObject(); + list.Add(new SynthesisStatusSegment + { + StartTime = so["start"].GetValue(), + EndTime = so["end"].GetValue(), + Status = so["status"]?.GetValue() switch + { + "pending" => SynthesisSegmentStatus.Pending, + "synthesizing" => SynthesisSegmentStatus.Synthesizing, + "failed" => SynthesisSegmentStatus.Failed, + _ => SynthesisSegmentStatus.Synthesized, + }, + Message = so["message"]?.GetValue(), + Progress = so["progress"]?.GetValue() ?? 0, + }); + } + return list; + } + + internal static IReadOnlyMap ParseParameters(JsonNode? args) + { + var map = new Map(); + if (args is JsonObject o && o["parameters"] is JsonObject ps) + foreach (var p in ps) + { + var segments = new List>(); + if (p.Value is JsonObject po && po["segments"] is JsonArray segs) + foreach (var seg in segs) + segments.Add(((JsonArray)seg!).Select(pt => new Point(pt![0]!.GetValue(), pt[1]!.GetValue())).ToList()); + map[p.Key] = new SynthesizedParameter { Segments = segments }; + } + return map; + } + + internal static SynthesizedPitch ParsePitch(JsonNode? args) + { + var segments = new List>(); + if (args is JsonObject o && o["segments"] is JsonArray segs) + foreach (var seg in segs) + segments.Add(((JsonArray)seg!).Select(pt => new Point(pt![0]!.GetValue(), pt[1]!.GetValue())).ToList()); + return new SynthesizedPitch { Segments = segments }; + } + + internal static IReadOnlyMap ParsePhonemes(JsonNode? args) + { + var map = new Map(); + if (args is JsonObject o && o["syllables"] is JsonObject ys) + foreach (var y in ys) + { + if (y.Value is not JsonObject so) continue; + map[y.Key] = new SynthesizedSyllable( + ReadPhonemes(so["leading"] as JsonArray), + ReadPhonemes(so["body"] as JsonArray), + so["body_offset"]?.GetValue() ?? 0); + } + return map; + } + + static IReadOnlyList ReadPhonemes(JsonArray? arr) + => arr == null ? [] : arr.Select(p => new SynthesizedPhoneme + { + Symbol = p!["symbol"].GetValue(), + Duration = p["duration"].GetValue(), + StretchWeight = p["stretch_weight"].GetValue(), + }).ToList(); +} + +internal static class RemoteLimits +{ + public const int MaxChunkSamples = 1 << 20; // 单次反向读上限(4MB 原样 / ≈5.3MB base64) + public static readonly TimeSpan MarshalTimeout = TimeSpan.FromSeconds(30); +} diff --git a/TuneLab/Extensions/Scripted/RemoteVoice.cs b/TuneLab/Extensions/Scripted/RemoteVoice.cs new file mode 100644 index 00000000..1dac31d1 --- /dev/null +++ b/TuneLab/Extensions/Scripted/RemoteVoice.cs @@ -0,0 +1,600 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using TuneLab.Foundation; +using TuneLab.SDK; + +namespace TuneLab.Extensions.Scripted; + +// 脚本 voice 引擎的进程内代理(设计 §5;wire 形状见 scripting/wire/v1.json "voice")。 +// VoiceGraph 管线眼里它与 in-proc IVoiceSynthesisEngine 不可分辨: +// - catalog/layout:Init 期拉取、volatile 缓存("必须立即返回"契约);engine.catalog_changed 通知触发重拉。 +// - 声明五件套:TryCallRunning 有界(250ms)+ 指纹缓存(§3.5),数据线程永不无界等待。 +// - 会话:宿主侧订阅 context 事件 → voice.dirty 通知推送(粗粒度信号);sidecar 按需经反向 +// host.notes/host.snapshot/host.evaluate 重拉全量真相——"廉价标脏、拉取式重算"是 in-proc +// 三层最小事实的 remote 映射,worker 从不触活视图(协议与线程纪律同构)。 +// - 调度:peek 走有界+缓存;synthesize 无超时(应答即完成),取消尽力。 +// - 产物四路(pitch/parameters/phonemes/status)经总线通知换引用发布 + changed 事件。 +internal sealed class RemoteVoiceEngine : IVoiceSynthesisEngine, IExtensionSettings +{ + readonly SidecarHost mHost; + readonly string mEngineId; + readonly bool mHasSettings; + readonly ConcurrentDictionary mSessions = new(); + int mSessionCounter; + + volatile OrderedMap mCatalog = new(); + volatile List mLayout = []; + JsonNode? mCatalogRaw; + + const int DeclarationTimeoutMs = 250; + (string Fingerprint, JsonNode? Result)? mAutoCfgCache, mSynthCfgCache, mPartCfgCache, mNoteCfgCache, mPhonemeCfgCache; + + public RemoteVoiceEngine(SidecarHost host, string engineId, bool hasSettings) + { + mHost = host; mEngineId = engineId; mHasSettings = hasSettings; + host.RegisterLocalNotify("engine.catalog_changed", args => + { + if ((args as JsonObject)?["engine"]?.GetValue() == engineId) + _ = Task.Run(RefreshCatalogAsync); + }); + } + + public IReadOnlyOrderedMap VoiceSourceInfos => mCatalog; + public IReadOnlyList VoiceSourceLayout => mLayout; + + public void Init() + { + mHost.CallAsync("engine.init", new JsonObject { ["engine"] = mEngineId }, Timeout.InfiniteTimeSpan) + .GetAwaiter().GetResult(); + RefreshCatalogAsync().GetAwaiter().GetResult(); + } + + public void Destroy() + { + try { mHost.Notify("engine.destroy", new JsonObject { ["engine"] = mEngineId }); } catch { } + } + + async Task RefreshCatalogAsync() + { + try + { + var reply = await mHost.CallAsync("voice.get_catalog", new JsonObject { ["engine"] = mEngineId }).ConfigureAwait(false); + if (reply == null) return; + mCatalogRaw = reply; + ApplyCatalog(reply); + } + catch (Exception ex) + { + Log.Warning(string.Format(System.Globalization.CultureInfo.InvariantCulture, "voice catalog fetch failed for {0}: {1}", mEngineId, ex.Message)); + } + } + + void ApplyCatalog(JsonNode reply) + { + var catalog = new OrderedMap(); + if (reply["sources"] is JsonArray arr) + foreach (var item in arr) + { + var o = item!.AsObject(); + var id = o["id"]!.GetValue(); + catalog[id] = new VoiceSourceInfo + { + Name = o["name"]?.GetValue() ?? id, + Description = o["description"]?.GetValue() ?? string.Empty, + Portrait = null, // 图像资源跨线(M3d:发文件引用再挂 FileImageResource) + }; + } + var layout = new List(); + if (reply["layout"] is JsonArray lay) + foreach (var item in lay) + if (ParseLayoutItem(item) is { } li) + layout.Add(li); + mCatalog = catalog; + mLayout = layout; + } + + static VoiceSourceLayoutItem? ParseLayoutItem(JsonNode? node) + { + if (node is not JsonObject o) return null; + if (o["group"] is { } g && g is JsonArray members) + { + var items = members.Select(ParseLayoutItem).Where(x => x != null).Select(x => x!).ToList(); + return VoiceSourceLayoutItem.Group(o["name"]?.GetValue() ?? "", items); + } + if (o["voice"]?.GetValue() is { Length: > 0 } vid) + return VoiceSourceLayoutItem.Voice(vid); + return null; + } + + public IVoiceSynthesisSession CreateSession(IVoiceSynthesisContext context) + { + var key = $"voice:{mEngineId}#{Interlocked.Increment(ref mSessionCounter)}"; + var session = new RemoteVoiceSession(this, context, key); + mSessions[key] = session; + RemotePullBus.Register(mHost, session); + session.CreateHandshake = mHost.CallAsync("engine.create_session", new JsonObject + { + ["engine"] = mEngineId, ["session"] = key, ["voice_id"] = context.VoiceId, + }) + .ContinueWith(t => { if (t.Exception != null) session.CreateFault = t.Exception.GetBaseException(); }); + return session; + } + + internal void DropSession(string key) + { + mSessions.TryRemove(key, out _); + RemotePullBus.Unregister(mHost, key); + } + + // ── 声明五件套(缓存 + 有界;§3.5)── + + public IReadOnlyOrderedMap GetAutomationConfigs(IVoiceSynthesisPartPropertyContext context) + { + var node = CachedDecl(ref mAutoCfgCache, "voice.get_automation_configs", PartArgs(context)); + return ScriptedConfigWire.ReadAutomationMap(node?["tracks"], "automations"); + } + + public IReadOnlyOrderedMap GetSynthesizedParameterConfigs(IVoiceSynthesisPartPropertyContext context) + { + var node = CachedDecl(ref mSynthCfgCache, "voice.get_synthesized_parameter_configs", PartArgs(context)); + return ScriptedConfigWire.ReadAutomationMap(node?["tracks"], "synthesized_parameters"); + } + + public ObjectConfig GetPartPropertyConfig(IVoiceSynthesisPartPropertyContext context) + { + var node = CachedDecl(ref mPartCfgCache, "voice.get_part_property_config", PartArgs(context)); + return node?["config"] is { } cfg ? (ObjectConfig)ScriptedConfigWire.ReadConfig(cfg) : VoiceViewWire.EmptyObject(); + } + + public ObjectConfig GetNotePropertyConfig(IVoiceSynthesisNotePropertyContext context) + { + var node = CachedDecl(ref mNoteCfgCache, "voice.get_note_property_config", NoteArgs(context)); + return node?["config"] is { } cfg ? (ObjectConfig)ScriptedConfigWire.ReadConfig(cfg) : VoiceViewWire.EmptyObject(); + } + + public IReadOnlyMap GetPhonemePropertyConfigs(IVoiceSynthesisNotePropertyContext context) + { + var node = CachedDecl(ref mPhonemeCfgCache, "voice.get_phoneme_property_configs", NoteArgs(context)); + var map = new Map(); + if (node?["slots"] is JsonArray slots) + foreach (var s in slots) + { + var so = s!.AsObject(); + map[so["slot"].GetValue()] = (ObjectConfig)ScriptedConfigWire.ReadConfig(so["config"]); + } + return map; + } + + JsonObject PartArgs(IVoiceSynthesisPartPropertyContext context) + { + var parts = new JsonArray(); + foreach (var part in context.Parts) + parts.Add(VoiceViewWire.PartView(part)); + return new JsonObject { ["engine"] = mEngineId, ["parts"] = parts }; + } + + JsonObject NoteArgs(IVoiceSynthesisNotePropertyContext context) + => new() + { + ["engine"] = mEngineId, + ["part"] = VoiceViewWire.PartView(context.Part), + ["notes"] = new JsonArray(context.Notes.Select(VoiceViewWire.NoteView).ToArray()), + }; + + JsonNode? CachedDecl(ref (string Fingerprint, JsonNode? Result)? cache, string method, JsonObject payload) + { + var fingerprint = payload.ToJsonString(); + if (mHost.TryCallRunning(method, JsonNode.Parse(fingerprint), TimeSpan.FromMilliseconds(DeclarationTimeoutMs), out var result, out _)) + { + cache = (fingerprint, result); + return result; + } + if (cache?.Fingerprint == fingerprint) + return cache.Value.Result; + return cache?.Result; + } + + // ── 设置面(§5.7;key 用 engine id)── + + ObjectConfig IExtensionSettings.GetSettingsConfig(IExtensionSettingsContext context) + { + if (!mHasSettings) + return VoiceViewWire.EmptyObject(); + var reply = mHost.CallAsync("settings.get_config", new JsonObject { ["entry"] = mEngineId }) + .GetAwaiter().GetResult(); + return reply?["config"] is { } cfg ? (ObjectConfig)ScriptedConfigWire.ReadConfig(cfg) : VoiceViewWire.EmptyObject(); + } + + void IExtensionSettings.ApplySettings(PropertyObject settings) + { + if (!mHasSettings) + return; + mHost.SetSettings(mEngineId, ScriptedWire.SerializeNode(settings)); + } + + // ══════════════════════════════ 会话 ══════════════════════════════ + + sealed class RemoteVoiceSession : IVoiceSynthesisSession, IPullSession + { + readonly RemoteVoiceEngine mEngine; + readonly IVoiceSynthesisContext mContext; + readonly SidecarHost mHost; + readonly string mVoiceId; + SynchronizationContext? mSync; + long mDirtySeq; // 任何推送事件 +1;peek/continuation 缓存键的一部分 + int mBusy; + bool mDisposed; + + volatile SynthesizedPitch mPitch = new() { Segments = [] }; + volatile IReadOnlyMap mParameters = new Map(); + volatile IReadOnlyMap mPhonemes = new Map(); + volatile IReadOnlyList mStatus = Array.Empty(); + volatile string mDefaultLyric = string.Empty; + (SynthesisRange? Range, long Seq)? mPeekCache; + readonly ConcurrentDictionary mContinuationCache = new(); + + readonly ActionEvent mPitchChanged = new(); + readonly ActionEvent mParametersChanged = new(); + readonly ActionEvent mPhonemesChanged = new(); + readonly ActionEvent mStatusChanged = new(); + + // context 订阅句柄(Dispose 全摘) + readonly IActionEvent mNotesAny, mNotesMembership, mPartProps, mCommitted; + readonly Action mNotesAnyHandler, mNotesMembershipHandler, mPartPropsHandler, mCommittedHandler; + readonly List<(ISynthesisAutomation Auto, Action Handler)> mRangeSubs = []; + + public string SessionKey { get; } + public Exception? CreateFault; + public Task CreateHandshake = Task.CompletedTask; + + public RemoteVoiceSession(RemoteVoiceEngine engine, IVoiceSynthesisContext context, string key) + { + mEngine = engine; mContext = context; SessionKey = key; mHost = engine.mHost; mVoiceId = context.VoiceId; + mSync = SynchronizationContext.Current; + + // 订阅即"廉价标脏 + 转发粗信号"(数据线程;CreateSession 保证在数据线程被调)。 + mNotesAnyHandler = () => PushDirty("fields", null, 0, 0); + mNotesMembershipHandler = () => PushDirty("notes", null, 0, 0); + mPartPropsHandler = () => PushDirty("part_properties", null, 0, 0); + mCommittedHandler = () => PushDirty("committed", null, 0, 0); + mNotesAny = context.Notes.WhenAny( + n => n.StartTime.Modified, n => n.EndTime.Modified, n => n.Pitch.Modified, + n => n.Lyric.Modified, n => n.LeadingPhonemes.Modified, n => n.BodyPhonemes.Modified, + n => n.BodyOffset.Modified, n => n.Properties.Modified); + mNotesAny.Subscribe(mNotesAnyHandler); + mNotesMembership = context.Notes.MembershipModified; + mNotesMembership.Subscribe(mNotesMembershipHandler); + mPartProps = context.PartProperties.Modified; + mPartProps.Subscribe(mPartPropsHandler); + mCommitted = context.Committed; + mCommitted.Subscribe(mCommittedHandler); + SubscribeRange(context.Pitch, "pitch"); + SubscribeRange(context.PitchDeviation, "pitch_deviation"); + foreach (var kv in context.Automations) + SubscribeRange(kv.Value, kv.Key); + } + + void SubscribeRange(ISynthesisAutomation auto, string track) + { + Action handler = (s, e) => PushDirty("automation", track, s, e); + auto.RangeModified.Subscribe(handler); + mRangeSubs.Add((auto, handler)); + } + + void PushDirty(string kind, string? track, double start, double end) + { + Interlocked.Increment(ref mDirtySeq); + if (mDisposed || !mHost.Running) + return; // 未起/已终结:sidecar 首次 peek/synthesize 反正全量拉,事件丢不得也白丢 + var args = new JsonObject { ["session"] = SessionKey, ["kind"] = kind }; + if (track != null) + { + args["track"] = track; + args["start"] = start; + args["end"] = end; + } + try { mHost.Notify("voice.dirty", args); } catch { } + } + + public string DefaultLyric + { + get + { + if (mHost.TryCallRunning("voice.default_lyric", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = SessionKey }, + TimeSpan.FromMilliseconds(250), out var reply, out _) && + reply?["lyric"]?.GetValue() is { } l) + { + mDefaultLyric = l; + return l; + } + return mDefaultLyric; + } + } + + public bool IsContinuation(IVoiceSynthesisNote note) + { + var id = note.Id; + var fingerprint = VoiceViewWire.NoteFingerprint(note); + if (mContinuationCache.TryGetValue(id, out var hit) && hit.Fingerprint == fingerprint) + return hit.Value; + if (mHost.TryCallRunning("voice.is_continuation", + new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = SessionKey, ["note"] = VoiceViewWire.NoteLive(note) }, + TimeSpan.FromMilliseconds(250), out var reply, out _)) + { + var v = reply?["continuation"]?.GetValue() ?? false; + mContinuationCache[id] = (fingerprint, v); + return v; + } + return hit.Value; // 有界失败:旧判定兜着(下一拍 commit 后值变了自然换指纹重问) + } + + public SynthesisRange? GetNextPendingSynthesisRange(double startTime, double endTime) + { + var seq = Interlocked.Read(ref mDirtySeq); + if (mPeekCache is { } c) + { + // 窗同、seq 未推进:直接复用;推进了但 RPC 未成也拿它垫一拍(synthesize 电平语义幂等)。 + double cs = c.Range?.StartTime ?? double.NaN, ce = c.Range?.EndTime ?? double.NaN; + if (c.Seq == seq && Math.Abs(cs - startTime) < 1e-9 && Math.Abs(ce - endTime) < 1e-9) + return c.Range; + } + if (mHost.TryCallRunning("voice.peek", new JsonObject + { + ["engine"] = mEngine.mEngineId, ["session"] = SessionKey, ["start"] = startTime, ["end"] = endTime, + }, TimeSpan.FromMilliseconds(250), out var reply, out _)) + { + SynthesisRange? range = null; + if (reply?["range"] is JsonArray r && r.Count == 2) + range = new SynthesisRange(r[0]!.GetValue(), r[1]!.GetValue()); + mPeekCache = (range, seq); + return range; + } + return mPeekCache?.Range; + } + + public async Task SynthesizeNext(double startTime, double endTime, CancellationToken cancellation = default) + { + if (mDisposed) throw new ObjectDisposedException(nameof(RemoteVoiceSession)); + if (Interlocked.CompareExchange(ref mBusy, 1, 0) != 0) + throw new InvalidOperationException("voice session already synthesizing"); + mSync = SynchronizationContext.Current ?? mSync; + try + { + await CreateHandshake.ConfigureAwait(false); + if (CreateFault != null) + throw CreateFault; + using var reg = cancellation.CanBeCanceled + ? cancellation.Register(() => + { + try { mHost.Notify("voice.cancel", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = SessionKey }); } catch { } + }) + : default; + await mHost.CallAsync("voice.synthesize", new JsonObject + { + ["engine"] = mEngine.mEngineId, ["session"] = SessionKey, ["start"] = startTime, ["end"] = endTime, + }, Timeout.InfiniteTimeSpan).ConfigureAwait(false); + } + finally + { + Interlocked.Exchange(ref mBusy, 0); + } + } + + public SynthesizedPitch SynthesizedPitch => mPitch; + public IReadOnlyMap SynthesizedParameters => mParameters; + public IReadOnlyMap SynthesizedPhonemes => mPhonemes; + public IReadOnlyList Status => mStatus; + public IActionEvent SynthesizedPitchChanged => mPitchChanged; + public IActionEvent SynthesizedParametersChanged => mParametersChanged; + public IActionEvent SynthesizedPhonemesChanged => mPhonemesChanged; + public IActionEvent StatusChanged => mStatusChanged; + + public void Dispose() + { + if (mDisposed) return; + mDisposed = true; + mNotesAny.Unsubscribe(mNotesAnyHandler); + mNotesMembership.Unsubscribe(mNotesMembershipHandler); + mPartProps.Unsubscribe(mPartPropsHandler); + mCommitted.Unsubscribe(mCommittedHandler); + foreach (var (auto, handler) in mRangeSubs) + auto.RangeModified.Unsubscribe(handler); + mRangeSubs.Clear(); + RemotePullBus.DisposeOwnedSegments(mHost, SessionKey); + mEngine.DropSession(SessionKey); + try { mHost.Notify("session.dispose", new JsonObject { ["engine"] = mEngine.mEngineId, ["session"] = SessionKey }); } catch { } + } + + // ── IPullSession:voice 会话的拉取面(总线已 marshal 回数据线程)── + + SynchronizationContext? IPullSession.Sync => mSync; + bool IPullSession.TryInputGeometry(out long offset, out int count, out int rate) + { + offset = 0; count = 0; rate = 0; + return false; // voice 无"输入段"(那是 effect 的面) + } + bool IPullSession.TryReadInput(int offset, int count, out float[] samples) + { + samples = []; + return false; + } + + PropertyObject IPullSession.PullProperties(IReadOnlyList? keys) + { + var map = new Map(); + if (keys != null) + foreach (var k in keys) + map[k] = mContext.PartProperties.GetValue(k, default); + return new PropertyObject(map); + } + + bool IPullSession.TryEvaluateTrack(string track, IReadOnlyList positions, out double[] results) + { + ISynthesisAutomation? auto = track switch + { + "pitch" => mContext.Pitch, + "pitch_deviation" => mContext.PitchDeviation, + _ => mContext.Automations.TryGetValue(track, out var a) ? a : null, + }; + if (auto == null) + { + results = []; + return false; + } + results = new double[positions.Count]; + auto.Evaluate(positions, results); + return true; + } + + IReadOnlyList IPullSession.AutomationTracks() + => new[] { "pitch", "pitch_deviation" }.Concat(mContext.Automations.Keys).ToList(); + + IAudioSegment IPullSession.CreateOutputSegment(long sampleOffset, int sampleCount, int sampleRate) + => mContext.CreateAudioSegment(sampleOffset, sampleCount, sampleRate); + + void IPullSession.PublishStatus(IReadOnlyList segments) + { + mStatus = segments; + mStatusChanged.Invoke(); + } + + void IPullSession.PublishParameters(IReadOnlyMap parameters) + { + mParameters = parameters; + mParametersChanged.Invoke(); + } + + void IPullSession.PublishPitch(SynthesizedPitch pitch) + { + mPitch = pitch; + mPitchChanged.Invoke(); + } + + void IPullSession.PublishPhonemes(IReadOnlyMap phonemes) + { + mPhonemes = phonemes; + mPhonemesChanged.Invoke(); + } + + JsonNode? IPullSession.PullNotes() + => new JsonObject { ["notes"] = new JsonArray(mContext.Notes.Select(VoiceViewWire.NoteLive).ToArray()) }; + + JsonNode? IPullSession.PullSnapshot(IReadOnlyList noteIds) + { + var wanted = new HashSet(noteIds, StringComparer.Ordinal); + var picked = new List(); + var byId = new Dictionary(); + foreach (var n in mContext.Notes) + { + byId[n.Id] = n; + if (wanted.Contains(n.Id)) + picked.Add(n); + } + // 按请求顺序对齐(façade 传的序 = 它自己标脏的序) + var ordered = noteIds.Select(id => byId.TryGetValue(id, out var n) ? n : null).Where(x => x != null).Select(x => x!).ToList(); + var snap = mContext.GetSnapshot(ordered); + var notes = new JsonArray(); + for (int i = 0; i < ordered.Count; i++) + { + var sn = snap.Notes[i]; + var o = VoiceViewWire.SnapshotNote(sn); + o["request_id"] = ordered[i].Id; // 归属回指(façade 用它回传 phonemes 的 map 键) + notes.Add(o); + } + return new JsonObject + { + ["snapshot"] = new JsonObject + { + ["notes"] = notes, + ["part_properties"] = ScriptedWire.SerializeNode(snap.PartProperties), + ["pitch"] = "pitch", ["pitch_deviation"] = "pitch_deviation", + ["automations"] = JsonSerializer.SerializeToNode(snap.Automations.Keys.ToArray()), + }, + }; + } + } +} + +// voice 值视图的线上形(声明 context / is_continuation / notes / snapshot 共用一套, +// 形状规范同 scripting/wire/v1.json)。 +internal static class VoiceViewWire +{ + public static ObjectConfig EmptyObject() => ObjectConfig.Create(new OrderedMap()); + + public static JsonObject PartView(IVoiceSynthesisPartView part) => new() + { + ["voice_id"] = part.VoiceId, + ["part_properties"] = ScriptedWire.SerializeNode(part.PartProperties), + ["notes"] = new JsonArray(part.Notes.Select(NoteView).ToArray()), + }; + + public static JsonObject NoteView(IVoiceSynthesisNoteView note) => new() + { + ["start"] = note.StartTime, + ["end"] = note.EndTime, + ["pitch"] = note.Pitch, + ["lyric"] = note.Lyric, + ["body_offset"] = note.BodyOffset, + ["leading"] = new JsonArray(note.LeadingPhonemes.Select(PhonemeView).ToArray()), + ["body"] = new JsonArray(note.BodyPhonemes.Select(PhonemeView).ToArray()), + ["properties"] = ScriptedWire.SerializeNode(note.Properties), + }; + + static JsonObject PhonemeView(IVoiceSynthesisPhonemeView p) => new() + { + ["symbol"] = p.Symbol, + ["duration"] = p.Duration, + ["stretch_weight"] = p.StretchWeight, + ["properties"] = ScriptedWire.SerializeNode(p.Properties), + }; + + public static JsonObject NoteLive(IVoiceSynthesisNote note) => new() + { + ["id"] = note.Id, + ["start"] = note.StartTime.Value, + ["end"] = note.EndTime.Value, + ["pitch"] = note.Pitch.Value, + ["lyric"] = note.Lyric.Value, + ["body_offset"] = note.BodyOffset.Value, + ["leading"] = PhonemesLive(note.LeadingPhonemes.Value), + ["body"] = PhonemesLive(note.BodyPhonemes.Value), + ["properties"] = JsonNode.Parse("{}"), // 钉死 phonemes 的 properties 只在快照侧出现(活视图无,见 plugin-development §5.7) + }; + + static JsonArray PhonemesLive(IReadOnlyList list) => new( + list.Select(p => (JsonNode)new JsonObject + { + ["symbol"] = p.Symbol, ["duration"] = p.Duration, ["stretch_weight"] = p.StretchWeight, + }).ToArray()); + + public static JsonObject SnapshotNote(VoiceSynthesisNoteSnapshot sn) => new() + { + ["id"] = sn.Id, + ["start"] = sn.StartTime, + ["end"] = sn.EndTime, + ["pitch"] = sn.Pitch, + ["lyric"] = sn.Lyric, + ["body_offset"] = sn.BodyOffset, + ["leading"] = new JsonArray(sn.LeadingPhonemes.Select(SnapshotPhoneme).ToArray()), + ["body"] = new JsonArray(sn.BodyPhonemes.Select(SnapshotPhoneme).ToArray()), + ["properties"] = ScriptedWire.SerializeNode(sn.Properties), + }; + + static JsonObject SnapshotPhoneme(VoiceSynthesisPhonemeSnapshot p) => new() + { + ["symbol"] = p.Symbol, + ["duration"] = p.Duration, + ["stretch_weight"] = p.StretchWeight, + ["properties"] = ScriptedWire.SerializeNode(p.Properties), + }; + + public static string NoteFingerprint(IVoiceSynthesisNote n) + => string.Join('\u2063', n.StartTime.Value.ToString("R"), n.EndTime.Value.ToString("R"), n.Pitch.Value.ToString(), + n.Lyric.Value, n.BodyOffset.Value.ToString("R"), n.LeadingPhonemes.Value.Count, n.BodyPhonemes.Value.Count); +} diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md index 5ced91f6..b5ece68e 100644 --- a/docs/scripted-plugin-sidecar-design.md +++ b/docs/scripted-plugin-sidecar-design.md @@ -1,8 +1,8 @@ # 脚本插件 Sidecar 系统设计(Python / Node.js / Lua) -> 状态:**定稿;M3b 已落地**(后台重启环 + stderr 保留策略 + 三平台 CI 测试矩阵;运行时自动部署=M3a、 -> effect=M2b、声明/设置/心跳=M2a、format 全链=M1、manifest/探测=M0)。 -> 剩余:voice + shm(M3c)、Node/Lua façade(M3d)。 +> 状态:**定稿;M3c 已落地**(voice 全表面:catalog/声明五件套/续延缓存/peek 缓存/合成调度/ +> 反向拉取/四路发布/事件推送,Python `@voice` + 真解释器 E2E;shm 音频面为后续快车道、inline 恒为兜底)。 +> 剩余:Node/Lua façade(M3d)、【下载运行时】stderr 清理策略与 shm(M4)。 > 基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 > 配套阅读:[plugin-development.md](plugin-development.md)(V1 插件契约)、[sdk-api-evolution.md](sdk-api-evolution.md)(冻结 ABI 纪律)、[naming-glossary.md](naming-glossary.md)。 > 本文只设计**宿主侧与协议侧**;`TuneLab.SDK` / `TuneLab.Foundation` 一行不动(§1.3)。 @@ -517,9 +517,20 @@ scripted.initTimeoutSec int = 0 # 0 = 无上限(§5.5 长任 (每 boot 一清、只留最近 2 个旧文件,§3.4 兑现);`.github/workflows/tests.yml` 三平台测试矩阵 (win/ubuntu/macos × setup-python 3.12 + node 20 × 真解释器 E2E 强制档 TUNELAB_REQUIRE_PYTHON=1 × façade 单测 × legacy 工程;Setup 三类因既有 RID 输出拷盘问题暂除,注释指回本仓)。 -- **仍推迟**:voice + shm 音频面(M3c——反向通道与快照/推送面协议大,需专轮设计:get_snapshot - 反向拉、note/automation 事件推送流、peek 缓存、四路产物发布);Node/Lua façade(M3d, - CI 已备 node——façade 落地即绿)。 +- **M3c 落地**:voice 垂直切片——`RemotePullBus`(常驻会话共享的反向拉取/段/发布总线: + effect 迁入、voice 复用,wire 语义零变化——M2b 测试原样全绿作证);`RemoteVoiceEngine` + (catalog/layout volatile 缓存 + engine.catalog_changed 重拉;声明五件套走"有界 + 指纹缓存"); + `RemoteVoiceSession`(context 订阅→voice.dirty 粗粒度推送流;IsContinuation/peek 有界+缓存 + (§3.5 的"晚一拍安全"由 synthesize 电平幂等兜底);synthesize 无超时应答即完成; + 反向 host.notes/host.snapshot/host.evaluate(pitch|pitch_deviation|track) + 段写回; + pitch/phonemes/parameters/status 四路 volatile 发布 + changed 事件)。Python façade `@voice` + (VoiceCtx:notes/snapshot/pitch evaluator/output/status/pitch/phonemes/parameters——求值器=句柄远程 + 拉,§5.6 (a) 决定的完整落地)。样例 `tests/plugins/PyVoice`(真 3.11 E2E:两段合成、 + dirty 驱动 peek 游标推进、音素按 request_id 归属回指)。`scripting/wire/v1.json` 补齐 + voice 方法表(46 方法)并加规范封条(可解析 + 方法覆盖 + 版本一致——期间就抓出两处 + spec 自身 JSON 病灶)。 +- **仍推迟**:shm 音频面(M4——性能快车道,非正确性;voice 已全链可用); + 【下载运行时】按钮的 stderr 清理策略(M4);Node/Lua façade(M3d)。 - **实现差异**: - 崩溃后不自动定时复活,**下一次调用驱动重 spawn**(backoff/熔断仍生效)——format 拉模式下 自动重启只有缓存收益;effect push 调度落地时(M2b)再补后台重启环。 diff --git a/scripting/python/tunelab/__init__.py b/scripting/python/tunelab/__init__.py index 0d8f3564..e62eed56 100644 --- a/scripting/python/tunelab/__init__.py +++ b/scripting/python/tunelab/__init__.py @@ -1,8 +1,9 @@ """TuneLab scripted-plugin facade (pure stdlib). -Speaks the sidecar wire protocol (docs/scripted-plugin-sidecar-design.md §5): LSP-style -Content-Length frames of JSON-RPC 2.0 over stdio. M1 covers `format` entries; voice/effect -decorators land with M2/M3 and will keep this public surface additive. +Speaks the sidecar wire protocol (docs/scripted-plugin-sidecar-design.md §5, spec in +scripting/wire/v1.json): LSP-style Content-Length frames of JSON-RPC 2.0 over stdio. +Covers `format`, `effect` and `voice` entries (shm audio plane is a later fast lane; +the inline plane is always the fallback). Usage (in the package's entry script, e.g. plugin/main.py): @@ -16,7 +17,7 @@ def export_project(self, stream_ref, project): ... tunelab.serve() """ -from ._service import format, effect, serve, info, warn, error, capabilities +from ._service import format, effect, voice, serve, info, warn, error, capabilities -__all__ = ["format", "effect", "serve", "info", "warn", "error", "capabilities"] +__all__ = ["format", "effect", "voice", "serve", "info", "warn", "error", "capabilities"] __version__ = "0.1.0" diff --git a/scripting/python/tunelab/_service.py b/scripting/python/tunelab/_service.py index a5735eea..45403ec2 100644 --- a/scripting/python/tunelab/_service.py +++ b/scripting/python/tunelab/_service.py @@ -151,8 +151,9 @@ def _reply(req_id, result=None, err=None): def _handle_initialize(params): - from . import _effect + from . import _effect, _voice _effect.set_channels(_call_host, _notify) + _voice.set_channels(_call_host, _notify) provides = [] for e in params.get("entries") or []: if e.get("type") == "format": @@ -179,9 +180,10 @@ def _handle_initialize(params): error("apply_settings(handshake) failed: %s" % ex) else: engine_id = e.get("engine") - if engine_id in _effect.declared_engine_ids(): + if engine_id in _effect.declared_engine_ids() or engine_id in _voice.declared_engine_ids(): + settings_ok = engine_id in _effect.settings_engines() or engine_id in _voice.settings_engines() provides.append({"type": e.get("type", "effect"), "engine": engine_id, - "settings": engine_id in _effect.settings_engines()}) + "settings": settings_ok}) return { "provides": provides, "capabilities": capabilities(), @@ -202,6 +204,23 @@ def _find_entry(entry_id): return e +def voice(engine_id=None): + """Decorator registering a scripted voice/singing engine. See tunelab._voice for the + recognized members; engine_id MUST match the manifest `engine` field.""" + from . import _voice + + def deco(obj): + inst = obj() if isinstance(obj, type) else obj + key = engine_id or obj.__name__ + _voice.register_voice(inst, key) + return obj + + if callable(engine_id): + obj, engine_id = engine_id, None + return deco(obj) + return deco + + def _dispatch(req): # 应答帧(宿主对我们反向调用的回复):先结算 pending,再谈其它。 if "method" not in req and "id" in req: @@ -215,12 +234,15 @@ def _dispatch(req): ev.set() return - from . import _effect + from . import _effect, _voice method = req.get("method") params = req.get("params") or {} req_id = req.get("id") + def _engine_registry_is_voice(): + return params.get("engine") in _voice.declared_engine_ids() + try: if method == "initialize": _reply(req_id, _handle_initialize(params)) @@ -230,24 +252,44 @@ def _dispatch(req): if req_id is not None: _reply(req_id, {}) os._exit(0) - elif method in ("engine.init", "engine.destroy", "engine.create_session", - "effect.get_property_config", "effect.get_automation_configs", - "effect.get_synthesized_parameter_configs", "effect.process"): - if method == "engine.destroy": - try: - _effect.handle(method, params) - except Exception: - pass - if req_id is not None: - _reply(req_id, {}) - return - result = _effect.handle(method, params) + elif method == "engine.create_session": + # session 键自带命名空间前缀(effect:/voice:)——两族注册表互不猜测。 + if str(params.get("session", "")).startswith("voice:"): + _voice.create_session(params) + else: + _effect.handle(method, params) if req_id is not None: - _reply(req_id, result) - elif method in ("effect.cancel", "session.dispose"): + _reply(req_id, {}) + elif method == "session.dispose": + if str(params.get("session", "")).startswith("voice:"): + _voice.destroy_session(params) + else: + _effect.on_notification(method, params) + elif method == "effect.cancel": _effect.on_notification(method, params) + elif method == "voice.dirty": + _voice.on_dirty(params) + elif method in ("engine.init", "engine.destroy"): + if _engine_registry_is_voice(): + target = _voice + else: + target = _effect + try: + target.handle(method, params) + except Exception: + if method != "engine.destroy": # destroy 是通知:吞错照常应答 + raise if req_id is not None: _reply(req_id, {}) + elif method.startswith("voice."): + result = _voice.handle(method, params) + if req_id is not None: + _reply(req_id, result) + elif method in ("effect.get_property_config", "effect.get_automation_configs", + "effect.get_synthesized_parameter_configs", "effect.process"): + result = _effect.handle(method, params) + if req_id is not None: + _reply(req_id, result) elif method == "format.import": entry = _find_entry(params.get("entry")) fn = entry and entry.get("import") diff --git a/scripting/python/tunelab/_voice.py b/scripting/python/tunelab/_voice.py new file mode 100644 index 00000000..726abfe3 --- /dev/null +++ b/scripting/python/tunelab/_voice.py @@ -0,0 +1,271 @@ +"""Voice engine plumbing: declarative surface + scheduling + snapshot pulls (wire §5 voice). + +User code registers a class with @tunelab.voice("EngineId"). Required members: + + catalog() -> (sources, layout) sources: [{"id","name","description"}] + layout: optional [{"voice":id}|{"name":g,"group":[...]}] + automation_configs(parts) -> [{id,displayText?,config}] (wire automation shape) + synthesized_parameter_configs(parts) -> [{id,...}] + part_property_config(parts) -> config dict + note_property_config(part, notes) -> config dict + phoneme_property_configs(part, notes) -> [{slot,config}] (or {} — empty slots) + create_session(voice_id) -> session object + + session members: default_lyric (attr), is_continuation(note_view)->bool, + peek(start,end)->(s,e)|None, synthesize(start,end) (level semantics; pull truth via the ctx below), + optional cancel(), dispose(), on_dirty(kind, payload) +""" +import base64 +import struct +import threading + +_voice_engines = {} # engine id -> instance +_sessions = {} # session key -> VoiceCtx + +_call_host = None +_notify_host = None + + +def set_channels(call_host, notify_host): + global _call_host, _notify_host + _call_host, _notify_host = call_host, notify_host + + +def register_voice(obj, engine_id): + _voice_engines[engine_id] = obj + + +def declared_engine_ids(): + return list(_voice_engines) + + +def settings_engines(): + return {k for k, e in _voice_engines.items() if hasattr(e, "get_settings_config")} + + +def owns(session_key): + return session_key in _sessions + + +def create_session(params): + engine = _voice_engines.get(params["engine"]) + if engine is None: + raise RuntimeError("no such voice engine " + str(params.get("engine"))) + ctx = VoiceCtx(params["engine"], params["session"], params.get("voice_id", "")) + user = engine.create_session(ctx) if hasattr(engine, "create_session") else engine + ctx.user = user + _sessions[ctx.key] = ctx + return ctx + + +def destroy_session(params): + ctx = _sessions.pop((params.get("session")), None) + if ctx: + ctx.dispose() + + +def on_dirty(params): + ctx = _sessions.get(params.get("session")) + if ctx and ctx.user is not None: + hook = getattr(ctx.user, "on_dirty", None) + if hook: + try: + hook(params.get("kind"), params) + except Exception: + pass + + +def handle(method, params): + engine_id = params.get("engine") + if method == "engine.init": + fn = getattr(_need(engine_id), "init", None) + if fn: + fn() + return {} + if method == "engine.destroy": + fn = getattr(_need(engine_id), "destroy", None) + if fn: + fn() + return {} + if method == "voice.get_catalog": + sources, layout = _coerce_catalog(_need(engine_id).catalog()) + return {"sources": sources, "layout": layout} + if method == "voice.get_automation_configs": + return {"tracks": _need(engine_id).automation_configs(params.get("parts") or [])} + if method == "voice.get_synthesized_parameter_configs": + fn = getattr(_need(engine_id), "synthesized_parameter_configs", None) + return {"tracks": fn(params.get("parts") or []) if fn else []} + if method == "voice.get_part_property_config": + return {"config": _need(engine_id).part_property_config(params.get("parts") or [])} + if method == "voice.get_note_property_config": + eng = _need(engine_id) + return {"config": eng.note_property_config(params.get("part") or {}, params.get("notes") or [])} + if method == "voice.get_phoneme_property_configs": + eng = _need(engine_id) + fn = getattr(eng, "phoneme_property_configs", None) + slots = fn(params.get("part") or {}, params.get("notes") or []) if fn else [] + return {"slots": slots} + if method == "voice.peek": + ctx = _sessions.get(params["session"]) + r = ctx.user.peek(params["start"], params["end"]) + return {"range": list(r) if r else None} + if method == "voice.default_lyric": + return {"lyric": getattr(_sessions[params["session"]].user, "default_lyric", "la")} + if method == "voice.is_continuation": + note = _note_from(params["note"]) + return {"continuation": bool(_sessions[params["session"]].user.is_continuation(note))} + if method == "voice.synthesize": + ctx = _sessions[params["session"]] + ctx.user.synthesize(params["start"], params["end"]) + return {} + raise VoiceNotHandled(method) + + +class VoiceNotHandled(Exception): + pass + + +def _coerce_catalog(result): + if isinstance(result, tuple) and len(result) == 2: + return list(result[0]), list(result[1] or []) + return list(result), [] + + +def _need(engine_id): + eng = _voice_engines.get(engine_id) + if eng is None: + raise RuntimeError("no such engine: " + str(engine_id)) + return eng + + +class NoteView: + """A pulled note (plain values; pinned phonemes included; properties are natives).""" + def __init__(self, d): + from ._props import decode_map + self.id = d.get("id", "") + self.start = d["start"] + self.end = d["end"] + self.pitch = d["pitch"] + self.lyric = d["lyric"] + self.body_offset = d.get("body_offset", 0.0) + self.leading = [PhonemeView(p) for p in d.get("leading", [])] + self.body = [PhonemeView(p) for p in d.get("body", [])] + self.properties = decode_map(d.get("properties") or {}) + + +class PhonemeView: + def __init__(self, d): + from ._props import decode_map + self.symbol = d["symbol"] + self.duration = d.get("duration", 0.0) + self.stretch_weight = d.get("stretch_weight", 0.0) + self.properties = decode_map(d.get("properties") or {}) + + +def _note_from(d): + return NoteView(d) + + +class Evaluator: + """Remote automation evaluator: evaluate(positions) pulls the host's interpolators.""" + def __init__(self, key, track): + self._key = key + self.track = track + + def evaluate(self, positions): + pos = base64.b64encode(struct.pack("<%dd" % len(positions), *positions)).decode() + r = _call_host("host.evaluate", {"engine": self._key[0], "session": self._key[1], + "track": self.track, "positions": pos}) + data = base64.b64decode(r["values"]) + return list(struct.unpack("<%dd" % (len(data) // 8), data)) + + +class Snapshot: + def __init__(self, engine, session, data): + from ._props import decode_map + self.key = (engine, session) + self.notes = [NoteView(n) for n in data["snapshot"]["notes"]] + self.part_properties = decode_map(data["snapshot"].get("part_properties") or {}) + self.pitch = Evaluator(self.key, "pitch") + self.pitch_deviation = Evaluator(self.key, "pitch_deviation") + self.automations = {t: Evaluator(self.key, t) for t in data["snapshot"].get("automations", [])} + + +class VoiceCtx: + """The API handed to user sessions (mirrors IVoiceSynthesisSession semantics).""" + + def __init__(self, engine, key, voice_id): + self.engine = engine + self.key = key + self.voice_id = voice_id + self.user = None + self.cancelled = threading.Event() + self._segments = [] + + def _via(self, extra=None): + d = {"engine": self.engine, "session": self.key} + if extra: + d.update(extra) + return d + + # pulls ------------------------------------------------------------------ + def notes(self): + return [NoteView(n) for n in _call_host("host.notes", self._via())["notes"]] + + def snapshot(self, note_ids): + """note_ids: [NoteView | id-string] — the host returns the snapshot in request order.""" + ids = [getattr(n, "id", n) for n in note_ids] + data = _call_host("host.snapshot", self._via({"note_ids": ids})) + return Snapshot(self.engine, self.key, data) + + def part_properties(self, keys): + r = _call_host("host.properties", self._via({"keys": list(keys)})) + from ._props import decode_map + return decode_map(r["values"]) + + # output ----------------------------------------------------------------- + def output(self, sample_offset, sample_count, sample_rate): + r = _call_host("host.segment_create", self._via({"sample_offset": sample_offset, + "sample_count": sample_count, "sample_rate": sample_rate})) + from ._effect import _Segment + seg = _Segment(self.engine, self.key, r["segment"], sample_count) + self._segments.append(seg) + return seg + + # publishing -------------------------------------------------------------- + def status(self, segments): + _notify_host("voice.status", self._via({"segments": [_status_dict(s) for s in segments]})) + + def pitch(self, segments): + """segments: [ [(t,hz), ...], ... ]""" + _notify_host("voice.pitch", self._via({"segments": [[list(p) for p in seg] for seg in segments]})) + + def parameters(self, by_key): + _notify_host("voice.parameters", self._via({"parameters": by_key})) + + def phonemes(self, by_note_id): + """by_note_id: {note_id: (leading, body, body_offset)} with phoneme dicts {symbol,duration,stretch_weight}""" + out = {} + for nid, syl in by_note_id.items(): + leading, body = syl[0], syl[1] + offset = syl[2] if len(syl) > 2 else 0.0 + out[nid] = {"leading": list(leading), "body": list(body), "body_offset": offset} + _notify_host("voice.phonemes", self._via({"syllables": out})) + + def dispose(self): + for seg in self._segments: + try: + seg.dispose() + except Exception: + pass + self._segments.clear() + + +def _status_dict(s): + start, end, state = s[0], s[1], s[2] + progress = s[3] if len(s) > 3 else 0.0 + message = s[4] if len(s) > 4 else None + return {"start": start, "end": end, "status": state, "progress": progress, "message": message} + + +import base64 # noqa: E402 (used by Evaluator) diff --git a/scripting/wire/v1.json b/scripting/wire/v1.json index bce50b5d..7290929a 100644 --- a/scripting/wire/v1.json +++ b/scripting/wire/v1.json @@ -68,7 +68,25 @@ "notes": "sidecar publishes via host.* reverse calls + effect.status/effect.parameters notifications below" }, "effect.status": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "segments": [{ "start": "d", "end": "d", "status": "pending|synthesizing|synthesized|failed", "progress": "d?", "message": "s?" }] } }, - "effect.parameters": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "parameters": { "": { "segments": [[ [t, v], ... ], ...] } } } }, + "effect.parameters": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "parameters": { "": { "segments": "list of list of [t, v] pairs" } } } }, + "voice.get_catalog": { "direction": "host->sidecar", "params": { "engine": "string" }, "result": { "sources": [{ "id": "string", "name": "string", "description": "string" }], "layout": "list of {voice:id} (leaf) or {name, group:[...]} (group node)" }, "notes": "pulled at engine.init and on engine.catalog_changed; host caches (VoiceSourceInfos must never block)" }, + "voice.get_automation_configs": { "direction": "host->sidecar", "params": { "engine": "string", "parts": "[part-view]" }, "result": { "tracks": "" } }, + "voice.get_synthesized_parameter_configs": { "direction": "host->sidecar", "params": { "engine": "string", "parts": "[part-view]" }, "result": { "tracks": "" } }, + "voice.get_part_property_config": { "direction": "host->sidecar", "params": { "engine": "string", "parts": "[part-view]" }, "result": { "config": "" } }, + "voice.get_note_property_config": { "direction": "host->sidecar", "params": { "engine": "string", "part": "[part-view]", "notes": "[note-view]" }, "result": { "config": "" } }, + "voice.get_phoneme_property_configs": { "direction": "host->sidecar", "params": { "engine": "string", "part": "[part-view]", "notes": "[note-view]" }, "result": { "slots": [{ "slot": "int", "config": "" }] } }, + "voice.default_lyric": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string" }, "result": { "lyric": "string" }, "notes": "bounded (250ms) call from the data thread, host caches last value" }, + "voice.is_continuation": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string", "note": "[note-view incl id/start/end/pitch/lyric/leading/body/body_offset]" }, "result": { "continuation": "bool" }, "notes": "bounded + fingerprint-cached host-side; the note rides along as values (stateless sidecar)" }, + "voice.peek": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string", "start": "d", "end": "d" }, "result": { "range": "[s, e] | null" }, "notes": "bounded + (window,dirtySeq)-cached host-side; commit is level-idempotent so a stale one-beat answer is safe" }, + "voice.synthesize": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string", "start": "d", "end": "d" }, "result": {}, "timeout": "none (the reply IS completion)", "notes": "sidecar pulls truth via host.notes / host.snapshot / host.evaluate (pitch|pitch_deviation|) and emits via host.segment_* + voice.status/pitch/phonemes/parameters" }, + "voice.dirty": { "direction": "host->sidecar", "notification": true, "params": { "session": "string", "kind": "notes|fields|automation|part_properties|committed", "track": "string?", "start": "d?", "end": "d?" }, "notes": "coarse forwarded signals (the remote mapping of 廉价标脏): the sidecar keeps an incremental dirty ledger and RE-PULLS full truth (notes/snapshot) when it acts" }, + "voice.status": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "segments": "[same shape as effect.status]" } }, + "voice.pitch": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "segments": "list of list of [seconds, semitones] pairs" } }, + "voice.phonemes": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "syllables": { "": { "leading": "[{symbol,duration,stretch_weight}]", "body": "[same]", "body_offset": "d" } } }, "notes": "keyed by note id = the request_id echoed back from host.snapshot" }, + "voice.parameters": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "parameters": "{same shape as effect.parameters}" } }, + "engine.catalog_changed": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string" }, "notes": "voice: catalog changed → host re-pulls voice.get_catalog" }, + "host.notes": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string" }, "result": { "notes": "[note-view with ids]" }, "notes": "voice reverse pull, data-thread marshal" }, + "host.snapshot": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "note_ids": ["string"] }, "result": { "snapshot": { "notes": "[snapshot-note incl request_id]", "part_properties": "", "pitch": "pitch", "pitch_deviation": "pitch_deviation", "automations": ["track id"] } }, "notes": "evaluators never cross: pull their values via host.evaluate with these track keys" }, "host.input_read": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "offset": "i32 (segment-relative)", "count": "i32?" }, "result": { "data": "base64 f32-LE" }, "notes": "host executes on the data thread (live-view discipline) with a 30s marshal cap; chunk limit 1<<20 samples" }, "host.properties": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "keys": ["string"] }, "result": { "values": "" } }, "host.automation_tracks": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string" }, "result": { "tracks": ["string"] } }, diff --git a/tests/ScriptedFakeSidecar/Program.cs b/tests/ScriptedFakeSidecar/Program.cs index fcf3dec9..e65eeb63 100644 --- a/tests/ScriptedFakeSidecar/Program.cs +++ b/tests/ScriptedFakeSidecar/Program.cs @@ -123,15 +123,116 @@ void Notify(string method, JsonObject args) Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); break; } + case "engine.init": + case "engine.create_session": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); + break; case "engine.destroy": case "session.dispose": if (id != null) Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); break; - case "engine.init": - case "engine.create_session": + case "voice.get_catalog": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject + { + ["sources"] = new JsonArray { new JsonObject { ["id"] = "sine", ["name"] = "Sine Bank", ["description"] = "fake catalog" } }, + ["layout"] = new JsonArray { new JsonObject { ["voice"] = "sine" } }, + } }); + break; + case "voice.get_automation_configs": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject { ["tracks"] = new JsonArray + { + new JsonObject { ["id"] = "vibrato_rate", ["displayText"] = "VR", ["config"] = new JsonObject + { ["type"] = "automation", ["minValue"] = 0.0, ["maxValue"] = 1.0, ["defaultValue"] = 0.5 } }, + } } }); + break; + case "voice.get_synthesized_parameter_configs": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject { ["tracks"] = new JsonArray + { + new JsonObject { ["id"] = "energy", ["config"] = new JsonObject + { ["type"] = "automation", ["minValue"] = 0.0, ["maxValue"] = 2.0, ["defaultValue"] = "NaN" } }, + } } }); + break; + case "voice.get_part_property_config": + case "voice.get_note_property_config": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject { ["config"] = new JsonObject + { + ["type"] = "object", + ["properties"] = new JsonArray { new JsonObject { ["id"] = "tension", ["config"] = new JsonObject + { ["type"] = "slider", ["defaultValue"] = 0.0, ["scale"] = new JsonObject { ["kind"] = "linear", ["min"] = -1.0, ["max"] = 1.0 } } } }, + } } }); + break; + case "voice.get_phoneme_property_configs": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject { ["slots"] = new JsonArray() } }); + break; + case "voice.default_lyric": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject { ["lyric"] = "la" } }); + break; + case "voice.is_continuation": + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject { ["continuation"] = false } }); + break; + case "voice.peek": + { + var session = p!["session"]!.GetValue(); + var starts = p["start"]!.GetValue(); + var ends = p["end"]!.GetValue(); + var n = VoicePeeks.TryGetValue(session, out var old) ? old + 1 : 1; + VoicePeeks[session] = n; + JsonNode? range = n == 1 + ? new JsonArray { starts, Math.Min(starts + 0.5, ends) } + : null; + Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject { ["range"] = range } }); + break; + } + case "voice.synthesize": + { + if (mode != "voice-sine") + break; + var engine = p!["engine"]!.GetValue(); + var session = p["session"]!.GetValue(); + double start = p["start"]!.GetValue(), end = p["end"]!.GetValue(); + JsonObject Via2(JsonObject extra) { extra["engine"] = engine; extra["session"] = session; return extra; } + + var notesResp = Reverse("host.notes", Via2(new JsonObject())); + var ids = ((JsonArray)notesResp!["notes"]!).Select(n => n!["id"]!.GetValue()).ToList(); + if (ids.Count == 0) + throw new InvalidOperationException("host.notes returned no notes"); + var snap = Reverse("host.snapshot", Via2(new JsonObject { ["note_ids"] = new JsonArray(ids.Select(i => (JsonNode)i).ToArray()) })); + var snapNotes = (JsonArray)snap!["snapshot"]!["notes"]!; + if (snapNotes.Count != ids.Count) + throw new InvalidOperationException("snapshot misaligned"); + var pos = new byte[16]; + BinaryPrimitives.WriteDoubleLittleEndian(pos.AsSpan(0, 8), start); + BinaryPrimitives.WriteDoubleLittleEndian(pos.AsSpan(8, 8), end); + var ev = Reverse("host.evaluate", Via2(new JsonObject { ["track"] = "pitch", ["positions"] = Convert.ToBase64String(pos) })); + var curve = Convert.FromBase64String(ev!["values"]!.GetValue()); + if (BinaryPrimitives.ReadDoubleLittleEndian(curve.AsSpan(0, 8)) != 0.25) + throw new InvalidOperationException("pitch evaluate wrong"); + + const int rate = 44100; + int count = (int)((end - start) * rate); + var samples = new byte[count * 4]; + for (int i = 0; i < count; i++) + BinaryPrimitives.WriteSingleLittleEndian(samples.AsSpan(i * 4), 0.25f * MathF.Sin(2 * MathF.PI * 440f * i / rate)); + var seg = Reverse("host.segment_create", Via2(new JsonObject + { ["sample_offset"] = (long)(start * rate), ["sample_count"] = count, ["sample_rate"] = rate }))!["segment"]!.GetValue(); + Reverse("host.segment_write", Via2(new JsonObject { ["segment"] = seg, ["offset"] = 0, ["data"] = Convert.ToBase64String(samples) })); + Reverse("host.segment_commit", Via2(new JsonObject { ["segment"] = seg })); + Notify("voice.status", Via2(new JsonObject { ["segments"] = new JsonArray { new JsonObject + { ["start"] = start, ["end"] = end, ["status"] = "synthesized", ["progress"] = 1.0 } } })); + Notify("voice.pitch", Via2(new JsonObject { ["segments"] = new JsonArray { new JsonArray + { new JsonArray { start, 60.0 }, new JsonArray { end, 62.0 } } } })); + Notify("voice.phonemes", Via2(new JsonObject { ["syllables"] = new JsonObject { + [snapNotes[0]!["request_id"]!.GetValue()] = new JsonObject { + ["leading"] = new JsonArray(), + ["body"] = new JsonArray { new JsonObject { ["symbol"] = "a", ["duration"] = end - start, ["stretch_weight"] = 1.0 } }, + ["body_offset"] = 0.0, + } } })); + Notify("voice.parameters", Via2(new JsonObject { ["parameters"] = new JsonObject { ["energy"] = new JsonObject + { ["segments"] = new JsonArray { new JsonArray { new JsonArray { start, 0.7 }, new JsonArray { end, 0.7 } } } } } })); Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject() }); break; + } case "effect.get_property_config": Write(stdout, new JsonObject { ["jsonrpc"] = "2.0", ["id"] = id, ["result"] = new JsonObject { ["config"] = GainConfig() } }); break; @@ -234,6 +335,8 @@ JsonObject Via(JsonObject extra) return 0; // stdin EOF(宿主死)→ 自卫退出(防线 1 的另一半:真 façade 也照此) } + static readonly System.Collections.Generic.Dictionary VoicePeeks = new(); + static JsonNode HandleInitialize(JsonObject req, string mode, Action logSettings) { // req = initialize 的 params(宿主发 {protocol, package_id, entries[]}) diff --git a/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs b/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs index 8e6209a5..6fe878c6 100644 --- a/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs +++ b/tests/TuneLab.Tests/ScriptedPythonE2ETests.cs @@ -84,6 +84,65 @@ static RuntimeSpec ParseSpec(string s) return spec; } + [Fact(Timeout = 120_000)] + public void RealPythonVoiceSynthesisRoundTrip() + { + if (!RuntimeLocator.Default.TryResolve(ParseSpec("python>=3.10"), out var python, out var trail)) + { + if (Environment.GetEnvironmentVariable("TUNELAB_REQUIRE_PYTHON") == "1") + Assert.Fail("python not resolved: " + trail); + return; + } + Environment.SetEnvironmentVariable("PYTHONPATH", LocateUp("scripting", "python")); + + var pkgDir = LocateUp("tests", "plugins", "PyVoice"); + var entry = "plugin/main.py"; + var host = SidecarRegistry.For("com.tunelab.test.pyvoice", pkgDir, python, entry); + host.AutoRestart = true; + host.ExpectEngine("voice", "PySine"); + var engine = new RemoteVoiceEngine(host, "PySine", hasSettings: false); + var ctx = new VoiceTestContext( + new VoiceTestNote("n0", 0.0, 0.5, 60, "la"), + new VoiceTestNote("n1", 0.5, 1.0, 64, "du")); + IVoiceSynthesisSession? session = null; + try + { + engine.Init(); + Assert.Equal("sine", Assert.Single(engine.VoiceSourceInfos.Keys)); + + session = engine.CreateSession(ctx); + Assert.Equal("la", session.DefaultLyric); + Assert.False(session.IsContinuation(ctx.Notes.First()!)); + + var first = session.GetNextPendingSynthesisRange(0, 1.0) ?? throw new Exception("expected first chunk"); + Assert.Equal(0.0, first.StartTime, 6); + Assert.Equal(0.5, first.EndTime, 6); + + session.SynthesizeNext(0, 0.5).GetAwaiter().GetResult(); + + var seg = Assert.Single(ctx.Segments); + Assert.True(seg.Committed); + Assert.Equal(22050, seg.Written.Count); + Assert.True(Math.Abs(seg.Written.Max(Math.Abs) - 0.2f) < 0.01f); // 振幅 0.2 的正弦 + var syll = session.SynthesizedPhonemes["n0"]; + Assert.Equal("la", Assert.Single(syll.BodyPhonemes).Symbol); + var st = Assert.Single(session.Status); + Assert.Equal(SynthesisSegmentStatus.Synthesized, st.Status); + Assert.Equal(62.0, session.SynthesizedPitch.Segments[0][1].Y, 6); + + // 第二拍:无事件则 peek 命中缓存(§3.5 设计语义);触发 dirty 后重问 py 游标推进 + session.SynthesizeNext(0.5, 1.0).GetAwaiter().GetResult(); + Assert.Equal(2, ctx.Segments.Count); + ctx.TriggerPitchRange(0, 1); // dirty++ → peek 重问 + Assert.Null(session.GetNextPendingSynthesisRange(0, 1.0)); // py 游标已到 1.0 + } + finally + { + session?.Dispose(); + host.Shutdown(); + } + } + [Fact(Timeout = 60_000)] public void RealPythonEffectGainRoundTrip() { diff --git a/tests/TuneLab.Tests/ScriptedVoiceTests.cs b/tests/TuneLab.Tests/ScriptedVoiceTests.cs new file mode 100644 index 00000000..ee84cb2c --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedVoiceTests.cs @@ -0,0 +1,144 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using TuneLab.Extensions.Scripted; +using TuneLab.Foundation; +using TuneLab.SDK; +using Xunit; + +namespace TuneLab.Tests; + +// M3c 垂直切片:脚本 voice 引擎全链——catalog/声明五件套/continuation/peek 缓存/synthesize +// (反向 notes→snapshot→evaluate→segment 写回→四路产物发布)/事件推送转发。 +// 对端是假 sidecar 的 voice-sine 靶形。 +[Collection("SidecarSpawns")] +public class ScriptedVoiceTests +{ + static (SidecarHost host, RemoteVoiceEngine engine) MakeEngine() + { + var dll = ScriptedSidecarHostTests.FakeDllPath; + var host = new SidecarHost("com.test.voice", Path.GetDirectoryName(dll)!, + new RuntimeInfo(ScriptedSidecarHostTests.Dotnet, "dotnet-fake", "0.0", "x64"), Path.GetFileName(dll), + callTimeout: TimeSpan.FromSeconds(10), heartbeat: TimeSpan.FromSeconds(60)) + { + ExtraEnvironment = new() { ["TUNELAB_FAKE_MODE"] = "voice-sine" }, + }; + host.ExpectEngine("voice", "FakeSine"); + return (host, new RemoteVoiceEngine(host, "FakeSine", hasSettings: false)); + } + + [Fact(Timeout = 90_000)] + public async Task VoiceEngineFullSurfaceRoundTrip() + { + var (host, engine) = MakeEngine(); + IVoiceSynthesisSession? session = null; + var ctx = new VoiceTestContext( + new VoiceTestNote("n0", 0.0, 0.5, 60, "la"), + new VoiceTestNote("n1", 0.5, 1.0, 64, "du")); + try + { + engine.Init(); + + // —— catalog / layout(Init 期拉取、属性读缓存,"永不阻塞"契约)—— + Assert.Equal("sine", Assert.Single(engine.VoiceSourceInfos.Keys)); + Assert.Equal("Sine Bank", engine.VoiceSourceInfos["sine"].Name); + var layoutItem = Assert.Single(engine.VoiceSourceLayout); + Assert.Equal("sine", Assert.IsType(layoutItem).VoiceId); + + // —— 声明五件套(config wire 重建)—— + var autos = engine.GetAutomationConfigs(new VoiceTestPartContext(ctx)); + Assert.Equal("VR", autos.Keys.First().DisplayText); + Assert.Equal(0.5, autos["vibrato_rate".AsKey()].DefaultValue); + Assert.True(engine.GetSynthesizedParameterConfigs(new VoiceTestPartContext(ctx))["energy".AsKey()].IsPiecewise); + var partCfg = Assert.IsType(engine.GetPartPropertyConfig(new VoiceTestPartContext(ctx))); + Assert.IsType(partCfg.Properties["tension"]); + var noteCtx = new VoiceTestNoteContext(ctx, ctx.Notes.First()!); + Assert.Single(((ObjectConfig)engine.GetNotePropertyConfig(noteCtx)).Properties); + Assert.Empty(engine.GetPhonemePropertyConfigs(noteCtx)); + + // —— 会话 —— + session = engine.CreateSession(ctx); + Assert.Equal("la", session.DefaultLyric); + Assert.False(session.IsContinuation(ctx.Notes.First()!)); + + // peek:首拍有段、二拍空(fake 计数),并验证缓存随 dirty 推进重问 + var first = session.GetNextPendingSynthesisRange(0, 2) ?? throw new Exception("expected pending range"); + Assert.Equal(0.0, first.StartTime); + Assert.Equal(0.5, first.EndTime); + Assert.Null(session.GetNextPendingSynthesisRange(0, 2)); // 二拍:fake 已无 pending + + // synthesize:反向链全活 + 四路产物 + await session.SynthesizeNext(0, 0.5, CancellationToken.None); + + var seg = Assert.Single(ctx.Segments); + Assert.True(seg.Committed); + Assert.Equal(22050, seg.Written.Count); // 0.5s × 44100 + Assert.Equal(0f, seg.Written[0], 5); + Assert.True(Math.Abs(seg.Written[50] - 0.25f * MathF.Sin(2 * MathF.PI * 440f * 50 / 44100f)) < 1e-4); + var pitch = session.SynthesizedPitch; + Assert.Single(pitch.Segments); + Assert.Equal(2, pitch.Segments[0].Count); + Assert.Equal(62.0, pitch.Segments[0][1].Y); + var syll = session.SynthesizedPhonemes["n0"]; // 归属回指 = snapshot request_id + Assert.Equal("a", Assert.Single(syll.BodyPhonemes).Symbol); + Assert.Equal(1.0, syll.BodyPhonemes[0].StretchWeight); + var st = Assert.Single(session.Status); + Assert.Equal(SynthesisSegmentStatus.Synthesized, st.Status); + Assert.Single(session.SynthesizedParameters["energy"].Segments); + + // 事件推送转发:context 变更 → dirtySeq 推进 → peek 重问(fake 计数已尽 → null 也证明问了) + ctx.TriggerPitchRange(0, 1); + Assert.Null(session.GetNextPendingSynthesisRange(0, 2)); + } + finally + { + session?.Dispose(); + host.Shutdown(); + } + Assert.True(ctx.Segments[0].Disposed); // 会话终结收割未提交的段句柄 + } + + sealed class VoiceTestPartContext : IVoiceSynthesisPartPropertyContext + { + public VoiceTestPartContext(VoiceTestContext ctx) => Parts = [new VoiceTestPartView(ctx)]; + public System.Collections.Generic.IReadOnlyList Parts { get; } + } + + sealed class VoiceTestNoteContext : IVoiceSynthesisNotePropertyContext + { + public VoiceTestNoteContext(VoiceTestContext ctx, IVoiceSynthesisNote note) + { + Part = new VoiceTestPartView(ctx); + Notes = [new VoiceTestNoteView(note)]; + } + public IVoiceSynthesisPartView Part { get; } + public System.Collections.Generic.IReadOnlyList Notes { get; } + } + + sealed class VoiceTestPartView : IVoiceSynthesisPartView + { + public VoiceTestPartView(VoiceTestContext ctx) { _ctx = ctx; } + readonly VoiceTestContext _ctx; + public string VoiceId => _ctx.VoiceId; + public System.Collections.Generic.IReadOnlyList Notes + => _ctx.Notes.Select(n => (IVoiceSynthesisNoteView)new VoiceTestNoteView(n)).ToList(); + public PropertyObject PartProperties => PropertyObject.Empty; + public IReadOnlyMap Automations { get; } = new Map(); + } + + sealed class VoiceTestNoteView : IVoiceSynthesisNoteView + { + public VoiceTestNoteView(IVoiceSynthesisNote n) { _n = n; } + readonly IVoiceSynthesisNote _n; + public double StartTime => _n.StartTime.Value; + public double EndTime => _n.EndTime.Value; + public int Pitch => _n.Pitch.Value; + public string Lyric => _n.Lyric.Value; + public PropertyObject Properties => PropertyObject.Empty; + public double BodyOffset => _n.BodyOffset.Value; + public System.Collections.Generic.IReadOnlyList LeadingPhonemes => System.Array.Empty(); + public System.Collections.Generic.IReadOnlyList BodyPhonemes => System.Array.Empty(); + } +} diff --git a/tests/TuneLab.Tests/ScriptedWireSpecTests.cs b/tests/TuneLab.Tests/ScriptedWireSpecTests.cs new file mode 100644 index 00000000..1267a854 --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedWireSpecTests.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using TuneLab.Extensions.Scripted; +using Xunit; + +namespace TuneLab.Tests; + +// scripting/wire/v1.json 是宿主↔fake↔三语言 façade 的规范真源——它必须机器可读、且与 +// 实现的方法面同步(新增方法不更 spec = 本类红;spec 坏了 = 三方各猜形状的开始)。 +public class ScriptedWireSpecTests +{ + static readonly string[] RequiredMethods = + { + "initialize", "ping", "shutdown", "log.write", + "format.import", "format.export", + "settings.get_config", "settings.apply", + "engine.init", "engine.destroy", "engine.create_session", "session.dispose", + "effect.get_property_config", "effect.get_automation_configs", "effect.get_synthesized_parameter_configs", + "effect.process", "effect.cancel", "effect.status", "effect.parameters", + "voice.get_catalog", "voice.get_automation_configs", "voice.get_synthesized_parameter_configs", + "voice.get_part_property_config", "voice.get_note_property_config", "voice.get_phoneme_property_configs", + "voice.default_lyric", "voice.is_continuation", "voice.peek", "voice.synthesize", "voice.dirty", + "voice.status", "voice.pitch", "voice.phonemes", "voice.parameters", "engine.catalog_changed", + "host.input_read", "host.properties", "host.automation_tracks", "host.evaluate", + "host.snapshot", "host.notes", + "host.segment_create", "host.segment_write", "host.segment_commit", "host.segment_resize", "host.segment_dispose", + }; + + static string SpecPath() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(dir.FullName, "scripting", "wire", "v1.json"); + if (File.Exists(candidate)) + return candidate; + dir = dir.Parent; + } + throw new FileNotFoundException("scripting/wire/v1.json not found from test output"); + } + + [Fact] + public void SpecParsesAndMatchesHostProtocolVersion() + { + using var doc = JsonDocument.Parse(File.ReadAllText(SpecPath())); + var root = doc.RootElement; + Assert.Equal(WireProtocol.Version, root.GetProperty("protocol-version").GetInt32()); + } + + [Fact] + public void MethodTableCoversEveryWireMethod() + { + using var doc = JsonDocument.Parse(File.ReadAllText(SpecPath())); + var methods = new HashSet(doc.RootElement.GetProperty("methods").EnumerateObject().Select(p => p.Name)); + Assert.Subset(RequiredMethods.ToHashSet(), methods); // spec ⊇ 实现清单:加方法不写 spec 即红 + } + + [Fact] + public void ValueEncodingSectionIsPresent() + { + using var doc = JsonDocument.Parse(File.ReadAllText(SpecPath())); + Assert.True(doc.RootElement.GetProperty("value-encoding").TryGetProperty("config", out _)); + Assert.True(doc.RootElement.GetProperty("value-encoding").TryGetProperty("partinfo-discriminator", out _)); + } +} diff --git a/tests/TuneLab.Tests/TuneLab.Tests.csproj b/tests/TuneLab.Tests/TuneLab.Tests.csproj index 2d2450bb..c072ddcf 100644 --- a/tests/TuneLab.Tests/TuneLab.Tests.csproj +++ b/tests/TuneLab.Tests/TuneLab.Tests.csproj @@ -15,6 +15,9 @@ + + diff --git a/tests/TuneLab.Tests/VoiceTestDoubles.cs b/tests/TuneLab.Tests/VoiceTestDoubles.cs new file mode 100644 index 00000000..1dd1e2c3 --- /dev/null +++ b/tests/TuneLab.Tests/VoiceTestDoubles.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using TuneLab.Foundation; +using TuneLab.SDK; + +namespace TuneLab.Tests; + +// 脚本 voice 测试共享替身(宿主实现面):与 EffectTestDoubles 同一族语义—— +// 记录反向调用触达、可被测试主动触发事件,fake / 真解释器两路对拍同一形状。 + +internal sealed class VoiceTestNote : IVoiceSynthesisNote +{ + public VoiceTestNote(string id, double start, double end, int pitch, string lyric) + { + Id = id; + StartTime = new NotifiableProperty(start); + EndTime = new NotifiableProperty(end); + Pitch = new NotifiableProperty(pitch); + Lyric = new NotifiableProperty(lyric); + LeadingPhonemes = new NotifiableProperty>(Array.Empty()); + BodyPhonemes = new NotifiableProperty>(Array.Empty()); + BodyOffset = new NotifiableProperty(0); + Properties = new EffectTestProperties(); + } + + public string Id { get; } + public IReadOnlyNotifiableProperty StartTime { get; } + public IReadOnlyNotifiableProperty EndTime { get; } + public IReadOnlyNotifiableProperty Pitch { get; } + public IReadOnlyNotifiableProperty Lyric { get; } + public IReadOnlyNotifiableProperty> LeadingPhonemes { get; } + public IReadOnlyNotifiableProperty> BodyPhonemes { get; } + public IReadOnlyNotifiableProperty BodyOffset { get; } + public IReadOnlyNotifiablePropertyObject Properties { get; } + public IVoiceSynthesisNote? Next { get; internal set; } + public IVoiceSynthesisNote? Previous { get; internal set; } +} + +// 可通知链表替身:测试经 Add/Remove 驱动结构事件(WhenAny 组合子靠 ItemAdded/ItemRemoved 自动接线)。 +internal sealed class VoiceTestNoteList : IReadOnlyNotifiableLinkedList +{ + readonly List mItems = []; + + // 接口实现要求属性类型精确匹配(return-type covariance 不适用于属性)——公开面声明为接口形态, + // 内部留具体实例以便测试侧 Invoke。 + readonly ActionEvent mItemAdded = new(); + readonly ActionEvent mItemRemoved = new(); + readonly ActionEvent mMembershipModified = new(); + public IActionEvent ItemAdded => mItemAdded; + public IActionEvent ItemRemoved => mItemRemoved; + public IActionEvent MembershipModified => mMembershipModified; + + public IEnumerable Items => mItems; + public int Count => mItems.Count; + public IEnumerator GetEnumerator() => mItems.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => mItems.GetEnumerator(); + public IVoiceSynthesisNote? First => mItems.FirstOrDefault(); + public IVoiceSynthesisNote? Last => mItems.LastOrDefault(); + + public void Add(VoiceTestNote note) + { + note.Next = null; + var last = Last as VoiceTestNote; + note.Previous = last; + if (last != null) + last.Next = note; + mItems.Add(note); + mItemAdded.Invoke(note); + mMembershipModified.Invoke(); + } +} + +internal sealed class VoiceTestContext : IVoiceSynthesisContext +{ + public VoiceTestContext(params VoiceTestNote[] notes) + { + foreach (var n in notes) + Notes.Add(n); + Automations = new Map { ["vibrato_rate"] = VibratoRate }; + } + + public string VoiceId => "sine"; + public VoiceTestNoteList Notes { get; } = new(); + IReadOnlyNotifiableLinkedList IVoiceSynthesisContext.Notes => Notes; + public IReadOnlyNotifiablePropertyObject PartProperties { get; } = new EffectTestProperties(); + public EffectTestAutomation PitchAuto { get; } = new(); + public EffectTestAutomation DeviationAuto { get; } = new(); + public EffectTestAutomation VibratoRate { get; } = new(); + ISynthesisAutomation IVoiceSynthesisContext.Pitch => PitchAuto; + ISynthesisAutomation IVoiceSynthesisContext.PitchDeviation => DeviationAuto; + public IReadOnlyMap Automations { get; } + public ActionEvent CommittedEvent { get; } = new(); + IActionEvent IVoiceSynthesisContext.Committed => CommittedEvent; + public readonly List Segments = []; + + public void TriggerPitchRange(double start, double end) + => ((ActionEvent)PitchAuto.RangeModified).Invoke(start, end); + + VoiceSynthesisSnapshot IVoiceSynthesisContext.GetSnapshot(IReadOnlyList notes) + { + var autos = new Map(); + autos["vibrato_rate"] = new SynthesisAutomationSnapshot { Evaluator = VibratoRate }; + return new VoiceSynthesisSnapshot + { + Notes = notes.Select(n => new VoiceSynthesisNoteSnapshot + { + Id = n.Id, + StartTime = n.StartTime.Value, + EndTime = n.EndTime.Value, + Pitch = n.Pitch.Value, + Lyric = n.Lyric.Value, + LeadingPhonemes = Array.Empty(), + BodyPhonemes = Array.Empty(), + BodyOffset = n.BodyOffset.Value, + Properties = PropertyObject.Empty, + }).ToList(), + Pitch = new SynthesisAutomationSnapshot { Evaluator = PitchAuto }, + PitchDeviation = new SynthesisAutomationSnapshot { Evaluator = DeviationAuto }, + PartProperties = PropertyObject.Empty, + Automations = autos, + }; + } + + IAudioSegment IVoiceSynthesisContext.CreateAudioSegment(long sampleOffset, int sampleCount, int sampleRate) + { + var s = new EffectTestSegment(); + Segments.Add(s); + return s; + } +} diff --git a/tests/plugins/PyVoice/manifest.json b/tests/plugins/PyVoice/manifest.json new file mode 100644 index 00000000..2a20ed48 --- /dev/null +++ b/tests/plugins/PyVoice/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "com.tunelab.test.pyvoice", + "name": "Py Voice (sine)", + "version": "1.0.0", + "author": "TuneLab Tests", + "description": "脚本 voice 样例:每 note 渲一段 440Hz 正弦。真解释器 E2E 靶(ScriptedPythonE2ETests)。", + "sdk-version": "1.1", + "type": "voice", + "engine": "PySine", + "runtime": "python>=3.10,<4", + "entry": "plugin/main.py", + "platforms": ["win", "osx", "linux"] +} diff --git a/tests/plugins/PyVoice/plugin/main.py b/tests/plugins/PyVoice/plugin/main.py new file mode 100644 index 00000000..03bcc921 --- /dev/null +++ b/tests/plugins/PyVoice/plugin/main.py @@ -0,0 +1,77 @@ +# PySine — scripted voice sample: one 440Hz sine per chunk, per-session peek progression. +# Real-interpreter E2E target (ScriptedPythonE2ETests); PYTHONPATH supplies the facade. +import math + +import tunelab + + +@tunelab.voice("PySine") +class PySine: + RATE = 44100 + + def init(self): + pass + + def destroy(self): + pass + + def catalog(self): + return [{"id": "sine", "name": "Sine Bank", "description": "440Hz test bank"}], [] + + def automation_configs(self, parts): + return [] + + def synthesized_parameter_configs(self, parts): + return [] + + def part_property_config(self, parts): + return {"type": "object", "properties": []} + + def note_property_config(self, part, notes): + return {"type": "object", "properties": []} + + def phoneme_property_configs(self, part, notes): + return [] + + def create_session(self, ctx): + return PySineSession(ctx) + + +class PySineSession: + default_lyric = "la" + + def __init__(self, ctx): + self.ctx = ctx + self.pos = 0.0 + + def is_continuation(self, note): + return False + + def peek(self, start, end): + if self.pos >= end: + return None + return (max(start, self.pos), min(self.pos + 0.5, end)) + + def synthesize(self, start, end): + if start < self.pos: # 电平语义可被重问:已合成的块直接返回 + return + notes = [n for n in self.ctx.notes() if n.end > start and n.start < end] + snap = self.ctx.snapshot(notes) + rate = PySine.RATE + count = int((end - start) * rate) + # control-rate pitch pull proves the remote evaluator path (design §5.6 remote-handle choice) + ctrl = 64 + times = [start + (end - start) * i / (ctrl - 1) for i in range(ctrl)] + curve = snap.pitch.evaluate(times) + assert len(curve) == ctrl and curve[0] == 0.25 + seg = self.ctx.output(int(start * rate), count, rate) + seg.write(0, [0.2 * math.sin(2 * math.pi * 440.0 * i / rate) for i in range(count)]) + seg.commit() + self.ctx.status([(start, end, "synthesized", 1.0)]) + self.ctx.pitch([[(start, 60.0), (end, 62.0)]]) + self.ctx.phonemes({n.id: ([], [{"symbol": n.lyric or "a", "duration": n.end - n.start, + "stretch_weight": 1.0}], 0.0) for n in snap.notes}) + self.pos = end + + +tunelab.serve() From 7685379c1c4644c800fc227b0def4b255e588d17 Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Tue, 15 Sep 2026 20:17:26 +0800 Subject: [PATCH 09/12] M3d: Node & Lua facades, cross-language handshake, spec gap fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripting/node/tunelab/: zero-dep CommonJS facade (format/effect/voice, tagged property codec, special-float stringify at the JSON boundary to survive JSON.stringify's null-mangling of bare NaN), node --test codec suite - scripting/lua/tunelab.lua: single-file pure-Lua facade (5.1-5.4; embedded JSON codec, base64 + f32/f64 packing with string.pack where available and a manual fallback for 5.1/LuaJIT), codec unit tests - Lua facade verified locally via lupa (embedded Lua 5.5) - caught 4 real bugs (^ anchored match ignoring pos, reserved-word .end field access, double separator skip in the json array/object loops, a broken single-line IIFE) - samples tests/plugins/NodeFormat + LuaFormat (.npx/.lpx one-note-per-line) - ScriptedNodeE2ETests / ScriptedLuaE2ETests (needs-interpreter trait, vacuous without interpreter, TUNELAB_REQUIRE_NODE/LUA to assert) - ScriptedFacadeHandshakeTests: real-interpreter initialize smoke shared by all three languages (node verified locally; lua runs on posix CI) - wire spec: add voice.cancel (host->sidecar notify the host already sends but v1.json never declared); seal it in ScriptedWireSpecTests. Facade parity: python gains voice.cancel dispatch, node/lua gain voice.dirty -> user on_dirty hooks; lua/effect user.cancel now invoked with self - CI: install lua on posix runners, run node --test + lua unit tests, TUNELAB_REQUIRE_NODE=1, lua E2E forced on posix only - docs: design §12.4 M3d notes, scripting/README facade table + commands, AGENTS.md node/lua test commands Full suite 680/680 (excl. pre-existing Setup* env baseline), legacy 13/13. --- .github/workflows/tests.yml | 23 +- AGENTS.md | 12 +- docs/scripted-plugin-sidecar-design.md | 18 +- scripting/README.md | 17 +- scripting/lua/tests/test_codec.lua | 60 ++ scripting/lua/tunelab.lua | 564 ++++++++++++++++++ scripting/node/README.md | 3 + scripting/node/test/codec.test.js | 62 ++ scripting/node/tunelab/index.js | 479 +++++++++++++++ scripting/node/tunelab/package.json | 9 + scripting/python/tunelab/_service.py | 2 + scripting/python/tunelab/_voice.py | 12 + scripting/wire/v1.json | 3 +- .../ScriptedFacadeHandshakeTests.cs | 132 ++++ tests/TuneLab.Tests/ScriptedLuaE2ETests.cs | 79 +++ tests/TuneLab.Tests/ScriptedNodeE2ETests.cs | 80 +++ tests/TuneLab.Tests/ScriptedWireSpecTests.cs | 1 + tests/plugins/LuaFormat/manifest.json | 13 + tests/plugins/LuaFormat/plugin/main.lua | 65 ++ tests/plugins/NodeFormat/manifest.json | 13 + tests/plugins/NodeFormat/plugin/main.js | 43 ++ 21 files changed, 1675 insertions(+), 15 deletions(-) create mode 100644 scripting/lua/tests/test_codec.lua create mode 100644 scripting/lua/tunelab.lua create mode 100644 scripting/node/README.md create mode 100644 scripting/node/test/codec.test.js create mode 100644 scripting/node/tunelab/index.js create mode 100644 scripting/node/tunelab/package.json create mode 100644 tests/TuneLab.Tests/ScriptedFacadeHandshakeTests.cs create mode 100644 tests/TuneLab.Tests/ScriptedLuaE2ETests.cs create mode 100644 tests/TuneLab.Tests/ScriptedNodeE2ETests.cs create mode 100644 tests/plugins/LuaFormat/manifest.json create mode 100644 tests/plugins/LuaFormat/plugin/main.lua create mode 100644 tests/plugins/NodeFormat/manifest.json create mode 100644 tests/plugins/NodeFormat/plugin/main.js diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e9a98be6..90990ce0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,6 +40,15 @@ jobs: with: node-version: "20" + # Lua 靶需要解释器:ubuntu/macos 装官方 5.4;windows 暂无打包(façade 逻辑单测在 posix 上跑, + # E2E 在 win 上 vacuous——见 ScriptedLuaE2ETests)。 + - name: Install Lua (posix) + if: runner.os != 'Windows' + run: | + if [ "$RUNNER_OS" = "Linux" ]; then sudo apt-get update && sudo apt-get install -y lua5.4 && sudo ln -sf $(which lua5.4) /usr/local/bin/lua; fi + if [ "$RUNNER_OS" = "macOS" ]; then brew install lua; fi + shell: bash + - name: Restore and build run: dotnet build TuneLab.sln -c Debug @@ -51,10 +60,13 @@ jobs: dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj -c Debug --no-build --filter "FullyQualifiedName!~SetupCliOptionsTests&FullyQualifiedName!~SetupInstallManifestTests&FullyQualifiedName!~SetupUninstallTests" - # 真解释器 E2E 强制断言(缺解释器即红,而不是 vacuous pass)——CI 保证了 setup 装了。 + # 真解释器 scripted E2E 强制断言(缺解释器即红,而不是 vacuous pass)——CI 保证了 setup 装了。 + # node/lua 经环境变量指到 setup/系统安装位(RuntimeLocator ② 级)。 - name: Real-interpreter scripted E2E env: TUNELAB_REQUIRE_PYTHON: "1" + TUNELAB_REQUIRE_NODE: "1" + TUNELAB_REQUIRE_LUA: "${{ runner.os != 'Windows' && '1' || '' }}" run: > dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj -c Debug --no-build --filter "runtime=needs-interpreter" @@ -62,5 +74,14 @@ jobs: - name: Python facade unit tests run: python -m unittest discover -s scripting/python/tests + - name: Node facade unit tests + run: node --test scripting/node/test + + - name: Lua facade unit tests (posix) + if: runner.os != 'Windows' + working-directory: scripting/lua + run: lua tests/test_codec.lua + + - name: Legacy-compat tests run: dotnet test legacy/compat/TuneLab.Hosting.Compat.Legacy.Tests/TuneLab.Hosting.Compat.Legacy.Tests.csproj -c Debug diff --git a/AGENTS.md b/AGENTS.md index db5ea626..c9b7b61d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,15 +17,21 @@ ## Scripted plugins (sidecar) — see docs/scripted-plugin-sidecar-design.md -- Python/Node/Lua plugins run **out of process** (JSON-RPC over stdio); M1 landed (format only). +- Python/Node/Lua plugins run **out of process** (JSON-RPC over stdio); M0–M3d landed + (format/effect/voice, all three languages). Façades live under `scripting/` (not in the .sln); wire spec: `scripting/wire/v1.json`. - Build & test (parallel to the dotnet pipeline): - fake sidecar (protocol seal, no interpreter needed): built with the solution; the .NET test project launches it via `dotnet exec`. Manual: `dotnet build tests/ScriptedFakeSidecar -c Debug`. - Python façade: `python -m unittest discover -s scripting/python/tests` (pure stdlib). + - Node façade: `node --test scripting/node/test` (needs node>=18; zero-dependency package at + `scripting/node/tunelab/` — samples resolve it via `NODE_PATH=/scripting/node`). + - Lua façade: `lua tests/test_codec.lua` in `scripting/lua/` (pure Lua 5.1–5.4; no packaged + lua on windows — CI posix runs it; samples resolve via `LUA_PATH=/scripting/lua/?.lua;;`). - Real-interpreter E2E: `dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj --filter "runtime=needs-interpreter"` - (set `TUNELAB_REQUIRE_PYTHON=1` to fail-fast when no Python>=3.10 is discoverable, else it - passes vacuously; `TUNELAB_PYTHON=` pins the interpreter — probing order: settings → env → + (set `TUNELAB_REQUIRE_PYTHON=1` / `TUNELAB_REQUIRE_NODE=1` / `TUNELAB_REQUIRE_LUA=1` to + fail-fast when the interpreter is not discoverable, else it passes vacuously; + `TUNELAB_PYTHON` / `TUNELAB_NODE` / `TUNELAB_LUA` pin the path — probing order: settings → env → runtime packages → PATH). - Scripted test plugins (e.g. `tests/plugins/PyFormat/`) are manifest + sources only — **no csproj, no build step**; `pack-tlx.ps1` picks csproj-less folders under `tests/plugins/` up directly. diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md index b5ece68e..ba87f097 100644 --- a/docs/scripted-plugin-sidecar-design.md +++ b/docs/scripted-plugin-sidecar-design.md @@ -1,8 +1,8 @@ # 脚本插件 Sidecar 系统设计(Python / Node.js / Lua) -> 状态:**定稿;M3c 已落地**(voice 全表面:catalog/声明五件套/续延缓存/peek 缓存/合成调度/ -> 反向拉取/四路发布/事件推送,Python `@voice` + 真解释器 E2E;shm 音频面为后续快车道、inline 恒为兜底)。 -> 剩余:Node/Lua façade(M3d)、【下载运行时】stderr 清理策略与 shm(M4)。 +> 状态:**定稿;M3d 已落地**(Node façade format+真解释器 E2E/握手冒烟;Lua façade 逻辑单测+CI +> posix 真跑(windows 无打包 lua → 本地 vacuous);voice=M3c、运行时自动部署=M3a)。 +> 剩余:M4 = shm 音频面、Lua windows 分发、stderr 清理策略。 > 基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 > 配套阅读:[plugin-development.md](plugin-development.md)(V1 插件契约)、[sdk-api-evolution.md](sdk-api-evolution.md)(冻结 ABI 纪律)、[naming-glossary.md](naming-glossary.md)。 > 本文只设计**宿主侧与协议侧**;`TuneLab.SDK` / `TuneLab.Foundation` 一行不动(§1.3)。 @@ -529,8 +529,18 @@ scripted.initTimeoutSec int = 0 # 0 = 无上限(§5.5 长任 dirty 驱动 peek 游标推进、音素按 request_id 归属回指)。`scripting/wire/v1.json` 补齐 voice 方法表(46 方法)并加规范封条(可解析 + 方法覆盖 + 版本一致——期间就抓出两处 spec 自身 JSON 病灶)。 +- **M3d 落地**:`scripting/node/`(@tunelab/plugin,零依赖 CommonJS:framing / 属性 tagged + codec / 特殊浮点出口统一字符串化 / format·effect·voice 三族 dispatch / 反向 callHost)+ + `scripting/lua/`(单文件纯 Lua façade:内嵌 JSON codec、base64+f32 打包 5.1/5.2+ 双路、 + 三族 dispatch)+ 样例 `NodeFormat`/`LuaFormat` + 真解释器 E2E 与三语言共用的握手冒烟 + (`ScriptedFacadeHandshakeTests`)。Node 全链本机真跑(20.17.0 经 npmmirror 下载、sha256 + 对 feed 钉——顺带自证 M3a 表);Lua 无 windows 打包发行版,本机用 lupa(嵌入式 Lua 5.5)真跑 + 单测揪出 4 处 bug(`^` 锚点无视 pos、保留字 `.end`、json 数组循环双跳分隔符、坏 IIFE), + CI posix 再跑一遍(ubuntu apt lua5.4 / macos brew lua),windows 端 E2E vacuous。 + 交叉核对 spec×C#×三 façade 时补了 spec 漏记的 `voice.cancel`(47 方法)与三语言的 + `voice.dirty`→`user.on_dirty`、`voice.cancel`→`user.cancel` 钩子一致性。 - **仍推迟**:shm 音频面(M4——性能快车道,非正确性;voice 已全链可用); - 【下载运行时】按钮的 stderr 清理策略(M4);Node/Lua façade(M3d)。 + Lua 的 windows 分发(随 feed 加格,M4);【下载运行时】按钮的 stderr 清理策略(M4)。 - **实现差异**: - 崩溃后不自动定时复活,**下一次调用驱动重 spawn**(backoff/熔断仍生效)——format 拉模式下 自动重启只有缓存收益;effect push 调度落地时(M2b)再补后台重启环。 diff --git a/scripting/README.md b/scripting/README.md index d3a769b9..1cb8a116 100644 --- a/scripting/README.md +++ b/scripting/README.md @@ -8,17 +8,22 @@ | 路径 | 内容 | 状态 | |---|---|---| -| `wire/v1.json` | 线协议规范(帧、方法表、值编码) | M1 ✅(format 面) | -| `python/tunelab/` | Python façade(纯 stdlib,PyPI 名 `tunelab`) | M1 ✅ | -| `node/` | Node façade `@tunelab/plugin` | M2 | -| `lua/` | Lua façade(纯 Lua 单文件 + LuaJIT FFI 能力探测) | M2 | +| `wire/v1.json` | 线协议规范(帧、方法表、值编码) | M3d ✅(47 方法全表) | +| `python/tunelab/` | Python façade(纯 stdlib,PyPI 名 `tunelab`) | M3c ✅(format/effect/voice) | +| `node/tunelab/` | Node façade(零依赖 CommonJS,包名 `tunelab`,发布名 `@tunelab/plugin`) | M3d ✅(format/effect/voice) | +| `lua/tunelab.lua` | Lua façade(纯 Lua 单文件,5.1–5.4;内嵌 JSON codec 与 f32 打包双路) | M3d ✅(CI posix 真跑) | ## 构建与测试(不进 .sln;与 dotnet 管线平行) ``` -python -m unittest discover -s scripting/python/tests -v # façade 单测(无第三方依赖) -# pip install -e scripting/python # 本地联调时给样例插件供包 +python -m unittest discover -s scripting/python/tests -v # Python façade 单测(无第三方依赖) +node --test scripting/node/test # Node façade 单测(需 node>=18) +cd scripting/lua && lua tests/test_codec.lua # Lua façade 单测(需 lua5.1+;win 本机无 = CI 跑) +# pip install -e scripting/python # 本地联调时给样例插件供包 ``` +真解释器 E2E(C# 侧)默认 vacuous;置 `TUNELAB_REQUIRE_PYTHON/NODE/LUA=1` 变强制。 +Node 供包用 `NODE_PATH=/scripting/node`,Lua 用 `LUA_PATH=/scripting/lua/?.lua;;`。 + 线协议改动纪律(设计文档 §1.2):不兼容改动 = `WireProtocol.Version` + 本目录规范文件**同批提档**; 加性新方法不提档。宿主 `tests/TuneLab.Tests/Scripted*` 与 `tests/ScriptedFakeSidecar` 是封条的另一端。 diff --git a/scripting/lua/tests/test_codec.lua b/scripting/lua/tests/test_codec.lua new file mode 100644 index 00000000..98b0ca75 --- /dev/null +++ b/scripting/lua/tests/test_codec.lua @@ -0,0 +1,60 @@ +-- Unit tests for the Lua facade codec (run: lua tests/test_codec.lua) +package.path = "./?.lua;../?.lua;" .. package.path +local t = require("tunelab") +local json, ev, dv = t._json, t._encode_value, t._decode_value + +local function eq(a, b, msg) + if type(a) ~= type(b) then error((msg or "") .. ": type " .. type(a) .. " ~= " .. type(b)) end + if type(a) == "table" then + for k in pairs(a) do if a[k] ~= b[k] then error((msg or "") .. ": key " .. tostring(k)) end end + for k in pairs(b) do if a[k] == nil then error((msg or "") .. ": extra key " .. tostring(k)) end end + elseif a ~= b then + if type(a) == "number" and a ~= a and b ~= b then return end -- NaN == NaN for our purposes + error((msg or "") .. ": " .. tostring(a) .. " ~= " .. tostring(b)) + end +end + +-- tagged property round-trip +local tagged = ev(0.5); eq(tagged.n, 0.5, "number tag") +eq(ev(true), { b = true }, "bool tag") +eq(ev("x"), { s = "x" }, "string tag") +eq(dv({ n = "NaN" }), 0/0, "NaN decode") +eq(dv({ s = "hi" }), "hi", "string decode") +local nested = ev({ inner = 3 }) +eq(nested.o.inner.n, 3, "nested object") +eq(dv(nested), { inner = 3 }, "nested round-trip") + +-- JSON encode/decode +local s = json.encode({ a = 1, b = { 2, 3 }, c = "z" }) +local back = json.decode(s) +eq(back.a, 1); eq(back.b[1], 2); eq(back.b[2], 3); eq(back.c, "z") +eq(json.decode(json.encode({ [1] = "x", [2] = "y" }))[1], "x", "array round-trip") +eq(json.decode(json.encode(0/0)), "NaN", "NaN stringifies as string on the wire") + +-- walk encoder only touches "properties" +local proj = { tracks = { { parts = { { notes = { { pitch = 60, properties = { tension = 0.5 } } } } } } } } +local out = t._walk_encode(proj) +local note = out.tracks[1].parts[1].notes[1] +eq(note.pitch, 60, "pitch untouched") +eq(note.properties.tension.n, 0.5, "property tagged") +local rt = t._walk_decode(out) +eq(rt.tracks[1].parts[1].notes[1].properties.tension, 0.5, "walk decode") + +-- base64 + f32 packing round-trip +local packed = t._b64_encode_f32({ 0.25, -1.5 }) +local raw = t._unb64(packed) +local function f32_at(s, i) -- i 为 1-based 样本序号 + if string.unpack then return (string.unpack(" 1 then out[#out+1] = "," end encode(v[i], out) end + out[#out+1] = "]" + else + out[#out+1] = "{" + local first = true + local keys = {} + for k in pairs(v) do keys[#keys+1] = tostring(k) end + table.sort(keys) + for _, k in ipairs(keys) do + if not first then out[#out+1] = "," end + first = false + out[#out+1] = string.format("%q", k) .. ":" + encode(v[k], out) + end + out[#out+1] = "}" + end + else error("json: cannot encode " .. tv) end +end + +function json.encode(v) local out = {}; encode(v, out); return table.concat(out) end + +local function decode(s, pos) + while s:sub(pos, pos):match("%s") do pos = pos + 1 end + local c = s:sub(pos, pos) + if c == "{" then + local t, first = {}, true + pos = pos + 1 + while s:sub(pos, pos) ~= "}" do + first = false + local k, v + k, pos = decode(s, pos) + pos = s:find(":", pos, true) + v, pos = decode(s, pos + 1) + t[k] = v + while s:sub(pos, pos):match("[%s,]") do pos = pos + 1 end -- 吃掉分隔符,循环直接落在下一键 + end + return t, pos + 1 + elseif c == "[" then + local t, first = {}, true + pos = pos + 1 + while s:sub(pos, pos) ~= "]" do + first = false + local v + v, pos = decode(s, pos) + t[#t+1] = v + while s:sub(pos, pos):match("[%s,]") do pos = pos + 1 end + end + return t, pos + 1 + elseif c == '"' then + local buf, i = {}, pos + 1 + while true do + local ch = s:sub(i, i) + if ch == '"' then break end + if ch == "\\" then + local esc = s:sub(i + 1, i + 1) + if esc == "n" then buf[#buf+1] = "\n" + elseif esc == "t" then buf[#buf+1] = "\t" + elseif esc == "r" then buf[#buf+1] = "\r" + elseif esc == "u" then + local cp = tonumber(s:sub(i + 2, i + 5), 16) + if cp < 0x80 then buf[#buf+1] = string.char(cp) + elseif cp < 0x800 then buf[#buf+1] = string.char(0xC0 + math.floor(cp/0x40), 0x80 + cp % 0x40) + else buf[#buf+1] = string.char(0xE0 + math.floor(cp/0x1000), 0x80 + math.floor(cp/0x40) % 0x40, 0x80 + cp % 0x40) end + i = i + 4 + else buf[#buf+1] = esc end + i = i + 2 + else buf[#buf+1] = ch; i = i + 1 end + end + return table.concat(buf), i + 1 + elseif s:sub(pos, pos + 3) == "true" then return true, pos + 4 + elseif s:sub(pos, pos + 4) == "false" then return false, pos + 5 + elseif s:sub(pos, pos + 3) == "null" then return nil, pos + 4 + else + -- 注意:'^' 模式无视 pos 参数(永远锚串首),这里必须用非锚定 + 起始位搜索 + local numstr = s:match("[-+0-9.eE]+", pos) + assert(numstr, "json: unexpected token at " .. pos) + return tonumber(numstr), pos + #numstr + end +end + +function json.decode(s) local v, pos = decode(s, 1); return v end + +-- ---------------------------------------------------------------- property wire +local function encode_value(v) + if v == nil then return nil end + local t = type(v) + if t == "boolean" then return { b = v } end + if t == "number" then + if v ~= v then return { n = "NaN" } + elseif v == math.huge then return { n = "Infinity" } + elseif v == -math.huge then return { n = "-Infinity" } end + return { n = v } + end + if t == "string" then return { s = v } end + if t == "table" then + if #v > 0 then local a = {}; for i = 1, #v do a[i] = encode_value(v[i]) end; return { a = a } end + local o = {} + for k, val in pairs(v) do o[tostring(k)] = encode_value(val) end + return { o = o } + end + error("cannot encode property value of type " .. t) +end + +local function decode_value(v) + if v == nil then return nil end + if type(v) ~= "table" then return v end + local tag, payload = next(v) + if next(v, tag) == nil then + if tag == "b" or tag == "s" then return payload end + if tag == "n" then + if type(payload) == "string" then + if payload == "NaN" then return 0/0 end + if payload == "Infinity" then return math.huge end + if payload == "-Infinity" then return -math.huge end + return tonumber(payload) + end + return payload + end + if tag == "o" then + local out = {} + for k, x in pairs(payload or {}) do out[k] = decode_value(x) end + return out + end + if tag == "a" then + local out = {} + for i, x in ipairs(payload or {}) do out[i] = decode_value(x) end + return out + end + end + local out = {} + for k, x in pairs(v) do out[k] = decode_value(x) end + return out +end + +local function walk_encode(node) + if type(node) ~= "table" then return node end + local out = {} + for k, v in pairs(node) do + if k == "properties" and type(v) == "table" then + local m = {} + for pk, pv in pairs(v) do m[tostring(pk)] = encode_value(pv) end + out[k] = m + else + out[k] = walk_encode(v) + end + end + return out +end + +local function walk_decode(node) + if type(node) ~= "table" then return node end + local out = {} + for k, v in pairs(node) do + if k == "properties" and type(v) == "table" then + local m = {} + for pk, pv in pairs(v) do m[pk] = decode_value(pv) end + out[k] = m + else + out[k] = walk_decode(v) + end + end + return out +end + +-- ---------------------------------------------------------------- framing +local stdin, stdout = io.stdin, io.stdout +stdin:setvbuf("no") + +local function read_message() + local len + while true do + local line = stdin:read("l") + if line == nil then return nil end + if line == "" then break end + local n = line:match("^[Cc]ontent-[Ll]ength:%s*(%d+)") + if n then len = tonumber(n) end + end + if not len then return nil end + local body = stdin:read(len) + if not body then return nil end + return json.decode(body) +end + +local function write_message(obj) + local body = json.encode(obj) + stdout:write("Content-Length: " .. #body .. "\r\n\r\n" .. body) + stdout:flush() +end + +-- ---------------------------------------------------------------- registries +local format_handlers = {} -- entry id -> {import_project, export_project, get_settings_config, apply_settings} +local effect_engines, voice_engines = {}, {} +local sessions = {} -- key -> {kind, engine, user, segments} +local pending = {} -- reverse-call id -> box +local seq = 0 + +local function reply(id, result) write_message({ jsonrpc = "2.0", id = id, result = result or {} }) end +local function reply_err(id, code, message) write_message({ jsonrpc = "2.0", id = id, error = { code = code, message = message } }) end +local function notify(method, params) write_message({ jsonrpc = "2.0", method = method, params = params or {} }) end + +local function call_host(method, params) + seq = seq + 1 + local id = "l" .. seq + local box = {} + pending[id] = box + write_message({ jsonrpc = "2.0", id = id, method = method, params = params }) + while true do + local msg = read_message() + if not msg then os.exit(0) end + if msg.method == nil and msg.id and tostring(msg.id) == id then + if msg.error then error("host error on " .. method .. ": " .. tostring(msg.error.message)) end + return msg.result or {} + elseif msg.method and msg.id then + dispatch(msg) -- nested inbound request while waiting (host reverse-call during our reverse-call) + end + end +end + +-- ---------------------------------------------------------------- segments +local function segment_create(engine, key, offset, count, rate) + local r = call_host("host.segment_create", { engine = engine, session = key, + sample_offset = offset, sample_count = count, sample_rate = rate }) + return r.segment +end +local function segment_write(engine, key, seg, offset, samples) + local b = {} + for i = 1, #samples do b[i] = samples[i] end + call_host("host.segment_write", { engine = engine, session = key, segment = seg, + offset = offset, data = tunelab._b64_encode_f32(b) }) +end +local function segment_commit(engine, key, seg) + call_host("host.segment_commit", { engine = engine, session = key, segment = seg }) +end + +-- base64 (binary-safe, no external deps) +local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" +local function b64(s) + local out, pad = {}, 0 + for i = 1, #s, 3 do + local a, b, c = s:byte(i, i + 2) + b = b or 0; c = c or 0; pad = (s:byte(i + 2)) and 0 or ((s:byte(i + 1)) and 1 or 2) + local n = a * 65536 + b * 256 + c + out[#out+1] = B64:sub(math.floor(n/262144) % 64 + 1, math.floor(n/262144) % 64 + 1) + out[#out+1] = B64:sub(math.floor(n/4096) % 64 + 1, math.floor(n/4096) % 64 + 1) + out[#out+1] = B64:sub(math.floor(n/64) % 64 + 1, math.floor(n/64) % 64 + 1) + out[#out+1] = B64:sub(n % 64 + 1, n % 64 + 1) + end + local s = table.concat(out) + if pad == 1 then s = s:sub(1, -2) .. "=" elseif pad == 2 then s = s:sub(1, -3) .. "==" end + return s +end +local function unb64(s) + s = s:gsub("[^" .. B64 .. "=]", ""):gsub("=", "") + local out, acc, bits = {}, 0, 0 + for i = 1, #s do + acc = acc * 64 + (B64:find(s:sub(i, i), 1, true) - 1) + bits = bits + 6 + if bits >= 8 then bits = bits - 8; out[#out+1] = string.char(math.floor(acc / 2^bits) % 256) end + end + return table.concat(out) +end + +-- little-endian f32/f64 (string.pack where available; manual fallback for 5.1/LuaJIT) +local pack_f32 +if string.pack then + pack_f32 = function(samples) local b = {} for i = 1, #samples do b[i] = string.pack(" 127 then m, e = 0, 255 + else m = math.floor((m - 1) * 2^23 + 0.5); e = e + 127 end + end + local w = sign * 2^31 + e * 2^23 + m + out[i] = string.char(w % 256, math.floor(w / 256) % 256, math.floor(w / 65536) % 256, math.floor(w / 16777216) % 256) + end + return table.concat(out) + end +end +function tunelab._b64_encode_f32(samples) return b64(pack_f32(samples)) end + +local unpack_f64 +if string.unpack then + unpack_f64 = function(s) local out, i = {}, 1 for _ = 1, #s // 8 do out[#out+1], i = string.unpack(" { + const native = { s: "x", n: 0.5, b: true, nothing: null, arr: [1, "a"], obj: { inner: -2.25 } }; + const tagged = encodeMap(native); + assert.deepStrictEqual(tagged.s, { s: "x" }); + assert.deepStrictEqual(tagged.n, { n: 0.5 }); + assert.deepStrictEqual(tagged.b, { b: true }); + assert.strictEqual(tagged.nothing, null); + assert.deepStrictEqual(tagged.obj, { o: { inner: { n: -2.25 } } }); + const back = decodeMap(tagged); + assert.strictEqual(back.s, "x"); + assert.strictEqual(back.n, 0.5); + assert.strictEqual(back.b, true); + assert.deepStrictEqual(back.arr, [1, "a"]); + assert.deepStrictEqual(back.obj, { inner: -2.25 }); +}); + +test("special floats ride string literals, never bare", () => { + assert.deepStrictEqual(encodeValue(NaN), { n: "NaN" }); + assert.deepStrictEqual(encodeValue(Infinity), { n: "Infinity" }); + assert.deepStrictEqual(encodeValue(-Infinity), { n: "-Infinity" }); + assert(Number.isNaN(decodeValue({ n: "NaN" }))); + assert.strictEqual(decodeValue({ n: "Infinity" }), Infinity); + // sanitize catches specials anywhere; JSON.stringify would otherwise emit `null` + const payload = sanitize({ defaultValue: NaN, list: [1, Infinity], text: "NaN stays a string" }); + assert.strictEqual(payload.defaultValue, "NaN"); + assert.strictEqual(payload.list[1], "Infinity"); + assert.strictEqual(JSON.parse(JSON.stringify(payload)).defaultValue, "NaN"); + assert.strictEqual(payload.text, "NaN stays a string"); // plain strings untouched +}); + +test("project walker only encodes properties maps", () => { + const project = { tracks: [{ parts: [{ notes: [{ pos: 0, dur: 1, pitch: 60, properties: { tension: 0.5 } }] }] }] }; + const out = walkEncode(project); + assert.deepStrictEqual(out.tracks[0].parts[0].notes[0].properties, { tension: { n: 0.5 } }); + assert.strictEqual(out.tracks[0].parts[0].notes[0].pitch, 60); + assert.deepStrictEqual(walkDecode(out).tracks[0].parts[0].notes[0].properties, { tension: 0.5 }); +}); + +test("audio/curve binary round-trip", () => { + const f32 = new Float32Array([0.25, -1.5, 0]); + assert.deepStrictEqual(Array.from(b64ToF32(f32ToB64(f32))), Array.from(f32)); + const f64 = new Float64Array([0.1, 1e300, NaN]); + const back = b64ToF64(f64ToB64(f64)); + assert.strictEqual(back[0], 0.1); + assert.strictEqual(back[1], 1e300); + assert(Number.isNaN(back[2])); +}); + +test("tolerant decode accepts plain natives", () => { + assert.strictEqual(decodeValue(1.5), 1.5); + assert.strictEqual(decodeValue("x"), "x"); + assert.strictEqual(decodeValue(true), true); +}); diff --git a/scripting/node/tunelab/index.js b/scripting/node/tunelab/index.js new file mode 100644 index 00000000..f175cced --- /dev/null +++ b/scripting/node/tunelab/index.js @@ -0,0 +1,479 @@ +// TuneLab scripted-plugin facade (Node.js, zero-dependency). +// +// Speaks the sidecar wire protocol (spec: scripting/wire/v1.json): LSP-style +// Content-Length frames of JSON-RPC 2.0 over stdio. Covers format / effect / voice +// entries on the inline plane (shm is a later fast lane). +// +// Usage (package entry, e.g. plugin/main.js — CommonJS): +// +// const { format, serve } = require("tunelab"); +// format({ +// importProject(path) { return { tracks: [], tempos: [], timeSignatures: [] }; }, +// exportProject(path, project) { ... }, +// }); +// serve(); +"use strict"; + +const fs = require("fs"); +const crypto = require("crypto"); + +const WIRE_PROTOCOL = 1; +const VERSION = "0.1.0"; + +// ---------------------------------------------------------------- property wire +// Tagged values (host readers accept tagged and plain alike; we always emit tagged +// inside "properties" maps): {b}|{n}(number|"NaN"|"Infinity"|"-Infinity")|{s}|{o}|{a}|null. + +function encodeValue(v) { + if (v === null || v === undefined) return null; + const t = typeof v; + if (t === "boolean") return { b: v }; + if (t === "number") return { n: specialStr(v) || v }; + if (t === "string") return { s: v }; + if (Array.isArray(v)) return { a: v.map(encodeValue) }; + if (t === "object") return { o: encodeMap(v) }; + throw new TypeError("cannot encode property value of type " + t); +} + +function encodeMap(m) { + const out = {}; + for (const [k, v] of Object.entries(m)) out[k] = encodeValue(v); + return out; +} + +function decodeValue(v) { + if (v === null) return null; + if (typeof v === "boolean" || typeof v === "string") return v; + if (typeof v === "number") return v; + if (Array.isArray(v)) return v.map(decodeValue); + if (typeof v === "object") { + const keys = Object.keys(v); + if (keys.length === 1) { + const tag = keys[0], p = v[tag]; + if (tag === "b" || tag === "s") return p; + if (tag === "n") return typeof p === "string" ? Number(p === "Infinity" ? Infinity : p === "-Infinity" ? -Infinity : NaN) : p; + if (tag === "o") return decodeMap(p); + if (tag === "a") return p.map(decodeValue); + } + return decodeMap(v); + } + return v; +} + +function decodeMap(m) { + const out = {}; + for (const [k, v] of Object.entries(m || {})) out[k] = decodeValue(v); + return out; +} + +function specialStr(v) { + if (Number.isNaN(v)) return "NaN"; + if (v === Infinity) return "Infinity"; + if (v === -Infinity) return "-Infinity"; + return null; +} + +// Anywhere in an outgoing payload, bare special floats would stringify to `null` — +// convert them to their wire string literals (never emit bare NaN tokens: illegal JSON). +function sanitize(node) { + if (Array.isArray(node)) return node.map(sanitize); + if (node && typeof node === "object") { + const out = {}; + for (const [k, v] of Object.entries(node)) out[k] = sanitize(v); + return out; + } + if (typeof node === "number") return specialStr(node) || node; + return node; +} + +function walkEncode(node) { + if (Array.isArray(node)) return node.map(walkEncode); + if (node && typeof node === "object") { + const out = {}; + for (const [k, v] of Object.entries(node)) + out[k] = k === "properties" && v && typeof v === "object" && !Array.isArray(v) ? encodeMap(v) : walkEncode(v); + return out; + } + return node; +} + +function walkDecode(node) { + if (Array.isArray(node)) return node.map(walkDecode); + if (node && typeof node === "object") { + const out = {}; + for (const [k, v] of Object.entries(node)) + out[k] = k === "properties" && v && typeof v === "object" && !Array.isArray(v) ? decodeMap(v) : walkDecode(v); + return out; + } + return node; +} + +// ---------------------------------------------------------------- binary audio helpers +const b64ToF32 = (s) => { const buf = Buffer.from(s, "base64"); const out = new Float32Array(buf.length / 4); for (let i = 0; i < out.length; i++) out[i] = buf.readFloatLE(i * 4); return out; }; +const f32ToB64 = (a) => { const buf = Buffer.alloc(a.length * 4); for (let i = 0; i < a.length; i++) buf.writeFloatLE(a[i], i * 4); return buf.toString("base64"); }; +const b64ToF64 = (s) => { const buf = Buffer.from(s, "base64"); const out = new Float64Array(buf.length / 8); for (let i = 0; i < out.length; i++) out[i] = buf.readDoubleLE(i * 8); return out; }; +const f64ToB64 = (a) => { const buf = Buffer.alloc(a.length * 8); for (let i = 0; i < a.length; i++) buf.writeDoubleLE(a[i], i * 8); return buf.toString("base64"); }; + +// ---------------------------------------------------------------- service +const formatEntries = new Map(); // entry id -> {importProject, exportProject, getSettingsConfig?, applySettings?} +const effectEngines = new Map(); // engine id -> object +const voiceEngines = new Map(); // engine id -> object +const pullSessions = new Map(); // session key -> {kind, ctx, user} +const defaultEntry = () => (formatEntries.size === 1 ? [...formatEntries.keys()][0] : null); + +let stdinBuf = Buffer.alloc(0); +let stdoutLock = false; +const pendingHostCalls = new Map(); // id -> {resolve, reject} +let hostCallSeq = 0; + +function writeMessage(obj) { + const body = Buffer.from(JSON.stringify(obj), "utf8"); + const frame = Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, "ascii"), body]); + // single pending writer: interleaving would corrupt framing + const drain = () => fs.writeSync(1, frame); // sync write to fd 1: never interleaves + drain(); +} + +function reply(id, result) { writeMessage({ jsonrpc: "2.0", id, result: result ?? {} }); } +function replyErr(id, code, message) { writeMessage({ jsonrpc: "2.0", id, error: { code, message } }); } + +function callHost(method, params) { + return new Promise((resolve, reject) => { + const id = "n" + ++hostCallSeq; + pendingHostCalls.set(id, { resolve, reject }); + writeMessage({ jsonrpc: "2.0", id, method, params }); + }); +} + +function notify(method, params) { writeMessage({ jsonrpc: "2.0", method, params }); } + +// ---- output segments (shared by effect/voice ctx) ---- +class OutputSegment { + constructor(ctxRef, id, count) { this.ctx = ctxRef; this.id = id; this.count = count; this.closed = false; } + async write(offset, samples) { + const arr = samples instanceof Float32Array ? samples : Float32Array.from(samples); + await callHost("host.segment_write", this.ctx._via({ segment: this.id, offset, data: f32ToB64(arr) })); + } + async commit() { await callHost("host.segment_commit", this.ctx._via({ segment: this.id })); } + async resize(sampleOffset, sampleCount) { + this.count = sampleCount; + await callHost("host.segment_resize", this.ctx._via({ segment: this.id, sample_offset: sampleOffset, sample_count: sampleCount })); + } + async dispose() { + if (this.closed) return; + this.closed = true; + await callHost("host.segment_dispose", this.ctx._via({ segment: this.id })); + } +} + +class PullCtx { + constructor(engine, key) { this.engine = engine; this.session = key; } + _via(extra) { return { engine: this.engine, session: this.session, ...(extra || {}) }; } + async properties(keys) { return decodeMap((await callHost("host.properties", this._via({ keys: keys || null }))).values); } + async automationTracks() { return (await callHost("host.automation_tracks", this._via())).tracks; } + async evaluate(track, positions) { + const pos = positions instanceof Float64Array ? positions : Float64Array.from(positions); + const r = await callHost("host.evaluate", this._via({ track, positions: f64ToB64(pos) })); + return b64ToF64(r.values); + } + async output(sampleOffset, sampleCount, sampleRate) { + const r = await callHost("host.segment_create", this._via({ sample_offset: sampleOffset, sample_count: sampleCount, sample_rate: sampleRate })); + return new OutputSegment(this, r.segment, sampleCount); + } + status(segments) { notify("effect.status", this._via({ segments: segments.map(statusDict) })); } + parameters(map) { notify("effect.parameters", this._via({ parameters: map })); } +} + +class EffectCtx extends PullCtx { + constructor(engine, key) { super(engine, key); this.segments = []; this.cancelled = false; } + async inputGeometry() { const r = await callHost("host.input_read", this._via({ offset: 0, count: 0 })); return r; } + async inputRead(offset = 0, count = null) { + const r = await callHost("host.input_read", this._via({ offset, count: count ?? undefined })); + return { data: b64ToF32(r.data), sampleRate: r.sample_rate }; + } + async outputSegment(sampleOffset, sampleCount, sampleRate) { const s = await this.output(sampleOffset, sampleCount, sampleRate); this.segments.push(s); return s; } +} + +class Evaluator { + constructor(ctx, track) { this.ctx = ctx; this.track = track; } + evaluate(positions) { return this.ctx.evaluate(this.track, positions); } +} + +class VoiceCtx extends PullCtx { + constructor(engine, key, voiceId) { super(engine, key); this.voiceId = voiceId; this.segments = []; this.cancelled = false; } + async notes() { return (await callHost("host.notes", this._via())).notes.map(noteFromWire); } + async snapshot(noteIds) { + const ids = noteIds.map((n) => (typeof n === "string" ? n : n.id)); + const d = (await callHost("host.snapshot", this._via({ note_ids: ids }))).snapshot; + return { + notes: d.notes.map(noteFromWire), + partProperties: decodeMap(d.part_properties), + pitch: new Evaluator(this, "pitch"), + pitchDeviation: new Evaluator(this, "pitch_deviation"), + automations: Object.fromEntries((d.automations || []).map((t) => [t, new Evaluator(this, t)])), + }; + } + async outputSegment(sampleOffset, sampleCount, sampleRate) { const s = await this.output(sampleOffset, sampleCount, sampleRate); this.segments.push(s); return s; } + statusPublished(segments) { notify("voice.status", this._via({ segments: segments.map(statusDict) })); } + pitch(segments) { notify("voice.pitch", this._via({ segments: segments.map((seg) => seg.map((p) => [p[0], p[1]])) })); } + phonemes(byNote) { + const out = {}; + for (const [id, s] of Object.entries(byNote)) out[id] = { leading: s[0], body: s[1], body_offset: s.length > 2 ? s[2] : 0 }; + notify("voice.phonemes", this._via({ syllables: out })); + } +} + +function noteFromWire(d) { + const ph = (p) => ({ symbol: p.symbol, duration: p.duration ?? 0, stretchWeight: p.stretch_weight ?? 0, properties: decodeMap(p.properties || {}) }); + return { + id: d.id ?? d.request_id ?? "", start: d.start, end: d.end, pitch: d.pitch, lyric: d.lyric, + bodyOffset: d.body_offset ?? 0, + leading: (d.leading || []).map(ph), body: (d.body || []).map(ph), + properties: decodeMap(d.properties || {}), + }; +} + +function statusDict(s) { + return { start: s[0], end: s[1], status: s[2], progress: s.length > 3 ? s[3] : 0, message: s.length > 4 ? s[4] : null }; +} + +// ---- inbound dispatch ---- +async function handleRequest(msg) { + const { id, method, params = {} } = msg; + try { + switch (method) { + case "initialize": return reply(id, onInitialize(params)); + + case "ping": return reply(id, {}); + + case "shutdown": reply(id, {}); process.exit(0); return undefined; + + case "format.import": { + const h = formatEntries.get(params.entry) || (defaultEntry() && formatEntries.get(defaultEntry())); + if (!h || !h.importProject) throw new Error("no importer for entry " + params.entry); + const project = await h.importProject(params.stream_ref); + return reply(id, { project: walkEncode(project) }); + } + case "format.export": { + const h = formatEntries.get(params.entry) || (defaultEntry() && formatEntries.get(defaultEntry())); + if (!h || !h.exportProject) throw new Error("no exporter for entry " + params.entry); + await h.exportProject(params.stream_ref, walkDecode(params.project || {})); + return reply(id, {}); + } + case "settings.get_config": { + const h = formatEntries.get(params.entry); + const fn = h && h.getSettingsConfig; + if (!fn) throw new Error("no settings for entry " + params.entry); + return reply(id, { config: sanitize(fn()) }); + } + case "settings.apply": { + const h = formatEntries.get(params.entry) || (defaultEntry() && formatEntries.get(defaultEntry())); + if (h && h.applySettings) await h.applySettings(decodeMap(params.values || {})); + return id != null ? reply(id, {}) : undefined; + } + + case "engine.init": { + const obj = effectEngines.get(params.engine) || voiceEngines.get(params.engine); + if (!obj) throw new Error("no such engine " + params.engine); + if (obj.init) await obj.init(); + return reply(id, {}); + } + case "engine.destroy": { + const obj = effectEngines.get(params.engine) || voiceEngines.get(params.engine); + try { if (obj && obj.destroy) await obj.destroy(); } catch { /* best effort */ } + return id != null ? reply(id, {}) : undefined; + } + case "engine.create_session": { + const key = String(params.session || ""); + if (key.startsWith("voice:")) { + const eng = voiceEngines.get(params.engine); + const ctx = new VoiceCtx(params.engine, key, params.voice_id || ""); + const user = eng.create_session ? await eng.create_session(ctx) : eng; + ctx.user = user; pullSessions.set(key, { kind: "voice", ctx, user }); + } else { + const eng = effectEngines.get(params.engine); + const ctx = new EffectCtx(params.engine, key); + const user = eng.create_session ? await eng.create_session(ctx) : eng; + ctx.user = user; pullSessions.set(key, { kind: "effect", ctx, user }); + } + return reply(id, {}); + } + case "session.dispose": { + const s = pullSessions.get(String(params.session || "")); + pullSessions.delete(String(params.session || "")); + if (s) { + for (const seg of s.ctx.segments) { try { await seg.dispose(); } catch { /* gone */ } } + try { if (s.user && s.user.dispose) await s.user.dispose(); } catch { /* best effort */ } + } + return id != null ? reply(id, {}) : undefined; + } + case "effect.cancel": case "voice.cancel": { + const s = pullSessions.get(String(params.session || "")); + if (s) { s.ctx.cancelled = true; if (s.user && s.user.cancel) s.user.cancel(); } + return id != null ? reply(id, {}) : undefined; + } + case "voice.dirty": { + const s = pullSessions.get(String(params.session || "")); + const hook = s && s.user && (s.user.onDirty || s.user.on_dirty); + if (hook) { try { hook.call(s.user, params.kind, params); } catch { /* a coarse signal must never kill the loop */ } } + return undefined; + } + + case "effect.process": { + const s = requireSession(params.session); + if (s.user.process) await s.user.process(s.ctx); + return reply(id, {}); + } + case "effect.get_property_config": { + const eng = requireEngine(effectEngines, params.engine); + return reply(id, { config: sanitize(eng.getPropertyConfig ? eng.getPropertyConfig(params.effects || []) : { type: "object", properties: [] }) }); + } + case "effect.get_automation_configs": { + const eng = requireEngine(effectEngines, params.engine); + return reply(id, { tracks: sanitize(eng.getAutomationConfigs ? eng.getAutomationConfigs(params.effects || []) : []) }); + } + case "effect.get_synthesized_parameter_configs": { + const eng = requireEngine(effectEngines, params.engine); + return reply(id, { tracks: sanitize(eng.getSynthesizedParameterConfigs ? eng.getSynthesizedParameterConfigs(params.effects || []) : []) }); + } + + case "voice.get_catalog": { + const eng = requireEngine(voiceEngines, params.engine); + const [sources, layout] = normalizeCatalog(await eng.catalog()); + return reply(id, { sources, layout }); + } + case "voice.get_automation_configs": + return reply(id, { tracks: sanitize((requireEngine(voiceEngines, params.engine).automationConfigs || (() => []))(params.parts || [])) }); + case "voice.get_synthesized_parameter_configs": + return reply(id, { tracks: sanitize((requireEngine(voiceEngines, params.engine).synthesizedParameterConfigs || (() => []))(params.parts || [])) }); + case "voice.get_part_property_config": + return reply(id, { config: sanitize(requireEngine(voiceEngines, params.engine).partPropertyConfig(params.parts || [])) }); + case "voice.get_note_property_config": + return reply(id, { config: sanitize(requireEngine(voiceEngines, params.engine).notePropertyConfig(params.part || {}, params.notes || [])) }); + case "voice.get_phoneme_property_configs": + return reply(id, { slots: sanitize((requireEngine(voiceEngines, params.engine).phonemePropertyConfigs || (() => []))(params.part || {}, params.notes || [])) }); + case "voice.default_lyric": + return reply(id, { lyric: pullSessions.get(params.session).user.defaultLyric ?? "la" }); + case "voice.is_continuation": + return reply(id, { continuation: !!(await pullSessions.get(params.session).user.isContinuation(noteFromWire(params.note))) }); + case "voice.peek": { + const r = await pullSessions.get(params.session).user.peek(params.start, params.end); + return reply(id, { range: r ? [r[0], r[1]] : null }); + } + case "voice.synthesize": { + const s = requireSession(params.session); + await s.user.synthesize(params.start, params.end); + return reply(id, {}); + } + + case "host.input_read": case "host.properties": case "host.evaluate": + case "host.automation_tracks": case "host.snapshot": case "host.notes": + case "host.segment_create": case "host.segment_write": case "host.segment_commit": + case "host.segment_resize": case "host.segment_dispose": + return undefined; // responses arrive as replies (handled below), never as requests + + default: + if (id != null) replyErr(id, "bad_request", "unknown method " + method); + return undefined; + } + } catch (ex) { + if (id != null) replyErr(id, "runtime_error", String(ex && ex.message ? ex.message : ex)); + else console.error("notification failed:", ex); + return undefined; + } +} + +function requireSession(key) { + const s = pullSessions.get(String(key)); + if (!s) throw new Error("unknown session " + key); + return s; +} +function requireEngine(map, id) { + const eng = map.get(id); + if (!eng) throw new Error("no such engine " + id); + return eng; +} +function normalizeCatalog(r) { + if (Array.isArray(r) && r.length === 2 && Array.isArray(r[0])) return [r[0], r[1] || []]; + return [Array.isArray(r) ? r : [], []]; +} + +function onInitialize(params) { + const provides = []; + for (const e of params.entries || []) { + if (e.type === "format") { + let h = formatEntries.get(e.entry); + if (!h && formatEntries.size === 1) { h = [...formatEntries.values()][0]; formatEntries.set(e.entry, h); } + if (!h) continue; + provides.push({ type: "format", entry: e.entry, can_import: !!h.importProject, can_export: !!h.exportProject, settings: !!h.getSettingsConfig }); + if (e.settings && h.applySettings) { try { h.applySettings(decodeMap(e.settings)); } catch { /* user code threw */ } } + } else { + const known = effectEngines.has(e.engine) || voiceEngines.has(e.engine); + if (!known) continue; + const eng = effectEngines.get(e.engine) || voiceEngines.get(e.engine); + provides.push({ type: e.type, engine: e.engine, settings: !!eng.getSettingsConfig }); + if (e.settings && eng.applySettings) { try { eng.applySettings(decodeMap(e.settings)); } catch { /* ignore */ } } + } + } + return { + provides, + capabilities: { audio_plane: "inline", curve_plane: "inline" }, + runtime: { exe: process.execPath, version: process.versions.node, arch: process.arch, flavor: "node" }, + facade_version: VERSION, + }; +} + +function onStdinChunk(chunk) { + stdinBuf = Buffer.concat([stdinBuf, chunk]); + for (;;) { + const headerEnd = stdinBuf.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const headers = stdinBuf.subarray(0, headerEnd).toString("ascii"); + const m = /content-length:\s*(\d+)/i.exec(headers); + if (!m) { stdinBuf = stdinBuf.subarray(headerEnd + 4); continue; } + const len = parseInt(m[1], 10); + if (stdinBuf.length < headerEnd + 4 + len) return; + const body = stdinBuf.subarray(headerEnd + 4, headerEnd + 4 + len).toString("utf8"); + stdinBuf = stdinBuf.subarray(headerEnd + 4 + len); + let msg; + try { msg = JSON.parse(body); } catch (ex) { process.exit(1); } // broken frame = protocol violation + if (msg && msg.method == null && msg.id != null) { + const p = pendingHostCalls.get(String(msg.id)); + if (p) { + pendingHostCalls.delete(String(msg.id)); + if (msg.error) p.reject(new Error(`${msg.error.code}: ${msg.error.message}`)); + else p.resolve(msg.result || {}); + } + continue; + } + if (msg && msg.method) { + handleRequest(msg).catch((ex) => console.error("dispatch error:", ex)); + } + } +} + +function serve() { + // parent-death guard (defense line 2 cross-platform half): reparenting = host gone. + const parent = process.ppid; + const t = setInterval(() => { if (process.ppid !== parent) process.exit(0); }, 2000); + t.unref(); + process.stdin.on("data", onStdinChunk); + process.stdin.on("end", () => process.exit(0)); // defense line 1: stdin EOF + process.stdin.resume(); +} + +// ---------------------------------------------------------------- registration +function format(handlers) { formatEntries.set(handlers.entry || `auto-${formatEntries.size}`, handlers); return handlers; } +function effect(engineId, obj) { effectEngines.set(engineId, obj); return obj; } +function voice(engineId, obj) { voiceEngines.set(engineId, obj); return obj; } + +module.exports = { + format, effect, voice, serve, + info: (msg) => notify("log.write", { level: "info", message: String(msg) }), + warn: (msg) => notify("log.write", { level: "warning", message: String(msg) }), + error: (msg) => notify("log.write", { level: "error", message: String(msg) }), + capabilities: () => ({ audio_plane: "inline", curve_plane: "inline" }), + encodeValue, decodeValue, encodeMap, decodeMap, walkEncode, walkDecode, sanitize, + f32ToB64, b64ToF32, f64ToB64, b64ToF64, + WIRE_PROTOCOL, VERSION, +}; diff --git a/scripting/node/tunelab/package.json b/scripting/node/tunelab/package.json new file mode 100644 index 00000000..3985c3a5 --- /dev/null +++ b/scripting/node/tunelab/package.json @@ -0,0 +1,9 @@ +{ + "name": "@tunelab/plugin", + "version": "0.1.0", + "description": "TuneLab scripted-plugin facade (Node.js, zero-dependency)", + "main": "index.js", + "license": "MIT", + "engines": { "node": ">=16.14" }, + "scripts": { "test": "node --test test/" } +} diff --git a/scripting/python/tunelab/_service.py b/scripting/python/tunelab/_service.py index 45403ec2..20d57815 100644 --- a/scripting/python/tunelab/_service.py +++ b/scripting/python/tunelab/_service.py @@ -269,6 +269,8 @@ def _engine_registry_is_voice(): _effect.on_notification(method, params) elif method == "voice.dirty": _voice.on_dirty(params) + elif method == "voice.cancel": + _voice.on_cancel(params) elif method in ("engine.init", "engine.destroy"): if _engine_registry_is_voice(): target = _voice diff --git a/scripting/python/tunelab/_voice.py b/scripting/python/tunelab/_voice.py index 726abfe3..b583e7a8 100644 --- a/scripting/python/tunelab/_voice.py +++ b/scripting/python/tunelab/_voice.py @@ -75,6 +75,18 @@ def on_dirty(params): pass +def on_cancel(params): + ctx = _sessions.get(params.get("session")) + if ctx: + ctx.cancelled.set() + hook = getattr(ctx.user, "cancel", None) + if hook: + try: + hook() + except Exception: + pass + + def handle(method, params): engine_id = params.get("engine") if method == "engine.init": diff --git a/scripting/wire/v1.json b/scripting/wire/v1.json index 7290929a..1088a730 100644 --- a/scripting/wire/v1.json +++ b/scripting/wire/v1.json @@ -79,7 +79,8 @@ "voice.is_continuation": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string", "note": "[note-view incl id/start/end/pitch/lyric/leading/body/body_offset]" }, "result": { "continuation": "bool" }, "notes": "bounded + fingerprint-cached host-side; the note rides along as values (stateless sidecar)" }, "voice.peek": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string", "start": "d", "end": "d" }, "result": { "range": "[s, e] | null" }, "notes": "bounded + (window,dirtySeq)-cached host-side; commit is level-idempotent so a stale one-beat answer is safe" }, "voice.synthesize": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string", "start": "d", "end": "d" }, "result": {}, "timeout": "none (the reply IS completion)", "notes": "sidecar pulls truth via host.notes / host.snapshot / host.evaluate (pitch|pitch_deviation|) and emits via host.segment_* + voice.status/pitch/phonemes/parameters" }, - "voice.dirty": { "direction": "host->sidecar", "notification": true, "params": { "session": "string", "kind": "notes|fields|automation|part_properties|committed", "track": "string?", "start": "d?", "end": "d?" }, "notes": "coarse forwarded signals (the remote mapping of 廉价标脏): the sidecar keeps an incremental dirty ledger and RE-PULLS full truth (notes/snapshot) when it acts" }, + "voice.dirty": { "direction": "host->sidecar", "notification": true, "params": { "session": "string", "kind": "notes|fields|automation|part_properties|committed", "track": "string?", "start": "d?", "end": "d?" }, "notes": "coarse forwarded signals (the remote mapping of 廉价标脏): the sidecar keeps an incremental dirty ledger and RE-PULLS full truth (notes/snapshot) when it acts; facades surface it as user.on_dirty(kind, params)" }, + "voice.cancel": { "direction": "host->sidecar", "params": { "engine": "string", "session": "string" }, "notification": true, "notes": "same contract as effect.cancel (best-effort; a wedged user handler still faces heartbeat); facades surface it as user.cancel()/user.on_cancel()" }, "voice.status": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "segments": "[same shape as effect.status]" } }, "voice.pitch": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "segments": "list of list of [seconds, semitones] pairs" } }, "voice.phonemes": { "direction": "sidecar->host", "notification": true, "params": { "engine": "string", "session": "string", "syllables": { "": { "leading": "[{symbol,duration,stretch_weight}]", "body": "[same]", "body_offset": "d" } } }, "notes": "keyed by note id = the request_id echoed back from host.snapshot" }, diff --git a/tests/TuneLab.Tests/ScriptedFacadeHandshakeTests.cs b/tests/TuneLab.Tests/ScriptedFacadeHandshakeTests.cs new file mode 100644 index 00000000..4516ae3e --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedFacadeHandshakeTests.cs @@ -0,0 +1,132 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using TuneLab.Extensions.Scripted; +using Xunit; + +namespace TuneLab.Tests; + +// 三语言 façade 的真解释器握手冒烟:起进程 → initialize → 断言 provides 兑现与 capabilities。 +// 比完整 E2E 便宜,专门钉"façade 能起来、说得同一种话"这一层(Node/Lua 的运行时差异大)。 +// 缺解释器 vacuous;TUNELAB_REQUIRE_NODE / TUNELAB_REQUIRE_LUA 强制。 +[Trait("runtime", "needs-interpreter")] +[Collection("SidecarSpawns")] +public class ScriptedFacadeHandshakeTests +{ + static string LocateUp(params string[] tail) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(new[] { dir.FullName }.Concat(tail).ToArray()); + if (Directory.Exists(candidate)) + return candidate; + dir = dir.Parent; + } + throw new DirectoryNotFoundException(string.Join("/", tail)); + } + + static void Smoke(string label, string exe, string reqEnv, string packageDir, string entry, + string expectEntry, Action preSpawn) + { + if (Environment.GetEnvironmentVariable(reqEnv) != "1") + { + if (!File.Exists(exe) && !ResolveOnPath(exe)) + return; // vacuous + } + preSpawn(); + var resolved = exe.Contains('/') || exe.Contains('\\') ? exe : FindOnPath(exe); + Assert.NotNull($"interpreter not found on PATH: {exe}"); + var psi = new ProcessStartInfo(resolved!) + { + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + WorkingDirectory = packageDir, + }; + psi.ArgumentList.Add(entry); + using var proc = Process.Start(psi)!; + var req = """{"jsonrpc":"2.0","id":"h1","method":"initialize","params":{"protocol":1,"package_id":"com.test.smoke","entries":[{"type":"format","entry":"PLUGIN"}]}}""" + .Replace("PLUGIN", expectEntry); + var bytes = Encoding.UTF8.GetBytes(req); + proc.StandardInput.BaseStream.Write(Encoding.ASCII.GetBytes($"Content-Length: {bytes.Length}\r\n\r\n")); + proc.StandardInput.BaseStream.Write(bytes); + proc.StandardInput.BaseStream.Flush(); + + // 读一个应答帧:headers 直到空行,收 Content-Length,再读 body + long len = -1; + while (true) + { + var line = ReadLine(proc.StandardOutput.BaseStream); + if (line == null) break; + if (line.Length == 0) break; + var m = System.Text.RegularExpressions.Regex.Match(line, @"Content-Length:\s*(\d+)", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (m.Success) len = long.Parse(m.Groups[1].Value); + } + Assert.True(len > 0, label + ": no Content-Length frame received"); + proc.StandardInput.Close(); // EOF 保护:若 façade 卡在等什么 + var body = new byte[(int)len]; + int got = proc.StandardOutput.BaseStream.Read(body, 0, (int)len); + Assert.Equal(len, got); + var json = Encoding.UTF8.GetString(body, 0, (int)len); + using var doc = System.Text.Json.JsonDocument.Parse(json); + var result = doc.RootElement.GetProperty("result"); + Assert.Equal("inline", result.GetProperty("capabilities").GetProperty("audio_plane").GetString()); + var provides = result.GetProperty("provides"); + Assert.Equal(System.Text.Json.JsonValueKind.Array, provides.ValueKind); + Assert.True(provides.GetArrayLength() >= 1, label + " façade provided no entries: " + json); + try { proc.Kill(true); } catch { } + } + + static string? ReadLine(Stream s) + { + var sb = new StringBuilder(); + while (true) + { + int c = s.ReadByte(); + if (c < 0) return sb.Length == 0 ? null : sb.ToString(); + if (c == '\n') return sb.ToString().TrimEnd('\r'); + sb.Append((char)c); + } + } + + static bool ResolveOnPath(string exe) => FindOnPath(exe) != null; + static string FindOnPath(string exe) + => (Environment.GetEnvironmentVariable("PATH") ?? "") + .Split(Path.PathSeparator).Select(d => Path.Combine(d, OperatingSystem.IsWindows() ? exe + ".exe" : exe)) + .FirstOrDefault(File.Exists)!; + + [Fact(Timeout = 60_000)] + public void NodeFacadeShakesHands() + { + var node = Environment.GetEnvironmentVariable("TUNELAB_NODE") ?? "node"; + if (node == "node" && FindOnPath("node") == null) { /* vacuous below */ } + var facade = LocateUp("scripting", "node"); + Smoke("node", node, "TUNELAB_REQUIRE_NODE", + LocateUp("tests", "plugins", "NodeFormat"), Path.Combine("plugin", "main.js"), + "plugin/main.js", + () => Environment.SetEnvironmentVariable("NODE_PATH", facade)); + } + + [Fact(Timeout = 60_000)] + public void LuaFacadeShakesHands() + { + if (FindOnPath("lua") == null && FindOnPath("lua5.4") == null) + { + if (Environment.GetEnvironmentVariable("TUNELAB_REQUIRE_LUA") == "1") + Assert.Fail("no lua on PATH"); + return; // vacuous(windows CI/本地) + } + var exe = FindOnPath("lua") ?? FindOnPath("lua5.4")!; + var facadeDir = LocateUp("scripting", "lua"); + Smoke("lua", exe, "TUNELAB_REQUIRE_LUA", + LocateUp("tests", "plugins", "LuaFormat"), Path.Combine("plugin", "main.lua"), + "plugin/main.lua", + () => Environment.SetEnvironmentVariable("LUA_PATH", facadeDir + Path.DirectorySeparatorChar + "?.lua;;")); + } +} diff --git a/tests/TuneLab.Tests/ScriptedLuaE2ETests.cs b/tests/TuneLab.Tests/ScriptedLuaE2ETests.cs new file mode 100644 index 00000000..889f769b --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedLuaE2ETests.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using TuneLab.Extensions; +using TuneLab.Extensions.Scripted; +using TuneLab.SDK; +using Xunit; + +namespace TuneLab.Tests; + +// 真 Lua 解释器 format E2E(与 Node/Python 同构)。CI 的 ubuntu/macos 装了 lua5.4 才真跑 +// (windows 暂无打包 lua → vacuous);TUNELAB_REQUIRE_LUA=1 强制断言。 +[Trait("runtime", "needs-interpreter")] +[Collection("SidecarSpawns")] +public class ScriptedLuaE2ETests : IDisposable +{ + static readonly string sPackageDir = LocateUp("tests", "plugins", "LuaFormat"); + + static string LocateUp(params string[] tail) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(new[] { dir.FullName }.Concat(tail).ToArray()); + if (Directory.Exists(candidate)) + return candidate; + dir = dir.Parent; + } + throw new DirectoryNotFoundException("LuaFormat package not found: " + Path.Combine(tail)); + } + + static RuntimeSpec Parse(string s) + { + Assert.True(RuntimeSpec.TryParse(s, out var spec, out _)); + return spec; + } + + [Fact(Timeout = 120_000)] + public void RealLuaFormatRoundTrip() + { + if (!RuntimeLocator.Default.TryResolve(Parse("lua>=5.1"), out var lua, out var trail)) + { + if (Environment.GetEnvironmentVariable("TUNELAB_REQUIRE_LUA") == "1") + Assert.Fail("lua not resolved: " + trail); + return; // 无解释器环境:vacuous pass + } + Environment.SetEnvironmentVariable("LUA_PATH", LocateUp("scripting", "lua") + Path.DirectorySeparatorChar + "?.lua;;"); + + var entry = "plugin/main.lua"; + var host = SidecarRegistry.For("com.tunelab.test.luaformat", sPackageDir, lua, entry); + host.ExpectFormat(entry, ["lpx"], ["lpx"]); + var proxy = new RemoteFormat(host, entry, hasSettings: false); + try + { + using var input = new MemoryStream(Encoding.UTF8.GetBytes("60 0 480 la\n62 480 240 du\n# comment\n65 720 120 mi\n")); + var project = ((IImportFormat)proxy).Deserialize(input); + + var part = Assert.IsType(Assert.Single(Assert.Single(project.Tracks).Parts)); + Assert.Equal(3, part.Notes.Count); + Assert.Equal(60, part.Notes[0].Pitch); + Assert.Equal("mi", part.Notes[2].Lyric); + Assert.Equal("lpx", part.Notes[0].Properties.GetString("source")); // tagged property 跨语言往返 + + using var output = new MemoryStream(); + ((IExportFormat)proxy).Serialize(output, project); + var text = Encoding.UTF8.GetString(output.ToArray()); + Assert.Contains("60 0 480 la", text); + Assert.Contains("65 720 120 mi", text); + Assert.DoesNotContain("# comment", text); + } + finally + { + host.Shutdown(); + } + } + + public void Dispose() => SidecarRegistry.ShutdownAll(); +} diff --git a/tests/TuneLab.Tests/ScriptedNodeE2ETests.cs b/tests/TuneLab.Tests/ScriptedNodeE2ETests.cs new file mode 100644 index 00000000..d112ec35 --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedNodeE2ETests.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using TuneLab.Extensions; +using TuneLab.Extensions.Scripted; +using TuneLab.SDK; +using Xunit; + +namespace TuneLab.Tests; + +// 真 Node 解释器 format E2E(与 ScriptedPythonE2ETests 同构)。node 经 TUNELAB_NODE 或 PATH 探测; +// 不在场默认 vacuous pass,TUNELAB_REQUIRE_NODE=1 变红(CI 矩阵装了就必须真跑)。 +[Trait("runtime", "needs-interpreter")] +[Collection("SidecarSpawns")] +public class ScriptedNodeE2ETests : IDisposable +{ + static readonly string sPackageDir = LocateUp("tests", "plugins", "NodeFormat"); + + static string LocateUp(params string[] tail) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine(new[] { dir.FullName }.Concat(tail).ToArray()); + if (Directory.Exists(candidate)) + return candidate; + dir = dir.Parent; + } + throw new DirectoryNotFoundException("NodeFormat package not found: " + Path.Combine(tail)); + } + + static RuntimeSpec Parse(string s) + { + Assert.True(RuntimeSpec.TryParse(s, out var spec, out _)); + return spec; + } + + [Fact(Timeout = 120_000)] + public void RealNodeFormatRoundTrip() + { + if (!RuntimeLocator.Default.TryResolve(Parse("node>=18"), out var node, out var trail)) + { + if (Environment.GetEnvironmentVariable("TUNELAB_REQUIRE_NODE") == "1") + Assert.Fail("node not resolved: " + trail); + return; // 无解释器环境:vacuous pass + } + Environment.SetEnvironmentVariable("NODE_PATH", LocateUp("scripting", "node")); + + var entry = "plugin/main.js"; + var host = SidecarRegistry.For("com.tunelab.test.nodeformat", sPackageDir, node, entry); + host.ExpectFormat(entry, ["ntxt"], ["ntxt"]); + var proxy = new RemoteFormat(host, entry, hasSettings: false); + try + { + using var input = new MemoryStream(Encoding.UTF8.GetBytes("60 0 480 la\n62 480 240 du\n# comment\n65 720 120 do re\n")); + var project = ((IImportFormat)proxy).Deserialize(input); + + var part = Assert.IsType(Assert.Single(Assert.Single(project.Tracks).Parts)); + Assert.Equal(3, part.Notes.Count); + Assert.Equal(60, part.Notes[0].Pitch); + Assert.Equal("do re", part.Notes[2].Lyric); // 空格歌词整段取回 + Assert.Equal("ntxt", part.Notes[0].Properties.GetString("source")); // tagged property 跨语言往返 + + using var output = new MemoryStream(); + ((IExportFormat)proxy).Serialize(output, project); + var text = Encoding.UTF8.GetString(output.ToArray()); + Assert.Contains("60 0 480 la", text); + Assert.Contains("65 720 120 do re", text); + Assert.DoesNotContain("# comment", text); + } + finally + { + host.Shutdown(); + } + } + + public void Dispose() => SidecarRegistry.ShutdownAll(); +} diff --git a/tests/TuneLab.Tests/ScriptedWireSpecTests.cs b/tests/TuneLab.Tests/ScriptedWireSpecTests.cs index 1267a854..b224cba8 100644 --- a/tests/TuneLab.Tests/ScriptedWireSpecTests.cs +++ b/tests/TuneLab.Tests/ScriptedWireSpecTests.cs @@ -23,6 +23,7 @@ public class ScriptedWireSpecTests "voice.get_catalog", "voice.get_automation_configs", "voice.get_synthesized_parameter_configs", "voice.get_part_property_config", "voice.get_note_property_config", "voice.get_phoneme_property_configs", "voice.default_lyric", "voice.is_continuation", "voice.peek", "voice.synthesize", "voice.dirty", + "voice.cancel", "voice.status", "voice.pitch", "voice.phonemes", "voice.parameters", "engine.catalog_changed", "host.input_read", "host.properties", "host.automation_tracks", "host.evaluate", "host.snapshot", "host.notes", diff --git a/tests/plugins/LuaFormat/manifest.json b/tests/plugins/LuaFormat/manifest.json new file mode 100644 index 00000000..23b11b43 --- /dev/null +++ b/tests/plugins/LuaFormat/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "com.tunelab.test.luaformat", + "name": "Lua Format (txt notes)", + "version": "1.0.0", + "author": "TuneLab Tests", + "description": "脚本 format 样例(Lua):一行一音符的 .lpx 文本格式。CI 三平台的 lua 靶(本地无解释器时 vacuous)。", + "sdk-version": "1.1", + "type": "format", + "suffixes": ["lpx"], + "runtime": "lua>=5.1", + "entry": "plugin/main.lua", + "platforms": ["win", "osx", "linux"] +} diff --git a/tests/plugins/LuaFormat/plugin/main.lua b/tests/plugins/LuaFormat/plugin/main.lua new file mode 100644 index 00000000..667e93d7 --- /dev/null +++ b/tests/plugins/LuaFormat/plugin/main.lua @@ -0,0 +1,65 @@ +-- LuaFormat sample — a .lpx file is one note per line: " ". +-- CI target for PUC Lua 5.1-5.4 on ubuntu/macos (windows has no packaged lua yet). +-- The host test injects LUA_PATH pointing at scripting/lua; real users vendor the facade +-- into plugin/ (pip --target equivalent) and `require "tunelab"` finds it via the default path. +local tunelab = require("tunelab") + +local function split(s) + local out = {} + for line in (s .. "\n"):gmatch("([^\n]*)\n") do + if line ~= "" and line:sub(1, 1) ~= "#" then out[#out+1] = line end + end + return out +end + +tunelab.format({ + import_project = function(path) + local f = assert(io.open(path, "r")) + local text = f:read("*a") + f:close() + local notes = {} + for _, line in ipairs(split(text)) do + local p, pos, dur, lyric = line:match("^(%S+)%s+(%S+)%s+(%S+)%s*(.*)$") + if p then + notes[#notes+1] = { + pitch = tonumber(p), pos = tonumber(pos), dur = tonumber(dur), + lyric = (lyric ~= "" and lyric) or "la", + pronunciation = "", + properties = { source = "lpx" }, + leadingPhonemes = tunelab.empty_array(), bodyPhonemes = tunelab.empty_array(), bodyOffset = 0.0, + } + end + end + return { + tempos = { { pos = 0.0, bpm = 120.0 } }, + timeSignatures = { { barIndex = 0, numerator = 4, denominator = 4 } }, + tracks = { { + name = "Lua Track", gain = 0.0, pan = 0.0, mute = false, solo = false, asRefer = true, color = "", + parts = { { + kind = "midi", name = "P", pos = 0.0, startOffset = 0.0, endOffset = 0.0, gain = 0.0, + soundSource = { kind = "voice", type = "", id = "" }, + effects = tunelab.empty_array(), notes = notes, + automations = {}, piecewiseAutomations = {}, + pitch = { segments = tunelab.empty_array() }, vibratos = tunelab.empty_array(), properties = {}, + } }, + } }, + } + end, + + export_project = function(path, project) + local lines = {} + for _, track in ipairs((project or {}).tracks or {}) do + for _, part in ipairs(track.parts or {}) do + for _, note in ipairs(part.notes or {}) do + lines[#lines+1] = string.format("%s %s %s %s", + tostring(note.pitch), tostring(note.pos), tostring(note.dur), note.lyric or "la") + end + end + end + local f = assert(io.open(path, "w")) + f:write(table.concat(lines, "\n") .. (#lines > 0 and "\n" or "")) + f:close() + end, +}) + +tunelab.serve() diff --git a/tests/plugins/NodeFormat/manifest.json b/tests/plugins/NodeFormat/manifest.json new file mode 100644 index 00000000..27e2dc92 --- /dev/null +++ b/tests/plugins/NodeFormat/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "com.tunelab.test.nodeformat", + "name": "Node Format (txt notes)", + "version": "1.0.0", + "author": "TuneLab Tests", + "description": "脚本 format 样例(Node):一行一音符的 .ntxt 文本格式。真解释器 E2E 靶(ScriptedNodeE2ETests)。", + "sdk-version": "1.1", + "type": "format", + "suffixes": ["ntxt"], + "runtime": "node>=18", + "entry": "plugin/main.js", + "platforms": ["win", "osx", "linux"] +} diff --git a/tests/plugins/NodeFormat/plugin/main.js b/tests/plugins/NodeFormat/plugin/main.js new file mode 100644 index 00000000..6b9a9030 --- /dev/null +++ b/tests/plugins/NodeFormat/plugin/main.js @@ -0,0 +1,43 @@ +// NodeFormat sample — a .ntxt file is one note per line: " ". +// Real-interpreter E2E target (ScriptedNodeE2ETests); NODE_PATH supplies the facade. +"use strict"; +const fs = require("fs"); +const { format, serve } = require("tunelab"); + +format({ + importProject(path) { + const lines = fs.readFileSync(path, "utf8").split(/\r?\n/).filter((l) => l && !l.startsWith("#")); + const notes = lines.map((l) => { + const [p, pos, dur, ...rest] = l.split(/\s+/); + return { + pos: Number(pos), dur: Number(dur), pitch: Number(p), lyric: rest.join(" ") || "la", + pronunciation: "", properties: { source: "ntxt" }, + leadingPhonemes: [], bodyPhonemes: [], bodyOffset: 0.0, + }; + }); + return { + tempos: [{ pos: 0.0, bpm: 120.0 }], + timeSignatures: [{ barIndex: 0, numerator: 4, denominator: 4 }], + tracks: [{ + name: "Node Track", gain: 0.0, pan: 0.0, mute: false, solo: false, asRefer: true, color: "", + parts: [{ + kind: "midi", name: "P", pos: 0.0, startOffset: 0.0, endOffset: 0.0, gain: 0.0, + soundSource: { kind: "voice", type: "", id: "" }, + effects: [], notes, automations: {}, piecewiseAutomations: {}, + pitch: { segments: [] }, vibratos: [], properties: {}, + }], + }], + }; + }, + + exportProject(path, project) { + const lines = []; + for (const track of project.tracks || []) + for (const part of track.parts || []) + for (const note of part.notes || []) + lines.push(`${note.pitch} ${note.pos} ${note.dur} ${note.lyric || "la"}`); + fs.writeFileSync(path, lines.length ? lines.join("\n") + "\n" : ""); + }, +}); + +serve(); From a48f188f8b49d550315b1d27df604e148c337778 Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Tue, 15 Sep 2026 23:03:11 +0800 Subject: [PATCH 10/12] M4: stderr retention hardening + shm audio plane (Python vertical slice) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M4a - stderr policy closed out: - per-line truncate at 8KB (binary-dump lines no longer land in full), hard cap 2MB per boot file (stop writing to disk, memory tail keeps rolling for the sidebar summary), old boots still pruned to 2 - three direct policy tests on AppendStderr (line cut, boot cap, prune window) M4b - shared-memory audio plane (design §6/§7), fast lane, correctness-neutral: - ShmPlane/ShmRegion: file-backed MemoryMappedFile regions named tlx--- under %TEMP%/tunelab-regions; 24B header ('TLXA' LE bytes on disk, version, flags, sample_offset i64, count, rate) + interleaved f32-LE body; stale non-current-boot leftovers swept once per boot - negotiation: facade self-reports capabilities.audio_plane at initialize; host only ships a region{path,data_offset,...} with segment_create on shm; commit is the sole barrier (host copies region -> segment buffer there); segment_write on a shm segment proxies into the region (authoritative copy); segment_resize flushes region->segment, resizes, lays a fresh region and returns it for re-mapping (identity preserved, alignment stays in AudioSegment) - env injection: TUNELAB_NONCE (naming domain) + TUNELAB_SHM_DIR - Python facade declares shm: stdlib mmap + memoryview writes (bytes/list/ memoryview duck-typed), shm_view() hands out a numpy float32 view when numpy exists; resize re-maps, dispose unmaps; Node/Lua stay inline (no stdlib mmap) - fake sidecar gains shm-sine mode (a second independent implementation sealing the region reply contract; opens the region with FileShare.ReadWrite - the CreateFromFile(path) overload defaults FileShare.Read and collides with the host's live RW handle) - spec: environment gains TUNELAB_NONCE/TUNELAB_SHM_DIR; segment_create/ segment_resize results document the optional region; write/commit notes pin the barrier semantics - tests: ShmRegion header layout on disk, cross-handle sharing, grow-keeps- content, boot sweep, slug safety; shm-sine E2E through RemoteVoiceEngine; real-3.11 voice+effect E2E now assert AudioPlaneShm (fast lane actually taken) M4c - windows Lua feed cell deliberately NOT added: no portable (zip) win64 lua exists at a mirror we can sha256-pin, and NSIS installers violate the no-execution discipline (§9); deferred to M5 backlog (Windows Lua users go via PATH/vendor, as documented). Full suite 689/689 (excl. pre-existing Setup* env baseline), legacy 13/13, python unittest 16/16. --- AGENTS.md | 9 +- TuneLab/Extensions/Scripted/RemotePull.cs | 100 +++++++++-- TuneLab/Extensions/Scripted/ShmPlane.cs | 166 ++++++++++++++++++ TuneLab/Extensions/Scripted/SidecarHost.cs | 22 ++- docs/scripted-plugin-sidecar-design.md | 26 ++- scripting/README.md | 8 +- scripting/python/tests/test_facade.py | 84 +++++++++ scripting/python/tunelab/_effect.py | 77 ++++++-- scripting/python/tunelab/_service.py | 6 +- scripting/python/tunelab/_voice.py | 2 +- scripting/wire/v1.json | 14 +- tests/ScriptedFakeSidecar/Program.cs | 54 +++++- tests/TuneLab.Tests/ScriptedPythonE2ETests.cs | 2 + tests/TuneLab.Tests/ScriptedShmPlaneTests.cs | 107 +++++++++++ .../TuneLab.Tests/ScriptedSidecarHostTests.cs | 66 +++++++ tests/TuneLab.Tests/ScriptedVoiceTests.cs | 35 ++++ 16 files changed, 727 insertions(+), 51 deletions(-) create mode 100644 TuneLab/Extensions/Scripted/ShmPlane.cs create mode 100644 tests/TuneLab.Tests/ScriptedShmPlaneTests.cs diff --git a/AGENTS.md b/AGENTS.md index c9b7b61d..7336f38e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,9 +17,14 @@ ## Scripted plugins (sidecar) — see docs/scripted-plugin-sidecar-design.md -- Python/Node/Lua plugins run **out of process** (JSON-RPC over stdio); M0–M3d landed - (format/effect/voice, all three languages). +- Python/Node/Lua plugins run **out of process** (JSON-RPC over stdio); M0–M4b landed + (format/effect/voice, all three languages; shm audio plane for Python). Façades live under `scripting/` (not in the .sln); wire spec: `scripting/wire/v1.json`. + The shm audio plane (M4b) is opt-in per façade: a façade declares `capabilities.audio_plane` + at handshake; only `shm` makes the host return a `region{path,data_offset,…}` from + `host.segment_create` (file-backed mapping under `TUNELAB_SHM_DIR`, body = interleaved f32-LE, + header 24B). On that plane the data body never crosses JSON — `commit` is the barrier. + Node/Lua stay `inline` (no stdlib mmap); Python maps via `mmap`/`memoryview` (numpy optional). - Build & test (parallel to the dotnet pipeline): - fake sidecar (protocol seal, no interpreter needed): built with the solution; the .NET test project launches it via `dotnet exec`. Manual: `dotnet build tests/ScriptedFakeSidecar -c Debug`. diff --git a/TuneLab/Extensions/Scripted/RemotePull.cs b/TuneLab/Extensions/Scripted/RemotePull.cs index c533a335..126ce0bd 100644 --- a/TuneLab/Extensions/Scripted/RemotePull.cs +++ b/TuneLab/Extensions/Scripted/RemotePull.cs @@ -1,11 +1,13 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using TuneLab.Data.Synthesis; using TuneLab.Foundation; using TuneLab.SDK; @@ -45,6 +47,8 @@ sealed class Table { public readonly ConcurrentDictionary Sessions = new(); public readonly ConcurrentDictionary Segments = new(); + // shm 面(M4b):协商为 shm 后创建的段的共享区域(id→region);体的唯一权威副本在区域里。 + public readonly ConcurrentDictionary Regions = new(); public int SegCounter; public int Attached; } @@ -75,6 +79,8 @@ public static void DisposeOwnedSegments(SidecarHost host, string sessionKey) foreach (var id in table.Segments.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList()) if (table.Segments.TryRemove(id, out var seg)) { + if (table.Regions.TryRemove(id, out var region)) + try { region.Dispose(); } catch { } if (table.Sessions.TryGetValue(sessionKey, out var session)) _ = OnSessionThread(session, () => { try { seg.Dispose(); } catch { } return 0; }); else @@ -168,30 +174,103 @@ static void Attach(SidecarHost host, Table table) host.RegisterLocalCall("host.segment_create", args => NeedAsync(args, async s => { var o = args!.AsObject(); + var offset = o["sample_offset"]!.GetValue(); + var count = (int)o["sample_count"]!.GetValue(); + var rate = (int)o["sample_rate"]!.GetValue(); return await OnSessionThread(s, () => { - var seg = s.CreateOutputSegment(o["sample_offset"]!.GetValue(), - (int)o["sample_count"]!.GetValue(), (int)o["sample_rate"]!.GetValue()); + var seg = s.CreateOutputSegment(offset, count, rate); var id = s.SessionKey + "/seg" + Interlocked.Increment(ref table.SegCounter); table.Segments[id] = seg; - return (JsonNode)new JsonObject { ["segment"] = id }; + var res = new JsonObject { ["segment"] = id }; + if (host.AudioPlaneShm) + { + var region = ShmPlane.Allocate(host.PackageId, offset, count, rate); + table.Regions[id] = region; + res["region"] = new JsonObject + { + ["path"] = region.FilePath, + ["name"] = Path.GetFileName(region.FilePath), + ["data_offset"] = ShmRegion.HeaderBytes, + ["sample_offset"] = offset, + ["sample_count"] = count, + ["sample_rate"] = rate, + }; + } + return (JsonNode)res; }).ConfigureAwait(false); })); - host.RegisterLocalCall("host.segment_write", args => SegmentCall(args, table, (seg, o) => + host.RegisterLocalCall("host.segment_write", args => SegmentCall(args, table, (seg, region, o) => { var offset = (int)o["offset"]!.GetValue(); - seg.Write(offset, WireAudio.DecodeF32(o["data"]!.GetValue())); + var samples = WireAudio.DecodeF32(o["data"]!.GetValue()); + if (region != null) + { + // shm 段的权威副本在区域里:宿主代理写也写区域(否则 commit 会拿 region 覆盖掉这次 inline 写)。 + if (offset < 0 || offset + samples.Length > region.SampleCount) + throw new SidecarRpcException("invalid_params", "segment_write out of range"); + region.WriteBody(offset, samples); + } + else + seg.Write(offset, samples); + })); + host.RegisterLocalCall("host.segment_commit", args => SegmentCall(args, table, (seg, region, _) => + { + if (region != null) + { + var buf = new float[region.SampleCount]; + region.ReadBody(0, buf); + seg.Write(0, buf); + } + seg.Commit(); + })); + host.RegisterLocalCall("host.segment_resize", args => NeedAsync(args, async s => + { + var o = args!.AsObject(); + var id = o["segment"]!.GetValue(); + if (!table.Segments.TryGetValue(id, out var seg)) + throw new SidecarRpcException("stale_handle", "unknown segment " + id); + if (seg is not AudioSegment audio) + throw new SidecarRpcException("invalid_params", "resize needs host AudioSegment"); + var newOffset = o["sample_offset"]!.GetValue(); + var newCount = (int)o["sample_count"]!.GetValue(); + return await OnSessionThread(s, () => + { + var res = new JsonObject(); + if (table.Regions.TryGetValue(id, out var region)) + { + // region 先冲回 buffer(Resize 保内容靠 buffer),改几何后重铺 region。 + var cur = new float[region.SampleCount]; + region.ReadBody(0, cur); + seg.Write(0, cur); + seg.Resize(newOffset, newCount); + region.Dispose(); + var fresh = ShmPlane.Allocate(host.PackageId, newOffset, newCount, audio.SampleRate); + fresh.WriteBody(0, audio.Samples); + table.Regions[id] = fresh; + res["region"] = new JsonObject + { + ["path"] = fresh.FilePath, + ["name"] = Path.GetFileName(fresh.FilePath), + ["data_offset"] = ShmRegion.HeaderBytes, + ["sample_offset"] = newOffset, + ["sample_count"] = newCount, + ["sample_rate"] = audio.SampleRate, + }; + } + else seg.Resize(newOffset, newCount); + return (JsonNode)res; + }).ConfigureAwait(false); })); - host.RegisterLocalCall("host.segment_commit", args => SegmentCall(args, table, (seg, _) => seg.Commit())); - host.RegisterLocalCall("host.segment_resize", args => SegmentCall(args, table, (seg, o) => - seg.Resize(o["sample_offset"]!.GetValue(), (int)o["sample_count"]!.GetValue()))); host.RegisterLocalCall("host.segment_dispose", args => { var o = args!.AsObject(); var id = o["segment"]!.GetValue(); if (!table.Segments.TryRemove(id, out var seg)) throw new SidecarRpcException("stale_handle", "unknown segment " + id); + if (table.Regions.TryRemove(id, out var region)) + try { region.Dispose(); } catch { } var (tbl, session) = Resolve(args); if (session == null) throw new SidecarRpcException("stale_handle", "session gone"); @@ -207,16 +286,17 @@ static void Attach(SidecarHost host, Table table) host.RegisterLocalNotify("voice.phonemes", args => Publish(args, (s, v) => s.PublishPhonemes(v), ParsePhonemes)); } - static Task SegmentCall(JsonNode? args, Table table, Action op) + static Task SegmentCall(JsonNode? args, Table table, Action op) { var o = args!.AsObject(); var id = o["segment"]!.GetValue(); if (!table.Segments.TryGetValue(id, out var seg)) throw new SidecarRpcException("stale_handle", "unknown segment " + id); + table.Regions.TryGetValue(id, out var region); var (_, session) = Resolve(args); if (session == null) throw new SidecarRpcException("stale_handle", "session gone"); - return OnSessionThread(session, () => { op(seg, o); return (JsonNode?)new JsonObject(); }); + return OnSessionThread(session, () => { op(seg, region, o); return (JsonNode?)new JsonObject(); }); } static void Publish(JsonNode? args, Action sink, Func parse) diff --git a/TuneLab/Extensions/Scripted/ShmPlane.cs b/TuneLab/Extensions/Scripted/ShmPlane.cs new file mode 100644 index 00000000..7b679253 --- /dev/null +++ b/TuneLab/Extensions/Scripted/ShmPlane.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using System.IO.MemoryMappedFiles; +using System.Text; +using System.Threading; + +namespace TuneLab.Extensions.Scripted; + +// 共享内存音频面(设计 §7,M4b)。区域 = 文件后备的 MemoryMappedFile(Windows 下 Global\ 命名映射 +// 需提权,POSIX 下 shm_open 是优化非必须 ⇒ 一律文件后备,随启动清扫/Dispose 删除)。 +// 头部 24 字节:magic u32 | version u16 | flags u16 | sample_offset i64 | sample_count i32 | sample_rate i32, +// 其后 body = 交织 f32 little-endian(与 inline 面 base64 体同形)。 +// 同步语义:同一区域页跨进程天然一致(page cache),commit 是唯一屏障——façade 写完发 commit, +// 宿主拷贝 body 进段缓冲;无锁、无原子序要求(对齐 in-proc "Write 后 Commit 才进 effect" 闸门)。 +internal sealed class ShmRegion : IDisposable +{ + public const int HeaderBytes = 24; + public const uint Magic = 0x41584C54; // 写 LE 后盘上字节 = 'T','L','X','A' + public const ushort LayoutVersion = 1; + + readonly FileStream m_file; + MemoryMappedFile m_mapped; + + public readonly string FilePath; + public long SampleOffset { get; private set; } + public int SampleCount { get; private set; } + public int SampleRate { get; private set; } + long m_capacity; + + ShmRegion(FileStream file, MemoryMappedFile mapped, string path) + { + m_file = file; + m_mapped = mapped; + FilePath = path; + } + + /// 创建/重开区域并把容量设为 header + 4*sampleCount(不足则扩文件,body 交集内容保留)。 + public static ShmRegion Open(string path, long sampleOffset, int sampleCount, int sampleRate) + { + var capacity = (long)HeaderBytes + 4L * sampleCount; + var file = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); + if (file.Length < capacity) + file.SetLength(capacity); + var mapped = MemoryMappedFile.CreateFromFile(file, null, capacity, MemoryMappedFileAccess.ReadWrite, + HandleInheritability.None, leaveOpen: true); + var region = new ShmRegion(file, mapped, path) { SampleOffset = sampleOffset, SampleCount = sampleCount, SampleRate = sampleRate, m_capacity = capacity }; + region.WriteHeader(sampleOffset, sampleCount, sampleRate); + return region; + } + + /// 改几何:先冲 header 进旧映射,再扩文件重开映射(body 内容留在文件里,映射须重建)。 + public void SetGeometry(long sampleOffset, int sampleCount, int sampleRate) + { + var capacity = (long)HeaderBytes + 4L * sampleCount; + if (capacity > m_capacity) + { + WriteHeader(sampleOffset, sampleCount, sampleRate); // header 经旧映射落文件 + m_mapped.Dispose(); + if (m_file.Length < capacity) + m_file.SetLength(capacity); + m_mapped = MemoryMappedFile.CreateFromFile(m_file, null, capacity, MemoryMappedFileAccess.ReadWrite, + HandleInheritability.None, leaveOpen: true); + m_capacity = capacity; + } + else + { + WriteHeader(sampleOffset, sampleCount, sampleRate); + } + SampleOffset = sampleOffset; + SampleCount = sampleCount; + SampleRate = sampleRate; + } + + void WriteHeader(long sampleOffset, int sampleCount, int sampleRate) + { + var header = new byte[HeaderBytes]; + var span = header.AsSpan(); + System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(span[0..4], Magic); + System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(span[4..6], LayoutVersion); + // span[6..8] flags = 0(读视图位留 §6.2 input) + System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian(span[8..16], sampleOffset); + System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(span[16..20], sampleCount); + System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(span[20..24], sampleRate); + using var view = m_mapped.CreateViewAccessor(0, HeaderBytes, MemoryMappedFileAccess.ReadWrite); + view.WriteArray(0, header, 0, HeaderBytes); + } + + /// 读 body 区 [sampleStart, +sampleCount) 进 span(commit 拷贝 / resize 快照)。 + public void ReadBody(int sampleStart, Span destination) + { + var byteLen = destination.Length * 4; + if (byteLen == 0) return; + using var view = m_mapped.CreateViewAccessor(HeaderBytes + 4L * sampleStart, byteLen, MemoryMappedFileAccess.ReadWrite); + var bytes = new byte[byteLen]; + view.ReadArray(0, bytes, 0, byteLen); + for (int i = 0; i < destination.Length; i++) + destination[i] = System.Buffers.Binary.BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * 4, 4)); + } + + /// 宿主代理写 body(inline segment_write 在 shm 段的透传路径)。 + public void WriteBody(int sampleStart, ReadOnlySpan samples) + { + if (samples.Length == 0) return; + var bytes = new byte[samples.Length * 4]; + for (int i = 0; i < samples.Length; i++) + System.Buffers.Binary.BinaryPrimitives.WriteSingleLittleEndian(bytes.AsSpan(i * 4, 4), samples[i]); + using var view = m_mapped.CreateViewAccessor(HeaderBytes + 4L * sampleStart, bytes.Length, MemoryMappedFileAccess.ReadWrite); + view.WriteArray(0, bytes, 0, bytes.Length); + } + + public void Dispose() + { + try { m_mapped.Dispose(); } catch { } + try { m_file.Dispose(); } catch { } + try { File.Delete(FilePath); } catch { } // 删不掉(对端仍映射)没关系:启动清扫兜底 + } +} + +/// 宿主侧区域分配器(按 boot-nonce 成族;§7:上次崩溃的残留可识别、可清扫)。 +internal static class ShmPlane +{ + static int sSwept; + static int sSeq; + + public static string RegionsDir => Path.Combine(Path.GetTempPath(), "tunelab-regions"); + + public static string Slug(string packageId) + { + var sb = new StringBuilder(); + foreach (var c in packageId) + if (char.IsLetterOrDigit(c) && sb.Length < 24) sb.Append(char.ToLowerInvariant(c)); + return sb.Length == 0 ? "pkg" : sb.ToString(); + } + + /// 首次使用时清扫非本 boot 的残留(尽力而为)。 + public static void EnsureSwept() + { + if (Interlocked.Exchange(ref sSwept, 1) != 0) return; + SweepExcept(SidecarIo.Nonce, RegionsDir); + } + + /// 删除目录里非本 boot 的 tlx-* 残留(测试注入形态)。 + internal static void SweepExcept(string keepNonce, string dir) + { + try + { + if (!Directory.Exists(dir)) return; + foreach (var f in Directory.GetFiles(dir, "tlx-*")) + { + if (f.Contains(keepNonce, StringComparison.Ordinal)) continue; + try { File.Delete(f); } catch { } + } + } + catch { } + } + + public static ShmRegion Allocate(string packageId, long sampleOffset, int sampleCount, int sampleRate) + { + EnsureSwept(); + Directory.CreateDirectory(RegionsDir); + var name = $"tlx-{SidecarIo.Nonce}-{Slug(packageId)}-{Interlocked.Increment(ref sSeq)}"; + return ShmRegion.Open(Path.Combine(RegionsDir, name), sampleOffset, sampleCount, sampleRate); + } + + public static string Nonce => SidecarIo.Nonce; +} diff --git a/TuneLab/Extensions/Scripted/SidecarHost.cs b/TuneLab/Extensions/Scripted/SidecarHost.cs index 381d86bd..3548329f 100644 --- a/TuneLab/Extensions/Scripted/SidecarHost.cs +++ b/TuneLab/Extensions/Scripted/SidecarHost.cs @@ -61,6 +61,10 @@ public SidecarHost(string packageId, string packageDir, RuntimeInfo runtime, str public string StderrTail { get; private set; } = string.Empty; internal Exception? LastReaderFault { get; private set; } // 诊断(测试与失败文案用) + /// 握手协商的音频面(§6:façade 自报 capabilities.audio_plane=="shm" 才开区域面)。 + public bool AudioPlaneShm { get; private set; } + public string PackageId => mPackageId; + // 熔断复位(侧栏"重新加载"的语义;测试亦用)。不复活进行中的调用,只放行下一次 spawn。 public void ResetCircuit() { @@ -268,6 +272,8 @@ void StartAndHandshake() psi.Environment["TUNELAB_PROTOCOL"] = WireProtocol.Version.ToString(CultureInfo.InvariantCulture); psi.Environment["TUNELAB_EPOCH"] = (mEpoch + 1).ToString(CultureInfo.InvariantCulture); psi.Environment["TUNELAB_PACKAGE_ID"] = mPackageId; + psi.Environment["TUNELAB_NONCE"] = ShmPlane.Nonce; // §7 命名域核对 + psi.Environment["TUNELAB_SHM_DIR"] = ShmPlane.RegionsDir; // 区域目录(façade 声明 shm 才用得到) if (ExtraEnvironment != null) foreach (var (k, v) in ExtraEnvironment) { @@ -395,6 +401,8 @@ void StopHeartbeat() // 就判死("作者写了、sidecar 不认"是最容易静默失联的一类不匹配,拦在最前)。 void ValidateHandshake(JsonNode? reply) { + AudioPlaneShm = reply?["capabilities"] is JsonObject caps + && caps["audio_plane"]?.GetValue() == "shm"; var provided = new HashSet(StringComparer.Ordinal); if (reply!["provides"] is JsonArray arr) foreach (var item in arr) @@ -414,8 +422,15 @@ void ValidateHandshake(JsonNode? reply) } } - void AppendStderr(string line) + // stderr 保留策略(§3.4 + M4a 收口):单行截 8KB(二进制 dump 类爆行不落全量); + // 本次 boot 文件硬顶 2MB(到顶停落盘,内存 tail 仍滚动供侧栏摘要);旧 boot 只留 2 份。 + internal const int StderrLineCap = 8 * 1024; + internal const long StderrBootCap = 2 * 1024 * 1024; + + internal void AppendStderr(string line) { + if (line.Length > StderrLineCap) + line = line.Substring(0, StderrLineCap) + " …[truncated]"; const int Cap = 64 * 1024; var tail = StderrTail + line + "\n"; if (tail.Length > Cap) @@ -431,7 +446,10 @@ void AppendStderr(string line) } var dir = Path.Combine(mPackageDir, "logs"); Directory.CreateDirectory(dir); - File.AppendAllText(Path.Combine(dir, "stderr-" + SidecarIo.Nonce + ".txt"), line + "\n"); + var path = Path.Combine(dir, "stderr-" + SidecarIo.Nonce + ".txt"); + if (File.Exists(path) && new FileInfo(path).Length > StderrBootCap) + return; // boot 文件到硬顶:停写盘但不算失败 + File.AppendAllText(path, line + "\n"); } catch { } } diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md index ba87f097..eb42e209 100644 --- a/docs/scripted-plugin-sidecar-design.md +++ b/docs/scripted-plugin-sidecar-design.md @@ -1,8 +1,8 @@ # 脚本插件 Sidecar 系统设计(Python / Node.js / Lua) -> 状态:**定稿;M3d 已落地**(Node façade format+真解释器 E2E/握手冒烟;Lua façade 逻辑单测+CI -> posix 真跑(windows 无打包 lua → 本地 vacuous);voice=M3c、运行时自动部署=M3a)。 -> 剩余:M4 = shm 音频面、Lua windows 分发、stderr 清理策略。 +> 状态:**定稿;M4 已落地**(shm 音频面:区域分配器/头布局/协商/fake 与 Python 直写/CI; +> Node façade format+真解释器 E2E/握手冒烟;Lua façade 逻辑单测+CI posix 真跑)。 +> 剩余:M5 = 曲线面 shm 结果缓冲、Lua windows 分发、stderr 明细清理策略。 > 基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 > 配套阅读:[plugin-development.md](plugin-development.md)(V1 插件契约)、[sdk-api-evolution.md](sdk-api-evolution.md)(冻结 ABI 纪律)、[naming-glossary.md](naming-glossary.md)。 > 本文只设计**宿主侧与协议侧**;`TuneLab.SDK` / `TuneLab.Foundation` 一行不动(§1.3)。 @@ -539,8 +539,24 @@ scripted.initTimeoutSec int = 0 # 0 = 无上限(§5.5 长任 CI posix 再跑一遍(ubuntu apt lua5.4 / macos brew lua),windows 端 E2E vacuous。 交叉核对 spec×C#×三 façade 时补了 spec 漏记的 `voice.cancel`(47 方法)与三语言的 `voice.dirty`→`user.on_dirty`、`voice.cancel`→`user.cancel` 钩子一致性。 -- **仍推迟**:shm 音频面(M4——性能快车道,非正确性;voice 已全链可用); - Lua 的 windows 分发(随 feed 加格,M4);【下载运行时】按钮的 stderr 清理策略(M4)。 +- **M4 落地**: + - **M4a stderr 收口**:滚动落盘加单行截断(8KB,二进制 dump 类爆行不落全量)与 boot 文件 + 硬顶(2MB,到顶停落盘、内存 tail 继续滚);旧 boot 仍只留 2 份。 + - **M4b shm 音频面**:`ShmPlane`/`ShmRegion`(文件后备 MemoryMappedFile,命名 + `tlx---`,头 24B:magic 'TLXA'/version/flags/sample_offset/ + sample_count/sample_rate + 交织 f32 体;boot 残留启动清扫)。协商:façade 在 `initialize` + 自报 `capabilities.audio_plane`,宿主仅在 `shm` 时于 `segment_create` 回 `region{path, + data_offset,…}`;façade 映射直写,`commit` 是唯一屏障(宿主拷 region→段缓冲)。 + `segment_write` 在 shm 段透传进区域(否则 commit 会覆盖 inline 写);`segment_resize` + 先冲区域回段、宿主 Resize 后重铺新区域并在应答回新 region(façade 重映射)。 + Python façade(stdlib `mmap` + `memoryview`/numpy 视图)声明 shm;Node/Lua 保持 inline。 + 封条:`ScriptedShmPlaneTests`(头布局/跨句柄共享/扩容保内容/清扫)+ fake `shm-sine` 模式 + E2E + 真 3.11 voice/effect E2E 断言 `AudioPlaneShm`。 + - 期间踩到 Windows 共享模式坑:宿主 FileStream 是 RW,sidecar 用 `CreateFromFile(path,…)` + 重载(默认 `FileShare.Read`)会 ERROR_LOCK_VIOLATION——两侧都须 `FileShare.ReadWrite` + (SafeFileHandle 重载)。 +- **仍推迟**:曲线面 shm 结果缓冲(M5);Lua 的 windows 分发(随 feed 加格,M5); + 【下载运行时】按钮的 stderr 明细清理策略(M5)。 - **实现差异**: - 崩溃后不自动定时复活,**下一次调用驱动重 spawn**(backoff/熔断仍生效)——format 拉模式下 自动重启只有缓存收益;effect push 调度落地时(M2b)再补后台重启环。 diff --git a/scripting/README.md b/scripting/README.md index 1cb8a116..3a140197 100644 --- a/scripting/README.md +++ b/scripting/README.md @@ -8,10 +8,10 @@ | 路径 | 内容 | 状态 | |---|---|---| -| `wire/v1.json` | 线协议规范(帧、方法表、值编码) | M3d ✅(47 方法全表) | -| `python/tunelab/` | Python façade(纯 stdlib,PyPI 名 `tunelab`) | M3c ✅(format/effect/voice) | -| `node/tunelab/` | Node façade(零依赖 CommonJS,包名 `tunelab`,发布名 `@tunelab/plugin`) | M3d ✅(format/effect/voice) | -| `lua/tunelab.lua` | Lua façade(纯 Lua 单文件,5.1–5.4;内嵌 JSON codec 与 f32 打包双路) | M3d ✅(CI posix 真跑) | +| `wire/v1.json` | 线协议规范(帧、方法表、值编码、shm 纹理) | M4b ✅(47 方法 + 音频面协商) | +| `python/tunelab/` | Python façade(纯 stdlib,PyPI 名 `tunelab`;`audio_plane: shm`) | M4b ✅(format/effect/voice + shm 音频面) | +| `node/tunelab/` | Node façade(零依赖 CommonJS,包名 `tunelab`,发布名 `@tunelab/plugin`;inline) | M3d ✅(format/effect/voice) | +| `lua/tunelab.lua` | Lua façade(纯 Lua 单文件,5.1–5.4;inline;内嵌 JSON codec 与 f32 打包双路) | M3d ✅(CI posix 真跑) | ## 构建与测试(不进 .sln;与 dotnet 管线平行) diff --git a/scripting/python/tests/test_facade.py b/scripting/python/tests/test_facade.py index 45d74c51..3a213a73 100644 --- a/scripting/python/tests/test_facade.py +++ b/scripting/python/tests/test_facade.py @@ -3,7 +3,9 @@ import json import math import os +import shutil import sys +import tempfile import unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) @@ -165,5 +167,87 @@ def test_roundtrip_through_ctx(self): self.assertEqual(vals, [0.25, 0.25]) +class SegmentShmPlaneTests(unittest.TestCase): + """M4b: mapped region writes bypass the JSON/base64 plane; commit is the only message.""" + + def setUp(self): + from tunelab import _effect + self.calls = [] + self._segments = [] + _effect.set_channels(lambda m, p: self._respond(m, p), lambda m, p: None) + self._effect = _effect + self._tmp = tempfile.mkdtemp(prefix="tlx-shm-test-") + self.path = os.path.join(self._tmp, "tlx-test-region") + self.count = 8 + with open(self.path, "wb") as f: + f.write(b"\x41XLt" + b"\x01\x00\x00\x00" + (0).to_bytes(8, "little") + + self.count.to_bytes(4, "little") + (44100).to_bytes(4, "little")) + f.write(b"\x00" * (self.count * 4)) + + def tearDown(self): + for seg in self._segments: + try: + seg.dispose() # 关闭映射/文件句柄(也要覆盖断言失败路径) + except Exception: + pass + from tunelab import _effect + _effect.set_channels(None, None) + shutil.rmtree(self._tmp, ignore_errors=True) + + def _segment(self, seg_id, region=None): + seg = self._effect._Segment("Eng", "s1", seg_id, self.count, region) + self._segments.append(seg) + return seg + + def _respond(self, method, params): + self.calls.append((method, params)) + return {} + + def _region(self): + return {"path": self.path, "data_offset": 24, "sample_count": self.count, "sample_rate": 44100} + + def test_region_write_bypasses_wire(self): + import struct + seg = self._segment("seg1", self._region()) + seg.write(2, [0.25, -1.5]) + self.assertFalse(any(c[0] == "host.segment_write" for c in self.calls), "shm write must not hit the wire") + with open(self.path, "rb") as f: + f.read(24) + raw = f.read() + vals = struct.unpack("<8f", raw) + self.assertEqual(vals[2], 0.25) + self.assertEqual(vals[3], -1.5) + + def test_commit_is_control_only(self): + seg = self._segment("seg1", self._region()) + seg.write(0, [1.0] * self.count) + seg.commit() + commit = [c for c in self.calls if c[0] == "host.segment_commit"] + self.assertEqual(len(commit), 1) + self.assertNotIn("data", commit[0][1]) + + def test_plain_segment_still_uses_base64(self): + import base64, struct + seg = self._segment("seg2") + seg.write(0, [0.5, 0.5, 0.5, 0.5]) + write = [c for c in self.calls if c[0] == "host.segment_write"][0][1] + self.assertEqual(struct.unpack("<4f", base64.b64decode(write["data"])), (0.5,) * 4) + + def test_capabilities_declare_shm(self): + from tunelab import _service + self.assertEqual(_service.capabilities()["audio_plane"], "shm") + + def test_shm_view_only_on_shm_segments(self): + plain = self._segment("seg3") + self.assertIsNone(plain.shm_view()) + shm = self._segment("seg4", self._region()) + try: + import numpy # noqa: F401 + except ImportError: + self.skipTest("numpy not installed") + view = shm.shm_view() + self.assertEqual(view.shape, (self.count,)) + + if __name__ == "__main__": unittest.main() diff --git a/scripting/python/tunelab/_effect.py b/scripting/python/tunelab/_effect.py index 79f527b2..9204fe72 100644 --- a/scripting/python/tunelab/_effect.py +++ b/scripting/python/tunelab/_effect.py @@ -107,19 +107,70 @@ def __init__(self, d): class _Segment: - """Output audio segment; write takes bytes/bytearray/list[float] (f32 samples).""" + """Output audio segment; write takes bytes/bytearray/memoryview/numpy/list[float] (f32 samples). - def __init__(self, engine, session, seg_id, count): + When the handshake negotiated the shm plane, segment_create carries a + region{name,path,data_offset,...} — we map it and writes go straight into + shared memory (the commit control message is the only barrier; the host + copies region -> segment buffer there, so zero serialization).""" + + def __init__(self, engine, session, seg_id, count, region=None): self._k = (engine, session) self.id = seg_id self.count = count self._closed = False + self._region = None + self._file = None + self._view = None + self._data_offset = 0 + if region: + self._map(region) + + def _map(self, region): + import mmap + self._region = region + self._data_offset = int(region.get("data_offset", 24)) + self._file = open(region["path"], "r+b") + self._view = mmap.mmap(self._file.fileno(), 0) + + def _unmap(self): + try: + if self._view is not None: + self._view.close() + if self._file is not None: + self._file.close() + except Exception: + pass + self._view = self._file = self._region = None + + def shm_view(self, sample_start=0, count=None): + """numpy float32 view over the region body (None on the inline plane).""" + if self._region is None: + return None + import numpy as np + n = self.count if count is None else count + mv = memoryview(self._view)[self._data_offset + sample_start * 4: + self._data_offset + (sample_start + n) * 4] + return np.ndarray(shape=(n,), dtype=np.float32, buffer=mv) + + @staticmethod + def _to_payload(samples): + if isinstance(samples, (bytes, bytearray)): + return bytes(samples) + if isinstance(samples, memoryview): + return samples.cast("B").tobytes() if samples.format != "B" else samples.tobytes() + if hasattr(samples, "tobytes"): # numpy / array 等 + return samples.tobytes() + return struct.pack("<%df" % len(samples), *samples) def write(self, offset, samples): - if isinstance(samples, (bytes, bytearray)) and len(samples) % 4 == 0: - payload = bytes(samples) - else: - payload = struct.pack("<%df" % len(samples), *samples) + payload = self._to_payload(samples) + if self._region is not None: + start = self._data_offset + offset * 4 + if offset < 0 or start + len(payload) > len(self._view): + raise ValueError("segment_write out of range") + self._view[start:start + len(payload)] = payload + return _call_host("host.segment_write", {"engine": self._k[0], "session": self._k[1], "segment": self.id, "offset": offset, "data": base64.b64encode(payload).decode("ascii")}) @@ -129,14 +180,18 @@ def commit(self): def resize(self, sample_offset, sample_count): self.count = sample_count - _call_host("host.segment_resize", {"engine": self._k[0], "session": self._k[1], - "segment": self.id, "sample_offset": sample_offset, - "sample_count": sample_count}) + r = _call_host("host.segment_resize", {"engine": self._k[0], "session": self._k[1], + "segment": self.id, "sample_offset": sample_offset, + "sample_count": sample_count}) + if self._region is not None and isinstance(r, dict) and r.get("region"): + self._unmap() # 旧视图作废,重映射新区域(§7 身份保持、区域重铺) + self._map(r["region"]) def dispose(self): if self._closed: return self._closed = True + self._unmap() _call_host("host.segment_dispose", {"engine": self._k[0], "session": self._k[1], "segment": self.id}) @@ -180,8 +235,8 @@ def evaluate(self, track, positions): def output(self, sample_offset, sample_count, sample_rate): r = _call_host("host.segment_create", self._via({"sample_offset": sample_offset, - "sample_count": sample_count, "sample_rate": sample_rate})) - seg = _Segment(self.engine, self.session, r["segment"], sample_count) + "sample_count": sample_count, "sample_rate": sample_rate})) + seg = _Segment(self.engine, self.session, r["segment"], sample_count, r.get("region")) self._segments.append(seg) return seg diff --git a/scripting/python/tunelab/_service.py b/scripting/python/tunelab/_service.py index 20d57815..9ad5b520 100644 --- a/scripting/python/tunelab/_service.py +++ b/scripting/python/tunelab/_service.py @@ -137,8 +137,10 @@ def error(message): def capabilities(): - """What this facade declares at handshake (inline planes only until shm lands in M3).""" - return {"audio_plane": "inline", "curve_plane": "inline"} + """What this facade declares at handshake. The stdlib mmap gives the audio plane + its shm fast lane (segment_create then carries a region view); the curve plane + stays inline f64 (the 64KB threshold path).""" + return {"audio_plane": "shm", "curve_plane": "inline"} def _reply(req_id, result=None, err=None): diff --git a/scripting/python/tunelab/_voice.py b/scripting/python/tunelab/_voice.py index b583e7a8..14bfd4d7 100644 --- a/scripting/python/tunelab/_voice.py +++ b/scripting/python/tunelab/_voice.py @@ -240,7 +240,7 @@ def output(self, sample_offset, sample_count, sample_rate): r = _call_host("host.segment_create", self._via({"sample_offset": sample_offset, "sample_count": sample_count, "sample_rate": sample_rate})) from ._effect import _Segment - seg = _Segment(self.engine, self.key, r["segment"], sample_count) + seg = _Segment(self.engine, self.key, r["segment"], sample_count, r.get("region")) self._segments.append(seg) return seg diff --git a/scripting/wire/v1.json b/scripting/wire/v1.json index 1088a730..958c274d 100644 --- a/scripting/wire/v1.json +++ b/scripting/wire/v1.json @@ -1,6 +1,6 @@ { "protocol-version": 1, - "status": "M2a: control plane + format + settings + heartbeat (all additive); effect/voice/audio-plane/shm sections land M2b/M3", + "status": "M4b: control plane + format + settings + effect + voice + inline/shm audio plane", "framing": { "transport": "stdio (binary), one message per frame", "frame": "Content-Length: \\r\\n\\r\\n", @@ -15,7 +15,9 @@ "environment": { "TUNELAB_PROTOCOL": "wire version the host speaks; child must refuse handshake otherwise", "TUNELAB_EPOCH": "monotonic process-generation counter, 1 per (re)spawn", - "TUNELAB_PACKAGE_ID": "manifest package id" + "TUNELAB_PACKAGE_ID": "manifest package id", + "TUNELAB_NONCE": "per-boot random string; the shm region name and stderr log carry it (stale leftovers stay identifiable)", + "TUNELAB_SHM_DIR": "absolute directory holding shm region files (file-backed, cross-platform); only meaningful when the facade declares audio_plane=shm" }, "methods": { "initialize": { @@ -92,10 +94,10 @@ "host.properties": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "keys": ["string"] }, "result": { "values": "" } }, "host.automation_tracks": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string" }, "result": { "tracks": ["string"] } }, "host.evaluate": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "track": "string", "positions": "base64 f64-LE (non-decreasing)" }, "result": { "values": "base64 f64-LE" }, "notes": "live automation evaluated on the host's data thread — interpolation stays host-side (§5.6)" }, - "host.segment_create": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "sample_offset": "i64", "sample_count": "i32", "sample_rate": "i32" }, "result": { "segment": "string" } }, - "host.segment_write": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string", "offset": "i32", "data": "base64 f32-LE" }, "result": {} }, - "host.segment_commit": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string" }, "result": {} }, - "host.segment_resize": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string", "sample_offset": "i64", "sample_count": "i32" }, "result": {} }, + "host.segment_create": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "sample_offset": "i64", "sample_count": "i32", "sample_rate": "i32" }, "result": { "segment": "string", "region?": "{path, name, data_offset, sample_offset, sample_count, sample_rate} when the handshake negotiated audio_plane=shm" }, "notes": "shm plane (§7): the facade maps the region file and writes the body directly; the host copies region->segment buffer on commit (the commit control message is the only barrier)" }, + "host.segment_write": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string", "offset": "i32", "data": "base64 f32-LE" }, "result": {}, "notes": "inline fallback (PUC Lua / non-mmap platforms). On the shm plane the facade normally skips this and writes the region; if it is sent for a shm segment the host proxies it into the region body" }, + "host.segment_commit": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string" }, "result": {}, "notes": "the sole barrier into the effect graph; on the shm plane this is where the host copies the region body into the segment buffer" }, + "host.segment_resize": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string", "sample_offset": "i64", "sample_count": "i32" }, "result": { "region?": "the fresh region when the segment is shm-backed (the facade must re-map; the old view is invalid)" }, "notes": "identity-preserving geometry change (§6); on the shm plane content is flushed region->segment, the host resizes, and a new region is laid over the resized buffer" }, "host.segment_dispose": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string" }, "result": {} }, "shutdown": { "direction": "host->sidecar", "params": { "reason": "string" }, "result": {} } }, diff --git a/tests/ScriptedFakeSidecar/Program.cs b/tests/ScriptedFakeSidecar/Program.cs index e65eeb63..421373df 100644 --- a/tests/ScriptedFakeSidecar/Program.cs +++ b/tests/ScriptedFakeSidecar/Program.cs @@ -186,7 +186,7 @@ void Notify(string method, JsonObject args) } case "voice.synthesize": { - if (mode != "voice-sine") + if (mode != "voice-sine" && mode != "shm-sine") break; var engine = p!["engine"]!.GetValue(); var session = p["session"]!.GetValue(); @@ -211,13 +211,45 @@ void Notify(string method, JsonObject args) const int rate = 44100; int count = (int)((end - start) * rate); - var samples = new byte[count * 4]; + var samples = new float[count]; for (int i = 0; i < count; i++) - BinaryPrimitives.WriteSingleLittleEndian(samples.AsSpan(i * 4), 0.25f * MathF.Sin(2 * MathF.PI * 440f * i / rate)); - var seg = Reverse("host.segment_create", Via2(new JsonObject - { ["sample_offset"] = (long)(start * rate), ["sample_count"] = count, ["sample_rate"] = rate }))!["segment"]!.GetValue(); - Reverse("host.segment_write", Via2(new JsonObject { ["segment"] = seg, ["offset"] = 0, ["data"] = Convert.ToBase64String(samples) })); - Reverse("host.segment_commit", Via2(new JsonObject { ["segment"] = seg })); + samples[i] = 0.25f * MathF.Sin(2 * MathF.PI * 440f * i / rate); + var createResp = Reverse("host.segment_create", Via2(new JsonObject + { ["sample_offset"] = (long)(start * rate), ["sample_count"] = count, ["sample_rate"] = rate })); + var seg = createResp!["segment"]!.GetValue(); + if (mode == "shm-sine") + { + // 区域协商验证:宿主应随应答回 region{path,data_offset,...},façade 映射直写、只发 commit。 + var region = createResp["region"] as JsonObject + ?? throw new InvalidOperationException("shm-sine: no region in segment_create reply"); + var path = region["path"]!.GetValue(); + int dataOffset = region["data_offset"]!.GetValue(); + int regionCount = region["sample_count"]!.GetValue(); + long headerCheck = region["sample_offset"]!.GetValue(); + if (regionCount != count || headerCheck != (long)(start * rate)) + throw new InvalidOperationException("shm-sine: region geometry mismatch"); + // FileShare 必须 ReadWrite:CreateFromFile(path,...) 重载默认 FileShare.Read, + // 会跟宿主仍持有的 RW 文件句柄sharing 冲突(ERROR_LOCK_VIOLATION)。 + using var regFile = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + using var mmf = System.IO.MemoryMappedFiles.MemoryMappedFile.CreateFromFile( + regFile.SafeFileHandle, null, 0, System.IO.MemoryMappedFiles.MemoryMappedFileAccess.ReadWrite, + System.IO.HandleInheritability.None, leaveOpen: true); + using var acc = mmf.CreateViewAccessor(dataOffset, count * 4, System.IO.MemoryMappedFiles.MemoryMappedFileAccess.ReadWrite); + var bytes = new byte[count * 4]; + for (int i = 0; i < count; i++) + BinaryPrimitives.WriteSingleLittleEndian(bytes.AsSpan(i * 4), samples[i]); + acc.WriteArray(0, bytes, 0, bytes.Length); + acc.Flush(); + Reverse("host.segment_commit", Via2(new JsonObject { ["segment"] = seg })); + } + else + { + var raw = new byte[count * 4]; + for (int i = 0; i < count; i++) + BinaryPrimitives.WriteSingleLittleEndian(raw.AsSpan(i * 4), samples[i]); + Reverse("host.segment_write", Via2(new JsonObject { ["segment"] = seg, ["offset"] = 0, ["data"] = Convert.ToBase64String(raw) })); + Reverse("host.segment_commit", Via2(new JsonObject { ["segment"] = seg })); + } Notify("voice.status", Via2(new JsonObject { ["segments"] = new JsonArray { new JsonObject { ["start"] = start, ["end"] = end, ["status"] = "synthesized", ["progress"] = 1.0 } } })); Notify("voice.pitch", Via2(new JsonObject { ["segments"] = new JsonArray { new JsonArray @@ -361,7 +393,13 @@ static JsonNode HandleInitialize(JsonObject req, string mode, Action(engine.GetPropertyConfig(new EffectTestPropContext())); Assert.IsType(cfg.Properties["amount".AsKey()]); diff --git a/tests/TuneLab.Tests/ScriptedShmPlaneTests.cs b/tests/TuneLab.Tests/ScriptedShmPlaneTests.cs new file mode 100644 index 00000000..702ba37d --- /dev/null +++ b/tests/TuneLab.Tests/ScriptedShmPlaneTests.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using System.Linq; +using TuneLab.Extensions.Scripted; +using Xunit; + +namespace TuneLab.Tests; + +// M4b 共享内存区域单元:头布局(§7 钉死的字节表)、body 读写、扩容量保内容、boot 清扫。 +public class ScriptedShmPlaneTests +{ + [Fact] + public void RegionHeaderMatchesWireSpecLayout() + { + var path = Path.Combine(Path.GetTempPath(), "tlx-test-" + Guid.NewGuid().ToString("N")); + try + { + var region = ShmRegion.Open(path, 88200, 44100, 44100); + try + { + // 共享模式:宿主句柄是 RW,任何旁观读必须带 FileShare.ReadWrite(同真实 sidecar 纪律) + byte[] bytes; + using (var probe = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + bytes = new byte[probe.Length]; + probe.ReadExactly(bytes); + } + Assert.Equal(24 + 44100 * 4, bytes.Length); + Assert.Equal(new byte[] { 0x54, 0x4C, 0x58, 0x41 }, bytes[0..4]); // 'TLXA' on disk + Assert.Equal(new byte[] { 1, 0 }, bytes[4..6]); // version + Assert.Equal(new byte[] { 0, 0 }, bytes[6..8]); // flags + Assert.Equal(BitConverter.GetBytes(88200L), bytes[8..16]); + Assert.Equal(BitConverter.GetBytes(44100), bytes[16..20]); + Assert.Equal(BitConverter.GetBytes(44100), bytes[20..24]); + } + finally { region.Dispose(); } + } + finally { if (File.Exists(path)) File.Delete(path); } + } + + [Fact] + public void RegionBodySharedAcrossIndependentHandles() + { + var path = Path.Combine(Path.GetTempPath(), "tlx-test-" + Guid.NewGuid().ToString("N")); + // 真实形态:宿主区域句柄全程存活,sidecar 另开句柄读写同一文件后备区。 + var host = ShmRegion.Open(path, 0, 8, 44100); + try + { + using var sidecarFile = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.ReadWrite); + using (var sidecar = System.IO.MemoryMappedFiles.MemoryMappedFile.CreateFromFile( + sidecarFile.SafeFileHandle, null, 0, System.IO.MemoryMappedFiles.MemoryMappedFileAccess.ReadWrite, + System.IO.HandleInheritability.None, leaveOpen: true)) + using (var acc = sidecar.CreateViewAccessor(ShmRegion.HeaderBytes + 2 * 4, 3 * 4, System.IO.MemoryMappedFiles.MemoryMappedFileAccess.ReadWrite)) + { + var raw = new byte[12]; + for (int i = 0; i < 3; i++) BitConverter.GetBytes(new[] { 0.25f, -1.5f, 3.5f }[i]).CopyTo(raw, i * 4); + acc.WriteArray(0, raw, 0, raw.Length); + acc.Flush(); + } + var buf = new float[3]; + host.ReadBody(2, buf); + Assert.Equal(new float[] { 0.25f, -1.5f, 3.5f }, buf); + } + finally { host.Dispose(); if (File.Exists(path)) File.Delete(path); } + } + + [Fact] + public void RegionGrowKeepsBodyContent() + { + var path = Path.Combine(Path.GetTempPath(), "tlx-test-" + Guid.NewGuid().ToString("N")); + try + { + using var region = ShmRegion.Open(path, 0, 4, 44100); + region.WriteBody(0, new float[] { 1f, 2f, 3f, 4f }); + region.SetGeometry(0, 8, 44100); // 扩容量(resize 重铺前的形态:同文件扩) + var buf = new float[8]; + region.ReadBody(0, buf); + Assert.Equal(new float[] { 1, 2, 3, 4, 0, 0, 0, 0 }, buf); + } + finally { File.Delete(path); } + } + + [Fact] + public void SweepDeletesForeignBootKeepsCurrent() + { + var dir = Path.Combine(Path.GetTempPath(), "tlx-sweep-" + Guid.NewGuid().ToString("N")); + try + { + Directory.CreateDirectory(dir); + var foreign = Path.Combine(dir, "tlx-0123456789ab-pkg-1"); + var mine = Path.Combine(dir, $"tlx-{ShmPlane.Nonce}-pkg-1"); + File.WriteAllText(foreign, "stale"); + File.WriteAllText(mine, "current"); + ShmPlane.SweepExcept(ShmPlane.Nonce, dir); + Assert.False(File.Exists(foreign)); + Assert.True(File.Exists(mine)); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void SlugIsPathSafeAndCapped() + { + Assert.Equal("comtestx", ShmPlane.Slug("com.test/../x")); + Assert.Equal(24, ShmPlane.Slug(new string('a', 100)).Length); + } +} diff --git a/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs b/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs index 13349043..46af2424 100644 --- a/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs +++ b/tests/TuneLab.Tests/ScriptedSidecarHostTests.cs @@ -244,4 +244,70 @@ static void TryDelete(string path) { try { File.Delete(path); } catch { } } + + // —— M4a: stderr 保留策略(单行截断 / boot 文件硬顶 / 旧 boot 只留 2 份)。 + // AppendStderr 是纯落盘方法,不 spawn 进程,用临时包目录直接驱动。 —— + + static SidecarHost StderrHost(string packageDir) + => new("com.test.stderr", packageDir, new RuntimeInfo(Dotnet, "dotnet-fake", "0.0", "x64"), "x.dll"); + + [Fact] + public void StderrLineIsTruncated() + { + var dir = Path.Combine(Path.GetTempPath(), "tlx-stderr-" + Guid.NewGuid().ToString("N")); + try + { + StderrHost(dir).AppendStderr(new string('x', SidecarHost.StderrLineCap * 3)); + var file = Path.Combine(dir, "logs", "stderr-" + SidecarIo.Nonce + ".txt"); + var written = File.ReadAllText(file); + Assert.Contains("[truncated]", written); + Assert.True(written.Length < SidecarHost.StderrLineCap * 2, "line must be cut to cap + marker"); + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void StderrBootFileStopsAtHardCap() + { + var dir = Path.Combine(Path.GetTempPath(), "tlx-stderr-" + Guid.NewGuid().ToString("N")); + try + { + var host = StderrHost(dir); + var line = new string('y', SidecarHost.StderrLineCap - 64); + for (long written = 0; written <= SidecarHost.StderrBootCap; written += line.Length + 1) + host.AppendStderr(line); + var file = Path.Combine(dir, "logs", "stderr-" + SidecarIo.Nonce + ".txt"); + var sizeAfterCap = new FileInfo(file).Length; + Assert.True(sizeAfterCap > SidecarHost.StderrBootCap, "cap not reached yet? " + sizeAfterCap); + host.AppendStderr("one more"); + Assert.Equal(sizeAfterCap, new FileInfo(file).Length); // 到顶后停写 + Assert.EndsWith("one more\n", host.StderrTail); // 内存 tail 继续滚动 + } + finally { Directory.Delete(dir, true); } + } + + [Fact] + public void StderrPruneKeepsTwoOlderBoots() + { + var dir = Path.Combine(Path.GetTempPath(), "tlx-stderr-" + Guid.NewGuid().ToString("N")); + try + { + var logs = Path.Combine(dir, "logs"); + Directory.CreateDirectory(logs); + for (int i = 1; i <= 5; i++) // boot1 最旧 … boot5 最新(旧 nonce 形态) + { + var f = Path.Combine(logs, $"stderr-boot{i:D}-other.txt"); + File.WriteAllText(f, "old"); + File.SetLastWriteTimeUtc(f, DateTime.UtcNow.AddDays(-10 + i)); + } + StderrHost(dir).AppendStderr("fresh"); + var survivors = Directory.GetFiles(logs, "stderr-*.txt") + .Select(Path.GetFileName).OrderBy(n => n).ToArray(); + Assert.Contains("stderr-boot5-other.txt", survivors); + Assert.Contains("stderr-boot4-other.txt", survivors); + Assert.DoesNotContain("stderr-boot3-other.txt", survivors); + Assert.Contains("stderr-" + SidecarIo.Nonce + ".txt", survivors); // 本次 boot 不算进 2 份 + } + finally { Directory.Delete(dir, true); } + } } diff --git a/tests/TuneLab.Tests/ScriptedVoiceTests.cs b/tests/TuneLab.Tests/ScriptedVoiceTests.cs index ee84cb2c..88e95c64 100644 --- a/tests/TuneLab.Tests/ScriptedVoiceTests.cs +++ b/tests/TuneLab.Tests/ScriptedVoiceTests.cs @@ -100,6 +100,41 @@ public async Task VoiceEngineFullSurfaceRoundTrip() Assert.True(ctx.Segments[0].Disposed); // 会话终结收割未提交的段句柄 } + [Fact(Timeout = 90_000)] + public async Task ShmPlaneSynthesizeRoundTrip() + { + // M4b:握手协商 audio_plane=shm → segment_create 带 region{path,data_offset}, + // fake 直写共享区域、只发 commit 控制消息;宿主在 commit 屏障处拷贝 region→段缓冲。 + var dll = ScriptedSidecarHostTests.FakeDllPath; + var host = new SidecarHost("com.test.shmvoice", Path.GetDirectoryName(dll)!, + new RuntimeInfo(ScriptedSidecarHostTests.Dotnet, "dotnet-fake", "0.0", "x64"), Path.GetFileName(dll), + callTimeout: TimeSpan.FromSeconds(10), heartbeat: TimeSpan.FromSeconds(60)) + { + ExtraEnvironment = new() { ["TUNELAB_FAKE_MODE"] = "shm-sine" }, + }; + host.ExpectEngine("voice", "FakeSine"); + var engine = new RemoteVoiceEngine(host, "FakeSine", hasSettings: false); + var ctx = new VoiceTestContext(new VoiceTestNote("n0", 0.0, 0.5, 60, "la")); + try + { + engine.Init(); + Assert.True(host.AudioPlaneShm, "capabilities.audio_plane=shm not honored"); + + var session = engine.CreateSession(ctx); + await session.SynthesizeNext(0, 0.5, CancellationToken.None); + + var seg = Assert.Single(ctx.Segments); + Assert.True(seg.Committed); + Assert.Equal(22050, seg.Written.Count); + Assert.True(Math.Abs(seg.Written[50] - 0.25f * MathF.Sin(2 * MathF.PI * 440f * 50 / 44100f)) < 1e-4); + session.Dispose(); + } + finally + { + host.Shutdown(); + } + } + sealed class VoiceTestPartContext : IVoiceSynthesisPartPropertyContext { public VoiceTestPartContext(VoiceTestContext ctx) => Parts = [new VoiceTestPartView(ctx)]; From 90362c507526697236cbd1fce6efda457e2c6034 Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Tue, 15 Sep 2026 23:43:37 +0800 Subject: [PATCH 11/12] M5: curve-plane shm result ring + lua-windows feed decision + fetch failure surfaced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M5a - curve result ring (§5.6/§6), completion of the shm transport trio: - host.evaluate now answers in one of two shapes: inline {values: base64 f64-LE} for small batches, or {region: {path, data_offset, count}} pointing at a per-session curve ring when on the shm plane and the payload is >=64KB - ShmRegion generalized: flags (audio-f32 | curve-f64), element size 4B/8B, WriteF64/ReadF64; ShmPlane.AllocateCurve names tlx---ev-; rings grow-only via SetGeometry and are keyed per session (the session serializes its own RPCs, so the ring is reused without a lock — the JSON-RPC response is the memory barrier) - rings freed on session teardown; boot sweep backstops crashes - python facade: shared eval_results() decodes both shapes (effect + voice); Node/Lua stay inline and never receive region (forward-compatible as designed) - seal: fake shm-sine adds a 20000-point evaluate asserting the reply MUST be a region with correct values (first/last == 0.25), reading the live host handle with FileShare.ReadWrite; python CurveRingDecodeTests cover both shapes M5b - Lua windows distribution: closed as a documented decision (§8.5/§8.6.1): platform matrix green ≠ feed cell — there is no portable (zip) prebuilt win64 lua at a mirror we can dual-source sha256-pin, and the NSIS installers that do exist are barred by §9's no-execution discipline. Windows Lua resolves via PATH/settings/-FromDir self-packed packages/vendoring; posix CI keeps real lua5.4 coverage. A future trustworthy portable artifact can add a feed cell with zero protocol change. M5c - fetch diagnostics reach the user: - RuntimeInstallQueue.Schedule executor/onDone now carry (ok, message) through the whole chain (the Result.Message was previously dropped to a bool) - manual [Fetch Runtime]: on failure the reason is parked on the button tooltip and the button stays clickable (retry); auto-fetch failures stay silent (log only) as before - queue test pinned for the tuple shapes + message passthrough assertion Wire spec: host.evaluate documents the dual result shape + response-as-barrier semantics; environment unchanged (no new methods). Full suite 689/689 (excl. pre-existing Setup* env baseline), Scripted 86x3 consecutive clean, python unittest 18/18. One pre-existing intermittent full-suite flake observed (9m run, unreproduced in 4 subsequent green runs; matches the long-standing SidecarSpawns-domain note). --- TuneLab/Extensions/Scripted/RemotePull.cs | 28 +++++++++ .../Scripted/RuntimeInstallQueue.cs | 20 +++++-- TuneLab/Extensions/Scripted/ShmPlane.cs | 57 +++++++++++++++++-- .../Extensions/ExtensionDetailWindow.cs | 9 +++ .../ExtensionSideBarContentProvider.cs | 11 +++- docs/scripted-plugin-sidecar-design.md | 29 ++++++++-- scripting/python/tests/test_facade.py | 27 +++++++++ scripting/python/tunelab/_effect.py | 19 ++++++- scripting/python/tunelab/_voice.py | 4 +- scripting/wire/v1.json | 2 +- tests/ScriptedFakeSidecar/Program.cs | 28 +++++++++ .../ScriptedRuntimeFetcherTests.cs | 17 +++--- 12 files changed, 219 insertions(+), 32 deletions(-) diff --git a/TuneLab/Extensions/Scripted/RemotePull.cs b/TuneLab/Extensions/Scripted/RemotePull.cs index 126ce0bd..7bba3c45 100644 --- a/TuneLab/Extensions/Scripted/RemotePull.cs +++ b/TuneLab/Extensions/Scripted/RemotePull.cs @@ -49,6 +49,8 @@ sealed class Table public readonly ConcurrentDictionary Segments = new(); // shm 面(M4b):协商为 shm 后创建的段的共享区域(id→region);体的唯一权威副本在区域里。 public readonly ConcurrentDictionary Regions = new(); + // 曲线面结果环(M5a / §5.6):per-session 一个 f64 区域,大批量求值走它;同一会话的 RPC 天然串行。 + public readonly ConcurrentDictionary EvalRings = new(); public int SegCounter; public int Attached; } @@ -76,6 +78,8 @@ public static void DisposeOwnedSegments(SidecarHost host, string sessionKey) if (!sTables.TryGetValue(host, out var table)) return; var prefix = sessionKey + "/seg"; + if (table.EvalRings.TryRemove(sessionKey, out var ring)) + try { ring.Dispose(); } catch { } foreach (var id in table.Segments.Keys.Where(k => k.StartsWith(prefix, StringComparison.Ordinal)).ToList()) if (table.Segments.TryRemove(id, out var seg)) { @@ -159,6 +163,30 @@ static void Attach(SidecarHost host, Table table) throw new SidecarRpcException("stale_handle", "no automation track '" + track + "'"); return r; }).ConfigureAwait(false); + + // §5.6/§6:小批量 inline f64 base64;shm 面下大批量(≥64KB)落 per-session 结果环, + // 应答改回 {region}(façade 映射读回)——序列化成本从 O(n) base64 降到 memcpy。 + if (host.AudioPlaneShm && (long)results.Length * 8 >= ShmPlane.CurveInlineBytes) + { + var ring = await OnSessionThread(s, () => + { + var reg = table.EvalRings.GetOrAdd(s.SessionKey, + _ => ShmPlane.AllocateCurve(host.PackageId, results.Length)); + if (results.Length > reg.SampleCount) + reg.SetGeometry(0, results.Length, 0); // 只增:扩容保内容 + reg.WriteF64(0, results); + return reg; + }).ConfigureAwait(false); + return new JsonObject + { + ["region"] = new JsonObject + { + ["path"] = ring.FilePath, + ["data_offset"] = ShmRegion.HeaderBytes, + ["count"] = results.Length, + }, + }; + } return new JsonObject { ["values"] = WireAudio.EncodeF64(results) }; })); diff --git a/TuneLab/Extensions/Scripted/RuntimeInstallQueue.cs b/TuneLab/Extensions/Scripted/RuntimeInstallQueue.cs index 1fccbbda..1c770794 100644 --- a/TuneLab/Extensions/Scripted/RuntimeInstallQueue.cs +++ b/TuneLab/Extensions/Scripted/RuntimeInstallQueue.cs @@ -16,26 +16,33 @@ internal static class RuntimeInstallQueue static readonly HashSet sInFlight = new(StringComparer.Ordinal); // executor 为测试注入点(单测不碰真网络);生产走 ScriptedRuntimeFetcher。 + // onDone 回执带 (packageDir, ok, message)——M5c:失败原因要能透到 UI(否则按钮只是转完就沉默)。 public static bool Schedule(string packageId, string packageDir, RuntimeSpec spec, - Func>? executor = null, Action? onDone = null) + Func>? executor = null, Action? onDone = null) { lock (sInFlight) { if (!sInFlight.Add(packageId)) return false; } - var run = executor ?? (async s => (await ScriptedRuntimeFetcher.FetchInstallAsync(s, progress: null, CancellationToken.None)).Ok); + var run = executor ?? (async s => + { + var res = await ScriptedRuntimeFetcher.FetchInstallAsync(s, progress: null, CancellationToken.None).ConfigureAwait(false); + return (res.Ok, res.Message); + }); var done = onDone ?? DefaultDone; _ = Task.Run(async () => { bool ok = false; + string message = ""; try { - ok = await run(spec).ConfigureAwait(false); - Log.Info(string.Format(System.Globalization.CultureInfo.InvariantCulture, "scripted runtime auto-fetch for {0}: {1}", packageId, ok ? "installed" : "failed")); + (ok, message) = await run(spec).ConfigureAwait(false); + Log.Info(string.Format(System.Globalization.CultureInfo.InvariantCulture, "scripted runtime auto-fetch for {0}: {1} ({2})", packageId, ok ? "installed" : "failed", message)); } catch (Exception ex) { + message = ex.Message; Log.Warning(string.Format(System.Globalization.CultureInfo.InvariantCulture, "scripted runtime auto-fetch for {0} threw: {1}", packageId, ex.Message)); } finally @@ -43,13 +50,14 @@ public static bool Schedule(string packageId, string packageDir, RuntimeSpec spe lock (sInFlight) sInFlight.Remove(packageId); } - try { done(packageDir); } catch { } + try { done(packageDir, ok, message); } catch { } }); return true; } - static void DefaultDone(string packageDir) + static void DefaultDone(string packageDir, bool ok, string message) { + if (!ok) return; // 自动档失败:静默(下次调用再试),仅日志 try { Dispatcher.UIThread.Post(() => ExtensionManager.Reload(packageDir)); } catch { } } diff --git a/TuneLab/Extensions/Scripted/ShmPlane.cs b/TuneLab/Extensions/Scripted/ShmPlane.cs index 7b679253..14001348 100644 --- a/TuneLab/Extensions/Scripted/ShmPlane.cs +++ b/TuneLab/Extensions/Scripted/ShmPlane.cs @@ -18,6 +18,9 @@ internal sealed class ShmRegion : IDisposable public const uint Magic = 0x41584C54; // 写 LE 后盘上字节 = 'T','L','X','A' public const ushort LayoutVersion = 1; + public const ushort FlagsAudioF32 = 0; // body = 交织 f32-LE,元素 4B(§6.2 音频段) + public const ushort FlagsCurveF64 = 1; // body = f64-LE,元素 8B(§5.6 求值结果环) + readonly FileStream m_file; MemoryMappedFile m_mapped; @@ -25,6 +28,8 @@ internal sealed class ShmRegion : IDisposable public long SampleOffset { get; private set; } public int SampleCount { get; private set; } public int SampleRate { get; private set; } + public ushort Flags { get; private set; } + public int ElementBytes => Flags == FlagsCurveF64 ? 8 : 4; long m_capacity; ShmRegion(FileStream file, MemoryMappedFile mapped, string path) @@ -34,16 +39,21 @@ internal sealed class ShmRegion : IDisposable FilePath = path; } - /// 创建/重开区域并把容量设为 header + 4*sampleCount(不足则扩文件,body 交集内容保留)。 - public static ShmRegion Open(string path, long sampleOffset, int sampleCount, int sampleRate) + /// 创建/重开区域并把容量设为 header + element*count(不足则扩文件,body 交集内容保留)。 + public static ShmRegion Open(string path, long sampleOffset, int sampleCount, int sampleRate, ushort flags = FlagsAudioF32) { - var capacity = (long)HeaderBytes + 4L * sampleCount; + var element = flags == FlagsCurveF64 ? 8 : 4; + var capacity = (long)HeaderBytes + (long)element * sampleCount; var file = new FileStream(path, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite); if (file.Length < capacity) file.SetLength(capacity); var mapped = MemoryMappedFile.CreateFromFile(file, null, capacity, MemoryMappedFileAccess.ReadWrite, HandleInheritability.None, leaveOpen: true); - var region = new ShmRegion(file, mapped, path) { SampleOffset = sampleOffset, SampleCount = sampleCount, SampleRate = sampleRate, m_capacity = capacity }; + var region = new ShmRegion(file, mapped, path) + { + SampleOffset = sampleOffset, SampleCount = sampleCount, SampleRate = sampleRate, + Flags = flags, m_capacity = capacity, + }; region.WriteHeader(sampleOffset, sampleCount, sampleRate); return region; } @@ -51,7 +61,7 @@ public static ShmRegion Open(string path, long sampleOffset, int sampleCount, in /// 改几何:先冲 header 进旧映射,再扩文件重开映射(body 内容留在文件里,映射须重建)。 public void SetGeometry(long sampleOffset, int sampleCount, int sampleRate) { - var capacity = (long)HeaderBytes + 4L * sampleCount; + var capacity = (long)HeaderBytes + (long)ElementBytes * sampleCount; if (capacity > m_capacity) { WriteHeader(sampleOffset, sampleCount, sampleRate); // header 经旧映射落文件 @@ -77,7 +87,7 @@ void WriteHeader(long sampleOffset, int sampleCount, int sampleRate) var span = header.AsSpan(); System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(span[0..4], Magic); System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(span[4..6], LayoutVersion); - // span[6..8] flags = 0(读视图位留 §6.2 input) + System.Buffers.Binary.BinaryPrimitives.WriteUInt16LittleEndian(span[6..8], Flags); System.Buffers.Binary.BinaryPrimitives.WriteInt64LittleEndian(span[8..16], sampleOffset); System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(span[16..20], sampleCount); System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(span[20..24], sampleRate); @@ -108,6 +118,29 @@ public void WriteBody(int sampleStart, ReadOnlySpan samples) view.WriteArray(0, bytes, 0, bytes.Length); } + /// 曲线面结果缓冲:写 f64-LE body(§5.6 大批量求值应答)。 + public void WriteF64(int start, ReadOnlySpan values) + { + if (values.Length == 0) return; + var bytes = new byte[values.Length * 8]; + for (int i = 0; i < values.Length; i++) + System.Buffers.Binary.BinaryPrimitives.WriteDoubleLittleEndian(bytes.AsSpan(i * 8, 8), values[i]); + using var view = m_mapped.CreateViewAccessor(HeaderBytes + 8L * start, bytes.Length, MemoryMappedFileAccess.ReadWrite); + view.WriteArray(0, bytes, 0, bytes.Length); + } + + /// 曲线面结果缓冲的 f64 读回(测试/诊断用;façade 侧自行映射读)。 + public double[] ReadF64(int start, int count) + { + var bytes = new byte[count * 8]; + using (var view = m_mapped.CreateViewAccessor(HeaderBytes + 8L * start, bytes.Length, MemoryMappedFileAccess.ReadWrite)) + view.ReadArray(0, bytes, 0, bytes.Length); + var vals = new double[count]; + for (int i = 0; i < count; i++) + vals[i] = System.Buffers.Binary.BinaryPrimitives.ReadDoubleLittleEndian(bytes.AsSpan(i * 8, 8)); + return vals; + } + public void Dispose() { try { m_mapped.Dispose(); } catch { } @@ -124,6 +157,9 @@ internal static class ShmPlane public static string RegionsDir => Path.Combine(Path.GetTempPath(), "tunelab-regions"); + /// 曲线面结果的 inline/shm 阈值(§6:base64 inline <64KB,超出走 shm 结果缓冲)。 + public const long CurveInlineBytes = 64 * 1024; + public static string Slug(string packageId) { var sb = new StringBuilder(); @@ -162,5 +198,14 @@ public static ShmRegion Allocate(string packageId, long sampleOffset, int sample return ShmRegion.Open(Path.Combine(RegionsDir, name), sampleOffset, sampleCount, sampleRate); } + /// 曲线面结果环(§5.6/§7:复用分配器,按 (boot, seq) 命名)。 + public static ShmRegion AllocateCurve(string packageId, int count) + { + EnsureSwept(); + Directory.CreateDirectory(RegionsDir); + var name = $"tlx-{SidecarIo.Nonce}-{Slug(packageId)}-ev-{Interlocked.Increment(ref sSeq)}"; + return ShmRegion.Open(Path.Combine(RegionsDir, name), 0, count, 0, ShmRegion.FlagsCurveF64); + } + public static string Nonce => SidecarIo.Nonce; } diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionDetailWindow.cs b/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionDetailWindow.cs index 858ff7f6..84904139 100644 --- a/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionDetailWindow.cs +++ b/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionDetailWindow.cs @@ -83,6 +83,7 @@ internal sealed class ExtensionDetailWindow : Window public event Action? ActivationChanged; TextBlock? mFetchRuntimeText; + Border? mFetchRuntimeBtn; bool mFetchRuntimeBusy; // 下载在途:按钮文案变"…"且不再触发事件(防双击重复排队;queue 本身也按包去重)。 @@ -93,6 +94,13 @@ public void SetFetchRuntimeBusy(bool busy) mFetchRuntimeText.Text = busy ? "…" : "Fetch Runtime".Tr(TC.Dialog); } + // M5c:下载失败时把原因透到按钮 tooltip(按钮保持可点=可重试),成功则交给 Reload 刷新窗口。 + public void SetFetchRuntimeResult(bool ok, string message) + { + if (ok || mFetchRuntimeBtn == null) return; + ToolTip.SetTip(mFetchRuntimeBtn, string.IsNullOrEmpty(message) ? "runtime fetch failed" : message); + } + public ExtensionDetailWindow(ExtensionDetailInfo info) { mPackageId = info.PackageId; @@ -386,6 +394,7 @@ Control BuildFetchRuntimeButton(string runtimeSpec) btn.PointerEntered += (_, _) => { if (!mFetchRuntimeBusy) btn.Background = Style.BUTTON_NORMAL_HOVER.ToBrush(); }; btn.PointerExited += (_, _) => { if (!mFetchRuntimeBusy) btn.Background = Style.BUTTON_NORMAL.ToBrush(); }; btn.PointerPressed += (_, e) => { e.Handled = true; if (!mFetchRuntimeBusy) FetchRuntimeRequested?.Invoke(); }; + mFetchRuntimeBtn = btn; return btn; } diff --git a/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionSideBarContentProvider.cs b/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionSideBarContentProvider.cs index 0c02735d..1c3f0e18 100644 --- a/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionSideBarContentProvider.cs +++ b/TuneLab/UI/MainWindow/Editor/SideBar/Extensions/ExtensionSideBarContentProvider.cs @@ -413,11 +413,16 @@ void OnFetchRuntime(ExtensionDetailWindow win, ExtensionLoadResult result) return; win.SetFetchRuntimeBusy(true); var pkgId = result.Id ?? result.DirectoryPath; - RuntimeInstallQueue.Schedule(pkgId, result.DirectoryPath, spec, onDone: dir => Avalonia.Threading.Dispatcher.UIThread.Post(() => + RuntimeInstallQueue.Schedule(pkgId, result.DirectoryPath, spec, onDone: (dir, ok, message) => Avalonia.Threading.Dispatcher.UIThread.Post(() => { win.SetFetchRuntimeBusy(false); - ExtensionManager.Reload(dir); - RefreshExtensions(); + if (ok) + { + ExtensionManager.Reload(dir); + RefreshExtensions(); + } + else + win.SetFetchRuntimeResult(false, message); // M5c:失败原因回挂按钮 tooltip(可重试) })); } diff --git a/docs/scripted-plugin-sidecar-design.md b/docs/scripted-plugin-sidecar-design.md index eb42e209..1a0454a3 100644 --- a/docs/scripted-plugin-sidecar-design.md +++ b/docs/scripted-plugin-sidecar-design.md @@ -1,8 +1,7 @@ # 脚本插件 Sidecar 系统设计(Python / Node.js / Lua) -> 状态:**定稿;M4 已落地**(shm 音频面:区域分配器/头布局/协商/fake 与 Python 直写/CI; -> Node façade format+真解释器 E2E/握手冒烟;Lua façade 逻辑单测+CI posix 真跑)。 -> 剩余:M5 = 曲线面 shm 结果缓冲、Lua windows 分发、stderr 明细清理策略。 +> 状态:**定稿;M0–M5 全部落地**(M5 = 曲线面 shm 结果环、Lua-windows 关闭决策、fetch 失败原因 +> 透 UI)。剩余 = 纯工程化:release 工具补 pin、性能基准驱动的值编码平替评估(§6 预留位)。 > 基线 = fork `master`(2.1.1,`cfa9fff7`),分支 `feat/scripted-extensions`。 > 配套阅读:[plugin-development.md](plugin-development.md)(V1 插件契约)、[sdk-api-evolution.md](sdk-api-evolution.md)(冻结 ABI 纪律)、[naming-glossary.md](naming-glossary.md)。 > 本文只设计**宿主侧与协议侧**;`TuneLab.SDK` / `TuneLab.Foundation` 一行不动(§1.3)。 @@ -389,6 +388,13 @@ macOS:下载的二进制在启动前剥 `com.apple.quarantine`(安装钩子 `fetch_scripted_runtime`,授权卡片 = ExtensionInstall 档);纯手动(设置路径 / -FromDir 自制包)。 - **实测**(真链路自证):win-x64 格经华为云 4.3s 装好 python 3.13.3 embed, 校验通过后 `python.exe` 自报 `3.13.3 AMD64`;`RuntimeLocator` ③ 级即命中。 +- **Lua 的 windows 格(M5b 定案:矩阵绿 ≠ feed 有格,feed 暂缺)**:§8.5 说 Windows 能**承载** + Lua(PUC C89 源码可便携编译),但**自动部署镜像表不发 Lua 的 windows 格**——因为拿不到 + "可钉 sha256 的便携 zip":官方只出源码;唯一预编译的 lua-for-windows 是 NSIS `.exe` 安装器, + §9 免执行纪律不允许后台静默跑第三方安装器;npmmirror/华为云无 lua 二进制树。故 windows 上 + Lua 只经 ① 设置指定 / ② PATH / §8.6 的 `-FromDir` 自制包 / 作者 vendoring 命中;posix(apt/brew + 出的是可 `lua` 命令)三平台 CI 真跑 façade 与 E2E。**未来若出现可信的便携 win64 lua 发行件** + (zip、可双源比对钉 hash),再补 feed 格,不改协议。 ### 8.7 并存与回收 @@ -555,8 +561,21 @@ scripted.initTimeoutSec int = 0 # 0 = 无上限(§5.5 长任 - 期间踩到 Windows 共享模式坑:宿主 FileStream 是 RW,sidecar 用 `CreateFromFile(path,…)` 重载(默认 `FileShare.Read`)会 ERROR_LOCK_VIOLATION——两侧都须 `FileShare.ReadWrite` (SafeFileHandle 重载)。 -- **仍推迟**:曲线面 shm 结果缓冲(M5);Lua 的 windows 分发(随 feed 加格,M5); - 【下载运行时】按钮的 stderr 明细清理策略(M5)。 +- **M5 落地**: + - **M5a 曲线面结果环**(§5.6/§6):`host.evaluate` 双形应答——小批量仍 `values` base64 f64; + shm 面下 ≥64KB 落 per-session 结果环(`ShmPlane.AllocateCurve`,`flags=curve-f64`、 + 元素 8B、只增扩容),应答改回 `region{path,data_offset,count}`。RPC 应答即内存屏障 + (同会话调用串行,环复用安全)。Python `eval_results` 统一解码两形;Node/Lua 恒 inline + 永不收 region(自动兼容)。封条:fake `shm-sine` 加 20000 点求值断言必走 region 且值对, + Python `CurveRingDecodeTests`。 + - **M5b Lua windows 分发(关闭决策)**:矩阵绿 ≠ feed 有格——见 §8.6.1 新注,无便携可钉 zip + 发行件、NSIS 安装器违 §9 免执行,故 feed 不发 windows-lua 格;windows 上 Lua 经 PATH/设置/ + 自制包/vendoring 命中,posix CI 真跑。将来有可信便携件再补格,不动协议。 + - **M5c fetch 失败原因透 UI**:`RuntimeInstallQueue.Schedule` 的 executor/onDone 改带 + `(ok, message)`;手动【下载运行时】失败时把原因挂回按钮 tooltip(按钮保持可重试), + 自动档失败仍静默(仅日志)。 +- **剩余(纯工程化,非设计缺口)**:release 工具补未钉 sha256 的 feed 格;性能基准若要求再评估 + 值编码平替位(§6)。 - **实现差异**: - 崩溃后不自动定时复活,**下一次调用驱动重 spawn**(backoff/熔断仍生效)——format 拉模式下 自动重启只有缓存收益;effect push 调度落地时(M2b)再补后台重启环。 diff --git a/scripting/python/tests/test_facade.py b/scripting/python/tests/test_facade.py index 3a213a73..cf7e8a03 100644 --- a/scripting/python/tests/test_facade.py +++ b/scripting/python/tests/test_facade.py @@ -249,5 +249,32 @@ def test_shm_view_only_on_shm_segments(self): self.assertEqual(view.shape, (self.count,)) +class CurveRingDecodeTests(unittest.TestCase): + """M5a: host.evaluate small batches stay inline base64; shm-plane big batches come back + as a region{path,data_offset,count} pointing at a curve ring read as f64-LE.""" + + def setUp(self): + self._tmp = tempfile.mkdtemp(prefix="tlx-curve-test-") + + def tearDown(self): + shutil.rmtree(self._tmp, ignore_errors=True) + + def test_inline_decode(self): + import base64, struct + from tunelab import _effect + r = {"values": base64.b64encode(struct.pack("<3d", 1.5, -2.5, 0.25)).decode()} + self.assertEqual(_effect.eval_results(r), [1.5, -2.5, 0.25]) + + def test_region_decode(self): + import struct + from tunelab import _effect + vals = [0.25] * 5 + [-1.0] + path = os.path.join(self._tmp, "curve") + with open(path, "wb") as f: + f.write(b"\x00" * 24 + struct.pack("<6d", *vals)) + r = {"region": {"path": path, "data_offset": 24, "count": len(vals)}} + self.assertEqual(_effect.eval_results(r), vals) + + if __name__ == "__main__": unittest.main() diff --git a/scripting/python/tunelab/_effect.py b/scripting/python/tunelab/_effect.py index 9204fe72..2739468e 100644 --- a/scripting/python/tunelab/_effect.py +++ b/scripting/python/tunelab/_effect.py @@ -19,6 +19,22 @@ def set_channels(call_host, notify_host): _call_host, _notify_host = call_host, notify_host +def eval_results(r): + """Decode a host.evaluate reply. Small batches come back as inline base64 f64; + on the shm plane a large batch (>=64KB) comes back as {region:{path,data_offset,count}} + pointing at a per-session curve ring — the RPC response itself is the barrier, so we + just read the file bytes and copy them into a fresh list (§5.6/§6).""" + reg = r.get("region") + if reg is not None: + count = int(reg["count"]) + with open(reg["path"], "rb") as f: + f.seek(int(reg.get("data_offset", 24))) + data = f.read(count * 8) + return list(struct.unpack("<%dd" % count, data)) + data = base64.b64decode(r["values"]) + return list(struct.unpack("<%dd" % (len(data) // 8), data)) + + def register_effect(obj, engine_id): _effect_engines[engine_id] = obj @@ -230,8 +246,7 @@ def automation_tracks(self): def evaluate(self, track, positions): pos = base64.b64encode(struct.pack("<%dd" % len(positions), *positions)).decode("ascii") r = _call_host("host.evaluate", self._via({"track": track, "positions": pos})) - data = base64.b64decode(r["values"]) - return list(struct.unpack("<%dd" % (len(data) // 8), data)) + return eval_results(r) def output(self, sample_offset, sample_count, sample_rate): r = _call_host("host.segment_create", self._via({"sample_offset": sample_offset, diff --git a/scripting/python/tunelab/_voice.py b/scripting/python/tunelab/_voice.py index 14bfd4d7..dff4ed6d 100644 --- a/scripting/python/tunelab/_voice.py +++ b/scripting/python/tunelab/_voice.py @@ -188,8 +188,8 @@ def evaluate(self, positions): pos = base64.b64encode(struct.pack("<%dd" % len(positions), *positions)).decode() r = _call_host("host.evaluate", {"engine": self._key[0], "session": self._key[1], "track": self.track, "positions": pos}) - data = base64.b64decode(r["values"]) - return list(struct.unpack("<%dd" % (len(data) // 8), data)) + from ._effect import eval_results + return eval_results(r) class Snapshot: diff --git a/scripting/wire/v1.json b/scripting/wire/v1.json index 958c274d..eb27481b 100644 --- a/scripting/wire/v1.json +++ b/scripting/wire/v1.json @@ -93,7 +93,7 @@ "host.input_read": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "offset": "i32 (segment-relative)", "count": "i32?" }, "result": { "data": "base64 f32-LE" }, "notes": "host executes on the data thread (live-view discipline) with a 30s marshal cap; chunk limit 1<<20 samples" }, "host.properties": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "keys": ["string"] }, "result": { "values": "" } }, "host.automation_tracks": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string" }, "result": { "tracks": ["string"] } }, - "host.evaluate": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "track": "string", "positions": "base64 f64-LE (non-decreasing)" }, "result": { "values": "base64 f64-LE" }, "notes": "live automation evaluated on the host's data thread — interpolation stays host-side (§5.6)" }, + "host.evaluate": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "track": "string", "positions": "base64 f64-LE (non-decreasing)" }, "result": { "values?": "base64 f64-LE (inline, <64KB)", "region?": "{path, data_offset, count} on the shm plane when the batch is >=64KB (§5.6 curve result ring)" }, "notes": "live automation evaluated on the host's data thread — interpolation stays host-side (§5.6). Exactly one of values/region is present. The RPC response is the memory barrier for the region (the session serializes its own calls, so the ring buffer is reused safely)" }, "host.segment_create": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "sample_offset": "i64", "sample_count": "i32", "sample_rate": "i32" }, "result": { "segment": "string", "region?": "{path, name, data_offset, sample_offset, sample_count, sample_rate} when the handshake negotiated audio_plane=shm" }, "notes": "shm plane (§7): the facade maps the region file and writes the body directly; the host copies region->segment buffer on commit (the commit control message is the only barrier)" }, "host.segment_write": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string", "offset": "i32", "data": "base64 f32-LE" }, "result": {}, "notes": "inline fallback (PUC Lua / non-mmap platforms). On the shm plane the facade normally skips this and writes the region; if it is sent for a shm segment the host proxies it into the region body" }, "host.segment_commit": { "direction": "sidecar->host", "params": { "engine": "string", "session": "string", "segment": "string" }, "result": {}, "notes": "the sole barrier into the effect graph; on the shm plane this is where the host copies the region body into the segment buffer" }, diff --git a/tests/ScriptedFakeSidecar/Program.cs b/tests/ScriptedFakeSidecar/Program.cs index 421373df..a7e9cb3d 100644 --- a/tests/ScriptedFakeSidecar/Program.cs +++ b/tests/ScriptedFakeSidecar/Program.cs @@ -209,6 +209,34 @@ void Notify(string method, JsonObject args) if (BinaryPrimitives.ReadDoubleLittleEndian(curve.AsSpan(0, 8)) != 0.25) throw new InvalidOperationException("pitch evaluate wrong"); + if (mode == "shm-sine") + { + // M5a:大批量求值(>64KB)在 shm 面应走结果环 region 而非 inline base64。 + const int bigN = 20000; + var bigPos = new byte[bigN * 8]; + for (int i = 0; i < bigN; i++) + BinaryPrimitives.WriteDoubleLittleEndian(bigPos.AsSpan(i * 8, 8), start + (end - start) * i / bigN); + var evBig = Reverse("host.evaluate", Via2(new JsonObject { ["track"] = "pitch", ["positions"] = Convert.ToBase64String(bigPos) })); + if (evBig!.AsObject().ContainsKey("values")) + throw new InvalidOperationException("big evaluate should have used the shm curve ring, not inline values"); + var creg = evBig["region"] as JsonObject ?? throw new InvalidOperationException("big evaluate: no region in reply"); + int cCount = creg["count"]!.GetValue(); + int cDataOff = creg["data_offset"]!.GetValue(); + if (cCount != bigN) throw new InvalidOperationException("curve ring count mismatch: " + cCount); + // FileShare.ReadWrite:宿主结果环句柄是 RW,旁观读必须兼容其共享模式(同段区域纪律)。 + using (var cf = new FileStream(creg["path"]!.GetValue(), FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (var cmf = System.IO.MemoryMappedFiles.MemoryMappedFile.CreateFromFile( + cf.SafeFileHandle, null, 0, System.IO.MemoryMappedFiles.MemoryMappedFileAccess.Read, + System.IO.HandleInheritability.None, leaveOpen: true)) + using (var view = cmf.CreateViewAccessor(cDataOff, cCount * 8, System.IO.MemoryMappedFiles.MemoryMappedFileAccess.Read)) + { + var first = view.ReadDouble(0); + var last = view.ReadDouble((cCount - 1) * 8); + if (first != 0.25 || last != 0.25) + throw new InvalidOperationException("curve ring values wrong: " + first + "/" + last); + } + } + const int rate = 44100; int count = (int)((end - start) * rate); var samples = new float[count]; diff --git a/tests/TuneLab.Tests/ScriptedRuntimeFetcherTests.cs b/tests/TuneLab.Tests/ScriptedRuntimeFetcherTests.cs index c92ca87d..18f046fd 100644 --- a/tests/TuneLab.Tests/ScriptedRuntimeFetcherTests.cs +++ b/tests/TuneLab.Tests/ScriptedRuntimeFetcherTests.cs @@ -192,20 +192,23 @@ public class RuntimeInstallQueueTests [Fact] public async Task DedupsInFlightAndCallsOnDoneAfterCompletion() { - var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var dones = new List(); + var gate = new TaskCompletionSource<(bool, string)>(TaskCreationOptions.RunContinuationsAsynchronously); + var dones = new List<(string, bool, string)>(); var scheduled = RuntimeInstallQueue.Schedule("com.test.q-a", "C:\\pkg\\a", Parse("python>=3.10"), - executor: _ => gate.Task, onDone: d => dones.Add(d)); + executor: _ => gate.Task, onDone: (d, ok, m) => dones.Add((d, ok, m))); Assert.True(scheduled); Assert.True(RuntimeInstallQueue.IsPending("com.test.q-a")); Assert.False(RuntimeInstallQueue.Schedule("com.test.q-a", "C:\\pkg\\a", Parse("python>=3.10"), - executor: _ => Task.FromResult(true), onDone: dones.Add)); // 同包在途去重 + executor: _ => Task.FromResult((true, "")), onDone: (d, ok, m) => dones.Add((d, ok, m)))); // 同包在途去重 Assert.True(RuntimeInstallQueue.Schedule("com.test.q-b", "C:\\pkg\\b", Parse("python>=3.10"), - executor: _ => Task.FromResult(true), onDone: dones.Add)); // 不同包不受影响 - gate.SetResult(true); + executor: _ => Task.FromResult((false, "no mirror reachable")), onDone: (d, ok, m) => dones.Add((d, ok, m)))); // 不同包不受影响;M5c:失败带 message + gate.SetResult((true, "installed")); var spin = Task.Run(() => { while (dones.Count < 2) Thread.SpinWait(1000); }); Assert.Same(spin, await Task.WhenAny(spin, Task.Delay(5000))); - Assert.Contains("C:\\pkg\\a", dones); + Assert.Contains("C:\\pkg\\a", dones.Select(x => x.Item1)); + var failed = dones.First(x => x.Item1 == "C:\\pkg\\b"); + Assert.False(failed.Item2); + Assert.Equal("no mirror reachable", failed.Item3); // 失败原因透到回执 Assert.False(RuntimeInstallQueue.IsPending("com.test.q-a")); } From 5f77b2a508cc7eb73858c60865efa21514595372 Mon Sep 17 00:00:00 2001 From: mhbalthasar Date: Wed, 16 Sep 2026 08:30:53 +0800 Subject: [PATCH 12/12] docs: scripted plugin development guides (Python / Node.js / Lua) for format/effect/voice plugin authors --- docs/LuaScriptPluginDevelopment.md | 427 ++++++++++++++++++++ docs/NodeJSScriptPluginDevelopment.md | 398 +++++++++++++++++++ docs/PythonScriptPluginDevelopment.md | 538 ++++++++++++++++++++++++++ 3 files changed, 1363 insertions(+) create mode 100644 docs/LuaScriptPluginDevelopment.md create mode 100644 docs/NodeJSScriptPluginDevelopment.md create mode 100644 docs/PythonScriptPluginDevelopment.md diff --git a/docs/LuaScriptPluginDevelopment.md b/docs/LuaScriptPluginDevelopment.md new file mode 100644 index 00000000..6d962877 --- /dev/null +++ b/docs/LuaScriptPluginDevelopment.md @@ -0,0 +1,427 @@ +# TuneLab Lua 脚本插件开发指南 + +> 面向**插件开发者**:如何用 **Lua** 为 TuneLab 编写 `format` / `effect` / `voice` 脚本插件。 +> +> - 与 [PythonScriptPluginDevelopment.md](PythonScriptPluginDevelopment.md)、[NodeJSScriptPluginDevelopment.md](NodeJSScriptPluginDevelopment.md) 结构对齐:**线协议同一套**,差别在语言 façade 的命名、异步模型(Lua 无 async,同步 RPC)与依赖打包。 +> - 进程外执行单元术语 **sidecar**([naming-glossary.md](naming-glossary.md));宿主侧设计/语言无关规范见 [scripted-plugin-sidecar-design.md](scripted-plugin-sidecar-design.md) 与 [`scripting/wire/v1.json`](../scripting/wire/v1.json)。 + +**关键事实**:Lua 插件由宿主 spawn 成独立 `lua` 进程(sidecar),走 **JSON-RPC 2.0 over stdio**。façade 是**单文件纯 Lua**(`scripting/lua/tunelab.lua`,内嵌 JSON codec 与 base64/f32 打包),兼容 **PUC Lua 5.1–5.4 与 LuaJIT**。 + +**Lua 的三点独有约束**(务必先记): + +1. **无异步**:反向调用(`ctx.input_read` / `ctx.evaluate` …)是**同步阻塞**函数,直接返回结果表——没有 `await`。 +2. **音频面 = inline**:PUC Lua 自报 `capabilities.audio_plane="inline"`,PCM 走 base64(无 stdlib mmap)。功能等价,不经共享内存。 +3. **表面更“薄”、更贴线**:段是**整数 id**(`ctx.output(...)` 返回 id,再 `ctx.write(seg, off, samples)` / `ctx.commit(seg)`),`ctx.input_read` 直接给你宿主回复的原语表。数组要序列化成 `[]`(而非 `{}`)时用 `tunelab.empty_array()`。`ctx.evaluate`/f64 载荷需 **Lua 5.2+** 的 `string.pack/unpack`。 + +> 现实取舍:**Lua 最成熟、有维护样例的路径是 `format`**。`effect`/`voice` 的 Lua façade 面已实现,但样例集目前只充分演练了 format;要写“读整段输入并原样偏移输出”的音频引擎时,注意下面 §6 标注的几何/偏移注意点——重音频引擎优先考虑 Python(有 shm 快车道与完整样例)。 + +--- + +## 目录 + +1. [运行前提与获取解释器](#1-运行前提与获取解释器) +2. [包结构与 manifest.json](#2-包结构与-manifestjson) +3. [执行模型与生命周期](#3-执行模型与生命周期) +4. [第一个插件:format](#4-第一个插件format) +5. [数据形状规范:ProjectInfo](#5-数据形状规范projectinfo) +6. [effect 插件](#6-effect-插件) +7. [voice 插件](#7-voice-插件) +8. [settings 设置面板](#8-settings-设置面板) +9. [日志与诊断](#9-日志与诊断) +10. [交互规范](#10-交互规范宿主契约--你的义务) +11. [代码规范](#11-代码规范) +12. [依赖 vendoring](#12-依赖-vendoring) +13. [本地调试与打包](#13-本地调试与打包) +14. [完整 Demo(format)](#14-完整-demoformat) +15. [常见坑 / FAQ](#15-常见坑--faq) + +--- + +## 1. 运行前提与获取解释器 + +manifest 写 `"runtime": "lua>=5.1"`(或 `"luajit>=2.1"`),宿主四级探测:**设置指定 → 环境变量 `TUNELAB_LUA`/`TUNELAB_LUAJIT` → `type:"runtime"` 资源包 → PATH(`lua`/`lua5.4`)**。 + +> **Windows 上 Lua 无自动分发**:平台矩阵上 Windows *能承载* Lua(PUC C89 可便携编译),但宿主的【下载运行时】镜像表**不发 windows-lua 格**——没有可钉 sha256 的便携 zip 发行件、预编译版只有 NSIS 安装器(违反免执行纪律)。故 Windows 上请:**自带编译好的 `lua.exe` 放进 PATH**、或用 `TUNELAB_LUA` 指定、或打一个 `type:"runtime"` 资源包。macOS/Linux(apt/brew 出 `lua`)则开箱即用,CI 也在 posix 上真跑 Lua 用例。 + +--- + +## 2. 包结构与 manifest.json + +``` +MyLuaPlugin/ +├── manifest.json +└── plugin/ + ├── main.lua # entry 指向它 + └── tunelab.lua # vendoring 的单文件 façade(§12) +``` + +字段同通用规则,只 flavor 不同: + +| 字段 | 必需 | 说明 | +|---|---|---| +| `id` / `name` / `version` / `author` / `description` | id/name ✓ | 同通用。 | +| `sdk-version` | ✓ | 脚本插件写 `"1.1"`。 | +| `type` | ✓ | `format` / `effect` / `voice`。 | +| `runtime` | ✓ | `"lua>=5.1"`(PUC)或 `"luajit>=2.1"`。 | +| `entry` | ✓ | 包内相对路径,如 `plugin/main.lua`;不能越出包目录。 | +| `suffixes` / `import-suffixes` / `export-suffixes` | format | 同通用。 | +| `engine` | effect/voice ✓ | 引擎 id。 | +| `platforms` | | `["win","osx","linux"]`。 | +| `settings` | | 布尔。 | +| `deps` | | 只用 `vendored`/留空。 | + +```json +{ + "id": "com.example.luafmt", + "name": "Lua Format (txt notes)", + "version": "1.0.0", + "sdk-version": "1.1", + "type": "format", + "suffixes": ["lpx"], + "runtime": "lua>=5.1", + "entry": "plugin/main.lua", + "platforms": ["win", "osx", "linux"] +} +``` + +--- + +## 3. 执行模型与生命周期 + +与 Python/Node 版一致:**懒 spawn → `initialize` 握手核对 `provides` → epoch 世代 → 崩溃 backoff 重拉 + 10 分钟 5 次熔断 → effect 回退上版 / voice 会话重建 → 心跳兜底 → 取消尽力通知 → stdin EOF 即退**。Lua 补充: + +- **同步无并发**:一条 RPC 处理完才读下一条;不存在“事件循环被堵”,但一个死循环的 `synthesize` 同样会让心跳超时→被判僵→重拉。长循环请轮询 `ctx.cancelled`。 +- **`os.exit` 慎用**:façade 收到 `shutdown` 会自己收尾;用户代码里 `os.exit(0)` 会跳过优雅应答。 + +> **不要用 `print`**——stdout 是协议信道。日志用 `tunelab.info/warn/error`(§9)。 + +--- + +## 4. 第一个插件:format + +`tunelab.format{ import_project = function(path) ... end, export_project = function(path, project) ... end }`。**`stream_ref`/`path` 是宿主铺好的文件路径**:import 读它、export 写它,用完自动删。 + +```lua +package.path = package.path .. ";./vendor/?.lua" -- vendored façade 优先(§12) +local tunelab = require("tunelab") + +local function lines(s) -- 小工具:按行切 + local out = {} + for line in (s .. "\n"):gmatch("([^\n]*)\n") do + if line ~= "" and line:sub(1,1) ~= "#" then out[#out+1] = line end + end + return out +end + +tunelab.format({ + import_project = function(path) + local f = assert(io.open(path, "r")); local text = f:read("*a"); f:close() + local notes = {} + for _, line in ipairs(lines(text)) do + local p, pos, dur, lyric = line:match("^(%S+)%s+(%S+)%s+(%S+)%s*(.*)$") + if p then + notes[#notes+1] = { + pitch = tonumber(p), pos = tonumber(pos), dur = tonumber(dur), + lyric = (lyric ~= "" and lyric) or "la", pronunciation = "", + properties = { source = "lpx" }, -- 自由值,自动 tagged + leadingPhonemes = tunelab.empty_array(), bodyPhonemes = tunelab.empty_array(), + bodyOffset = 0.0, + } + end + end + return { + tempos = { { pos = 0.0, bpm = 120.0 } }, + timeSignatures = { { barIndex = 0, numerator = 4, denominator = 4 } }, + tracks = { { + name = "Lua Track", gain = 0.0, pan = 0.0, mute = false, solo = false, asRefer = true, color = "", + parts = { { + kind = "midi", name = "P", pos = 0.0, startOffset = 0.0, endOffset = 0.0, + gain = 0.0, soundSource = { kind = "voice", type = "", id = "" }, + effects = tunelab.empty_array(), notes = notes, + automations = {}, piecewiseAutomations = {}, + pitch = { segments = tunelab.empty_array() }, vibratos = tunelab.empty_array(), properties = {}, + } }, + } }, + } + end, + + export_project = function(path, project) + local out = {} + for _, track in ipairs((project or {}).tracks or {}) do + for _, part in ipairs(track.parts or {}) do + for _, n in ipairs(part.notes or {}) do + out[#out+1] = string.format("%s %s %s %s", n.pitch, n.pos, n.dur, n.lyric or "la") + end + end + end + local f = assert(io.open(path, "w")); f:write(table.concat(out, "\n") .. (#out > 0 and "\n" or "")); f:close() + end, +}) + +tunelab.serve() +``` + +- **空数组要显式**:Lua 里 `{}` 既像空对象又像空数组——任何“线上必须是 `[]`”的字段(空的 `notes`/`effects`/`segments`/`properties` 列表)用 `tunelab.empty_array()` 包一下,否则会被编码成 `{}`。这是 Lua 版最容易踩的编码坑。 +- 只读格式省略 `export_project`。多格式用 `tunelab.format{ entry = "a", ... }` 对齐 manifest `entries[].entry`。 +- 结尾 `tunelab.serve()`。 + +--- + +## 5. 数据形状规范:ProjectInfo + +同通用:`tracks → parts → notes`,键名 **camelCase**(`soundSource`、`leadingPhonemes`、`bodyOffset`、`timeSignatures`、`piecewiseAutomations`);note 的 `pos/dur` 为 tick、`pitch` 半音、PartInfo 判别 `"kind":"midi"|"audio"`。`properties` 自由值自动打 tag/还原(`{source="lpx"}` 回来仍是表)。特殊浮点读写两端自动转字符串字面量(`0/0`、`math.huge`)。`Multiple` 不跨线。**再强调空数组:凡是宿主期望 `[]` 的,用 `tunelab.empty_array()`。** + +--- + +## 6. effect 插件 + +`tunelab.effect("引擎id", { ... })`。引擎表钩子(**均 snake_case**):`init` / `destroy` / `get_property_config(effects)` / `get_automation_configs(effects)` / `get_synthesized_parameter_configs(effects)` / `create_session(ctx)`(返回“会话用户表”,省略则用引擎表自身当会话)。会话表钩子:`process(ctx)` / `cancel` / `dispose`。 + +```lua +local tunelab = require("tunelab") + +-- façade 只导出 base64(_unb64)与编码(_b64_encode_f32);PCM 的 f32-LE 解码要你自己用 +-- string.unpack 完成(需 Lua 5.2+)。下面这个本地小 helper 在 effect/voice 读输入时反复用到。 +local function decode_f32(b64str) + local raw = tunelab._unb64(b64str) + local out, i = {}, 1 + while i <= #raw do out[#out+1], i = string.unpack(", sample_rate = } + local r = ctx.input_read(0, 0) -- count=0 ⇒ 读整段输入 + local rate = r.sample_rate + local data = decode_f32(r.data) + local count = #data + local seg = ctx.output(0, count, rate) -- 输出段(offset 见下方注意) + local out = {} + for i = 1, count do out[i] = data[i] * self.gain end + ctx.write(seg, 0, out) -- seg 是 id,out 是 f32 数值数组 + ctx.commit(seg) + local t0, t1 = 0, count / rate + ctx.status({ { t0, t1, "synthesized", 1.0 } }) + ctx.parameters({ loudness = { segments = { { { t0, 0.4 }, { t1, 0.4 } } } } }) +end +function Gain.cancel(self) end +function Gain.dispose(self) end + +tunelab.effect("LuaGain", Gain) +tunelab.serve() +``` + +会话 `ctx`(Lua,均同步,段用 id): + +| 方法 | 作用 | +|---|---| +| `ctx.input_read(offset, count)` | 返回 `{ data = , sample_rate }`(`count=0/nil` 读整段);PCM 用上面的 `decode_f32`(`tunelab._unb64` + `string.unpack`)解成逐样本 | +| `ctx.properties(keys)` | part 级参数值(已解码为原生表) | +| `ctx.automation_tracks()` | 自动化轨 id 列表 | +| `ctx.evaluate(track, positions)` | 曲线求值,返回 `list`(**需 Lua 5.2+** 的 `string.pack`;`positions` 非降序) | +| `ctx.output(offset, count, rate)` | 新建输出段,返回**段 id**(记入 `ctx.segments` 供自动回收) | +| `ctx.write(seg, offset, samples)` / `ctx.commit(seg)` | 写 / 提交(`samples` = 数字数组,f32) | +| `ctx.status(segments)` / `ctx.parameters(map)` | 发布状态 / 回显(effect 会话发 `effect.*`) | +| `ctx.segments` / `ctx.cancelled` | 段 id 列表 / 取消标志 | + +> **几何注意(Lua 特有)**:`host.input_read` 回复只含 `data` + `sample_rate`,**不回绝对 `sample_offset`**,而 Lua façade 也未把 create_session 携带的 `input.sample_offset` 透成 `ctx.input`。所以上例把输出放在 offset 0——对“作用于段首”的效果正确,但若 effect 挂在非零起点段上、又需要严格对齐绝对轴,请改用 Python/Node(它们的 `ctx.input`/`inputGeometry()` 带完整几何)。这是当前 Lua façade 面较薄之处。 + +--- + +## 7. voice 插件 + +`tunelab.voice("引擎id", { ... })`。引擎钩子:`init`/`destroy`/`catalog()`(返回 `sources, layout`)/`automation_configs(parts)`/`synthesized_parameter_configs(parts)`/`part_property_config(parts)`/`note_property_config(part, notes)`/`phoneme_property_configs(part, notes)`/`create_session(ctx)`。会话钩子:`default_lyric`(字段)、`is_continuation(note)`、`peek(start, end)`、`synthesize(start, end)`、`on_dirty(kind, payload)`、`cancel`、`dispose`。 + +```lua +local tunelab = require("tunelab") +local math = require("math") + +local Sine = {} +function Sine.init(self) self.pos = 0.0 end +function Sine.destroy(self) end + +function Sine.catalog(self) + return { { id = "sine", name = "Sine Bank", description = "440Hz" } }, tunelab.empty_array() +end +function Sine.automation_configs(self, parts) return tunelab.empty_array() end +function Sine.synthesized_parameter_configs(self, parts) return tunelab.empty_array() end +function Sine.part_property_config(self, parts) return { type = "object", properties = tunelab.empty_array() } end +function Sine.note_property_config(self, part, notes) return { type = "object", properties = tunelab.empty_array() } end +function Sine.phoneme_property_configs(self, part, notes) return tunelab.empty_array() end + +function Sine.is_continuation(self, note) return false end +function Sine.peek(self, start, end) + if self.pos >= end then return nil end + return { math.max(start, self.pos), math.min(self.pos + 0.5, end) } +end +function Sine.synthesize(self, start, end) + if start < self.pos then return end -- 幂等:已合成的块直接返回 + local rate = 44100 + local count = math.floor((end - start) * rate) + local seg = self.ctx.output(math.floor(start * rate), count, rate) + local buf = {} + for i = 0, count - 1 do buf[i+1] = 0.2 * math.sin(2 * math.pi * 440 * i / rate) end + self.ctx.write(seg, 0, buf) + self.ctx.commit(seg) + self.ctx.status({ { start, end, "synthesized", 1.0 } }) -- voice 会话自动路由到 voice.status + self.ctx.pitch({ { { start, 60.0 }, { end, 62.0 } } }) + local notes = self.ctx.notes() + local syl = {} + for _, n in ipairs(notes) do + if n.end > start and n.start < end then + syl[n.id] = { tunelab.empty_array(), { { symbol = n.lyric or "a", duration = n.end - n.start, stretch_weight = 1.0 } }, 0.0 } + end + end + self.ctx.phonemes(syl) -- key = note id(回指 request_id) + self.pos = end +end +function Sine.on_dirty(self, kind, payload) end +function Sine.cancel(self) end +function Sine.dispose(self) end + +function Sine.create_session(self, ctx) + -- 会话直接复用引擎表,但把 ctx 挂上(本例单会话;多会话请 new 一个带 ctx 的对象) + local s = setmetatable({}, { __index = Sine }); s.ctx = ctx; s.pos = 0.0; return s +end + +tunelab.voice("LuaSine", Sine) +tunelab.serve() +``` + +- voice 会话 `ctx` 比 effect 多:`ctx.notes()`(宿主回复的音符表数组,含 `id/start/end/pitch/lyric/body/leading/body_offset/properties`)、`ctx.snapshot(note_ids)`(返回 `snapshot` 原语表)、`ctx.pitch(segments)`、`ctx.phonemes(by_note_id)`;`ctx.status`/`ctx.parameters` 在 voice 会话会自动发到 `voice.*`(façade 按 `kind` 路由,不像 Node 需 `statusPublished`)。 +- **`peek`/`synthesize` 的 `end` 是 Lua 关键字**:façade 内部已用 `params["end"]` 读取,你写用户函数形参 `function Sine.peek(self, start, end)` 时 `end` 只是普通形参名(合法,因为不是 `params.end` 的属性访问)——但如果你自己构造 `{start=…, end=…}` 的表要按线协议发,记得键 `["end"]`。 +- 曲线求值 `ctx.evaluate` 需 Lua 5.2+;voice 的 `snapshot` 里若不用求值器可绕过该约束。 + +--- + +## 8. settings 设置面板 + +manifest `settings:true`。在 format handler 表 / 引擎表上实现 `get_settings_config()`(返回 config 树)与 `apply_settings(values)`(值已解码)。config 节点类型同通用:`slider/checkbox/textbox/combo/number/path/automation/object`;自定义 lambda/scale 不跨线。 + +```lua +tunelab.format({ + import_project = function(path) ... end, + get_settings_config = function() + return { type = "object", properties = { + { id = "quality", config = { type = "combo", items = { + { value = { s = "low" }, displayText = "低" }, + { value = { s = "high" }, displayText = "高" } }, default = { s = "high" } } }, + } } + end, + apply_settings = function(values) Q = values.quality end, +}) +``` + +--- + +## 9. 日志与诊断 + +```lua +tunelab.info("加载完成") -- level=info +tunelab.warn("缺字段用默认") -- warning +tunelab.error("失败: " .. e) -- error +tunelab.log("info", "...") -- 通用 +``` + +进宿主日志系统。**别 `print`**。崩溃栈/stderr 滚进包目录 `logs/stderr-.txt`(单行 8KB、本 boot 2MB、旧 boot 留 2 份)——排查“sidecar 起来又死”第一眼看它。日志/错误消息用英文。 + +--- + +## 10. 交互规范 + +同通用宿主契约与义务(独立进程崩溃隔离、真值经反向调用、恢复路径、inline 音频面)。你要履行:**stdout 只跑协议**(禁 `print`)、**别长阻塞**(心跳/取消兜底)、**产物必 `commit`**、**`peek`/`synthesize` 幂等**(电平语义可重问)、**`positions` 非降序**、**特殊浮点交给 façade**、**写不越界**、**一个进程一世代**。Lua 补充:**空数组用 `tunelab.empty_array()`**;**段是 id 不是对象**(`ctx.write(seg, …)`)。 + +--- + +## 11. 代码规范 + +- 入口 `main.lua` 只做:扩 `package.path` → `require("tunelab")` → 注册(`tunelab.format/effect/voice`)→ `tunelab.serve()`。 +- **命名**:façade 钩子一律 snake_case(`import_project`/`get_property_config`/`create_session`/`on_dirty`);`ctx.*` 也是 snake_case。ProjectInfo 的键仍是 camelCase。 +- **空数组纪律**:任何宿主期望 `[]` 的字段用 `tunelab.empty_array()`(§4/§5)。这是 Lua 版第一大坑。 +- **版本兼容**:只用 5.1–5.4/LuaJIT 交集 API;`string.pack`/`//` 整除是 5.3+/部分 5.2 特性——用到 `ctx.evaluate` 即需 5.2+,manifest 就写 `"lua>=5.2"`。 +- **确定性/纯**:合成逻辑别依赖时间/随机源(缓存一致性);错误让 façade 转 RPC error,别静默返回半成品。 + +--- + +## 12. 依赖 vendoring + +单文件 façade,vendoring 最轻:把 `scripting/lua/tunelab.lua` 拷进包里(如 `plugin/vendor/tunelab.lua`),入口首行: + +```lua +package.path = package.path .. ";./vendor/?.lua;./?.lua" +local tunelab = require("tunelab") +``` + +第三方纯 Lua 模块同放 `vendor/`。**别依赖需要 C 编译的 rock**(sidecar 无编译链、跨平台负担大)。 + +--- + +## 13. 本地调试与打包 + +- 开发期:包丢进扩展目录(Windows `%APPDATA%\TuneLab\Extensions\<包>\`),重新加载;看 `logs/stderr-*.txt`。 +- 指定解释器:`TUNELAB_LUA=`(Windows 需自带 `lua.exe`,§1)。 +- 联调 façade(不落包):`LUA_PATH=/scripting/lua/?.lua;;`。 +- 本仓库测试面: + ```bash + cd scripting/lua && lua tests/test_codec.lua # façade 单测(posix;Windows 本机通常无 lua) + dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj --filter "runtime=needs-interpreter" + # TUNELAB_REQUIRE_LUA=1 让“缺 lua”判红(Windows 上一般保持 vacuous) + ``` +- 打包 `.tlx`:`pwsh tests/pack-tlx.ps1` → `pwsh tests/install-tlx.ps1 <名>`(先关 TuneLab)。 + +--- + +## 14. 完整 Demo(format) + +**`.lpx` 文本格式包**(只读+写一行一音符)——与 §4 代码一一对应,是仓库里有维护样例的 Lua 路径: + +``` +LuaFmtDemo/ +├── manifest.json # §2 示例(suffixes ["lpx"]) +└── plugin/ + ├── main.lua # §4 全代码 + └── vendor/tunelab.lua # 从 scripting/lua/tunelab.lua 拷来(§12) +``` + +`song.lpx`: + +``` +60 0 480 la +62 480 240 du +65 720 720 mi +``` + +导入 → 三音符 MIDI part(note 的 `properties.source=="lpx"` 应回得去);导出字节等价。effect/voice 见 §6/§7(注意 §6 的几何注意)。 + +--- + +## 15. 常见坑 / FAQ + +- **空数组变空对象**:`{}` 被编码成 `{}` 而非 `[]`,宿主读不到列表。→ 空列表一律 `tunelab.empty_array()`。**Lua 头号坑**。 +- **`ctx.evaluate` 报 `need Lua 5.2+`**:PUC 5.1 无 `string.pack`。→ 用 5.2+/5.3+/LuaJIT(带 pack),或 manifest 写 `"lua>=5.2"`,或避开求值器。 +- **`params.end` 语法错**:`end` 是关键字,读宿主回复里的区间结束键要写 `params["end"]`(façade 内部已这么处理;你自己构造/读取时同理)。 +- **effect 段偏移不对**:Lua `input_read` 不回绝对 `sample_offset`、façade 未透 `ctx.input`(§6 几何注意)。非零起点段的严格对齐请用 Python/Node。 +- **段写成对象崩**:Lua 的段是**整数 id**,调 `ctx.write(seg, offset, samples)` / `ctx.commit(seg)`,不是 `seg:write(...)`。 +- **读到的输入是 base64 字符串**:Lua façade 不导出 f32 解码器,`ctx.input_read().data` 是 base64;用 §6 的 `decode_f32`(`tunelab._unb64` + `string.unpack(" 面向**插件开发者**:如何用 **Node.js** 为 TuneLab 编写 `format` / `effect` / `voice` 三类脚本插件。 +> +> - 与 Python 版 [PythonScriptPluginDevelopment.md](PythonScriptPluginDevelopment.md)、Lua 版 [LuaScriptPluginDevelopment.md](LuaScriptPluginDevelopment.md) 结构对齐:**线协议同一套**,差别只在语言 façade 的方法命名、异步模型与依赖打包方式。 +> - 进程外执行单元术语为 **sidecar**([naming-glossary.md](naming-glossary.md));宿主侧设计与语言无关规范见 [scripted-plugin-sidecar-design.md](scripted-plugin-sidecar-design.md) 与 [`scripting/wire/v1.json`](../scripting/wire/v1.json)。 +> - 本文只讲 Node 这一侧怎么写、怎么跑、怎么打包。 + +**关键事实**:Node 插件由宿主 spawn 成独立 `node` 进程(sidecar),走 **JSON-RPC 2.0 over stdio**(`Content-Length` 帧)。façade 包名 `tunelab`(CommonJS,零依赖,发布名 `@tunelab/plugin`)。 + +**Node 的两处独有约束**(务必先记): + +1. **全异步**:所有反向调用(读输入、求值、建段)返回 `Promise`——处理器写成 `async`、`await` 它们。 +2. **音频面 = inline**:Node façade 声明 `capabilities.audio_plane="inline"`(CommonJS 无内建 mmap),PCM 走 base64。功能与 Python 完全等价,只是大音频不经共享内存。 + +--- + +## 目录 + +1. [运行前提与获取解释器](#1-运行前提与获取解释器) +2. [包结构与 manifest.json](#2-包结构与-manifestjson) +3. [执行模型与生命周期](#3-执行模型与生命周期) +4. [第一个插件:format](#4-第一个插件format) +5. [数据形状规范:ProjectInfo](#5-数据形状规范projectinfo) +6. [effect 插件](#6-effect-插件) +7. [voice 插件](#7-voice-插件) +8. [settings 设置面板](#8-settings-设置面板) +9. [日志与诊断](#9-日志与诊断) +10. [交互规范](#10-交互规范宿主契约--你的义务) +11. [代码规范](#11-代码规范) +12. [依赖 vendoring](#12-依赖-vendoring) +13. [本地调试与打包](#13-本地调试与打包) +14. [完整 Demo](#14-完整-demo) +15. [常见坑 / FAQ](#15-常见坑--faq) + +--- + +## 1. 运行前提与获取解释器 + +manifest 写 `"runtime": "node>=18"`,宿主加载期按**四级探测**找解释器:**设置指定 → 环境变量 `TUNELAB_NODE` → `type:"runtime"` 资源包 → PATH(`node`)**。探测落空 → 侧栏 `Skipped · 未找到 Node 运行时` + **【下载运行时】**按钮(宿主按内置镜像表直发上游发行件,本地组装成 runtime 包登记、自动重载)。开发期本机有个 Node ≥18 即可(或 `TUNELAB_NODE=` 钉死)。 + +--- + +## 2. 包结构与 manifest.json + +``` +MyNodePlugin/ +├── manifest.json +└── plugin/ + ├── main.js # entry 指向它(CommonJS:require/module.exports) + └── vendor/ # 可选:vendored tunelab / 第三方依赖(§12) +``` + +manifest 字段与 Python 版**完全同构**,只有 flavor 前缀不同: + +| 字段 | 必需 | 说明 | +|---|---|---| +| `id` / `name` / `version` / `author` / `description` | id/name ✓ | 同通用规则。 | +| `sdk-version` | ✓(要探测时) | 脚本插件写 `"1.1"`。 | +| `type` | ✓ | `format` / `effect` / `voice`。 | +| `runtime` | ✓ | 例 `"node>=18"`(`node>=18,<21` 之类区间都行)。 | +| `entry` | ✓ | 包内相对路径入口脚本,如 `plugin/main.js`;不能越出包目录。 | +| `suffixes` / `import-suffixes` / `export-suffixes` | format | 同 Python。 | +| `engine` | effect/voice ✓ | 引擎 id,如 `NodeGain`。 | +| `platforms` | | `["win","osx","linux"]`。 | +| `settings` | | 布尔,声明设置面板(§8)。 | +| `deps` | | 只用 `vendored`/留空;`"pip"` 未实现(`Skipped`)。 | + +```json +{ + "id": "com.example.njsjson", + "name": "Node JSON Format", + "version": "1.0.0", + "sdk-version": "1.1", + "type": "format", + "suffixes": ["tnjson"], + "runtime": "node>=18", + "entry": "plugin/main.js", + "platforms": ["win", "osx", "linux"] +} +``` + +--- + +## 3. 执行模型与生命周期 + +与 Python 版一致(懒 spawn、`initialize` 握手核对 `provides`、**epoch** 世代、崩溃 backoff + 10 分钟 5 次熔断、effect 回退上版/voice 会话重建、心跳兜底、取消尽力通知、stdin EOF 即退)。Node 侧额外两条: + +- **事件循环别堵**:`process`/`synthesize` 是 async,别在里面跑同步死循环(会饿死心跳/取消的 IO 回调)。 +- **未处理拒绝**:`unhandledRejection` 会让进程崩;façade 的 dispatch 会 catch 用户异常转 RPC error,但你自己 `spawn` 的异步链要保证 catch,否则表现为反复重启→熔断。 + +> **不要 `console.log` 到 stdout**——那是协议信道。日志用 `tunelab.info/warn/error`(§9);`console.error` 到 stderr 会被宿主捕获但不进日志系统。 + +--- + +## 4. 第一个插件:format + +`require("tunelab")` 后用 `tunelab.format({ importProject, exportProject, ... })` 注册。`stream_ref` 是宿主铺好的**文件路径**(读入文件 in / 写出文件 out,用完自动删)。Node 里 `importProject` 可以是 `async`(`await` 读文件)。 + +```js +const fs = require("fs"); +let tunelab; +try { tunelab = require("tunelab"); } // vendored(§12) +catch { tunelab = require("./vendor/tunelab"); } // 兜底:明确放 vendor/ 下 + +tunelab.format({ + importProject(streamRef) { + const notes = []; + for (const line of fs.readFileSync(streamRef, "utf8").split("\n")) { + const s = line.trim(); + if (!s || s.startsWith("#")) continue; + const [p, pos, dur, lyric] = s.split(/\s+/); + notes.push({ + pitch: Number(p), pos: Number(pos), dur: Number(dur), + lyric: lyric || "la", pronunciation: "", + properties: { source: "njs" }, // 自由值,自动 tagged(§5) + leadingPhonemes: [], bodyPhonemes: [], bodyOffset: 0.0, + }); + } + return { + tempos: [{ pos: 0.0, bpm: 120.0 }], + timeSignatures: [{ barIndex: 0, numerator: 4, denominator: 4 }], + tracks: [{ + name: "Node Track", gain: 0, pan: 0, mute: false, solo: false, asRefer: true, color: "", + parts: [{ + kind: "midi", name: "P", pos: 0.0, startOffset: 0.0, endOffset: 0.0, + gain: 0, soundSource: { kind: "voice", type: "", id: "" }, + effects: [], notes, automations: {}, piecewiseAutomations: {}, + pitch: { segments: [] }, vibratos: [], properties: {}, + }], + }], + }; + }, + + exportProject(streamRef, project) { + const lines = []; + for (const track of (project || {}).tracks || []) + for (const part of track.parts || []) + for (const n of part.notes || []) + lines.push(`${n.pitch} ${n.pos} ${n.dur} ${n.lyric || "la"}`); + fs.writeFileSync(streamRef, lines.join("\n") + (lines.length ? "\n" : "")); + }, +}); + +tunelab.serve(); +``` + +- 只读格式省略 `exportProject`(manifest 也别声明写后缀)。多格式用 `tunelab.format({ entry: "a", ... })`,与 manifest `entries[].entry` 对齐。 +- 结尾 `tunelab.serve()` 必须有。 + +--- + +## 5. 数据形状规范:ProjectInfo + +同 Python 版:`tracks → parts → notes`,键名 **camelCase**,note 的 `pos/dur` 为 tick、`pitch` 半音、PartInfo 判别 `"kind":"midi"|"audio"`。`properties` 自由值由 façade 自动打 tag(`{source:"njs"}` 回来仍是对象)。`NaN/±Infinity` 出口自动转字符串字面量(Node `JSON.stringify` 会把裸 `NaN` 变 `null`,façade 在出口统一兜住——你直接用 `Number.NaN`/`Infinity` 即可)。`Multiple` 不跨线。 + +--- + +## 6. effect 插件 + +`tunelab.effect("NodeGain", engine)` 注册引擎对象;宿主对每个实例化调用 `create_session(ctx)`(注意 Node 里**这个方法名是 snake_case**,与下面的配置 getter 的 camelCase 不同——见 §15)。会话对象实现 `process(ctx)` 等。所有 ctx 方法**返回 Promise**。 + +```js +const tunelab = require("tunelab"); + +tunelab.effect("NodeGain", { + async init() { this.gain = 0.5; }, + async destroy() {}, + + getPropertyConfig(effects) { // camelCase 声明钩子 + return { type: "object", properties: [{ + id: "amount", + config: { type: "slider", defaultValue: 0.5, scale: { kind: "linear", min: 0, max: 1 } }, + }]}; + }, + getAutomationConfigs() { return []; }, + getSynthesizedParameterConfigs() { return []; }, + + create_session(ctx) { return new GainSession(this); }, // snake_case 工厂钩子 +}); + +class GainSession { + constructor(engine) { this.engine = engine; } + async process(ctx) { + const g = await ctx.inputGeometry(); // { sample_offset, sample_count, sample_rate } + const { data } = await ctx.inputRead(0, g.sample_count); // data: Float32Array + const seg = await ctx.outputSegment(g.sample_offset, g.sample_count, g.sample_rate); + const out = new Float32Array(data.length); + for (let i = 0; i < data.length; i++) out[i] = data[i] * this.engine.gain; + await seg.write(0, out); + await seg.commit(); // commit = 进下游链的闸门 + const t0 = g.sample_offset / g.sample_rate; + const t1 = (g.sample_offset + g.sample_count) / g.sample_rate; + ctx.status([[t0, t1, "synthesized", 1.0]]); // effect.status + ctx.parameters({ loudness: { segments: [[[t0, 0.4], [t1, 0.4]]] } }); + } + cancel() {} + async dispose() { for (const s of this.segments || []) await s.dispose().catch(() => {}); } +} + +tunelab.serve(); +``` + +Node effect 会话 `ctx` 方法(异步): + +| 方法 | 作用 | +|---|---| +| `await ctx.inputGeometry()` | 输入段几何 `{sample_offset, sample_count, sample_rate}` | +| `await ctx.inputRead(offset, count?)` | `{ data: Float32Array, sampleRate }` | +| `await ctx.properties(keys)` | part 级参数值(解码为原生) | +| `await ctx.automationTracks()` | 自动化轨 id 数组 | +| `await ctx.evaluate(track, positions)` | 曲线求值(`Float64Array`/数组,**非降序**;返回 `number[]`) | +| `await ctx.output(off,cnt,rate)` / `outputSegment(...)` | 建输出段(`outputSegment` 会登记到 `ctx.segments` 供统一回收)→ 段有 `await seg.write(off, Float32Array|array)` / `await seg.commit()` / `resize` / `dispose` | +| `ctx.status([...])` | 发 `effect.status`:`[[start,end,"synthesized",progress?],…]` | +| `ctx.parameters({...})` | 发回显曲线 `{id:{segments:[[[t,v],…],…]}}` | +| `ctx.cancelled` | 布尔,长任务轮询 | + +--- + +## 7. voice 插件 + +`tunelab.voice("NodeSine", engine)`。引擎钩子:`catalog`、`automationConfigs`、`synthesizedParameterConfigs`、`partPropertyConfig`、`notePropertyConfig`、`phonemePropertyConfigs`(camelCase)+ `create_session`(snake)。会话钩子:`defaultLyric`(属性)、`isContinuation`、`peek`、`synthesize`、`onDirty`、`cancel`、`dispose`。 + +**⚠ Node 特例**:voice 状态发布用 **`ctx.statusPublished([...])`**(`ctx.status` 是基类的 `effect.status`,voice 要用 `statusPublished` 才发 `voice.status`)。 + +```js +const tunelab = require("tunelab"); + +tunelab.voice("NodeSine", { + async init() {}, async destroy() {}, + catalog() { // 可 async 可同步 + return [{ id: "sine", name: "Sine Bank", description: "440Hz" }], [[]]; + }, + automationConfigs() { return []; }, + synthesizedParameterConfigs() { return []; }, + partPropertyConfig() { return { type: "object", properties: [] }; }, + notePropertyConfig() { return { type: "object", properties: [] }; }, + phonemePropertyConfigs() { return []; }, + create_session(ctx) { return new SineSession(ctx); }, +}); + +class SineSession { + constructor(ctx) { this.ctx = ctx; this.pos = 0.0; } + defaultLyric = "la"; // 会话级属性 + isContinuation(note) { return false; } + peek(start, end) { + if (this.pos >= end) return null; + return [Math.max(start, this.pos), Math.min(this.pos + 0.5, end)]; + } + async synthesize(start, end) { + if (start < this.pos) return; + const notes = await this.ctx.notes(); + const inWin = notes.filter((n) => n.end > start && n.start < end); + const snap = await this.ctx.snapshot(inWin); + const rate = 44100, count = Math.floor((end - start) * rate); + const times = Array.from({ length: 64 }, (_, i) => start + (end - start) * i / 63); + const curve = await snap.pitch.evaluate(times); // 曲线面远程求值(非降序) + const seg = await this.ctx.outputSegment(Math.floor(start * rate), count, rate); + const buf = new Float32Array(count); + for (let i = 0; i < count; i++) buf[i] = 0.2 * Math.sin(2 * Math.PI * 440 * i / rate); + await seg.write(0, buf); + await seg.commit(); + this.ctx.statusPublished([[start, end, "synthesized", 1.0]]); // ← voice.status + this.ctx.pitch([[[start, 60.0], [end, 62.0]]]); + const syl = {}; + for (const n of snap.notes) + syl[n.id] = [[], [{ symbol: n.lyric || "a", duration: n.end - n.start, stretchWeight: 1.0 }], 0.0]; + this.ctx.phonemes(syl); // key = note id(回指 request_id) + this.pos = end; + } + onDirty(kind, payload) {} + cancel() {} + async dispose() { for (const s of this.ctx.segments) await s.dispose().catch(() => {}); } +} + +tunelab.serve(); +``` + +voice 会话 `ctx`:`await ctx.notes()`(`NoteView`:`id/start/end/pitch/lyric/bodyOffset/leading/body/properties`)、`await ctx.snapshot(ids)`(`{ notes, partProperties, pitch, pitchDeviation, automations }`,求值器带 `await .evaluate(positions)`)、`await ctx.outputSegment(...)`、`ctx.statusPublished/.pitch/.phonemes/.parameters`。`phonemes` 每项 `[leading, body, bodyOffset]`。 + +--- + +## 8. settings 设置面板 + +同通用协议:manifest `settings:true`,引擎/format handler 上实现 `getSettingsConfig()`(返回 config 树)与 `applySettings(values)`(值已解码为原生;camelCase 方法名)。config 节点类型同 §(Python 版 §8):`slider/checkbox/textbox/combo/number/path/automation/object`;lambda/自定义 scale 不跨线。 + +```js +tunelab.format({ + importProject(ref) { /* ... */ }, + getSettingsConfig() { + return { type: "object", properties: [ + { id: "quality", config: { type: "combo", + items: [{ value: { s: "low" }, displayText: "低" }, { value: { s: "high" }, displayText: "高" }], + default: { s: "high" } } }, + { id: "volume", config: { type: "slider", defaultValue: 0.8, scale: { kind: "linear", min: 0, max: 1 } } }, + ]}; + }, + applySettings(values) { this.q = values.quality; }, +}); +``` + +--- + +## 9. 日志与诊断 + +```js +tunelab.info("加载完成"); // log.write level=info +tunelab.warn("缺字段用默认"); // warning +tunelab.error("失败: " + e); // error +``` + +- 进宿主日志系统。**别 `console.log` 到 stdout**;`console.error` 走 stderr(被捕获但不入日志)。 +- 崩溃栈/stderr 滚进包目录 `logs/stderr-.txt`(单行 8KB、本 boot 2MB 硬顶、旧 boot 留 2 份)——排查“起来又死”第一眼看它。 +- 日志/异常消息用英文(仓库约定)。 + +--- + +## 10. 交互规范 + +宿主保证(独立进程、真值经反向调用、恢复路径、Python 才有 shm 快车道——**Node 恒 inline**)、你的义务都同 [Python 版 §10](#10-交互规范宿主契约--你的义务):stdout 只跑协议、别堵事件循环、`commit` 才算数、`peek/synthesize` 幂等、`positions` 非降序、特殊浮点交给 façade、写不越界、一个进程一世代。Node 补充:**所有反向调用是 Promise,忘记 `await` = 拿到 pending 对象乱崩**。 + +--- + +## 11. 代码规范 + +- 入口 `main.js` 只做:`require tunelab` → 注册 → `tunelab.serve()`;重逻辑拆模块 `require` 进来。 +- CommonJS(`require`/`module.exports`)——façade 是零依赖 CommonJS 包。 +- **命名混合是 façade 现状**(见 §15):ctx/段方法是 camelCase,但两个“工厂/会话”钩子 `create_session` 是 snake_case。照抄本文示例最稳。 +- PCM 用 `Float32Array`(`seg.write`/`inputRead` 都收/给 Float32Array),别用普通数组绕序列化。 +- 音频/合成逻辑保持**确定性 + 纯**(可单测)。 + +--- + +## 12. 依赖 vendoring + +只支持 vendored。façade 是本地包(未上 npm),把 `scripting/node/tunelab/` 整个拷进 `plugin/vendor/tunelab/`,入口: + +```js +const path = require("path"); +module.paths.unshift(path.join(__dirname, "vendor")); // 或 require("./vendor/tunelab") +const tunelab = require("tunelab"); +``` + +第三方纯 JS 依赖 `npm install` 到 `plugin/node_modules/`(随包发);别装原生模块(跨平台 + sidecar 无编译链)。 + +--- + +## 13. 本地调试与打包 + +- 开发期:包文件夹丢进扩展目录(Windows `%APPDATA%\TuneLab\Extensions\<包名>\`),重新加载;看 `logs/stderr-*.txt`。 +- 钉解释器:`TUNELAB_NODE=`。联调 façade:`NODE_PATH=/scripting/node`。 +- 本仓库测试面: + ```bash + node --test scripting/node/test # façade 单测 + dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj --filter "runtime=needs-interpreter" + # TUNELAB_REQUIRE_NODE=1 让“缺 Node”判红 + ``` +- 打包 `.tlx`:`pwsh tests/pack-tlx.ps1` → `pwsh tests/install-tlx.ps1 <名>`(脚本包无 csproj,直接收编目录;先关 TuneLab)。 + +--- + +## 14. 完整 Demo + +**只读+写的 `.tnjson` 文本格式包**: + +``` +NodeJsonDemo/ +├── manifest.json # 见 §2 示例 +└── plugin/main.js # 即 §4 全代码(require fs → tunelab.format(...) → serve()) +``` + +放 `song.tnjson`: + +``` +60 0 480 la +62 480 240 du +65 720 720 mi +``` + +导入 → 三音符 MIDI part;导出应字节等价。effect(§6 `NodeGain`)、voice(§7 `NodeSine`)各抄一段即可挂上。三家族一句话 Demo 同 [Python 版 §14](#14-完整-demo)。 + +--- + +## 15. 常见坑 / FAQ + +- **`create_session is not a function` / 引擎被当会话用**:Node 里工厂钩子是 snake_case `create_session`(不是 `createSession`),而 config getter 是 camelCase `getPropertyConfig`——命名混合是 façade 现状,照示例写。 +- **voice 状态不更新**:用了 `ctx.status`(那是 effect.status);voice 要 `ctx.statusPublished(...)`。 +- **拿到 Promise 而不是数据**:忘了 `await` 一个反向调用。 +- **`write` 里 PCM 全 0 或乱码**:传了普通 number[] 且长度/类型不对;用 `Float32Array`。 +- **导入即崩、反复重启→熔断**:`serve()` 前的顶层代码同步阻塞或抛异常;或未 catch 的 `unhandledRejection`。把逻辑挪进回调、加 try/catch。 +- **握手 `does not declare entry`**:manifest `engine`/`entry` 与 `tunelab.effect("X")`/`format({entry})` 的键没对齐。 +- **产物不出现**:`seg.write` 了没 `await seg.commit()`。 +- **大音频慢**:Node 恒 inline base64(设计如此,非 bug);真要快车道用 Python(shm)。 +- **依赖找不到**:确认 `vendor/tunelab` 或 `NODE_PATH`;发布包里 `require("tunelab")` 前必须已把 façade vendoring 进去。 + +--- + +*协议/线程/生命周期完整设计*:[scripted-plugin-sidecar-design.md](scripted-plugin-sidecar-design.md) + [`scripting/wire/v1.json`](../scripting/wire/v1.json)。Python/Lua 版见同目录 `PythonScriptPluginDevelopment.md` / `LuaScriptPluginDevelopment.md`。 diff --git a/docs/PythonScriptPluginDevelopment.md b/docs/PythonScriptPluginDevelopment.md new file mode 100644 index 00000000..973479f2 --- /dev/null +++ b/docs/PythonScriptPluginDevelopment.md @@ -0,0 +1,538 @@ +# TuneLab Python 脚本插件开发指南 + +> 面向**插件开发者**:如何用 **Python** 为 TuneLab 编写 `format`(工程文件导入/导出)、`effect`(音频效果)、`voice`(人声/乐器合成引擎)三类脚本插件。 +> +> - 本文与 C# 插件开发指南([plugin-development.zh-CN.md](plugin-development.zh-CN.md) / [plugin-development.md](plugin-development.md))并列,但面向**进程外脚本插件**,讲的是**脚本这一侧**的写法。 +> - 进程外执行单元在术语上叫 **sidecar**(见 [naming-glossary.md](naming-glossary.md))。宿主侧协议/线程/内存布局的设计见 [scripted-plugin-sidecar-design.md](scripted-plugin-sidecar-design.md);语言无关的**线协议规范**见 [`scripting/wire/v1.json`](../scripting/wire/v1.json)。本文只讲“怎么写、怎么跑、怎么打包”,不重复协议细节。 +> - 三个前提:你会写 Python;你知道 TuneLab 里 format/effect/voice 各是干什么的(不清楚先看 C# 指南 §1);你对“插件是独立进程、通过标准输入输出通信”没有心理负担。 + +**关键事实**:TuneLab 里的 Python 插件**不在 TuneLab 进程内运行**。它由宿主 spawn 成一个独立的 `python` 进程(sidecar),两者用 **JSON-RPC 2.0 over stdio**(LSP 同款 `Content-Length` 帧)对话。你几乎不碰协议——`tunelab` façade 替你处理了——但它的所有约束会体现在你的代码里(见 §2 生命周期、§10 交互规范)。 + +--- + +## 目录 + +1. [运行前提与获取解释器](#1-运行前提与获取解释器) +2. [包结构与 manifest.json](#2-包结构与-manifestjson) +3. [执行模型与生命周期](#3-执行模型与生命周期) +4. [第一个插件:format](#4-第一个插件format) +5. [数据形状规范:ProjectInfo](#5-数据形状规范projectinfo) +6. [effect 插件](#6-effect-插件) +7. [voice 插件](#7-voice-插件) +8. [settings 设置面板](#8-settings-设置面板) +9. [日志与诊断](#9-日志与诊断) +10. [交互规范(宿主契约 / 你的义务)](#10-交互规范宿主契约--你的义务) +11. [代码规范](#11-代码规范) +12. [依赖 vendoring](#12-依赖-vendoring) +13. [本地调试与打包](#13-本地调试与打包) +14. [完整 Demo](#14-完整-demo) +15. [常见坑 / FAQ](#15-常见坑--faq) + +--- + +## 1. 运行前提与获取解释器 + +manifest 里写 `"runtime": "python>=3.10,<4"`,宿主在**加载期**(不跑你的代码)按**四级探测**找一个满足区间的解释器: + +1. **设置指定** —— 用户在设置里填 `ScriptedRuntimeSource`/解释器路径(最高优先级); +2. **环境变量** —— `TUNELAB_PYTHON` 指向一个 `python(.exe)`(开发/CI 常用); +3. **runtime 资源包** —— 扩展目录里一个 `type:"runtime"`、`flavor` 为 `python`、平台含当前 RID、版本落在区间的包; +4. **PATH** —— 直接在 PATH 上找 `python`/`python3`。 + +**探测落空会怎样**:侧栏该插件显示 `Skipped · 未找到 Python 运行时`,并给一个**【下载运行时】**按钮(`sdk-version` 需 ≥1.1)。点了以后宿主按内置**镜像表**(国内镜像在前、官方源兜底)直发上游发行件,本地组装成一个 `type:"runtime"` 包登记;成功即自动重新加载该条目。**全自动档**(设置项“自动获取脚本运行时”,默认关)则是探测一落空就在后台静默拉。你也可以自带一个编译好的 Python(走第 1/2/3 级)。 + +> 结论:开发期**不必**关心分发——本机有个 Python 3.10+(或用 `TUNELAB_PYTHON` 指定)就能跑。分发期才需要想 §15 的 vendoring/镜像问题。 + +--- + +## 2. 包结构与 manifest.json + +一个包 = 一个**文件夹**(不是 zip,安装/卸载的原子单位)。最小结构: + +``` +MyPyPlugin/ +├── manifest.json # 必需:包的唯一身份与声明清单 +└── plugin/ + ├── main.py # entry 指向它:唯一入口脚本 + └── vendor/ # 可选:把 tunelab façade 与第三方依赖塞这里(§12) +``` + +### 2.1 manifest 字段 + +| 字段 | 必需 | 说明 | +|---|---|---| +| `id` | ✓ | 全局唯一,反向域名风格 `com.example.mypyplugin`。跨版本保持稳定的身份键。 | +| `name` | ✓ | 展示名。 | +| `version` | | semver,默认 `1.0.0`。 | +| `author` / `description` | | 展示用;`description` 一句话。 | +| `sdk-version` | ✓(要能力探测时) | 你实际用到的**最新**那一档,脚本插件写 `"1.1"`(脚本面自 2.1.0 起)。填高填低都会被校验拦下。 | +| `type` | ✓ | 本包条目类型:`format` / `effect` / `voice`。 | +| `runtime` | ✓(脚本) | 解释器区间串,如 `"python>=3.10,<4"`。flavor 前缀参与判定。 | +| `entry` | ✓(脚本) | **包内相对路径**的入口脚本(`plugin/main.py`),与 `runtime` 成对出现;不能越出包目录(`..` 逃逸会 Failed)。 | +| `suffixes` | format | 该格式能读**又**能写的扩展名,如 `["pyt"]`。 | +| `import-suffixes` / `export-suffixes` | format | 只读不写 / 只写不读时用,分别声明,比 `suffixes` 精细。 | +| `engine` | effect/voice ✓ | 引擎 id(字符串,唯一兑现键),如 `"PyGain"`。 | +| `platforms` | | 平台过滤 `["win","osx","linux"]`;省略 = 全平台。 | +| `settings` | | 布尔:本条目声明了设置面板(走 sidecar 的 `settings.get_config`/`settings.apply`)。见 §8。 | +| `deps` | | 依赖模式。**留空或 `"vendored"`**(默认,作者自带);`"pip"` 已声明但当前宿主**未实现**,会被判 `Skipped`(非错误)。所以现在只有 vendoring 一条路(§12)。 | + +> 一个包可含多个条目用数组形式 `entries: [ {...}, {...} ]`(每个 entry 自带 `type`/`engine`/`entry`/`suffixes` 等),但**共享一个入口脚本**时通常拆成多包更清晰;同一实现(同一 `entry` 路径)不能既当这个格式又当那个格式,一个实现 = 一个条目。 + +### 2.2 单条目 manifest 示例 + +```json +{ + "id": "com.example.pytxt", + "name": "PyTxt Format", + "version": "1.0.0", + "author": "You", + "description": "一行一音符的 .pyt 文本工程格式。", + "sdk-version": "1.1", + "type": "format", + "suffixes": ["pyt"], + "runtime": "python>=3.10,<4", + "entry": "plugin/main.py", + "platforms": ["win", "osx", "linux"] +} +``` + +--- + +## 3. 执行模型与生命周期 + +理解这些能避免一大半“本地好好的、进 TuneLab 就崩”的问题。 + +- **懒 spawn**:条目注册时不启进程;**第一次真正用到**(导入文件、加载 effect、合成 voice)才拉起 sidecar 并握手。 +- **握手(`initialize`)**:宿主把协议版本、包 id、每个条目的期望(type/entry/suffixes/已持久化的 settings 值)发给 sidecar;`tunelab` 回 `provides`(每个 manifest 承诺的条目必须自报兑现,否则握手期就判死)+ `capabilities`(如 `audio_plane`)+ runtime 自报版本。 +- **epoch(世代)**:每次 spawn 自增。崩溃重拉后,旧世代的在途应答一律被宿主丢弃(防“尾包”串台)。你的代码不用管,但要知道:**一个进程实例只服务一个世代**。 +- **崩溃与恢复**:sidecar 挂了 → 当次调用报 `runtime_error` → 宿主按 backoff 在**下一次调用时**重 spawn(不是定时复活)。10 分钟内死 5 次 → **熔断**(侧栏可见 + stderr 尾部摘要)。effect 链会回退到上一版产物、voice 重建会话重合成的恢复路径由宿主负责(叫“会话重建”,别和侧栏那个手动“重新加载”按钮混)。 +- **心跳**:宿主周期性 `ping`;你的用户处理器若卡死(比如 synthesize 死循环),心跳超时会被判僵、触发取消/重拉。所以**别在回调里做无上限的阻塞**。 +- **取消**:`effect.cancel`/`voice.cancel` 尽力通知你的 session 的 `cancel()`;但一个彻底卡死的 handler 仍要靠心跳兜底——所以长任务应**周期性检查 `ctx.cancelled`**。 +- **关停**:应用退出/包被卸载 → 优雅 `shutdown`,你收到时 `sys.stdin` 到 EOF,façade 会直接退出(防御线一:父进程死 = stdin EOF)。 + +> 你**永远不要往 stdout 打印东西**。stdout 是协议专用信道,`print()` 会污染它、当场把会话搞崩。日志一律走 `tunelab.info/warn/error`(§9)。 + +--- + +## 4. 第一个插件:format + +`format` 负责把外部工程文件**读进** TuneLab(import → 返回一个 ProjectInfo dict)或**写回**磁盘(export → 把 ProjectInfo 落成文件)。 + +façade 用装饰器注册;`import_project(self, stream_ref)` / `export_project(self, stream_ref, project)` 里 **`stream_ref` 是一个文件路径**(宿主在临时目录 `…/TuneLab/Temp/io-/` 里铺好的临时文件,用完自动删): + +- **import**:`stream_ref` 是**待读**的输入文件(宿主已把你声明能吃的后缀对应的字节写进去)→ 你 `open(stream_ref)` 读,返回 project dict。 +- **export**:`stream_ref` 是**待写**的输出文件路径 → 你 `open(stream_ref,"w")` 写,宿主随后把它拷去真正的导出目标。 + +```python +import tunelab + +@tunelab.format +class PyTxtFormat: + def import_project(self, stream_ref): + notes = [] + with open(stream_ref, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#"): + continue + p, pos, dur, *lyric = line.split() + notes.append({ + "pitch": int(p), "pos": float(pos), "dur": float(dur), + "lyric": lyric[0] if lyric else "la", + "pronunciation": "", + "properties": {"source": "pytxt"}, # 自由值,自动编解码(§5) + "leadingPhonemes": [], "bodyPhonemes": [], "bodyOffset": 0.0, + }) + return { + "tempos": [{"pos": 0.0, "bpm": 120.0}], + "timeSignatures": [{"barIndex": 0, "numerator": 4, "denominator": 4}], + "tracks": [{ + "name": "Py Track", "gain": 0.0, "pan": 0.0, "mute": False, "solo": False, + "asRefer": True, "color": "", + "parts": [{ + "kind": "midi", "name": "P", "pos": 0.0, "startOffset": 0.0, "endOffset": 0.0, + "gain": 0.0, "soundSource": {"kind": "voice", "type": "", "id": ""}, + "effects": [], "notes": notes, "automations": {}, "piecewiseAutomations": {}, + "pitch": {"segments": []}, "vibratos": [], "properties": {}, + }], + }], + } + + def export_project(self, stream_ref, project): + lines = [] + for track in project["tracks"]: + for part in track["parts"]: + for n in part.get("notes", []): + lines.append("%d %g %g %s" % (n["pitch"], n["pos"], n["dur"], n.get("lyric", "la"))) + with open(stream_ref, "w", encoding="utf-8") as f: + f.write("\n".join(lines) + ("\n" if lines else "")) + +tunelab.serve() +``` + +- **只读格式**:省略 `export_project`(manifest 也别声明对应的导出后缀)。只写不读同理。 +- **多后缀/多格式**:一个包里用 `@tunelab.format(entry="a")` 注册多个 handler,manifest 的 `entries[]` 各自 `entry` + `suffixes`;不写 `entry` 时单 handler 自动绑到声明的条目。 +- 别忘了结尾 `tunelab.serve()`——不 serve 进程就退出。 + +--- + +## 5. 数据形状规范:ProjectInfo + +`import_project` 返回、`export_project` 收到的,是宿主 `ProjectInfo` 的 **JSON 镜像**,键名 **camelCase**(`soundSource`、`leadingPhonemes`、`bodyOffset`、`timeSignatures`、`piecewiseAutomations`…)。层级:`tracks → parts → notes`。 + +要点: + +- **note 的 `pos`/`dur` 是 tick(浮点),`pitch` 是整数半音**。PartInfo 用判别器 `"kind": "midi" | "audio"` 区分。 +- **`properties`(note/part/effect/工程各处都有)是自由属性 map**:随便放 bool/number/string/nested object/array,façade 自动打 tag、跨语言还原成原生 Python 值(`{"source":"pytxt"}` 回来就是 `{"source":"pytxt"}`)。 +- **特殊浮点**:`NaN / +Infinity / -Infinity` 在线上一律是**字符串** `"NaN"|"Infinity"|"-Infinity"`(合法的裸 JSON 数字 token net8 解析器不吃);façade 读写两端都自动转换,你只管用 `float("nan")`/`math.inf`。 +- **`Multiple` 三态永不跨线**(它是 UI 概念)。 +- 不认识的键会被宿主忽略,别指望往 ProjectInfo 塞自定义顶层字段能存活——自定义数据放 `properties` 里。 + +音频自动化“曲线”在 import/export 面是数据(`pitch.segments`、`piecewiseAutomations`);在 **effect/voice 合成期**则走**曲线面**的远程求值器(§6/§7),不在此列。 + +--- + +## 6. effect 插件 + +`effect` 处理一段音频:宿主把输入段的**几何 + 参数**给你,你产出**输出段**(通常等长)+ 可选回显曲线。用 `@tunelab.effect("引擎id")` 注册一个**引擎类**,宿主对每个“实例化到工程里的效果”调用 `create_session(ctx)` 得到一个**会话对象**,之后对该会话调 `process(ctx)`。 + +```python +import tunelab + +@tunelab.effect("PyGain") +class PyGain: + def init(self): # 引擎级一次性初始化(可选) + self.gain = 0.5 + + def destroy(self): # 引擎销毁(可选) + pass + + # —— 声明面:告诉宿主有哪些可调参数(返回 config 树,见 §8 的 config 类型)—— + def get_property_config(self, effects): + return {"type": "object", "properties": [{ + "id": "amount", + "config": {"type": "slider", "defaultValue": 0.5, + "scale": {"kind": "linear", "min": 0.0, "max": 1.0}}, + }]} + + def get_automation_configs(self, effects): # 可被自动化的参数轨(无则 []) + return [] + + def get_synthesized_parameter_configs(self, effects): # 本引擎会产出的回显轨(无则 []) + return [] + + def create_session(self, ctx): + return _Session(self) + +class _Session: + def __init__(self, engine): + self.engine = engine + + def process(self, ctx): + g = ctx.input # 输入段几何:sample_offset / sample_count / sample_rate + data = ctx.input_read(0, g.sample_count) # 读输入(list[float]) + out = ctx.output(g.sample_offset, g.sample_count, g.sample_rate) + out.write(0, [x * self.engine.gain for x in data]) + out.commit() # ← commit 是产物进下游 effect 链的唯一闸门 + t0 = g.sample_offset / g.sample_rate + t1 = (g.sample_offset + g.sample_count) / g.sample_rate + ctx.status([(t0, t1, "synthesized", 1.0)]) # 状态:pending|synthesizing|synthesized|failed + ctx.parameters({"loudness": {"segments": [[[t0, 0.4], [t1, 0.4]]]}}) # 回显曲线 + + def cancel(self): # 取消通知(可选,长任务里要主动查 ctx.cancelled) + pass + + def dispose(self): # 会话回收(可选) + pass + +tunelab.serve() +``` + +会话 `ctx` 的方法: + +| 方法 | 作用 | +|---|---| +| `ctx.input` | 输入段几何 `.sample_offset/.sample_count/.sample_rate` | +| `ctx.input_read(offset, count=None)` | 读输入一段,返回 `list[float]`(绝对轴寻址的段内偏移;越界返回 false/空) | +| `ctx.properties(keys)` | 拉 part 级当前参数值(`{id: native}`) | +| `ctx.automation_tracks()` | 可用自动化轨 id 列表 | +| `ctx.evaluate(track, positions)` | 对某条自动化轨求值(`positions` **必须非降序**,返回同长 `list[float]`)——曲线面,宿主算插值 | +| `ctx.output(offset, count, rate)` | 新建输出段 → `.write(off, samples)` / `.commit()` /(可选)`.resize()` / `.dispose()` | +| `ctx.status([...])` | 发布状态段 | +| `ctx.parameters({...})` | 发布回显曲线(`{id: {"segments": [[[t,v],…],…]}}`) | +| `ctx.cancelled` | 布尔,长任务轮询它 | + +**性能与快车道**:`ctx.output(...).write(...)` 在 Python façade 下会走 **shm 音频面**(握手时 façade 自报 `capabilities.audio_plane="shm"`,宿主回一个 `region` 让你用 `mmap`/`memoryview`/numpy 直写 PCM,`commit` 时才拷进宿主缓冲)。你**不用写 shm 代码**——façade 已经封装,`write/commit` 语义不变;只是大批量音频不会走 base64、内存带宽级完成。若 numpy 在,`out.shm_view()` 可拿 float32 视图直写。 + +--- + +## 7. voice 插件 + +`voice` 从乐谱(音符 + 歌词 + 参数)**合成出人声/乐器**:宿主按块问你要“接下来哪段没合成”(`peek`)、给定区间要产物(`synthesize`),并拉音符/参数/曲线。用 `@tunelab.voice("引擎id")` 注册引擎类。 + +引擎钩子(声明面 + 会话工厂): + +```python +import math, tunelab + +@tunelab.voice("PySine") +class PySine: + RATE = 44100 + + def init(self): pass + def destroy(self): pass + + def catalog(self): + # 返回 (声源列表, 布局列表);声源 = {"id","name","description"} + return [{"id": "sine", "name": "Sine Bank", "description": "440Hz 测试声库"}], [] + + def automation_configs(self, parts): # part 级声明(可空) + return [] + def synthesized_parameter_configs(self, parts): # 本声源产出的回显轨声明 + return [] + def part_property_config(self, parts): # part 级可调参数(config 树) + return {"type": "object", "properties": []} + def note_property_config(self, part, notes): # note 级可调参数 + return {"type": "object", "properties": []} + def phoneme_property_configs(self, part, notes): # 音素级参数槽(无则 []) + return [] + + def create_session(self, ctx): + return PySineSession(ctx) + +class PySineSession: + default_lyric = "la" # 会话级:缺省歌词(属性即可) + + def __init__(self, ctx): + self.ctx = ctx + self.pos = 0.0 + + def is_continuation(self, note): # 该音符是否延续上一段(一般 False) + return False + + def peek(self, start, end): + """告诉宿主 [start,end) 里下一个待合成区间;没有就返回 None。电平语义可被重问。""" + if self.pos >= end: + return None + return (max(start, self.pos), min(self.pos + 0.5, end)) + + def synthesize(self, start, end): + if start < self.pos: # 已合成的块:直接返回(幂等,电平语义可重问) + return + notes = [n for n in self.ctx.notes() if n.end > start and n.start < end] + snap = self.ctx.snapshot(notes) # 拉真值:音符 + part 属性 + 曲线求值器 + rate = PySine.RATE + count = int((end - start) * rate) + times = [start + (end - start) * i / 63 for i in range(64)] + curve = snap.pitch.evaluate(times) # 曲线面:远程句柄求值(非降序 positions) + seg = self.ctx.output(int(start * rate), count, rate) + seg.write(0, [0.2 * math.sin(2 * math.pi * 440.0 * i / rate) for i in range(count)]) + seg.commit() + self.ctx.status([(start, end, "synthesized", 1.0)]) + self.ctx.pitch([[(start, 60.0), (end, 62.0)]]) # 合成音高折线 + self.ctx.phonemes({n.id: ([], [{"symbol": n.lyric or "a", "duration": n.end - n.start, + "stretch_weight": 1.0}], 0.0) for n in snap.notes}) # 音素按 note_id 归属回指 + self.pos = end + + def on_dirty(self, kind, payload): # 宿主推送“标脏”粗信号(可选):缓存作废、下轮重算 + pass + def cancel(self): pass + def dispose(self): pass + +tunelab.serve() +``` + +会话 `ctx`(voice)方法: + +| 方法 | 作用 | +|---|---| +| `ctx.notes()` | 拉本 part 全部音符 `NoteView`(`.id/.start/.end/.pitch/.lyric/.body_offset/.leading/.body/.properties`) | +| `ctx.snapshot(notes_or_ids)` | 拉快照:`.notes`(请求序对齐)、`.part_properties`,以及求值器 `.pitch` / `.pitch_deviation` / `.automations[id]`(各带 `.evaluate(positions)`) | +| `ctx.output(offset,count,rate)` | 输出音频段(同上,走 shm 快车道) | +| `ctx.status / .pitch / .phonemes / .parameters` | 四路产物发布:状态 / 音高折线 / 音素 / 回显轨 | +| `ctx.cancelled` | 取消标志 | + +- **`phonemes` 的 key = note id**,且是 `snapshot` 回来的 `request_id`(归属回指);每个音素 `{"symbol","duration","stretch_weight","properties"}`。 +- **`peek` 的“可重问”**:宿主可能多次问同一段,你按内部游标 `self.pos` 决定还欠它什么,已交付的直接 `return`——幂等是硬要求。 +- `synthesize(start, end)` **同步返回即视为该块完成**;不要在这阻塞过久(心跳 + 取消会兜底,但你会被重拉)。 + +--- + +## 8. settings 设置面板 + +manifest `settings: true` 声明本条目有设置面板。宿主经 `settings.get_config`(要一棵 config 树)和 `settings.apply`(推来一组终值)与 sidecar 往返;持久化桶、密码字段的解密都在**宿主侧**(DPAPI/Keychain),你只见到终值明文。你的条目上实现(Python 用方法名,façade 反射发现): + +```python +@tunelab.format +class MyFmt: + def get_settings_config(self): + return {"type": "object", "properties": [ + {"id": "author", "displayText": "作者", "config": {"type": "textbox", "defaultValue": ""}}, + {"id": "quality", "config": {"type": "combo", "items": [ + {"value": {"s": "low"}, "displayText": "低"}, + {"value": {"s": "high"}, "displayText": "高"}], "default": {"s": "high"}}}, + {"id": "token", "config": {"type": "textbox", "defaultValue": "", "password": True}}, + {"id": "volume", "config": {"type": "slider", "defaultValue": 0.8, + "scale": {"kind": "linear", "min": 0.0, "max": 1.0}}}, + ]} + + def apply_settings(self, values): # values 已是原生 dict(解码好) + self.author = values.get("author", "") + # spawn 前用户就改过设置 → 握手时随 entries[].settings 补发,仍进这里,一次不落 +``` + +可用的 config 节点类型(`type` 字段):`slider` / `checkbox` / `textbox`(`password`/`multiline`/`maxVisibleLines`)/ `combo`(可嵌套 `subItems`)/ `number` / `path`(`file`/`folder` + `fileTypes`)/ `automation` / `object`(嵌套)。**lambda/自定义 scale 不能跨线**——宿主对未知 kind 抛错。effect/voice **引擎类**上实现 `get_settings_config`/`apply_settings` 同理。 + +--- + +## 9. 日志与诊断 + +```python +tunelab.info("加载完成") # level=info +tunelab.warn("字段缺失,用默认") # level=warning +tunelab.error("写盘失败: %s" % e) # level=error +``` + +- 这些通过 `log.write` 通知发回宿主,进 TuneLab 日志系统;**别用 `print`**(stdout 是协议信道)。 +- **stderr**:解释器崩溃栈、第三方库往 stderr 喷的东西,会被宿主捕获、滚进**包目录** `logs/stderr-.txt`(单行截 8KB、本次 boot 文件硬顶 2MB、旧 boot 只留 2 份)。排查“sidecar 起来又死了”第一时间看这个文件。 +- 报错误信息、日志正文用**英文**(仓库约定:日志/异常/断言消息英文;代码注释可中文)。 + +--- + +## 10. 交互规范(宿主契约 / 你的义务) + +**宿主给你的保证** + +- 你在自己的进程里,崩了不连累宿主,也不连累别的插件;宿主会重拉并走恢复(effect 回退上版、voice 会话重建)。 +- 数据真值(音符/参数/曲线/音频段)通过反向调用给,插值/重采样等**唯一实现在宿主**——你 `evaluate` 拿到的就是宿主口径,不必自己实现。 +- 你声明的 `capabilities.audio_plane="shm"` 时,大音频走共享内存(你无感);否则 inline base64。 + +**你要履行的义务** + +1. **stdout 只写协议**:禁 `print`;`tunelab` 之外任何东西别碰 stdout。 +2. **别长阻塞**:`process`/`synthesize` 会占用该会话;心跳和取消是最后防线不是常规。长循环查 `ctx.cancelled`。 +3. **产物必须 `commit` 才算数**:commit 前的写只供进度/波形预览,不进下游链。 +4. **幂等**:`peek`/`synthesize` 可能被电平语义重问,重复调用要能安全重入。 +5. **positions 非降序**:`evaluate` 的 `positions` 必须升序,宿主不校验(乱序=未定义,与 in-proc 同语义)。 +6. **特殊浮点交给 façade**:不要自己往 JSON 塞裸 `NaN`。 +7. **不越界**:`write(offset, samples)` 超出段 `sample_count` 非法。 +8. **一个进程一世代**:别缓存跨 epoch 的宿主对象句柄;snapshot 换代后旧 `note_ids`/`track` 句柄会 `stale_handle`。 + +--- + +## 11. 代码规范 + +- **入口脚本 = 唯一 `entry` 指向的文件**,只做:`import tunelab` → 注册(装饰器)→ `tunelab.serve()`。重逻辑拆成同级模块 import 进来(相对 import 走 `plugin/` 目录)。 +- **façade 导入**:生产包把 `tunelab` vendoring 进包里(§12)再 `import tunelab`;开发期可用 `PYTHONPATH` 指向 `scripting/python`。建议入口首行显式扩搜索路径,兼容 vendoring 与裸装: + ```python + import os, sys + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "vendor")) # vendored 优先 + import tunelab + ``` +- **命名**:Python 侧一切 snake_case(`import_project`、`get_property_config`、`on_dirty`),与宿主 wire 的 camelCase 由 façade 转换,你不用操心。 +- **类型**:给 config 树、ProjectInfo 这种嵌套结构建议用 `TypedDict`/常量集中管理,避免手抖写错键名(写错键会被静默忽略——最难查的那类 bug)。 +- **错误处理**:让用户可复现的错误经 façade 变成 RPC error(宿主侧显示);不可恢复的让进程死掉、由宿主按 backoff 处理,别自己吞异常返回半成品产物。 +- **确定性**:合成产物不应依赖当前时间/随机源/环境差异(复现和缓存一致性要求);要随机就吃 `note`/`pos` 派生 seed。 +- **别 spawn 子进程/别开端口**:sidecar 的世界是 stdio;额外 IPC/网络在 §9 纪律里不鼓励(防火墙、半关语义都是净亏)。 + +--- + +## 12. 依赖 vendoring + +当前宿主只支持 **vendored(作者自带)** 依赖,`"pip"` 模式未实现。做法(Python): + +```bash +# 把 façade 和第三方库都塞进包的 plugin/vendor/ +pip install --target=plugin/vendor tunelab numpy +``` + +- `tunelab` façade 目前是**本地源码包**(未上 PyPI):开发时从 `scripting/python/tunelab/` 拷进 `plugin/vendor/tunelab/`,或 `PYTHONPATH` 指过去。 +- 入口确保 `vendor` 在 `sys.path`(§11 首行片段)。 +- 纯 stdlib 的最省事(如本文所有 Demo:`math`/`struct` 足矣);能不加二进制依赖就不加(跨平台负担大)。 + +--- + +## 13. 本地调试与打包 + +**跑起来最快**:开发期直接把包文件夹放进 TuneLab 的**扩展目录**(Windows:`%APPDATA%\TuneLab\Extensions\<你的包名>\`),启动/重新加载即可。 + +- TuneLab 里“重新加载”(或重启)后,去 `TuneLab\Extensions\<包>\logs\stderr-*.txt` 看 sidecar 有没有起来、崩在哪。 +- 指定解释器便于对照版本:设 `TUNELAB_PYTHON=`(或让用户在设置里填)。 +- **不依赖宿主的最快回路**——façade 有完整单测,你也可以脱离宿主自测纯函数(把 import/export/process 逻辑拆成能单测的纯函数)。 + +**本仓库的脚本测试面**(贡献/自查时用): + +```bash +python -m unittest discover -s scripting/python/tests # façade 单测(纯 stdlib,无需宿主) +dotnet test tests/TuneLab.Tests/TuneLab.Tests.csproj \ + --filter "runtime=needs-interpreter" # 真解释器 E2E +# TUNELAB_REQUIRE_PYTHON=1 → 找不到 Python>=3.10 就判红;TUNELAB_PYTHON 钉版本 +``` + +**打包成可分发 `.tlx`**(本仓库工具链,三步):把包放 `tests/plugins/`,`pwsh tests/pack-tlx.ps1` → `pwsh tests/install-tlx.ps1 <名>`(脚本包无 csproj,`pack-tlx` 会直接收编目录)。生产分发的解释器/依赖问题见 §1 与 §12。 + +--- + +## 14. 完整 Demo + +下面是一个**能直接跑的最小 `.pyt` 格式包**全貌(只读+写一行一音符的文本工程)。 + +``` +PyTxtDemo/ +├── manifest.json +└── plugin/main.py +``` + +`manifest.json`: + +```json +{ + "id": "com.example.pytxtdemo", + "name": "PyTxt Demo", + "version": "1.0.0", + "author": "You", + "description": "一行一音符的 .pyt 文本工程格式(导入/导出)。", + "sdk-version": "1.1", + "type": "format", + "suffixes": ["pyt"], + "runtime": "python>=3.10,<4", + "entry": "plugin/main.py", + "platforms": ["win", "osx", "linux"] +} +``` + +`plugin/main.py`:即 §4 的完整代码(`import tunelab` → `@tunelab.format` 类 → `tunelab.serve()`)。把它放进扩展目录、造一个 `song.pyt`: + +``` +60 0 480 la +62 480 240 du +65 720 720 mi +``` + +在 TuneLab 里“导入”该文件 → 出现一个含三音符的 MIDI part;再导出回去应字节级等价。加一个 effect:另建一个包按 §6 的 `PyGain`(把 `@tunelab.effect("PyGain")` 那段照抄)即可给某条 part 挂“×0.5 增益”。voice 同 §7。 + +**三家族各自的“一句话 Demo”**: + +| 家族 | 做什么 | 关键 API | +|---|---|---| +| format | 读写工程文件 | `import_project`/`export_project` → ProjectInfo dict | +| effect | 音频进、音频出 | `create_session` → `process(ctx)`,`ctx.input_read`/`ctx.output().write/commit` | +| voice | 乐谱 → 音频 + 音素 + 音高 | `catalog`/`peek`/`synthesize`,`ctx.notes/snapshot/output/pitch/phonemes` | + +--- + +## 15. 常见坑 / FAQ + +- **一 import 就卡住/无响应**:忘了 `tunelab.serve()`,或在 `import` 阶段(serve 之前)做了阻塞/联网。serve 之前的顶层代码要快、无副作用。 +- **握手判死 `does not declare entry`**:manifest 声明了条目,façade 没注册(`@tunelab.effect("X")` 的 id 要和 manifest `engine` 一致;format 的 `entry` 要和文件路径一致)。 +- **产物不出现 / effect 链不更新**:`write` 了但没 `commit`。commit 是闸门。 +- **`MissingMethodException` 之类**(脚本面少见,多为 C# 样例):一般是加载了旧二进制;脚本包改了源码要重新走 §13 打包/安装三步并**先关掉 TuneLab**(否则 dll/文件被占用)。 +- **音频巨大却慢**:确认宿主 ≥2.1 且握手 `audio_plane=shm`(Python façade 默认声明)。老宿主/异常平台回落 inline base64,功能等价、性能自降。 +- **`evaluate` 结果怪**:`positions` 没升序,或用了换代后的旧 `snapshot` 句柄(会 `stale_handle`)。 +- **settings 值对不上**:combo 的 `value` 要用 tagged 形(`{"s":"high"}`/`{"n":1}`),别塞裸字符串到需要 typed 的位置;密码字段你只会在 apply 里见到宿主解密后的终值。 +- **多格式互相覆盖**:同包两条目用了重叠后缀 = 加载期判冲突;一个实现对应一个条目,按方向用 `import-suffixes`/`export-suffixes` 拆。 +- **Python 版本**:façade 纯 stdlib,3.10+ 皆可;`mmap`/`memoryview.cast` 依赖较新运行时,别在 3.8 上跑 shm(区间写 `>=3.10` 已挡)。 + +--- + +*线协议、线程/内存模型、三面传输、握手/心跳/熔断的完整设计*:见 [scripted-plugin-sidecar-design.md](scripted-plugin-sidecar-design.md) 与规范 [`scripting/wire/v1.json`](../scripting/wire/v1.json)。Node.js / Lua 的写法分别见 [NodeJSScriptPluginDevelopment.md](NodeJSScriptPluginDevelopment.md) / [LuaScriptPluginDevelopment.md](LuaScriptPluginDevelopment.md)——协议同一套,差别只在语言 façade 的方法命名与依赖打包方式。