要做什么
让曲线的一个 tick 上能放两个值(同 x 不同 y,画出来是一段垂直的直上直下),并把它作为模型内的一等表达,
而不是靠在附近补一个点去凑近似。
为什么
1. 音符交界点的归属
相邻两个音符共享 note.EndPos() == next.StartPos() 这一个 tick。参数同步模式(移动音符时把音高线与自动化
曲线一起搬走)在这里没有干净的表达:那个 tick 上只能放一个值,于是它要么被两个音符各搬一次(先后移动
两个相邻音符 → 交界处冒出一根 1 tick 宽的尖刺),要么得靠"让出一小段 + 在缝里补一个取现值的收尾点"去凑。
现在的实现(TuneLab/Data/ParameterSyncMove.cs)走的正是后者,且不得不按方向分叉:
- 纯移调(来处 = 落点):域右界让出
gap(= 参数边界扩展宽度),写出的线延伸到交界点、那一点取原值;
- 水平移动:不让出、不收尾——落点那一侧的 tick 上摆的是别人的曲线,把它的现值焊上去会在音符尾部竖起一根
近乎垂直的翘尾。
它是对的,但代价留在数据里:交界处是一段 gap 宽的陡斜坡而不是真正的跳变(默认 5 tick,约 5ms,
听感与视觉都可忽略,但它是"凑"出来的);每次搬运都会在交界附近落下封边点,反复移动会积累锚点。
2. 区间参数条 / 阶梯参数
"按区间刷参数"(相邻区间各自恒定、边界直上直下)这类编辑形态,没有同 x 两值就无法如实表达。
3. 两种曲线的理由是统一的
音高线是分段曲线(PiecewiseAutomation,一串 AnchorGroup),两个相接的段各带自己的端点,看似已经能
表达跳变——但那要在交界处切段,而 MonotonicHermiteSlopeCalculator 对段的首尾锚点返回斜率 0,切一刀
就把两侧的切线钉成水平,原本平滑跨过交界的曲线会多一个折角。自动化曲线(Automation,单列表 + DefaultValue、
处处有值)连这个凑合的办法都没有。
而分段曲线是连续曲线的超集:用户把一整个 part 画满时它就等价于连续曲线。所以"要不要跳变"的判据在两种
曲线上是同一个,有了它两边都不必再切段,跳变点两侧各自保持自己的切线。
已核实的落地范围
好的一面:
- 取值主循环天然支持。
HermiteInterpolation.Calculate 的推进判据是 points[pointIndex].X < xs[i]
(严格小于),故 (last, next) 永远不会是同 x 的一对,插值公式不会除零。
- SDK 冻结面不动。引擎、快照、legacy 兼容层拿到的都是采样值(
GetValues),跳变对它们只是"相邻两个
采样值不同",本来就允许。
- 已发布的客户端安全。它们走同一份插值:同 x 不同 y 时斜率是 ±Inf,而公式恰好是
2/(1/Inf + 1/k),落回有限值,不炸。只有完全重合点(同 x 同 y)才是 0/0 → NaN 传染。故"禁止写出
完全重合点"这一条同时保住新旧两侧。
要动的:
MonotonicHermiteSlopeCalculator.SlopeAt 显式处理 Δx == 0(今天靠 ±Inf 侥幸得到有限值,不该是运气)。
- 排序链表对同 x 锚点的插入顺序必须稳定且有定义——它决定谁是左值谁是右值(
Reorder / IsInOrder)。
- 两个快照类(
AutomationSnapshot / PiecewiseAutomationSnapshot)与活曲线"窗口内逐点全等"的封条同步。
AnchorDirtyRange 那条"等值相邻 ⇒ 常数段"的收窄判据在跳变点复核。
- 编辑交互:同 x 处点选抓哪一个(按 y 距离)、拖动其中一个能不能穿过另一个、删除的语义。
- 渲染:确认跳变落在两个采样点之间不会被画成斜线。
- 序列化:点列格式不变(同 x 就是两条记录),但写出前须保证无完全重合点。
两个契约点(先定,后面所有实现跟着它)
- 跳变 tick 上取右值。"新区间 / 新音符从这一点开始",与音符交界点归右邻的约定一致。当前实现天然是
左值(见上面的严格小于),改它需要动主循环的比较符号;既有工程没有同 x 点,故不影响任何现存数据。
- 完全重合点一律禁止(插入 / 拖动时就地合并或拒绝)。理由见上:NaN 传染 + 已发布客户端的安全。
做完之后可以删掉什么
ParameterSyncMove 里的 gap(让出)、收尾点、以及 posOffset == 0 那个方向分支——每个音符的域退化成
"自己那一段,含自己的两个端点",上下左右移动都是整段搬走。
- 与之相邻的另一件事:把边界扩展宽度从数据层
AddLine 挪到画笔一侧(数据层只做精确区间替换)。那是手绘的
手感参数,却成了每个程序化写入都要应付的固有行为;两件事一起做完,搬运就只剩"清一段、写一段"。
现在为什么可以推后
用户报的两个真问题(交界尖刺、水平移动的翘尾)都已修复并有回归封条。没有跳变时残留的是:交界处 gap 宽的
陡斜坡(默认 5 tick)、反复搬运在交界附近积累锚点、以及重写一段曲线导致邻域切线重算带来的百分之几的值变化
(最后一项不是跳变能解决的,属于上面"精确区间替换"那一条)。这些都不影响可用性,故推到后面的版本。
要做什么
让曲线的一个 tick 上能放两个值(同 x 不同 y,画出来是一段垂直的直上直下),并把它作为模型内的一等表达,
而不是靠在附近补一个点去凑近似。
为什么
1. 音符交界点的归属
相邻两个音符共享
note.EndPos() == next.StartPos()这一个 tick。参数同步模式(移动音符时把音高线与自动化曲线一起搬走)在这里没有干净的表达:那个 tick 上只能放一个值,于是它要么被两个音符各搬一次(先后移动
两个相邻音符 → 交界处冒出一根 1 tick 宽的尖刺),要么得靠"让出一小段 + 在缝里补一个取现值的收尾点"去凑。
现在的实现(
TuneLab/Data/ParameterSyncMove.cs)走的正是后者,且不得不按方向分叉:gap(= 参数边界扩展宽度),写出的线延伸到交界点、那一点取原值;近乎垂直的翘尾。
它是对的,但代价留在数据里:交界处是一段
gap宽的陡斜坡而不是真正的跳变(默认 5 tick,约 5ms,听感与视觉都可忽略,但它是"凑"出来的);每次搬运都会在交界附近落下封边点,反复移动会积累锚点。
2. 区间参数条 / 阶梯参数
"按区间刷参数"(相邻区间各自恒定、边界直上直下)这类编辑形态,没有同 x 两值就无法如实表达。
3. 两种曲线的理由是统一的
音高线是分段曲线(
PiecewiseAutomation,一串AnchorGroup),两个相接的段各带自己的端点,看似已经能表达跳变——但那要在交界处切段,而
MonotonicHermiteSlopeCalculator对段的首尾锚点返回斜率 0,切一刀就把两侧的切线钉成水平,原本平滑跨过交界的曲线会多一个折角。自动化曲线(
Automation,单列表 + DefaultValue、处处有值)连这个凑合的办法都没有。
而分段曲线是连续曲线的超集:用户把一整个 part 画满时它就等价于连续曲线。所以"要不要跳变"的判据在两种
曲线上是同一个,有了它两边都不必再切段,跳变点两侧各自保持自己的切线。
已核实的落地范围
好的一面:
HermiteInterpolation.Calculate的推进判据是points[pointIndex].X < xs[i](严格小于),故
(last, next)永远不会是同 x 的一对,插值公式不会除零。GetValues),跳变对它们只是"相邻两个采样值不同",本来就允许。
2/(1/Inf + 1/k),落回有限值,不炸。只有完全重合点(同 x 同 y)才是0/0→ NaN 传染。故"禁止写出完全重合点"这一条同时保住新旧两侧。
要动的:
MonotonicHermiteSlopeCalculator.SlopeAt显式处理Δx == 0(今天靠 ±Inf 侥幸得到有限值,不该是运气)。Reorder/IsInOrder)。AutomationSnapshot/PiecewiseAutomationSnapshot)与活曲线"窗口内逐点全等"的封条同步。AnchorDirtyRange那条"等值相邻 ⇒ 常数段"的收窄判据在跳变点复核。两个契约点(先定,后面所有实现跟着它)
左值(见上面的严格小于),改它需要动主循环的比较符号;既有工程没有同 x 点,故不影响任何现存数据。
做完之后可以删掉什么
ParameterSyncMove里的gap(让出)、收尾点、以及posOffset == 0那个方向分支——每个音符的域退化成"自己那一段,含自己的两个端点",上下左右移动都是整段搬走。
AddLine挪到画笔一侧(数据层只做精确区间替换)。那是手绘的手感参数,却成了每个程序化写入都要应付的固有行为;两件事一起做完,搬运就只剩"清一段、写一段"。
现在为什么可以推后
用户报的两个真问题(交界尖刺、水平移动的翘尾)都已修复并有回归封条。没有跳变时残留的是:交界处
gap宽的陡斜坡(默认 5 tick)、反复搬运在交界附近积累锚点、以及重写一段曲线导致邻域切线重算带来的百分之几的值变化
(最后一项不是跳变能解决的,属于上面"精确区间替换"那一条)。这些都不影响可用性,故推到后面的版本。