sv-pit-art
Skill33 starsChanged 36 days agoMITView on GitHub
SV2 彩蛋功能 —— 用音高线(PitchControlCurve)在钢琴窗里"画画"。利用 SV2 多条音高线可重叠且逐条渲染的特性,把 SVG 路径/文字轮廓/任意折线映射成时间×半音坐标,用一条曲线画一笔,组成图案。含已验证的 API 用法、坐标映射、笔画拆分、性能与限制、实测结论。⚠️ 开工前必读 §0:**画布必须锚定组内音符并夹进可见音高区间**(读 `getNavigation().getValueViewRange()`)、**点的 y 是相对 anchor pitch 的偏移量而非绝对音高**、**画完必须读回逐段核对**。触发:用音高线画图、pitch 画图、SV 彩蛋、在钢琴窗画图案/文字/logo、pitch art、把 SVG 画进 Synthesizer V、曲线重叠作图、图案画到天上/位置不对/顶到上限、曲线接缝断口等。
In this repository16 other files it gives its agents.
- ace-paramsSkill
- acepSkill
- aceSkill
- ace-studio-audio-pluginsSkill
- ace-studio-featuresSkill
- ace-studio-setupSkill
- ace-studio-workflowsSkill
- akdagent-playbookSkill
- akdagent-protocolSkill
- compositionSkill
- sv-bridgeSkill
- sv-ixSkill
- sv-lyricistSkill
- sv-project-formatSkill
- sv-scriptingSkill
- sv-textureSkill
The file
Files
sv-pit-art —— 用音高线画画(SV2 彩蛋)
一句话:SV2 的钢琴窗就是画布,
addPitchControl()就是画笔,多条音高线重叠就是图层。⚠️ 与
sv-scripting/references/08-pit-drawing.md的关系(重要): 那篇讲音乐性的音高线,核心诉求是避免相邻曲线重叠冲突; 本技能的诉求正好相反 —— 刻意让曲线重叠,用重叠来叠图层。 两套逻辑不可复用:08 的"全局轮廓"合成是为了让重叠区数值一致; 画画则要求每条笔画是互不相同的独立曲线。
0. ⛔ 开工必做(2026-09-12 真机教训 —— 这三条不做,画出来就是废的)
背景(必须记住):第一次真机画图(五角星)几何正确、音高数值也正确, 但用户看到的评价是 「画的太高了」→「看起来音高是对的,但是画在天上」,且三个内顶点处有可见断口/残段。 两次翻车的根因都不是画法,而是没读宿主状态、没核对结果。以下三条已固化为硬规矩。
0.1 放置:必须读"视图坐标" + 锚定音符,禁止写死基音
🆕 基准三选一 —— 画图先问用户要哪种,用户不指定就用
view:
# 模式 画布中心的绝对音高( base)取自适用 ① midi60固定 MIDI 60(C4)为中心(与音符、视图都无关) 图案要"钉死不动、便于反复对齐/比对";音符也在 C4 附近时够用 ② view用户当前屏幕可见范围(读 getNavigation().getValueViewRange()取中点)默认;图案一定出现在眼前、不用滚屏(= 现在的行为) ③ range用户指定范围(如「第四小节起」「从选中段开始」) 图案按段落落位,而不是"跟着当前屏幕走" 三选一只改
base;下方"锚定到本组音符(base = median(组内音高) − 画布高/2)"仍适用于要骑在音符上的场景 (可视作第 ④ 种语义,现阶段并入模式 ② 处理)。
钢琴窗的可见范围可以直接读(CoordinateSystem,见 sv-scripting/api/CoordinateSystem.md):
| 要读的 | 调用 | 单位 |
| 可见音高区间 | SV.getMainEditor().getNavigation().getValueViewRange() | MIDI 半音,{低, 高} |
| 可见时间区间 | …getTimeViewRange() | blick,{起, 止} |
| 纵向比例 | …getValuePxPerUnit() | px / 半音 |
| 值 ↔ 屏幕像素 | …v2y(v) / …y2v(y) · t2x(t) / x2t(x) | px |
实测示例(2026-09-12 真机):可见音高 44.54..64.21 · 组内音符中位数 53 · 24 px/半音 · v2y(62)=53px(贴顶);
而当时写死的画布是 62..76 ⇒ 绝大部分落在可见区之上 ⇒ 用户必须往上滚才看得见。
这就是"画在天上"的量化原因 —— 不是观感问题,是可测量的错误。
放置规则(必须同时满足):
- 垂直锚定到本组音符:
base = median(组内音符音高) − 画布高 / 2(图案"骑"在音符上); - 再夹进可见音高区间:
base + 高 > 可见高⇒ 下压;base < 可见低⇒ 上抬; - 收尾把落点换算成像素回报(
v2y),明确写出"完全可见"还是"需要滚动"。
❌ 反面教材:
BASE_PITCH = 62这种写死基音 —— 与你的工程、你的音符、你当前视图全都无关。 只在显式--base=/--anchor=fixed时才允许固定值,且仍必须回报可见性。
0.2 接缝:x 极值切分点在两条曲线里必须同坐标
一笔在 x 极值顶点处会被切成两条曲线(一条以它为终点、一条以它为起点)。 若两条曲线给同一个顶点算出不同的 x(典型:各自套用竖线水平漂移),接缝就错开 ⇒ 视觉上断口 + 小残段。
- 漂移只作用于"非共享"的内部点;末点(=共享顶点)最多挪
+1blick(既保严格递增,又能与对面接上)。 - 兜底:相邻两条曲线可在接缝处各向外多写半步重叠(重叠无害,见 §1)。
0.3 完成定义:必须读回核对,不许只看脚本自报
教训:脚本自报"曲线 N 条 / 点 M 个"完全可以同时是"画在天上 + 有断口"。 2026-09-12 我就是只看了自报,把废图当成成功交付。
画完必须做三件核对(现成实现在 sv/lua/draw-pit-art.cjs,默认开启,--no-verify 才关):
- 读回曲线(
getPitchControl(i).getPoints()),把点还原成绝对坐标; - 与"预期笔画逐段"比对 ⇒ 报告缺失段数(
缺 0 段才算完整);容差必须覆盖漂移(x ≈2e6blick); - 可见性核对:实际音高范围 vs
getValueViewRange()⇒ "完全可见" 还是 "要滚动"。
0.3.1 读回的确切形状(2026-09-20 真机复核,IX 1.0.1 · 桥 0.3.18)
local c = group:getPitchControl(i) -- 1 起
local pts = c:getPoints() -- ⚠️ 返回**普通数组**:pts[k] = { 位置, 半音 } 两个 number
local pos, pitch = pts[k][1], pts[k][2] -- ✅ 就这样取- 不是
pts[k]:getPosition()/:getPitch()(那套方法在 curve 对象上:c:getPosition()/c:getPitch()= 锚点位置 / 锚点音高), 点在 Lua 里就是两个 number 的数组(pairs出来是1=number, 2=number)。 - 桥自己的
PIT.levelAt()也是这么读的(pts[i][1]/pts[i][2]);离线回归里也钉了这条。 - 判断"这个 control 是 curve 还是 point"仍然用
has(pc,"getPoints")(对象上没有type字段)。
0.3.2 ⚠️ 和弦(同 onset)上写曲线:0.3.18 前会丢
write_pit 的幂等替换原本按 "窗口起点一致" 认"这是我这条曲线的旧版"。而和弦里多个音 onset 相同 ⇒ 窗口起点相同
⇒ 给第 2 个音写时会把第 1 个音的曲线当旧版删掉 ⇒ 只剩最后一条,而返回里只报 removedCurves、不报丢音。
- 0.3.18 起已修:写入时给曲线打
scriptData.akdagentNoteIndex(该音符的 0 起组内下标),重写只删自己那条。 - 仍在旧版桥上遇到"和弦写曲线只剩一条":逐个音写 + 每次写完读回条数核对;或先
clear="all"再只写一个音。 - 历史数据无损:旧版写的曲线没有标记 ⇒ 新版只在"该音符 onset 在组内唯一"时才按锚点删它(有同伴时不动,宁留不误删);
想彻底重来用
clear="all"。
0.4 桥接口的坑(2026-09-13 已全部查明,含我自己两次误判的更正)
| 坑 | 真相 | 正确做法 |
run_script 的 scope 注入 | ✅ 一直是好的。语义是:scope 的"内容"被注入成一个个独立全局变量(实现:for k,v in pairs(args.scope) do env[k]=v end)。<br>❌ 我此前写成"真机 type(scope)=="nil"、注入不生效" —— 那是我把 scope 当命名空间读了 scope.x;实读 _ENV 得到 SV,SVH,readonly,strokes,x 即证 | 脚本里直接写 x / strokes(顶层键名),不要写 scope.x(永远 nil)。<br>本技能的 draw-pit-art.cjs 采用内联数据(历史原因,也行且更省事),但知道正确语义才能不误诊 |
run_script 返回值有包封 | {result:<Lua值>, resultType} | 客户端 callOk() 已剥过响应外层 ⇒ 只剥一层;多剥一层得到 undefined(我因此误判成"两个修订抢答",纯属自伤) |
两个宿主共用 -sv 通道 | SV1/SV2/OPSV Flat 共用(协议如此) | 一次只开一个桥;写入脚本内置组名 + 音符数预检,不符即中止 |
❌ 已作废:在 SV2 里重跑桥后仍复现 ⇒ 再查:部署文件与仓库 MD5 相同、含注入代码、selftest 的 25 个返回键逐个吻合、嵌套数组参数(create_harmony_group 的 notes)正常工作 ⇒ 常驻代码就是这份文件,问题从来不在桥上 | 结论:别把"我没验证过"当"对方有问题" —— 先读 _ENV、先做最小对照 |
0.5 现成工具(别再手搓)
node sv/lua/draw-pit-art.cjs sv pelican --at=11.5 # 图形:star|heart|check|pelican
node sv/lua/draw-pit-art.cjs sv star --base=48 --height=16 # 显式覆盖基音/画布高
node sv/lua/draw-pit-art.cjs sv clear --force # 清当前活跃组全部曲线(需 --force)
node sv/lua/draw-pit-art.cjs sv remove # 删自建「AKDAgent画板」组
node sv/lua/probe-view.cjs sv # 读视图坐标(可见音高/时间、px 换算)
node sv/lua/probe-groups.cjs sv # 列组引用(名字/isMain/音符数/曲线数)默认画在"你现在活跃的那个组"(--new 才另建独立画板组);数据内联、写前预检、写后自动读回核对。
0.6 ⛔⛔ 坐标语义(本次翻车的真正根因,比前面四条更致命)
2026-09-12 用户实测抓出:导出的曲线是
{"pitch": 62, "points": [0, 69.76, …]}, 而实际音高变成了 ~132 —— 因为实际音高 = 曲线 anchor pitch + 点的 y。
官方语义(sv-scripting/api/PitchControlCurve.md 原文):
| 成员 | 原文 | 含义 |
getPitch() | "the anchor pitch value of this pitch control curve in semitones relative to the pitch offset of the note group" | 锚点音高(组内局部坐标;相对组的 pitchOffset) |
getPoints() | "value is the pitch offset from the anchor position in semitones" | 点的 y 是相对锚点的偏移量,不是绝对音高 |
getPosition() | "the anchor position … relative to the time offset of the note group" | 点的 x 是相对 position,position 相对组的 timeOffset |
⇒ 三处换算必须成对写对:
显示音高 = 组.pitchOffset + 曲线.anchorPitch + 点.y · 绝对时间 = 组.timeOffset + 曲线.position + 点.x
为什么会翻车(我抄了一半):桥里的 write_pit 一直是对的 ——
AKDAgentBridge.lua:2451 明确写了 yv = PIT.levelAt(...) - c.P + …(减掉 anchor),
:2467 再 setPitch(c.P)(音符音高)⇒ 实际 = 音高 + 偏移 ✓ 所以过去 18 项音高线验收全绿。
画图脚本照抄了 setPitch(base),却把绝对音高(62.56 / 69.76…)写进了 points ⇒
62 + 69.76 = 131.76 ⇒ 整幅图被抬高一个 base。
表面上像"画在天上",实际是画到了 ~130;用户看到的那点图案只是视口裁掉后剩下的底边 ——
我先前误判成"接缝断口"的三处缺口,其实是裁切边缘。
两条防再犯的硬措施:
- 写点值前先问自己:"这个 y 是偏移量还是绝对音高?" —— 统一只写偏移量(
y − anchorPitch),anchor 单独用setPitch设; - 读回核对必须按同一语义还原(
实际 = anchor + y): 任何一处把偏移量当绝对值(或反之)都会让核对立刻报错 —— 这就是画图脚本verifyReadback()现在的做法。
1. 原理与已实测的前提
| 前提 | 状态 | 依据 |
| 能创建任意形状的曲线 | ✅ | addPitchControl(PitchControlCurve) + setPosition/setPitch/setPoints |
| ⭐ 多条曲线可重叠,且 SV2 逐条分别渲染 | ✅ 实测 | 画两条交叉直线(X),用户确认"我看到了一个X" —— 两条线都显示,未被合成一条 |
| 点值可任意取值 | ✅ | 实测相对值出现过 -9.385 ~ +10.485 半音 |
| 可清空重画(幂等) | ✅ | removePitchControl(index) |
| SV1 不支持 | ❌ | addPitchControl 系 SV2 2.1.0+;SV1 上 getNumPitchControls 是 undefined |
| IX(Instrument X 1.0.0)支持 | ✅ 实测 | 2026-09-14:在 IX 当前组「音符组 1」画 pelican —— 曲线 28 条 / 165 点,读回核对缺 0 段;用户截图确认图案完整(鹈鹕+自行车)。IX 上 addPitchControl/getPitchControl/getNumPitchControls/removePitchControl/editor:getNavigation 逐个判过都存在。⇒ 画图彩蛋不限 SV2,IX 同样可用(--host=ix) |
| 组内至少要有 1 个音符 | ✅ 前提 | 曲线可以画在没有音符的时间区间,但组里一个音符都没有 ⇒ 整组都不显示 |
🔑 "逐条渲染"是整个功能成立的基础 —— 若 SV2 把重叠曲线合成一条,图案就退化成一条线。 这个结论已由实测敲定。
2. API 用法(ES5.1)
var group = SV.getMainEditor().getCurrentGroup().getTarget();
// —— 一笔 = 一条曲线 ——
var c = SV.create("PitchControlCurve");
c.setPosition(startBlick); // 曲线起点(绝对 blick)
c.setPitch(60); // 锚点音高(半音)。‼️ 点的 y 是**相对它的偏移量**,见 §0.6
c.setPoints([ // ⚠️ 必须是 [[相对blick, 相对半音], ...] 成对数组
[0, 0], // 0 = 正好落在锚点音高(60)
[15000000, 1.5], // +1.5 半音 ⇒ 实际 61.5
[30000000, 3.0] // +3.0 半音 ⇒ 实际 63.0
]); // ❌ 千万别写绝对音高(如 61.5/63.0)—— 会变成 60+61.5=121.5
group.addPitchControl(c); // 返回新曲线在**组内的索引(0 起,JS 绑定)**——Lua 绑定是 1 起,跨绑定要用时记得换算
// —— 读取 / 清空 ——
group.getNumPitchControls(); // 数量
var pc = group.getPitchControl(i);
typeof pc.getPoints === "function"; // true=curve,false=point(对象上**没有** type 字段)
group.removePitchControl(i); // 删除四个必须记住的坑
| 坑 | 说明 |
| ‼️ 点值是"偏移量"不是绝对音高 | setPitch(P) 是 anchor,点的 y 是相对它的偏移 ⇒ 实际音高 = P + y(再叠加组的 pitchOffset)。<br>写绝对音高 ⇒ 凭空抬高一个 anchor(60 + 61.5 = 121.5)。详见 §0.6 —— 这是 2026-09-12 真机翻车的根因 |
setPoints 要成对数组 | [[x,y],…] —— 不是 .svp 文件里那种扁平 [x,y,x,y,…]。<br>传扁平数组会报:每个点必须是 [time, value] 形式的数组。 |
判 curve 不能用 type | 对象上没有 type 字段;用 typeof pc.getPoints === "function" 区分 PitchControlCurve / PitchControlPoint |
| 写操作先建撤销点 | SV.getProject().newUndoRecord(); |
3. 坐标映射(画布 → 钢琴窗)
| 画布 | 钢琴窗 | 说明 |
| x(横) | 时间 —— 单位 blick | SV.QUARTER = 705600000 = 一拍。<br>120 BPM 时 1 秒 = 1,411,200,000 blick |
| y(纵) | 音高 —— 单位半音 | setPitch(P) + 点的 y = P + dy |
| 目标宽(拍) | spanBlick = 拍数 × SV.QUARTER | |
| 目标高(半音) | 图案总高,建议 ≤ 24 半音(两个八度),太高不便观看 |
换算模板(‼️ 两步,第二步是 §0.6 那次翻车的地方,绝不能省):
var t0 = group.getNote(0).getOnset(); // 画布原点 = 某个时刻(组内局部坐标)
var span = 2 * SV.QUARTER; // 画布宽 = 2 拍
var basePitch = 60; // 画布底部基准音高(= 曲线 anchor)
// —— 第一步:算出每个点的**目标绝对音高** ——
// blick = t0 + u * span
// 目标音高 = basePitch + v * heightSemitones
// —— 第二步:写进 setPoints 的 y 是**相对 anchor 的偏移量**,必须减掉 anchor ——
c.setPitch(basePitch); // anchor = 画布底部
// dy = 目标音高 − basePitch = v * heightSemitones ← 锚点取 basePitch 时就这么简单
// 若 anchor 取别的值(如音符音高 A),则 dy = 目标音高 − A⚠️ 写绝对音高会怎样:
setPitch(60)+ 点值61.5⇒ 实际 121.5(整幅图被抬高一个 anchor)。 症状是"图案跑到天上/顶到 127 上限",而导出的points看起来"数值很好看"(因为我们本来就是按绝对音高算的)。 唯一可靠的判据=读回后按实际 = anchor + y还原再比(见 §0.6、§0.3)。
采样步长:绘图要比音频曲线细。实测 3,000,000 blick(120 BPM 下约 4 ms)画 2 拍 →
每条笔画 471 点,视觉上够平滑。步长越小线越平滑、点越多(性能和体积的取舍)。
3.5 ‼️ 铁律:同一曲线内 x(blick)必须严格单调递增
根因:一条 PitchControlCurve 在数学上是 y(x) 单值函数 ——
同一个 blick 不能对应两个音高值。否则曲线无定义,
SV2 无法插值,我们自己的求值逻辑(如 08 与 sv/lua/AKDAgentBridge.lua 的 PIT 段)也会失效。
🎯 最直观的表述(用户给出的判据)
x(横向)必须单调;y(纵向)随便。
所以:能画上半圆,画不了左半圆。
| 图形 | x 走向 | 一条曲线能画吗 | 需要几条 |
| 上半圆 | 全程向右(−r → 0 → +r),y 先升后降 | ✅ 能 —— y 怎么来回都无所谓 | 1 条 |
| 下半圆 | 全程向右 | ✅ 能 | 1 条 |
| 左半圆 | 先向左再向右(0 → −r → 0) | ❌ 不能 | 2 条 |
| 右半圆 | 先向右再向左(0 → +r → 0) | ❌ 不能 | 2 条 |
| 完整圆 | 左右折返一次 | ❌ 不能 | 2 条(上半圆原样 + 下半圆反向写入) |
| 竖直笔画 | 不变(x 相同) | ⚠️ 需微倾斜 | 1 条 |
📌 实测印证:用户截图里 —— 上半圆画得干净漂亮 ✓,而左半圆崩了(x 先减小后增大, 中途折返 ⇒ 单条曲线表达不了)。这不是 bug,是介质本身的约束。
🔵 闭合曲线(圆/椭圆)的起笔点规则(2026-09-12 用户观察到的现象)
用户观察:"所有圆上半圆都是一个整条,下半圆都是两个 1/4 圆" —— 为什么?
| 起笔位置 | 切分结果 | 说明 |
正下方(a = −90°,最初写法) | 3 条:下半圆右 1/4 · 上半圆整条 · 下半圆左 1/4 | 起笔点落在下半圆中间 ⇒ 被"首尾接缝"多劈一刀 |
正右方(a = 0°,x 极值)✅ | 2 条:上半圆整条 · 下半圆整条 | 起笔点与两个切分点重合 ⇒ 不多切 |
机理(两条同时成立才出现这个现象):
- 切分只按 x 单调 ⇒ 圆必然在左右两个 x 极值处断开(上下半圆各一段);
- 折线是闭合的,首尾接缝也是一次"断" ⇒ 若起笔点不在 x 极值处,它所在的那一半就会再被切成两段; 另一半的两端恰好是那两个 x 极值点,整段 x 单调 ⇒ 一条画完。
✅ 规则:闭合曲线(圆、椭圆、闭合轮廓)一律把起笔点放在 x 极值处(圆取
a = 0或π)。 否则会在起笔处无辜多一刀(视觉上无害、曲线数变多、接缝变多)。 实测:circle()起笔从正下方改到正右方 ⇒ 3 条变 2 条。
由此导出两条必须遵守的规则:
| # | 情形 | 处理 |
| ① | 时间回退(折线在 x 方向折返,x[i+1] < x[i]) | 截断,另起一条新曲线(一笔拆成多笔)<br>或——若能反向——整段反向写入(见下) |
| ② | 垂直竖线(同一个 x 上有多个 y) | 制造一个微小的水平偏差(把竖线轻微倾斜),使 x 仍严格递增 |
⚠️ 注意"折返"的两种情形要区别对待:
- 整段递减(如左半圆的后半、下半圆)→ 反向写入即可(几何同一条线),不必拆 ✓
- 先增后减再增(如左半圆整体)→ 在极值点处拆开,每一段再按上一行处理
预处理算法(坐标映射之后、生成曲线之前必做)
三步:① 按 x 的极值点切分成单调段 ② 递减段反向 ③ 竖线(x 相同)给至少 1 像素的水平漂移(量见本节末尾的实测标定)。
💡 第 ② 步是关键且容易漏:从
(50,10)到(0,0)的递减线段, 反过来写成(0,0) → (50,10),画出来是同一条线 ✓ —— 所以递减段不必丢弃,只要反向写入即可。 (我第一版算法漏了这步,导致三角形的两条边整段消失。)
// 把「一笔」拆成「若干条 x 严格递增的曲线」
// pts: [[相对blick, 相对半音], ...]
// 返回 [[pts1], [pts2], ...],每条都能直接 setPoints()
function splitMonotonic(pts, step) { // step = 竖线每步的水平增量(blick),必须 ≥1px 的折合值
if (pts.length < 2) return [];
// ① 切分:在 x 方向的反转点处断开,得到一串单调段
var runs = [], start = 0, dir = 0;
for (var i = 1; i < pts.length; i++) {
var d = pts[i][0] - pts[i - 1][0];
var nd = d > 0 ? 1 : (d < 0 ? -1 : 0);
if (nd === 0) continue; // 平段不改变方向
if (dir === 0) { dir = nd; continue; }
if (nd !== dir) { runs.push([start, i - 1]); start = i - 1; dir = nd; }
}
runs.push([start, pts.length - 1]);
// ② 递减段反向(同一条线);③ 竖线给最小偏差(每点至少 +1 blick)
var out = [];
for (var r = 0; r < runs.length; r++) {
var seg = pts.slice(runs[r][0], runs[r][1] + 1);
if (seg.length < 2) continue;
if (seg[0][0] > seg[seg.length - 1][0]) seg.reverse(); // ← 关键:递减 → 反向
var res = [seg[0]];
for (var k = 1; k < seg.length; k++) {
var lx = res[res.length - 1][0];
var nx = seg[k][0];
if (nx <= lx) nx = lx + step; // 竖线/重复 x → 加水平漂移(默认整条 1 画布单位,见下)
res.push([nx, seg[k][1]]);
}
if (res.length >= 2) out.push(res);
}
return out;
}⚠️ 实测修正:偏差量不能取 +1 blick —— 竖线会整条看不见
本节原先主张「每点
+1blick 是最小可能偏差,无需可调参数」——这是错的,已实测推翻。
同一次作画内的对照(决定性证据):
| 笔画类型 | 该曲线横向跨度 | 结果 |
| 真竖线(自己独立一条曲线:亻竖、巾左竖、日左竖…) | 约 600 blick ≈ 0.0001 px | ❌ 不渲染(部分缩放/位置下只见断续) |
| 横折里的竖直段(竖与横在同一条曲线内:市横折钩、日右竖…) | 该曲线整体 ≈134 px | ✅ 正常显示 |
⇒ 可见性与斜率无关,只与整条曲线的 x 跨度有关。
机制(推断):SV 按曲线的 x 范围采样渲染,横向跨度 ≪ 1 像素时没有像素列可落 ⇒ 整条曲线不画。 所以竖线偏差不是「越小越忠于几何」,而是必须至少跨过 1 个像素。
换算(1 画布单位 = q100/100 × SV.QUARTER = 19,756,800 blick;截图缩放 ≈ 4.6 px/单位 ⇒ 1 px ≈ 4.29M blick ≈ 0.217 画布单位):
实测标定(唱 字 4 档对照,每条真竖线 40 点 / 39 步):
| 该版总水平漂移 | 折合像素 | 每步(blick) | 目视 |
| 0.0002 单位(每步 +100) | 0.0009 px | 100 | ❌ 部分看不见 |
| 0.197 单位(每步 +100000) | 0.91 px | 100,000 | ❌ 接近但看不见 |
| 0.25 单位 | 1.1 px | 126,646 | 未选定 |
| 0.5 单位 | 2.3 px | 253,292 | 未选定 |
| ⭐ 1 单位(默认) | 4.6 px | 506,585 | ✅ 用户选定 |
| 2 单位 | 9.2 px | 1,013,169 | 可用,但倾斜 2.9°,未采用 |
⇒ 默认:每条真竖线的总水平漂移 = 1 画布单位(≈ 4.6 px @ 4.6 px/单位 的缩放)
落地写法:
var DRIFT_UNIT = 1; // 总漂移(画布单位),默认 1
var NP = 40; // 竖线点数
var bpUnit = q100 / 100 * SV.QUARTER; // 1 画布单位 = 19,756,800 blick
var step = Math.max(1, Math.round(DRIFT_UNIT * bpUnit / (NP - 1))); // ≈506,585
// x_k = x0 + k * step ⇒ 总漂移 = (NP-1) * step ≈ DRIFT_UNIT 个单位- 只给"真竖线"加:横 / 撇 / 捺自带大 x 跨度;横折里的竖段也不用加(实测本来就能看见)。
- 倾斜代价:1 单位 ÷ 约 80 单位高 ≈ 0.7°,看不出斜。
- 像素数随缩放变:缩放再小 4 倍,4.6 px 就退回 1.1 px ⇒ 又会看不见。
要抗缩放就加大
DRIFT_UNIT,或按目标像素反算:DRIFT_UNIT = 目标px / (px每单位)。 - 换图幅时的通用写法(推荐):按占整幅图宽的比例给 —— 4‰ ≈ 图铺满屏幕宽时约 4 px(已实测可见的档)。这个写法与图幅绝对大小无关, 只有缩放变了才要调;图很宽 + 竖线很矮时斜率会变明显,把千分比往下调。
<details><summary>原文(已作废)</summary>关于竖线的偏差量:每点 +1 blick 就是最小可能偏差,无需可调参数 ——
一条 100 点的竖线总倾斜只有 100 blick(≈ 0.0001 拍),肉眼绝对不可察,
但 x 已是严格递增 ✓ 这是"最不失真"的做法。
其他注意:
- 单点段要丢弃(一个点画不出线)
- 拆分出的每条曲线都是独立
addPitchControl⇒ 与"图层"语义一致 ✓ - 切分点(极值点)会被两条曲线共用(段尾 = 下一段段首)⇒ 接缝处值相同,不会断裂 ✓
实例:一个矩形怎么画
一条"抬笔"路径 → ↓ ← ↑(矩形)只有第一条边是 x 递增的:
| 边 | x 走向 | 处理 | 结果 |
上边 → | 递增 | 原样 | 曲线 1 |
右边 ↓ | 不变(竖线) | ③ 每步 +step(整条共 1 画布单位) | 曲线 1 的延续 |
下边 ← | 递减 | ② 反向写入 | 曲线 2(几何同一条线) |
左边 ↑ | 不变(竖线) | ③ 每步 +step(整条共 1 画布单位) | 曲线 2 的延续 |
⇒ 一个矩形 = 2 条曲线(不是 1 条,也不是 4 条)✓
4. 从 SVG 到笔画(转换流程)
SVG path / 文字轮廓 / 位图描边
↓ ① 拆成独立笔画:每条 = 一串折线点 [[x,y], …](SVG 的 M/L/C 命令 → 离散化)
↓ ② 归一化到 [0,1]²
↓ ③ 坐标映射(见 §3)
↓ ④ 每条笔画 → 一条 PitchControlCurve(各自 setPosition + setPitch + setPoints)
↓ ⑤ 全部 addPitchControl要点:
- ⚠️ ④ 之前必须先做 §3.5 的单调性预处理 —— 否则回退/竖线会让曲线无定义
- 优先用描边(stroke/轮廓)而非填充 —— 音高线是"线",填充需要逐行扫描线(点数爆炸)
- 曲线(贝塞尔)先离散成折线,再用 §3 的步长重采样
- 复杂图案先降采样简化(如 RDP 简化算法)—— 减少笔画数和点数
- 每条笔画独立
setPosition,不要试图用一条曲线画不连续的图案 - 文字图案:用字体轮廓取描边(或直接手写七段/点阵风格的字更稳)
5. 实测数据与限制
| 项 | 实测值 | 备注 |
| 曲线条数上限 | 不卡就没上限(唯一约束是性能) | 实测:大市唱 19 条 / SVAGENT 15 条 / pelican 32 条都正常 |
| 单条曲线点数上限 | 不卡就没上限 | 实测 471 点/条(2 拍);汉字单条笔画约 150~250 点 |
| 真竖线的最小水平漂移 | ⭐ 整条 1 画布单位 = 4.6 px(用户选定) | 0.91 px 仍看不见;标定见 §3.5 |
| 多条曲线重叠 | ✅ 都渲染 | 用户确认看到 X —— "逐条渲染"成立 |
| 竖直可及范围 | MIDI 音高 0~127 | 超出范围画不了;点的相对值本身可任意(实测 -9.4 ~ +10.5 半音) |
| 能否画在没有音符的区间 | ✅ 可以 | 但组里至少要有一个音符,否则整组不显示 |
| 图案轨的渲染副作用 | 不用管 | 专门开一条图案轨时,不必考虑演唱音高被改成图案 |
已定论(原"尚未验证"清单)
- 重叠条数上限 / 单条曲线点数上限 —— 不卡就没上限,唯一约束是性能
- 竖直可及范围 —— MIDI 0~127;空音符区间可画,但组内至少要有 1 个音符
<br>⚠️ 注意:上限约束的是实际音高 =组.pitchOffset + anchor + 点偏移⇒ 若把绝对音高误写进点值(§0.6),会凭空加一个 anchor:anchor 62 + 点 69.76 ≈ 132⇒ 超出 127 上限/贴顶不可见。 这也是"图案跑到天上"的机械原因 —— 不是观感,是数值溢出。 - 竖线最小漂移 —— 整条 1 画布单位 ≈ 4.6 px(§3.5)
- 重叠是否逐条渲染 —— ✅ 是(这是整个功能成立的基础)
⚠️ 副作用(只对"真在唱的音符组"成立):这些曲线就是音高线,控制点处严格生效(见 08 §1.3) —— 在真在唱的组里画图案 = 把这个组唱成图案。 对策:专门开一条「图案轨」作画(空白组 + 至少 1 个占位音符即可),就不必管这件事。
6. 最小可跑示例(画一个 X)
// ES5.1;SV2;写操作
var ref = SV.getMainEditor().getCurrentGroup();
var g = ref.getTarget();
SV.getProject().newUndoRecord();
var t0 = g.getNote(0).getOnset(); // 从第一个音符起画(组里至少要有一个音符,否则整组不显示)
var span = 2 * SV.QUARTER; // 宽 2 拍
var step = 3000000; // 约 4ms/点
function stroke(dir) {
var pts = [];
for (var x = 0; x <= span; x += step) {
var u = x / span;
var y = dir > 0 ? (u * 10) : ((1 - u) * 10); // 0→+10 或 +10→0 半音
pts.push([x, Math.round(y * 1000) / 1000]);
}
var c = SV.create("PitchControlCurve");
c.setPosition(t0);
c.setPitch(60);
c.setPoints(pts);
g.addPitchControl(c);
}
stroke(1);
stroke(-1); // 两条交叉 → 组成 X验证结果:用户确认在钢琴窗看到 X(两条线分别渲染)✓
7. 相关技能
| 技能 | 关系 |
sv-scripting(references/08-pit-drawing.md) | 音高线的音乐性用法(音头/颤音/音尾、重音检测、两代落地)。§8.10 指向本技能 |
sv-project-format | .svp 里 pitchControl 的存储格式(注意:文件里是扁平数组,API 是成对数组) |
akdagent-protocol | 通过桥远程执行这些脚本(现役桥 = sv/lua/AKDAgentBridge.lua,文件通道;sv-bridge 是已退役的 JS 剪贴板桥,只作历史) |
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

