同一首曲子 《最长的电影》(wXmvEqzP),两个项目对 tie / slur / tuplet 的抓取规则从根上不同——因为它们拿到的根本不是同一种数据:一个走 yopu API 结构化 JSON,一个走浏览器渲染出的 SVG。规则差异全部源于这个数据形态差异。
yopu.co/view/wXmvEqzP数据形态:AlphaTab notation JSON —— 带音乐语义的字段,纯 httpx API 抓取,信息无损。
GET yopu.co/api/sheet?code=wXmvEqzP → 解出 scoreUrlV4GET <scoreUrlV4> (cdn.yopu.co/nier4/…) → decode → AlphaTab JSONbeat.notes[].tieDestinationNoteId、beat.tupletNumerator 等字段数据形态:浏览器渲染出的 SVG 几何 —— 弧线 path、数字文本、坐标盒子,像素级、有损反推。
yopu.co/view/wXmvEqzPsvg_systems / beat_groups / textspath d="…Q…" 弧线、数字文本、box 几何反推全仓搜 isTieOrigin / tieDestinationNoteId / isLegatoOrigin / tupletNumerator 在 cnmusicscore 零命中——它根本拿不到这些字段,只能从 SVG 反推。
用 CNYuepuService 爬虫从 scoreUrlV4 = cdn.yopu.co/nier4/276274cc… 实抓 701 KB alphatab JSON:43 小节、4 轨(Steel Guitar / Vocal / Bass / Drumkit)。层级 tracks[].staves[].bars[].voices[].beats[].notes[]。三种符号的真实挂载层级与字段:
# m5 beat[3] 起点(歌词"始")
{"id":276, "tieDestinationNoteId":277}
# m5 beat[4] 终点
{"id":277, "tieOriginNoteId":276,
"isTieDestination":true}
全曲无 isTieOrigin/isTie——起点只认 tieDestinationNoteId。
# m16 三处连续起点
beat[3] {"isLegatoOrigin":true}
beat[4] {"isLegatoOrigin":true}
beat[7] {"isLegatoOrigin":true}
只标起点,无任何终点字段——终点隐含为下一发声拍。
# m42 beat[6..8] 三连音
{"id":2144, "duration":8,
"tupletNumerator":3,
"tupletDenominator":2}
分母 2 是源字段直给,不用推算;全曲仅 m42 有连音。
同一首 wXmvEqzP,cnmusicscore 拿到的是 QWebEngine 渲染的 SVG——上面这些字段一个都不存在,它只能从弧线 path、数字文本、坐标盒子里反推出等价信息。
( ) 括号,靠"跨小节 + 两端同音"这条几何+音高启发式事后判定。# note 级字段。yopu 起点不带 isTieOrigin,
# 而带 tieDestinationNoteId(指向下一 note)
if (
n.get("isTieOrigin") or n.get("isTie")
or n.get("tieDestinationNoteId") is not None
):
tie_start = True
if n.get("isTieDestination") or \
n.get("tieOriginNoteId") is not None:
tie_stop = True
# 没有 tie 概念。tie 与 slur 共用同一个
# SVG 弧线抽取器 _extract_svg_slur_items;
# 一条弧到底是 tie 还是 slur,抓取阶段
# 无法区分,先一律当"弧线"收集。
#
# 区分推迟到 ② 配对(跨小节+同音)
# 和底座 document.py 的最终分类。
# 无需显式配对。tie_start / tie_stop
# 各自独立打在相邻 beat 上,
# MusicXML 靠"同音高相邻 start/stop"
# 隐式配对,无 number 属性。
# 链式 tie 中间音 start/stop 皆 True(合法)
# 跨小节弧线:必须两端同音才保留
if not _slots_share_pitch(
bars[left_global_bar], left_slot,
bars[right_global_bar], right_slot,
):
continue # 不同音 ⇒ 丢弃
_apply_jianpu_group_markers(…, slur_start=True)
_apply_jianpu_group_markers(…, slur_end=True)
cnmusicscore 里 tie 写出来的也是 slur_start/slur_end 括号——真正分成 tie 的动作在底座 document.py:_tokens_share_pitch_for_tie(相邻两音同音+有括号 ⇒ kind="tie",L275–308)。
# 声音级 <tie> + 视觉级 <tied> 双写
if beat.tie_stop:
SubElement(note, "tie", {"type":"stop"})
if beat.tie_start:
SubElement(note, "tie", {"type":"start"})
# … 再写 <notations><tied type=…>
# 从 doc.tie_links 的 (bar,slot) 端点集
# 匹配起止,再写 <tie> + <tied>
if tied_starts_here and not is_rest:
SubElement(note, "tie", {"type":"start"})
if tied_stops_here and not is_rest:
SubElement(note, "tie", {"type":"stop"})
isLegatoOrigin(只标起点)+ 后处理把连续区间合成一条弧,终点=下一发声拍;cnmusicscore 从 SVG 弧线 path 的几何盒子(宽 12–220px、高 3–12px、y 窗口)识别,用 left/right 覆盖音符定起止。# (b) beat 级 legato —— yopu 实测主用,只标起点
legato_origin = bool(
beat.get("isLegatoOrigin")
or beat.get("isLegato")
or beat.get("slur")
)
# (a) note 级显式 slur(少见)
if n.get("slurOrigin") or n.get("isSlurOrigin"):
slur_start = True
# 从 beat_groups 的子 path 找弧线几何
if not d or not re.search(r"[QC]", d):
continue # 必须是贝塞尔曲线
if width < 12.0 or width > 220.0: continue
if height < 3.0 or height > 12.0: continue
if not (jianpu_row_y-160 <= top
<= jianpu_row_y-6): continue
items.append({"left":…, "right":…, "y":top})
# 连续多个 legato 起点 = 同一条弧
while i < n:
if flat[i].legato_origin:
j = i
while j+1 < n and flat[j+1].legato_origin:
j += 1
if j+1 < n:
flat[i].slur_start = True
flat[j+1].slur_stop = True # 下一发声拍
i = j+1
# 号由 _SlurState 栈递增,支持嵌套
# 弧线 left/right 覆盖的音符定起止
# 起点取最靠近 slur_left 的音,
# 终点取最靠近 slur_right 的音
_apply_jianpu_group_markers(…, slur_start=True)
_apply_jianpu_group_markers(…, slur_end=True)
# yopu_slur_resolver.py 用正则
# M x,y Q … z 抽端点后就近对音,
# 是"连线唯一权威来源"。number 恒 "1"
if stop_num is not None:
SubElement(notations, "slur",
{"type":"stop", "number":str(stop_num)})
if start_num is not None:
SubElement(notations, "slur",
{"type":"start", "number":str(start_num)})
# 弧只挂和弦顶音(idx==0)
# document.py 用栈把括号配成对:
if has_end and slur_stack:
start_idx = slur_stack.pop()
slur_links.append(NoteLink(kind="slur", …))
if has_start:
slur_stack.append(idx)
# 导出恒 <slur number="1">,不支持嵌套
tupletNumerator / tupletDenominator,分母无损;cnmusicscore 从 SVG 数字文本反推,分母靠推算(3→2、6→4、否则 count−1),且语法只支持 3[ / 6[。# beat 级两个字段,直接读
tuplet_num = int(beat.get("tupletNumerator") or 0)
tuplet_den = int(beat.get("tupletDenominator") or 0)
# 判定统一: num > 1 and den > 0
# 从 texts 找连音数字文本
ratio = re.fullmatch(r"(\d+)\s*:\s*(\d+)", text)
if ratio: count, den = … # "8:4" 比例型
elif re.fullmatch(r"\d+", text):
count, den = int(text), None # 纯数字无分母
if re.search(r"\bb\d+\b", parent_class): continue
if count < 3: continue # 只认 ≥3
# actual/normal 都来自源字段,无损
tm = SubElement(note, "time-modification")
child(tm, "actual-notes", str(beat.tuplet_num))
child(tm, "normal-notes", str(beat.tuplet_den))
# 每 beat 各自开闭,不跨 beat 配组
# SVG 纯数字没有分母 → 硬推算
actual = int(token.tuplet_count)
normal = 2 if actual==3 else (
4 if actual==6 else max(1, actual-1))
# 位置靠数字 x 坐标匹配音符窗口:
# 起点 tuplet_start_count=count (L7444)
# 终点 tuplet_end=True (L7449)
if is_first:
SubElement(notations, "tuplet",
{"type":"start", "bracket":"yes"})
if is_last:
SubElement(notations, "tuplet", {"type":"stop"})
# 任意连音数(num/den)都能表达
# 简谱文本语法层
triplet = re.match(r"^(?P<count>[36])\[", remain)
if triplet:
tuplet_start = True
tuplet_count = int(triplet.group("count"))
# 正则 [36] → 只支持 3 连音 / 6 连音,
# 其它连音数无法被此语法保存
| 维度 | CNYuepuService · 结构化 JSON | cnmusicscore · 渲染 SVG |
|---|---|---|
| 数据源 | yopu api/sheet → scoreUrlV4 的 AlphaTab JSON(httpx) |
QWebEngine 渲染 view/ 页面的 SVG DOM |
| 核心文件 | alphatab_to_musicxml.py(单文件) |
yopu_importer_v2.py + yopu_slur_resolver.py + 底座 |
| tie 抓取 | note 级 tieDestinationNoteId / isTieDestination,起止独立标 |
无独立 tie有损=跨小节+同音弧线,_slots_share_pitch 判定 |
| slur 抓取 | beat 级 isLegatoOrigin(只起点)+ 后处理合弧 |
SVG path 几何([QC]/宽 12–220/高 3–12/y 窗口) |
| tie/slur 区分 | 源头即分开(不同字段) | 抓取端同为 ( ) 括号,底座按同音启发式再分有损 |
| slur number | _SlurState 栈递增,支持嵌套 |
恒 "1",不支持嵌套 |
| tuplet 分母 | 直读 tupletDenominator,无损 |
推算 3→2 / 6→4 / 否则 count−1有损 |
| tuplet 连音数 | 任意 num/den | 语法仅 3[ / 6[(正则 [36])受限 |