ACE Studio 工具箱 —— 独立、零依赖的单文件程序(主目标 Windows,macOS 也能编/能跑),
通过 ACE 自带的 acestudio-cli(.exe) 读/改 ACE Studio 工程(轨道 / 片段 / 音符 / 参数 / 导出)。
macOS:
./build-mac.sh(原生,出dist/ACETool.app)或在 Windows 上跑.\build-mac.ps1交叉编译。 面板 / 83 个工具 / 词典 / 开机自启在 mac 上可用;托盘、贴 ACE 的汉字侧条、系统级热键还没做 (要 mac + cgo)。细节见docs/移植-macOS.md。
资料仓是隔壁的
..\acetool\(HANDOVER.md + 1.8 MB 参考资料 + 参考脚本)。 本仓是代码仓;设计的单一事实源仍然是..\acetool\HANDOVER.md—— 有冲突以它为准并回写它。
| 项 | 状态 |
|---|---|
M0 启动自检(找 CLI → --version → 在线灯 → 本地只读面板) |
✅ 已跑通(真机) |
go vet ./... |
✅ 干净(Go 1.27.0 windows/amd64) |
go test ./... |
✅ 116 条离线用例全过(acetool 2 · acecli 10 · acep 19 · cbor 18 · tools 67),不依赖 ACE |
| 前端面板自检 | ✅ node tools/checkpanel.js 71 条全过(最小 DOM 桩,不需要浏览器) |
go build(CGO_ENABLED=0 + -trimpath -ldflags "-s -w") |
✅ acetool.exe 8.11 MB |
| 真机自检 | ✅ /api/selfcheck 三步全绿:CLI 从 PATH 找到 · cli 0.17.0 · built for ACE Studio 2.1.8 · Surface 17.1 · project info 读到工程(duration 57600, isNewProject/isTempProject = true, projectName "" —— 当时 ACE 里开着的是空白临时工程) |
| M1 只读巡检面板 | ✅ 已跑通(真机) —— 工具壳 + 9 个只读工具,对着「洛天依」Sing 轨真读到了:片段「啦」0–3840 tick / 1 音符(pitch 57 · dur 1920 · headConsonants 0.0697 · 带 clip-note-content fingerprint)· 120 BPM · 4/4 |
| M2 查找面板 | ✅ 已跑通(真机 · 2026-10-04):逐条复刻 [SV2]查找.js 的面板 + 18 属性 / 6 参数 / 6 参数层;find.goto、find.select 真写入并回读核对过(18 个命中全选中、notesNotFound: 0) |
| M2 歌词面板 | ✅ 已跑通(真机 · 2026-10-04):逐条复刻 [SV2]歌词侧栏.js;分页 / 分割成字符 / 跳句都在;两种写法都真写过并回读核对过,测完已把歌词还原 |
| 写入路径 | ✅ 已开(2026-10-04):color.cycle(轨道颜色循环)是第一个写工具,也是写路径的冒烟测试。干跑闸门在 runner 里(勾了「干跑」⇒ 一条都不执行,只回计划),写后回读可选,真机验收过「读 + 写 + 撤销」 |
| 文件路线 | ✅ 第一刀(只读):acep.inspect 判三种壳:① ACEP2 校验长度 + 解 CBOR payload;② 历史壳一(有 compressMethod)可读,内层是 JSON 文本;③ 历史壳二(没有 compressMethod)加密,只认出不解密。全部带往返自检。zstd 依赖实测 +0.46 MB;构建需 GOPROXY 镜像且 Go ≥1.25 |
net/http + zstd 编解码器),但仍在 < 10 MB 目标内。
要压体积可以试 -tags netgo / 换更轻的 HTTP 层;先记下,别为此改架构。
Go 工具链(开发环境而已 —— 产物是静态单文件,用户那边仍然零依赖):本机 2026-10-04 用
winget install --id GoLang.Go -e --source winget 装好 go1.27.0,在 C:\Program Files\Go。
Go\bin,已经开着的终端/会话看不到(我们这次是用全路径
C:\Program Files\Go\bin\go.exe 跑的)—— 新开一个终端再直接用 go / .\build.ps1。
.\build.ps1 # = go vet + go test + 静态单文件构建
# 或直接跑:
go run . # 起面板并自动开浏览器
go run . --no-browser --cli "C:\Program Files\ACE Studio\acestudio-cli.exe"源码改动后先 go fmt ./...(本仓首次提交前跑过一次,规整了 3 个文件的注释对齐)。
main.go 装配层:找 CLI → 起面板 → 交给 api.go
api.go 路由:/api/selfcheck · /api/tools · /api/run(本机跨站防护 + 错误码→可执行的下一步)
internal/acecli/ 唯一调用层(纪律都在这里)
run.go exec 直传 argv、分别捕获 stdout/stderr、剥 BOM、串行队列、逐条超时、退出码语义、错误信封
locate.go 找 CLI:配置 → ACETOOL_CLI → PATH → 注册表(reg.exe) → 常见位置 → 限深扫 %LOCALAPPDATA%
probe.go --version 三元组解析 + M0 自检(永不返回 error:四种例外都是面板上的一行)
run_test.go 离线可测:测试二进制自带 stub CLI(纪律 §10-4)
internal/tools/ ★ 工具注册表 —— SV 脚本移植就落在这里
registry.go Tool / Param / Call 类型 + 分类排序;表单 schema 直接序列化给前端
inspect.go 只读巡检 9 个工具
color.go 轨道颜色循环(第一个写工具 = 写路径冒烟测试)
find.go 查找:find.notes(读)+ find.goto / find.select(写)—— 复刻 [SV2]查找.js
find_match.go 22 个音符属性的匹配(6 条能实现 / 16 条明确拒绝并说明 ACE 缺什么)
lyric.go 歌词:lyric.pages(读)+ lyric.setSentence / lyric.goto(写)—— 复刻 [SV2]歌词侧栏.js
noteops.go 音符/歌词编辑:自动拼接 · 按播放头选中 · 复制/粘贴歌词 · 加减号/删除合并移位(移植 AKD工具侧栏.js)
tracks.go 按播放头分割轨道(Plan+Apply:要边跑边读,Build 做不到)
tones.go 汉字 → 语调(拼音/声调/调值/走向);来源可以是文本,也可以是工程里的音符
pinyin.go 拼音表装载 + 声调号换算(数字 ⇄ 调号)+ 歌词常用读法小表
pinyin_table.go ⚠️ **生成物**(tools/genpinyin.js 从 pinyin-pro 生成),不要手改
acep.go .acep 三件套:inspect(判壳)/ dump(解析)/ patch(回填)
internal/config/config.go %APPDATA%\ACETool\config.json(CLI 路径 / 超时 / 干跑默认开)
internal/web/ 只绑 127.0.0.1 随机端口的本地面板(go:embed,不背 Chromium)
assets/index.html 工具壳骨架
assets/style.css 深色主题
assets/app.js 侧栏 / 表单 / 结果表格 / 命令日志 + 三个专用面板(查找、歌词、变色测试台)
build.ps1 go vet + go test + 构建(CGO_ENABLED=0,不用 UPX)
tools/checkpanel.js 面板自检(Node + 最小 DOM 桩,不需要浏览器)—— 查找 + 歌词两个面板
tools/genpinyin.js **生成** internal/tools/pinyin_table.go(汉字读音表,从 pinyin-pro)
构建配方(HANDOVER.md §1):
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags "-s -w" -o acetool.exe .
数据驱动:工具清单和表单 schema 都由 GET /api/tools 现给;通用表单由前端按 schema 生成,
加一个工具 = 在 internal/tools/ 里加一条 + 写个 Build 函数,前端一行都不用改。
find.notes 的查找面板、color.cycle 的变色测试台)——
它们的界面是照原脚本/参考脚本逐条复刻的,布局有含义,不是 schema 能描述的。
专用面板仍然从 /api/tools 取下拉选项(findOpts()),不在前端重抄一份列表。
📌 SV 脚本移植就落在这里 —— 一个 SV 脚本 = 一个 Tool:
showCustomDialog 的 widgets → Tool.Params;main() 里那串 API 调用 → Tool.Build 返回的 []Call;
移植来源写进 Tool.Source,便于逐条对照原脚本。
| 工具 | 实际跑的命令 |
|---|---|
| 工程总览 | project info + project dirty + editor status |
| 轨道清单 | track list [--include-empty] |
| 片段清单 | clip list --track-index N |
| 片段内音符 + 歌词 | clip note-content + clip lyrics(回包带 fingerprint,面板单列一行提醒写时要带回来) |
| 速度与拍号 | tempo get + timesig get |
| 走带 / 标记线 / 选区 | transport state + caret get + selection get --scope arrangement |
| 人声参数分层 | vocalparam layers --clip-uuid … |
| 轨道音源 / 声库 | sound-source get + voice get |
| 撤销栈 | history list [--limit N] |
| 轨道颜色循环(写) | caret get(留空跟随 caret 时)→ track get 读当前色 → track set --color <下一格> →(可选)track get 回读核对 |
| 读 .acep 工程文件(只读) | 不经过 CLI:判壳(① ACEP2 / ② 历史壳一 / ③ 历史壳二加密)→ 校验长度字段 → 解 zstd payload → 往返自检 |
| 解析 .acep 内容(只读) | 不经过 CLI:① 走自写的 CBOR 解码器(带字节偏移),② 走 JSON;报顶层结构(键 / 各形状计数 / 真轨道按 type 分档)或取某个路径的值 |
| 回填 .acep(写 · 默认干跑) | 不经过 CLI:按路径改一处文本 → 重压 → 重建壳 → 两层共 6 条自检 → 只写副本(工程包布局) |
| 查找(只读) | editor status / caret get + clip list / track list → 逐片段 clip note-content → 本地过滤;参数搜索另加 vocalparam layers(现读花名册)+ vocalparam read;音素属性另加 phoneme list --clip … --range-begin <t>t;timesig list 算小节.拍 |
| 跳到某一项(写 · 干跑默认) | caret set --tick <t>t →(给了 uuid 才)selection set --scope editor --mode replace --notes-to-select [{"uuid":…}] → caret get --scope arrangement 回读 |
| 查找并全选(写 · 干跑默认) | 同「查找」→ selection set --scope editor --mode replace --notes-to-select [{"uuid":…}] → selection get --scope editor 回读 |
| 变更令牌(只读 · 给自动刷新用) | editor status + clip lyrics(取 fingerprint)+ selection get --scope editor(选中音符哈希)→ 合成一个 token,约 225 ms |
| 歌词侧栏(只读) | editor status → clip lyrics(ACE 的句:index / lyric / noteUuids / 起止 tick)+ clip note-content(逐音符歌词,字符模式要用)+ selection get --scope editor(可选)→ 本地分页 |
| 歌词:改一句(写 · 干跑默认) | 按原脚本规则:note set-lyric --note-uuid … --lyrics […] → clip lyrics 回读;或 ACE 原生:lyric fill --clip … --sentence N --text …(它自带 --dry-run) |
| 歌词:跳到某一句(写 · 干跑默认) | caret set --tick <句起点−lead>t --scope arrangement → transport seek --time <同一 tick>t → caret get 回读 |
| 汉字 → 语调(只读 · 纯本地) | 不经过 CLI(本地读音表)+ 可选一次 phoneme g2p --language Chinese --grapheme …(整串一次拿全);来源选工程时另加 editor status + clip note-content + timesig list |
caret,别用 marker —— 实测这个版本没有 marker 域:marker get 回 exit 2
unrecognized subcommand 'marker'(CLI 自己提示 "a similar subcommand exists: 'caret'"),
而 help marker-line 那篇 topic 通篇在讲 marker get/set/seek/marker get-focus ⇒ 文档漂移,别照抄。
失败怎么办:api.go 的 codeHints 把 error.code 翻成可执行的下一步
(NOTE_OVERLAP → 先把挡路音符移走;STALE_WRITE → 重读拿新指纹;NO_PROJECT_OPEN → 先开工程 …),
同时把 ACE 自己的 error.hint 原样附上。不翻译成「出错了」。
跨站防护:面板只绑 127.0.0.1,但本机其它网页照样能对 localhost 发请求,
所以 /api/run 要求自定义头 X-ACETool —— 简单表单 / <img> 带不上它,浏览器预检我们也不答 CORS。
接口边界的两条宽容(都踩过,都往"安全方向"收):
values宽进:工具签名只收map[string]string(表单值的自然形状), 但把absTick当数字发({"absTick": 29520})会让整个请求被拒,且错误信息里连工具名都没有。 现在字符串/数字/布尔/null 都归一成字符串(数字不用科学计数法,免得 tick 变2.952e+04); 只有嵌套对象/数组仍然报错,但会点名是哪个 key。isTruthy的失败方向是危险的 —— 它就是干跑闸门(values["dryRun"])。 认不出来 ⇒ 当成"没勾" ⇒ 真的去写。所以肯定式收宽(含是/有/开/真,与boolWant口径一致), 否定式也收,其余一律当假(宁可多干跑,不可误写)。真机验过true/"true"/"是"/"1"/1五种写法全都只出计划、一条都不执行。
第一个写工具是轨道颜色循环(color.cycle),它同时就是写路径的冒烟测试,
照参考脚本 ..\acetool\scratch\ace-color-cycle.ps1(-Mode next / -Loop)那一套做的。
三道闸门
- 干跑闸门在 runner 里,不是每个工具自己实现:写工具勾了「干跑」⇒
/api/run一条都不执行, 只把算好的调用当plan回给界面。注意读探针照做 —— 计划本身就需要当前颜色 (参考脚本的 dry run 同样只读不写)。 - 「读 → 决定 → 写」是一等形状:
Tool.Build的签名是func(ctx, rd ReadFunc, vals) ([]Call, error),rd是只读探针(自动带--json)。 这是 SV 脚本的主导形状,所以框架层直接给了读口子; 写口子只有返回的[]Call一条,必然经过干跑闸门。 - 写后回读核对是可选项,且默认关(照参考脚本的
-NoReadBack):track set成功即认为落地, 省掉一次往返。勾上才追加一条track get,并把实际落地的颜色读回来; 对不上就记一条 WARN 进命令日志(照参考脚本的WARN asked X, read back Y分支)。⚠️ 关掉回读时,面板上的色块与状态显示的是我们写出去的那个色(不是读回来的)—— 代码里就是back || want,别指望它证明落地。
面板上的那颗按钮:选「轨道颜色循环(写)」→ ▶ 开始变色 / ⏸ 暂停(再按一下停)。
循环是客户端自调度的,不是 setInterval:一步 = 一次 /api/run,干完看耗时,
再 setTimeout(max(0, 间隔 - 耗时)) ⇒ 绝不并发叠加。硬上限 400 步(照参考脚本的 -MaxWrites),
停下时报撤销代价。旁边有实时色块 + xxx ms/步 + 回读不符计数。
实测节奏(2026-10-04 · 本机 · 含 HTTP 往返):
| 每步的调用 | 命令数/步 | min | avg | max |
|---|---|---|---|---|
| 第 2 步起(现在默认) | 1 条 | 68 | 73 ms | 78 ms |
| 关回读、但每步仍读当前色 | 2 条 | 140 | 149 ms | 162 ms |
| 最初:+ 写后回读 | 3 条 | 218 | 229 ms | 245 ms |
第 1 步是「冷启动」(283 ms):caret get(目标轨道)+ track get(当前色)+ track set。
之后每步只有一条 track set —— 循环把第 1 步侦察到的轨道索引和上一步写出去的色传回来,
于是既不用问 caret、也不用 track get。三条优化合起来把单步从 229 ms 压到 73 ms(约 3.1×)。
⇒ 默认间隔 200 ms 现在绰绰有余(单步只占 73 ms),要更快可以把面板上那个间隔往下调
(输入框下限 120 ms,单步 73 ms 还留有余量)。间隔是下限、不是保证 —— 看面板上那行 ms/步。
fromHex 是信任传进来的值,不再核对真实颜色。
外部(你在 ACE 里手改颜色)若同时改了色,循环会从你给的那个值继续往下走 —— 会错位但不会报错。
要真值就把「写后回读核对」勾上(那样每步回到 2 条命令)。
help interaction-model),每写一步 = 一条撤销记录。
真机验证:撤销栈里出现 N 条 更改轨道颜色、actor='远程控制'。停下后要 Ctrl+Z 那么多次才全清。
这条在工具说明里和按钮旁边都写了。
技能 acep §0 的纪律:CLI 能做的永远走 CLI —— CLI 有撤销栈、指纹、原子拒写,文件改三样都没有。
文件路线只用在「CLI 没开放的能力」上,当前已知唯一一个是人声音高曲线(vocalparam 里 pitch 恒 available:false)。
第一个文件工具是 acep.inspect(只读:判壳 → 校验长度 → 解 payload → 往返自检)。
| 壳 | 判据 | 结构 | 我们的口径 |
|---|---|---|---|
| ① 当前格式 | 首字节 ACEP2 |
1280 B 头 + 裸 zstd 帧;payload 是 CBOR(顶层 map) | 完整支持 |
| ② 历史壳一 | 有 compressMethod |
包装 JSON:content = base64 的 zstd 帧,解出 JSON 文本 |
可读;改法走 set-json(整树改 → 重 zstd → 重 base64),不是 ① 那套原位覆盖 |
| ③ 历史壳二 | 没有 compressMethod |
加密(zstd/gzip/deflate/brotli 全解不开,熵 7.978 bit/B) | 只认出,不尝试解密 |
compressMethod",不是版本号 —— 技能明确:version:2 只是③唯一样本的特征。
真机分布(2026-10-04 扫本机 40+ 个 .acep):ACE 2.1.8 今天存的全是 ①;
2023-08~2026-02 的(ace.acep / senpa.acep / 腾讯收到的 / FL Studio 目录下的)全是 ②(跑得通、往返逐字节一致);
%LOCALAPPDATA%\Timedomain\ACE Studio\project\<hash>\ 下的临时工程缓存是 ③。
.acep 不是 ACE 独占的扩展名 —— Image-Line\FL Studio\... 下也有一堆,实测是 ②(逐字节核过,不是误判)。
| 项 | 实测 |
|---|---|
| 只用解码器 | acetool.exe 7.12 → 7.36 MB(+0.24 MB) |
| 编解码都要(写路线必需) | → 7.58 MB(+0.46 MB) |
| 依赖本身 | github.com/klauspost/compress v1.20.1 —— 纯 Go、无 CGO、无外部依赖 ⇒ 产物仍是零运行时依赖的单文件 |
| 拉依赖 | proxy.golang.org 在本机超时,必须 GOPROXY=https://goproxy.cn,direct |
| go.mod | 被提到 go 1.25(该库要求)⇒ 构建机要 Go ≥1.25 |
⇒ 加得起(+0.46 MB,仍在 <10 MB 内)。代价全在构建侧(要能出网、要配镜像、Go ≥1.25),产物侧零代价。
- CBOR 头别看一个字节:ACEP2 探针工程的顶层是
b8 1a——0xb8表示"map,长度写在紧跟的 1 字节里" (26 键);0xba是"长度跟在后面 4 字节",不是 map(26)。我第一版就按0xba写死了,报成"不认识的编码"。 另外 JSON 的{(0x7b)与 CBOR 的 text(27 字节) 首字节完全相同 ⇒ 要靠"头 96 字节是否可打印"区分。 - PowerShell 5.1 发 JSON body 会把中文变成
?⇒ 用命令行探面板时要显式发 UTF-8 字节 (-Body ([Text.Encoding]::UTF8.GetBytes($json))),否则会把"客户端编码问题"误判成"工具读不了中文路径"。 浏览器fetch+JSON.stringify没这个问题(显式 UTF-8)。
三个工具一条链:acep.inspect(判壳)→ acep.dump(解析)→ acep.patch(回填 · 默认干跑)。
为什么不引通用库:ACEP2 的写法是原位覆盖(技能 acep §2.3 / §4)—— 文本项只改自己的长度前缀、 float64 数组只要元素个数不变就逐值覆盖。那需要每个节点的字节范围,通用库一般只给值不给偏移。
技能 §2.3 记的两个坑都有专门用例卡死:
- half float(
0xf9)必须消费 2 字节 —— 漏了后面整段错位; - float64(
0xfb)只能消费 8 字节 —— 多读一次就整段错位(用"每个值偏移严格 +9"卡)。
另外整数键也认(phonemeTimings 的键就是 0/1,别假定键都是文本),不定长容器 / tag / 负数 / null 都能解。
解码必须恰好消费完所有字节 —— 这条本身就是技能 §4 第 ⑥ 步的自检之一。
| 壳 | 改法 | 依据 |
|---|---|---|
① ACEP2 |
CBOR 原位覆盖:文本换长换短只改它自己的长度前缀,外层容器一个字节都不动 | 技能 §2.3(map/array 长度是元素个数,不是字节数) |
| ② 历史壳一 | set-json:整树解析 → 改路径 → 重 stringify → 重 zstd → 重 base64 → 只换 content | 技能 §1.0 |
| ③ 历史壳二 | 不碰(加密) | 技能 §1.0 |
- 新 payload 整段可解(恰好消费完)
- 目标路径回读 == 想要的值
- 重建容器成功(报大小变化)
- 重建出来的文件按同一条路径再解一遍,payload 逐字节一致
- 头部除 off16/off24 之外与源文件逐字节相同(含那条 1248 B 高熵区)← 技能 §1.1 的强断言,自动验
- 文件大小 == 头长 + 压缩长
自检有任何一条没过 ⇒ 拒绝落盘(不是警告,是拒绝)。
<输出目录>\<名字>\<名字>.acep ← 改过的(源文件一个字节都没动)
<输出目录>\<名字>\autosave\<名字>_save_<时间戳>.acep ← **改动前**原文件的快照
<输出目录>\<名字>\Samples\ ← 空目录
.acep 的文件夹会被 ACE 当成"无名临时工程"
(projectName:""、projectPath:"")。目标已存在时拒绝覆盖。
解析:ACEP2 · payload 17686 · CBOR map(26 键)(b8 1a)
tracks array(100 项) —— 按 type 分档:empty 99 / sing 1 ← 正是技能 §2.5 那条提醒
tracks[0].patterns[0].notes[0].syllable = "l an" ← 正是技能 §13.3 那个合法覆盖样本
回填:tracks[0].name "" → "ACETool 测试轨"
payload 17686 → 17703 · 文件 5500 → 5530 · **6 条自检全过**
干跑:源文件哈希不变、输出目录都没建(真的一个字节没写)
真写:源文件 SHA256 不变 ✓ · 产物逐字段对照只有 name 变了(color/syllable/lyric/pos/version 全同)
- ❌ 加/删音符、加/删采样点 —— 那要整段重编码 CBOR,是另一条路(技能 §6 也划在
acep.cjs能力之外) - ❌ 历史壳二的解密
- ❌ 碰源文件 / 原地改用户正开着的工程
⚠️ "ACE 认不认"是第三层,本工具证明不了 —— 文件级自检只证明字节写对了。 要让 ACE 读回来复核(project open或手工打开产物)。
原脚本是 SV2 的 SidePanelSection(656 行):按「属性/参数 + 条件 + 值」在「当前组 / 当前轨 / 整个工程」
里查音符或参数,结果按位置排序,可一键全选。移植成三个工具(左栏「查找」分类),
外加一个逐条复刻原脚本面板的专用 UI:find.notes(只读)+ find.goto / find.select(写 · 干跑默认)。
用户 2026-10-04 的裁定:完全参照原脚本(面板行顺序、默认值、联动、文本格式都照抄), 只写中文、不建翻译表(原脚本带 ja-jp / zh-cn / zh-tw 三套字面,这里只留 zh-cn)。
原脚本(getSidePanelSectionState) |
面板里 | 备注 |
|---|---|---|
TextArea height 100 |
只读 <textarea id=fpinfo> |
结果文本,格式见下表 |
| Button「全选目标音符」(全宽) | 同 | 范围≠当前片段时禁用(原脚本 selectButton.setEnabled(isGroup)) |
| Button「上一项」「下一项」 | 同 | 两端禁用(原脚本 lastButton/nextButton.setEnabled) |
| Button「刷新」「说明」 | 同 | 「说明」= 原脚本 zh-cn 的 HelpText 原文 |
TextArea height 20 |
<input id=fpcontent> |
原脚本是 setValueChangeCallback ⇒ 用 change 触发(每次查找 300–450 ms,逐键触发会排队) |
| ComboBox 大于/小于/等于/包含 | 同 | 默认「等于」(condition.setValue(2)) |
| ComboBox 当前片段/轨道/工程 | 同 | 默认「当前片段」(ACE 的词是片段 = clip;SV 那边叫音符组) |
| CheckBox 音符属性 | 参数 | 同 | 互斥,默认勾「音符属性」;点一个就切 type 并立刻查 |
| ComboBox 22 属性 | 9 参数 | 22 属性 | 13 参数 | 参数多的 4 条是 ACE 独有(★) |
| ——(原脚本没有) | 参数层 | 我们加的:ACE 参数分层,必须说是哪一层(见下) |
结果文本(resultInfo() 逐行复刻):
序号: n/N → 范围=工程才给 轨道: → 范围≠当前片段才给 片段: → 音符属性才给 音符序号: → 位置: → 有值才给 数值:
两处容易抄错、已经用测试钉住的细节:
位置:用的是b2p(),不是"小节.拍.tick" —— 原脚本是measure.position + denominator*(blick - positionBlick)/(4*numerator*QUARTER) + 1再toFixed(2)⇒ 是个两位小数的"小节.拍内比例"(4/4 里1.25= 第 1 小节第 2 拍;tick 29040 →16.13)。 我们两种都留:posSv给面板显示(忠实),barBeat(16.1.240)给人精确定位。 有专门用例卡住"两者不相等",防止将来有人"顺手统一"。- 起始项不是第 1 项 —— 原脚本 search() 结尾按标记线算初始下标:
片段范围
index = 标记线之前的命中数(往后找),轨道/工程范围index = 标记线之后的命中数(于是displayNum = res.length - index是倒着数的),最后if (index == res.length) index--。 这条也照抄进后端(startIndexFor),不是前端另算一遍。 序号在两个数量级上不一样:片段范围正着数(index+1),轨道/工程倒着数(N-index)。
用户 2026-10-04 裁定(两条连着看才完整): ① 参数「只留 ace 存在的」;② 属性同理 ——「把 ace 没有的 key 去掉」。
| 类别 | 条数 | 明细 |
|---|---|---|
| 原脚本 22 条里 ACE 能做的 | 6 | 持续时间(拍)dur/480 · 歌词 lyric · 音素(实际发音)phonemes · 音素(默认发音)defaultPhonemes · 音高 pitch · 语言 language |
| ACE 独有(★,原脚本没有) | 12 | 音素覆盖(有/无) · 头辅音长度(秒) · 尾辅音长度(秒) · 时长(tick) · 结束位置(tick) · 片段类型 · 片段启用(有/无) · 有换气(有/无) · 换气请求长度(秒) · 换气实际生效(秒) · 换气被压掉(有/无) · 可插换气空间(秒) |
| 原脚本 22 条里 ACE 没有的 | 16(不在下拉里) | 音分 1 · 说唱 3 · 颤音调制 1 · 音高过渡 5 · 颤音 6 |
换气算音符属性(用户裁定「换气算在音符里吧」)⇒ 换气那 5 项就在属性下拉里,不另开工具。
⛔ 但**"不做静默失败"这条纪律不变**:那 16 条的说明仍然留着(unsupportedAttr)——
直接拿 API 调 attr=detune 照样得到带中文名 + 原因的明确拒绝
(「音分」在 ACE 里没有对应概念:…),不会退化成"0 命中"或"属性认不出"。
attrLabel 会在下拉查不到时退到原脚本 22 条全集去取中文名,所以话术里不会出现裸英文 key。
两条方向相反的不变量测试一起钉住这件事:
TestEveryAttrOptionIsAnswered—— 下拉里每一条都必须真能用(一条拒绝的都不许在里面), 且条数/顺序 = 6 + 12;同时断言那 16 条不在下拉里却仍被明确拒绝。TestUnsupportedSetIsExactComplement—— 拒绝表的键正好是「原脚本 22 − 能实现的 6 = 16」, 多一条少一条都算漂移。
照搬过来的两处巧思(原脚本的精华,别丢):
- 音高输入法:MIDI 数字 · 音名
C4/C#4/Db4· 音级C(匹配所有八度) · 黑 / 白(黑白键)。 - 语言关键字:中/普/汉 · 英 · 日 · 粤/广 · 西 · 韩/朝 → ACE 的语言全名(
Chinese…)。
| 参数 | ACE id | 参数 | ACE id | |
|---|---|---|---|---|
| 张力 | tension |
★ 假声 | falsetto |
|
| 气声(Air) | air |
★ 音色 | formant |
|
| ★ 能量 | energy |
★ 整体动态(ACE 2) | dynamic |
原脚本那 9 条里有 7 条 ACE 根本没有(音高偏差/颤音包络/响度/发声/性别/音区偏移/口型)
⇒ 已从下拉里摘掉,但 paramMap 里仍然留着:拿 API 直接调它们照样得到带原因的明确拒绝。
⛔ 可用性一律现读 vocalparam layers,绝不按名字猜(技能 ace-params §1 的硬纪律):
参数花名册随声库的引擎世代变。本次会话真机上就换过一次代(下面 18.7 那条),
所以这里三道门都会给可执行的下一步而不是空结果:
参数不在花名册(列出实际有的+engineGeneration)/ available:false(附官方 unavailableReason 原文)/
这一层不存在(列出实际有的层)。
参数层默认是 auto(auto 会认这一代引擎的「你画过的点」层 —— v1(verse24)是 direct、v2(singing-mamba)是 user,
两者互相都没有对方那个;解出来还会在报告里说明用了哪一层(layerUsed / layerNote)。
解不出来就照「没有可用层」拒绝,绝不静默换一个错的层。
显式指定具名层时严格按用户说的来(v2 上要 direct 就如实拒绝,不偷偷换成 user)。
参数命中的是"段"不是一个点:ACE 的参数是每 tick 一个值的 dense 数组,
所以连续命中的 tick 会被压成一段(报告里给 runLenTicks + 该段的值域 min~max)。
| 原脚本 | ACE 对应物 |
|---|---|
| 当前组 | editor status → 当前打开的片段(编辑器跟着标记线走,所以这就是"标记线所在的片段") |
| 当前轨 | caret get --scope arrangement → clip list --track-index N |
| 整个工程 | track list → 逐轨 clip list(只扫 Sing / Instrument / GenericMidi,跳过 Audio/Chord/空槽) |
SV 在内存里遍历;ACE 每片段一次 clip note-content,而且串行(只有一个 host)
⇒ 每片段约 75 ms。实测:当前片段(26 音符)234 ms · 当前轨(2 片段 27 音符)497 ms ·
整个工程(4 片段 36 音符)769 ms。带音素(额外一次 phoneme list)约 +150 ms。
所以有片段数/命中数上限,报告里如实给片段数、音符数、耗时,截断会明说。
--notes-to-select要的是[{"uuid":"{…}"}],不是 uuid 字符串数组 —— 传字符串回INVALID_ARG: field 'notesToSelect': expected object for NoteRef(原子失败,什么都没改)。 权威在help selection set。- 面板原来不渲染
plan—— CLI 写工具干跑时回包只有plan、calls是空的, 而界面只渲染calls⇒ 显示"0 条命令全部成功",看不到将要执行什么。现在干跑会画一张"将执行的命令"卡。 phoneme list拒绝"整片段"这种无参形式 —— 回INVALID_ARG,官方 hint: "There is no clip-wide form: a whole clip is hundreds of rows, andclip note-contentis the read that enumerates one." 合法的最小形式是--clip <UUID>配--range-begin或--range-end(另一个默认到片段端点), 且--range-scope默认project(全局时间轴)⇒ 给的是绝对 tick。实测一次拿全 25 个音符。⚠️ 另一个坑:--range-begin是TIME_VALUE,必须带单位(29040t),裸数字会被TIME_UNIT_REQUIRED拒。- 语言属性两边都要过关键字表 —— 这个是真机才暴露的 bug:ACE 报完整英文名(
English), 用户填中文单字(英),我第一版只映了一边 ⇒语言=英永远 0 命中(正是本工具最危险的失败方式)。 修完真机复核:英→ 18 条、中→ 7 条,18+7 = 25 = 该片段全部音符。补了专门的用例。
editor status其实给clipUuid(还有trackUuid/region/defaultLanguage/supportedLanguages), 但不给clipBegin/clipEnd。绝对位置(排序、跳转)和音素的--range-begin都要clipBegin⇒ 仍然要补一次clip list,理由和之前记的不一样了。clip note-content里没有articulation键(实测全文搜过)⇒ 之前"ACE 独有属性:技法"那条是错的, 已从列表里去掉。ACE 的技法要在别的地方读(Instrument 片段的技法接口还没摸)。
tools/checkpanel.js 把 assets/app.js 放进一个最小 DOM 桩里跑,然后:
- 调
findPanelHTML()收集生成的 id,再调bindFind()收集它去取的 id —— 两边对账 (面板是手写 HTML 字符串,id 拼错在浏览器里只表现为"按钮没反应",这条专门抓它) - 核对复刻原脚本的默认值(
无内容/ 等于 / 当前片段 / 音符属性) - 逐种范围×目标组合跑
resultInfo(),核对文本格式(正着数/倒着数、哪几行该有哪几行不该有) - 核对按钮可用性(两端禁用、范围≠当前片段时"全选"禁用)
const/let 落在 realm 的全局词法环境里、不是 globalThis 的属性 ⇒
探针脚本不能写 ctx.find,得在同一个 realm 里再 runInContext 一段脚本来读它们(已记在脚本注释里)。
真机验收(find.select / find.goto 真写入,不只干跑):
全选 18 个命中(范围=当前片段 · 语言=英)
→ selection set --scope editor --mode replace --notes-to-select […18 个 uuid…]
→ {"notesSelected":18,"notesNotFound":0,"selectionCount":18,"success":true}
→ 回读 selection get --scope editor:选中 18 个 ✓
跳到第 2 个音符(tick 29520)
→ caret set --tick 29520t → selection set(1 个)→ 回读 {"tick":29520,"trackIndex":4,…} ✓
原脚本是 SV2 的 SidePanelSection(299 行):把当前片段按句切开,一页 12 句显示成 12 个可编辑文本框;改哪个框就把那句话重新分配到该句的音符上;另有 上一页/下一页、 「分割成字符」开关、以及一个「跳到某句」的下拉(移视图 + seek 播放头)。
移植成三个工具 + 一个专用面板:lyric.pages(读)+ lyric.setSentence / lyric.goto(写 · 干跑默认)。
原脚本自己算分句(看相邻音符有没有缺口、有没有 br)。但 ACE 有自己的句模型:
clip lyrics 直接报 sentences[](index / lyric / noteUuids / 起止 tick),
而且写回原语就是按它的下标寻址的 —— lyric fill --sentence <N>。
⇒ 要是沿用原脚本那套启发式分句,--sentence N 会指到别的句子上去。
真实例:4 个音符、1440→1620 有个 180 tick 的小缺口 —— 原脚本的启发式会切成 2 句,
ACE 认为 1 句。以 ACE 为准才对得上它的面板与写回。
| 原脚本的行 | 面板里 |
|---|---|
① Container[TextArea h60 readOnly] |
只读 <textarea>,内容 共N页 M句歌词 / 当前为第P页 / 第A到B句 |
② Container[CheckBox "Separate into Characters"] |
同名复选框,紧跟信息框(中间不夹任何控件) |
③ Container[TextArea h20] × pageRowCount |
一行一个 <textarea data-sentence=…>,行内没有按钮、没有元信息行 |
④ Container[Button 上一页 width 1.0] |
整行按钮 |
⑤ Container[Button 下一页 width 1.0] |
整行按钮(④⑤ 各自一个 Container ⇒ 竖排,不是并排) |
⑥ Container[ComboBox choices=本页各句] |
<select>,本页各句,选中即跳 |
行序由面板自检卡住(checkpanel.js 一条断言按顺序找这 8 个 id 的位置)。
四处有意的偏离(都写在代码注释里,不藏着):
⚠️ 原脚本把行文本框声明成readOnly: true,却又给它们挂了setValueChangeCallback—— 自相矛盾(真 readOnly 的话回调永远不会触发)。以它的行为为准:文本框可编辑、 失焦/回车即写(就是那个回调的触发时机)。 我们加了一道「真的改了才写」的闸:用 Tab 把焦点过一遍不会重写歌词、也不会刷 ACE 的撤销栈。- 复选框标签:原脚本写的是
SV.T("Separate into Characters"),而它没有翻译表 ⇒ 在 SV 里显示的就是这串英文原文。按本仓「只写中文」的口径用了「分割成字符」。 - 没有范围控件:原脚本的范围是自动的(
Lyrics()里"有选中的音符就只算选中的, 否则算整个片段")⇒ 后端scope默认auto复刻这个语义,面板上就不放原脚本没有的下拉。 要强制整段可以在通用表单里显式选clip。 - 「刷新」放在面板外面:原脚本靠
registerSelectionCallback被动重读,ACE 没有这种推送, 所以得手动重读 —— 但它不该出现在被复刻的那 6 行里。
一处 off-by-one 没照搬:原脚本的跳句下拉取的是下一句(选项 0 起、SV 的 ComboBox 值 1 起
⇒ group[page*12+idx] 差一位)。这里按你选中的那一句跳。
SV 脚本靠推送被动重读(registerSelectionCallback / registerClearCallback),
ACE 没有任何推送 ⇒ 要这个效果只能轮询一个便宜的变更令牌。
inspect.revision(只读)把三样东西合成一个 token:
| 读点 | 变了说明什么 |
|---|---|
editor status |
标记线移到了另一个片段(面板该显示别的东西了) |
clip lyrics 的 fingerprint |
片段内容变了(域实测是 clip-note-content,覆盖歌词) |
selection get --scope editor 的音符 uuid 哈希 |
选中变了 |
selectionCount:换一个音符选,count 不变,但
scope=auto 下要显示的那一句可能就换了 —— 只比 count 会漏掉这种情况。哈希与顺序无关
(换着顺序点同一批音符不算"变了",否则会一直误报)。有专门用例卡这两条。
成本:3 次读、实测 221~256 ms。关键是每次读都约 65 ms,跟载荷大小几乎无关 (成本主要是 CLI 进程启动)—— 所以 329 个音符的大片段也是这个量级。
四道守卫(缺一个都会出事,面板自检逐条卡着):
- 正在编辑就不动:任何歌词文本框有焦点、或内容跟读到的基线不一致 ⇒ 跳过这一轮。 否则你打字打到一半,刷新会把输入覆盖掉。
- 正在写就不动:写请求在飞时跳过(写完本来就会自己重读)。
- 标签页看不见就不动:
document.hidden⇒ 跳过(切到 ACE 去干活时最该省)。 - 只在这个页面轮询:换工具就
pollStop()。
另外两条:轮询必须安静(失败不清面板、不弹红条,只在状态行记一笔 —— ACE 忙的时候读会失败,
那是正常的);轮询回来时页面可能已经切走了,收尾要再确认一次 poll.on 与面板还在,
否则 .innerHTML 会打在已经不在 DOM 的节点上(这个坑是写完守卫才想起来的)。
控件在面板外面(和「刷新」一排):☑ 宿主一改就自动刷新 · 间隔 [2500] ms · 状态行
(显示"已跟上宿主的改动 / 宿主变了 → 已重读 / 暂停(正在编辑)" + 刷新次数 + 读失败次数)。
间隔下限 1 s(一次轮询本身约 225 ms)。
真机验收:
连读两次什么都不改 → token 一致 ✅(不会误报变化、不会白重读)
选 brl2 → 选中=1 token 尾 …|1|dcae0671
选 shei → 选中=1 token 尾 …|1|82a87728 ← **同样 1 个选中、换了音符,token 变了** ✅
再选回 brl2 → token 尾 …|1|dcae0671 ← 与之前一致(可复现、不抖动)✅
清空选中 → 选中=0 token 尾 …|0|- ✅
静态页面选一个音符 → 歌词页范围自动变 selection(只剩含它的那句)✅
| 写法 | 命令 | 词比音符少时 |
|---|---|---|
| 「按原脚本规则」(默认) | note set-lyric --note-uuid … --lyrics […] |
按 o % 词数 循环铺满,一个音符都不会空着 |
| 「ACE 原生填充」 | lyric fill --sentence N --text …(一句一条命令,自带 --dry-run) |
实测(4 个音符的句子,填 ha ppy):
perNote(默认):ha | ppy | ha | ppy ← 循环铺满,与原脚本一致
fill(原生) :ha | ppy | | ← 后两个被清空(ACE 乐句框就是这个行为)
⇒ 所以默认是 perNote:既忠于原脚本,又不会因为少写两个词就把歌词抹掉。
fill 留着(它是 ACE 自己的行为、一句一条命令),但标签上直接写了"
- 「分割成字符」模式:每个音符一个字,长度 > 1 的词套括号(
(b i y)), 写回时括号里整段折叠到一个音符上 —— 这样"一个音符唱一整词"才有写法。⚠️ 括号折叠只在字符模式下成立:按词切时(不是独立的 token,(b i y)会被当成 3 个词 (原脚本就是这样,照实记下来)。 o % 词数的循环分配(见上)。
- ⛔ 原脚本有个死循环:字符模式下
(没闭合时,while (Lyr1[n] != ")")会无限拼undefined(undefined != ")"永远成立)。这里明确报错「括号没闭合」,有专门用例卡住(带 3 秒超时)。 - 空歌词时原脚本把
page钳到-1,信息行变成「共0页…第-11到0句」。这里钳到 0, 并给一句「这一份片段里没有歌词」的人话。
面板显示的那一行,原样解析回来必须得到同一串逐音符歌词。
把"显示"与"写回"钉在一起:用户看到 ha ppy、原样提交(没改),逐音符结果必须不变。
8 组用例覆盖中英混排、单字母、多字词、延长号 -。真机也复核过:把显示文本喂回去,
干跑出来的 --lyrics 与现状逐字一致。
lyric.pages → 句 0「a b c d e f g h i j k」11 音 · 句 1「m l o p … w#1 w#2 w#3 x」14 音
信息行「共1页2句歌词 / 当前为第1页 / 第1到2句」
勾「分割成字符」→「abcdefghijk」/「mlopqrstuv(w#1)(w#2)(w#3)x」 ← 多字词套括号
scope=auto → 没有选中时 = clip(2 句);选中 1 个音符后 = selection(只剩含它的那 1 句)✓
显式 scope=clip 仍然给整段(可覆盖自动行为)✓
perNote 真写 → ha | ppy | ha | ppy (循环铺满、无空音符)
fill 真写 → ha | ppy | | (后两个被清空 —— 所以它不是默认)
还原 → banana#1 | banana#2 | banana#3 | 啦 ✓
lyric.goto → caret 28320t + transport seek 28320t → 回读 caret tick=28320 ✓
规模:exe 7.86 MB · Go 测试 89 条(tools 40)· 面板自检 60 条。
备用工具:把一个字变成它的拼音 / 声调 / 调值 / 音高走向。来源三选一 —— 直接给文本、当前片段的歌词、或选中的音符(后两者顺带带上音符位置与音高走向)。
先确认了 ACE 侧没有语调面(这一步不能省,否则会去接一个不存在的东西):
help --search intonation→ 零匹配;help --search tone/rap/accent命中的都是无关的东西(chord "tone"、grapheme…);phoneme g2p(能汉字→音素)只给音素、不给声调,而且中文contextSupported: false—— 不做上下文消歧。
所以声调只能本地查表。反过来 phoneme g2p 补的是另一半:ACE 实际会唱什么音素。
两个合起来才是完整的"这个字唱成什么"——真机上立刻显出差别:
谁 shuí(2) 但 ACE 唱 shei
云 yún(2) 但 ACE 记作 yvn (ACE 的音素记法用 v 表示 ü)
燕 yàn(4) 但 ACE 记作 yian
数据源是本机已有的 pinyin-pro 3.29.4 —— 也就是 SV 侧技能里已经在用的那个库
(skills 里写着"拼音/声调来自 pinyin-pro"),口径一致。生成物 pinyin_table.go:
| 项 | 值 |
|---|---|
| 覆盖 | U+4E00..U+9FFF 连续(下标 = r - 0x4E00) |
| 规模 | 20,992 项,其中 136 个码位没有读音(空串)· 3,215 个多音字 |
| 数据量 | 123 KB(分块存,避免出现 150 KB 的巨行) |
| 产物代价 | exe 7.86 → 8.02 MB(+160 KB) |
| 重新生成 | node tools/genpinyin.js "<pinyin-pro 包目录>" |
懒解析(sync.Once,不是 init())—— 不用这个工具就不该为它付启动成本。
1 阴平 · 2 阳平 · 3 上声 · 4 去声 · 5 轻声 —— 与 SV 的 rapAccent 一致,
这样这个工具的输出将来能直接喂给那边。toneInfos 里 5 与 0 都登记(测试抓过一次"声调 5 变成 ?")。
调值与走向按标准五度标记法:
| 声调 | 调值 | 走向 |
|---|---|---|
| 阴平 | 55 | — 高平 |
| 阳平 | 35 | ↗ 上升 |
| 上声 | 214 | ↘↗ 降升 |
| 去声 | 51 | ↘ 下降 |
| 轻声 | — | · 轻短 |
⛔ 按词定音("银行"不是 yín xíng)需要词表,不做。取而代之的是三条路,都摆在明面上:
toneMode=library(默认):数据源的首选读音 —— 可复现、有据可查。 有意思的是 pinyin-pro 的单字默认与multiple:true的首选并不一样:了的单字默认是liao3,而 multiple 首选是le0(轻声)—— 后者才是歌词里的读法。 生成的表取的是 multiple 首选,有专门用例把这条钉住(重新生成表时若它变了会响)。toneMode=lyric:一张人工小表(了→le · 地→de · 得→de · 着→zhe · 的/呢/吗/吧/啊/么…), 只收证据清楚的虚字,界面上会标「歌词常用读法」。有用例卡住两条: 表不超过 20 条(别写成词典)、且每个读音都能在数据源里找到(不许凭空来)。overrides:手动指定,优先级最高 ——行=hang 地=di(全角=、:都收)。 写法不对会报错,不默默忽略。
燕鸟栖下林 → 燕4 鸟3 栖1 下4 林2(调值 51/214/55/51/35,走向 ↘ ↘↗ — ↘ ↗)
了的地得(library) → 了5 的5 地4 得2 ← 地/得 是词典首选(dì/dé)
了的地得(lyric) → 了5 的5 地5 得5 ← 都按歌词里的轻声来
银行行长 overrides=行=hang → 银2 行2 行2 长2(行 → háng)
当前片段(344 字) → 70 个多音字 · 音符位置 + 音高走向 + ACE 音素都在 · 431 ms
中英混排「燕鸟 fly 下林。」→ 非汉字**如实标出**(f/l/y/。各自一行),不当成没看见
| 症状 | 真因 |
|---|---|
hui4 标成 hùi |
ui 的调号该标在 i 上(iu/ui 都标"后面那个"),我一开始给 ui 标了 u |
声调 5 显示 ? |
toneInfos 只登记了 0(pinyin-pro 口径),而 toneOf 对外返回 5 |
行=hang 的值变乱码 |
IndexAny 给的是字节下标,+1 切进了全角 = 的中间 ⇒ 要按字符切 |
lv4 → lv(声调数字被吃掉) |
一个能标调的元音都没有时不该丢数字 ⇒ 原样返回 |
得 在 lyric 模式下变成 dé |
unmarkTone("dé") 也等于 de,于是 de 匹到了 dé ⇒ 先找完全相等的候选 |
还有两条是测试自己写错、被数据纠正的:U+9FFF 未必有读音(那一段有 136 个空位);
六 是 liù(第四声),我按 liú 写了。
用户点名要的那几项:自动拼接 · 选中之后/之前所有音符 · 复制/粘贴歌词 ·
添加 +/- 并后移 · 删除/合并歌词并前移。9 个动作落在 6 个工具上
(+/- 共用 lyric.shift;之后/之前共用 select.playhead;删除/合并共用 lyric.remove)。
面板照原脚本的版式:两列一行的按钮网格 + 末尾的说明 ComboBox/TextArea(9 条说明原文)。
用户要求:「把你写的工程总览等栏放一类,我说的放一类」。所以侧栏现在两栏:
| 栏 | 是什么 | 工具 |
|---|---|---|
| 移植脚本 | 用户点名的(SV 脚本移植 + 备用的语调工具) | 14 个 |
| 自建工具 | 我先搭的骨架与路线 | 14 个 |
实现上没有去改每个工具的 Category:工具自己那个细分类(查找/歌词/音符)在代码、
测试、文档里有意义,不该被 UI 分组抹掉 ⇒ 加了一层 toolGroups + groupOf(),
只在出给 UI 时换掉 Category(复制一份再改,不动注册表里的原件)。
有用例钉住"两次调用结果一致、且注册表里的细分类一个都没变"。
认不出的分类不会被吞掉 —— 它自己成一栏(将来加新分类会露出来)。
- ⛔
note resize一次调用只能给一个--dur(对所有点名音符生效)。 而"自动拼接"每个音符的新时长都不一样 ⇒ 不能一音符一条命令(329 个音符要跑 20 秒)。 改成按新时长分组:真机上 329 个音符 → 17 条命令(其中 262 个音符共享 240 tick, 压成 3 条),而不是 326 条。 - 播放头 ≠ 标记线。原脚本用
SV.getPlayback().getPlayhead()(播放头), ACE 侧对应transport state的positionTick;而find那边用的是caret(标记线)。 两者停止时通常重合,但"选中播放头之后的音符"必须用真播放头,否则播着的时候会选错。 ⇒ 两个读点分开命名(readPlaybackHead/ find 的readPlayhead),注释里互相点名。
selection set --mode replace 是替换语义。我一开始把"选中之后的所有音符"按 100 个一块
分块发(命令行长度护栏),每块都用 replace ⇒ 每一块把上一块顶掉,
329 个音符的片段最后只选上了 27 个,而且不报错。
修法:第一块 replace(先清干净),后续块 modify(往上加)。真机复核:
选中 100 个(replace) → selectionCount=100
选中 100 个(modify) → selectionCount=200
选中 100 个(modify) → selectionCount=300
选中 27 个(modify) → selectionCount=327 ✓
结束 > 播放头)+「之前」(起点 < 播放头)
会重叠一个 —— 正在唱的那个音符两边都算。329 个音符里 327 + 3 − 1 = 329 ✓。
添加 + 并后移 把选中序列整体右移一位 —— 末尾那个音符的歌词被挤出序列、丢掉;
删除并前移 左移一位,最后一个音符的歌词留在原地(原脚本的循环只到 len-1)。
所以它们不是彼此的逆操作。我一开始想用"+后移 再 删除前移 应当还原"来验收,
真机上没还原 —— 一查是原脚本算法本身的性质,不是移植错:
选中 3 个:[brl2, 谁, -],播放头落在**最后一个**上
+ 并后移 → [brl2, 谁, +] ← 原来的 "-" 被挤出末尾,丢了(后面没有音符可移)
删除并前移 → [brl2, 谁, +] ← 它是最后一个,左移没东西可拉,自身不动
⇒ 用这些操作时:选区别只选到你要改的地方为止,尾部一个音符的歌词会被吃掉。 (这条写进了工具说明里。)
--notes-to-select '[{"uuid":"…"}]' 从 PowerShell 发出去,内层双引号会被剥掉
(PowerShell 与 C 运行时两层处理),ACE 收到 [{uuid:{…}}] 直接 invalid JSON;
双写引号 ""uuid"" 也没绕过去。
⇒ 正解:用 Node 的 spawnSync(cli, argsArray) 数组传参(不经 shell、不经 PowerShell 的
引号处理)—— 这本来就是本仓的调用纪律("不经过 shell")。
ACETool 自己走 exec.Command(cli, args...),所以工具本身没这个问题;
只是手工用 PowerShell 调 CLI 验收时会踩。
复制歌词(无选中) → 329 个 · 整个片段(照原脚本退化)
自动拼接(干跑) → 17 条命令(按新时长分组:60/120/180/240…)
选中之后的所有音符 → 100→200→300→327(modify 累加正确)
选中之前的所有音符 → 3 个(与"之后"重叠 1 个 = 播放头那个)
粘贴「燕鸟栖下林」 → 5 个音符 ← 5 个词(一字一音符,标点跳过)
真写 +后移 / 删除前移 → 都成功(⚠️ 尾部有损,见上)
收尾 → 歌词已逐字还原、编辑器选区已清空
规模:exe 8.11 MB · Go 测试 116 条(tools 67)· 面板自检 71 条。
原脚本 splitTrackButton:把当前轨道克隆一份,再按播放头把内容切成两半 ——
克隆留下的那半在克隆上、另一半在原轨上,于是"一条轨"变成"两条轨"。
ACE 侧有更干净的原语(都核过 help):
| 用途 | 命令 |
|---|---|
| 复制一条轨(连片段与 FX 链,落在原轨旁边 index=原+1,回包直接给新轨身份) | track duplicate --track-index A |
| 在片段内部切一刀(原生,音符/曲线/换气都保留) | clip split --clip-uuid U --pos <全局 tick> |
| 按 uuid 删片段(全有或全无,一次可点名多个) | clip delete --clip-uuid U… |
| 删编排区的轨道 | selection set --tracks [{"trackUuid":…}] 再 track delete(无参)—— --track-uuid 只认 video/marker 带 |
判据照原脚本(两处边界都是闭的,差一个 tick 就是多切一刀/少切一刀,而且不报错):
| 片段位置 | 归谁 |
|---|---|
结束 ≤ 播放头 |
原轨 |
开始 ≥ 播放头 |
复制轨 |
| 夹在中间 | clip split 切开,各留一半 |
切完之后判据变简单,而且对没切到的片段同样成立:原轨删「开始 ≥ 播放头」、 复制轨删「结束 ≤ 播放头」。有用例证明这两个判据永远相反(每个片段恰好被一边删掉)。
Build 是"一次性把所有命令算完"——它看不到任何命令的返回值。
而分割天生要"先复制出一批片段,再改那些新片段"(新 uuid 只有跑完才知道),
所以必须边跑边读。框架里能干这个的只有 Apply(进程内写实现,自己拿 rd 一条条跑),
acep.patch 当初就是为同样的理由走这条路的。
⇒ 代价:干跑(Plan)只能给到第一步能确定的部分(片段怎么分、要切哪几个、分几步),
给的是条数与判据,不是每条命令的原文。这条写进了工具说明。
Plan 一个假 CLI,它响应所有读命令、
一旦 Plan 去调任何写动词就立刻失败 ⇒「干跑只读」是被真跑出来的。
中途失败会把复制出来的那条轨删掉(这就是回滚)。但 ⛔ 回滚不是万能的:
- 步骤顺序刻意把最容易失败的
clip split排在删除之前 ⇒ 到删除阶段为止只动过复制轨, 这时回滚是完整的(原轨没动过),结果里会这么说。 - 真在删除阶段挂了,原轨上已经删掉的片段回不来(每次
clip delete全有或全无, 但已成功的那次是既成事实)⇒ 结果里如实写明"原轨已删的片段没有自动还原,Ctrl+Z 可逐步退回", 不让用户以为一切复原了。
track delete",所以会改到编排区的轨道选择。
写这个功能时 ACE Studio 被关掉了(project info 回 exit 3、桥握手文件
%LOCALAPPDATA%\Timedomain\ACE Studio\mcp-bridge.json 消失)。
单元测试覆盖了:判据与边界、删除谓词互斥、工具注册形态、干跑只读(假 CLI 行为验证)。
真机要验的是:track duplicate → clip split → 两次 clip delete 串起来对不对、
track delete(先选中)这条路在真机上是否按预期工作、回滚是否真能撤干净。
ACE 一开就能补。
- 不经过 shell:一律
exec.Command(cli, args...)。内层 JSON 引号、代码页、--%那些坑全是 shell 带来的。 - 读动词一律
--json;写动词 stdout 可能是人话 ⇒ JSON 解析失败 ≠ 命令失败,只看退出码。 --json的错误信封写在 stderr ⇒ 两个流分别捕获。- 越界一律拒绝、从不 clamp(与 SV 侧相反)—— 别替它夹值,
error.hint当信息读。 - 串行队列 + 每条 30 s 超时:只有一个 ACE host,命令别并发。
- 写前读指纹,写时
--if-match;干跑默认,写入要显式开关、写后回读。 - 不缓存能力/花名册:每次现读
--version与layers(同一个 clip 的paramCount都变过)。 - 失败要像错误报告:exit code /
error.code/error.hint原样展示。
- ❌ 不碰 Synthesizer V / Instrument X(那是 AKDAgent 的事,走文件通道桥)
- ❌ 不注册 ACE 官方 MCP(63 个工具与 CLI 逐一对应、但参数没 schema;理由见 HANDOVER.md §6)
- ❌ 不做 ACE 的 UI 自动化 / 截图 / 鼠标模拟
- ❌ 不要求用户装任何运行时(选 Go 的全部理由)
⚠️ 文件路线(2026-10-04 已开第一刀,只读):① ACEP2 完整支持;② 历史壳一可读(改法走set-json); ③ 历史壳二加密,只认出、不尝试解密。写任何壳之前必须先过往返自检(acep.RoundTrip), 并且永远写副本;旧工程要处理就先问用户。