Skip to content

曲线支持同 x 两值(垂直跳变):把「交界点归属」从补点凑合升级为模型内表达 #151

Description

@LiuYunPlayer

要做什么

让曲线的一个 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 传染。故"禁止写出
    完全重合点"这一条同时保住新旧两侧。

要动的:

  1. MonotonicHermiteSlopeCalculator.SlopeAt 显式处理 Δx == 0(今天靠 ±Inf 侥幸得到有限值,不该是运气)。
  2. 排序链表对同 x 锚点的插入顺序必须稳定且有定义——它决定谁是左值谁是右值(Reorder / IsInOrder)。
  3. 两个快照类(AutomationSnapshot / PiecewiseAutomationSnapshot)与活曲线"窗口内逐点全等"的封条同步。
  4. AnchorDirtyRange 那条"等值相邻 ⇒ 常数段"的收窄判据在跳变点复核。
  5. 编辑交互:同 x 处点选抓哪一个(按 y 距离)、拖动其中一个能不能穿过另一个、删除的语义。
  6. 渲染:确认跳变落在两个采样点之间不会被画成斜线。
  7. 序列化:点列格式不变(同 x 就是两条记录),但写出前须保证无完全重合点。

两个契约点(先定,后面所有实现跟着它)

  1. 跳变 tick 上取右值。"新区间 / 新音符从这一点开始",与音符交界点归右邻的约定一致。当前实现天然是
    左值(见上面的严格小于),改它需要动主循环的比较符号;既有工程没有同 x 点,故不影响任何现存数据。
  2. 完全重合点一律禁止(插入 / 拖动时就地合并或拒绝)。理由见上:NaN 传染 + 已发布客户端的安全。

做完之后可以删掉什么

  • ParameterSyncMove 里的 gap(让出)、收尾点、以及 posOffset == 0 那个方向分支——每个音符的域退化成
    "自己那一段,含自己的两个端点",上下左右移动都是整段搬走。
  • 与之相邻的另一件事:把边界扩展宽度从数据层 AddLine 挪到画笔一侧(数据层只做精确区间替换)。那是手绘的
    手感参数,却成了每个程序化写入都要应付的固有行为;两件事一起做完,搬运就只剩"清一段、写一段"。

现在为什么可以推后

用户报的两个真问题(交界尖刺、水平移动的翘尾)都已修复并有回归封条。没有跳变时残留的是:交界处 gap 宽的
陡斜坡(默认 5 tick)、反复搬运在交界附近积累锚点、以及重写一段曲线导致邻域切线重算带来的百分之几的值变化
(最后一项不是跳变能解决的,属于上面"精确区间替换"那一条)。这些都不影响可用性,故推到后面的版本。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions