开发一个简谱打谱语言
本文讲述了简谱打谱语言 jpFun 的诞生,主要是宗旨理念和时间线;至于具体细节请参考文档。
起因#
开发一个简谱打谱语言的想法早就有之。最早接触文本记谱,是贴吧 justice_eternal(后面简记为“je吧”)的记谱方式:用数字1-7表示音名,用一层方括号表示升高八度,用一层圆括号表示降低八度。这样纯文本的记谱方式非常简洁,能高效地交流与传播,至今统治着各大评论区。随着乐谱的积累,还形成了专门的谱库。
在我看来,“文本形式”是其最大的优势之一;相比图片形式的乐谱,它更便于存储、检索和修改。我最早的算法设计便是对je谱的转调,本质只是文本的编码,非常简单。大三的时候我也做过图片简谱的转调,但光定位这一步就已经相当复杂。
然而,je 数字谱存在一个巨大的缺陷:无法记录时值。这意味着演奏者需要熟悉原曲的节奏和旋律,因而非常依赖音频文件。je吧贴子中偶尔会出现图片形式的正式乐谱,于是我了解到了番茄简谱,这是一个能把“代码”变为图片谱的软件。它的语法非常简单,即使不渲染,脚本也有不错的可读性。我非常欣赏这个软件,也围绕它开发了“midi 和番茄简谱的互转”这样的实用小工具。
然而使用中,我发现了很多问题:不支持和弦、多声部无法播放、播放时半音音高不对……还缺少很多功能,以至于每次使用,我都不得不用各种方法弥补。考虑到番茄简谱已经停更多年,在大三时,我便萌生了开发一个更完善的简谱打谱语言的想法。不过当时忙于开发 noteDigger,这个想法就搁置了。
插一嘴:为了“克服”je谱缺少时值信息的缺陷,我做过不少尝试:
- 人工确定时值:一边播放原曲,一边人工敲击屏幕/键盘,以记录每个音符的时间。这个想法在高中就用 app inventor 实现了,并于大一在网页上复刻,但效果其实不好。如果要卡准时机,需要使用者对乐曲无比地熟悉;而且受限于当时的技术力,没有后处理的步骤,因此一次都不能出错。
- 自动确定时值:输入je谱和音频,用算法直接对齐。这个方法本来是我本科毕业设计的一个选题,虽然最后没有做,但是毕业后利用 noteDigger 现有基础完成了,并在写了篇文章。效果和精度谈不上完美,但是借助 noteDigger 的能力,可以进行人工后处理。
这些都只能算是对je谱的补救,本质是转换为 midi,不仅没有根除这一劣势,还丧失了“文本形式”这一大优点。因而“拯救je”的方向,应该是拓展je记谱语法——这便是开发一个新的简谱打谱语言的又一动力。
其他工作的反思#
对于门外汉来说,设计一门排版语言困难重重。那就学习一下已有的排版语言吧!
首先是 lilypond,神似 latex,专业而强大,但是和 latex 一样复杂,脚本不具备任何可读性。jianpu-ly 是对其深度修改的简谱打谱变体,利用 lilypond 的排版能力,实现了对简谱的高质量排版,但依然很复杂。于是我的一个目标便是:脚本要有可读性,不能丧失je谱的直观性。
然后我了解到 SparksNMN——一个很新的简谱打谱语言,开源且功能丰富,简直是升级版的番茄简谱。初见时我一度认为这个领域已经不需要我的努力了,但使用后我发现它仍有很多缺点,其中我最讨厌三个地方:
- 一行的小节数要提前指定,要不同的宽度还得自己分配。排版效果不如番茄简谱
- 不支持“柱状音符”(即和弦)和“临时多声部”
- 语法虽然简短,但非常混乱,需要背诵各种写法
这三点带给我三个设计要点:
- 排版算法需要有弹性
- 必须考虑多声部和和弦的支持
- 语法要统一!!!
我曾试图自己拓展其语法,来实现和弦支持,但是阅读代码的时候我发现了一个更要紧的问题:每个功能的语法和编译过程深度绑定,导致扩展非常困难,牵一发而动全身。这让我意识到,解析一个既定的语言设计是相对简单的——哪怕是我的“番茄简谱转midi”工具,都基本实现了对番茄简谱的解析;困难的是后续的维护和拓展。拓展性对于这个任务来说至关重要,因为简谱的用法和标记千奇百怪,不可能刚开始就考虑得面面俱到,必然是一个先 MVP 再增量补充细节的过程。这就要求架构设计必须高度模块化,新功能可以轻松插入系统——这不就是“插件”架构吗!
批判了这么多,其实我还是非常佩服 SparksNMN 的。这个项目做得非常完整,令人惊叹。里面也不乏值得学习之处:
- 要配套教程和文档
- 需要支持语法诊断,提供良好的错误提示和调试信息
- 用前端语言实现,降低使用门槛
此外,我还去学习了 typst 的设计理念,补充了一些解析和排版的知识。我特别喜欢 typst 的“函数式”和“语法糖”的设计,这深深影响了 jpFun 的诞生。
有段时间甚至误入歧途,沉浸于学习造语法高亮编辑器和增量解析,后来发现这些根本不重要,属于过早思考复杂性了。番茄简谱的编辑器是他们自己搓的,用两层叠加实现高亮,但现在有更强大的编辑器可以直接用,比如 SparksNMN 就是用的 ACEeditor;番茄简谱也没有增量解析,每次从后端直接返回所有svg,使用却没多大影响,可见性能其实并不重要。
参考了这么多之后,我便开始设计自己的简谱打谱语言。
设计思路#
如上所说,整体架构为“插件”式,各个功能模块相对独立,通过统一的接口接入引擎接受调度。各个功能只要实现这些接口(就像pytorch新建模块时一样),注册到引擎中即可。所有函数之间的交流,全部走引擎提供的机制。这样便将函数之间解耦,并可以专心设计引擎的流程了。
“插件式”和“语法统一”天生一对!受到typst的启发,语法设计上我决定采用“函数式”的风格,不同的函数只要更改函数名和传参,而函数调用格式一样,不仅使用时语法统一,解析时也可以统一处理,特别适合插件式架构。就这样,语法和架构相辅相成,整个项目的地基已然建立完成。
但是函数的写法有时过于冗长,所以我学习了“语法糖”的设计,以获得便利性和可读性。语法糖的解析依然建立在“插件”架构下,经过很长一段时间的思考和尝试,我抽象出两个接口优雅实现~
整个解析的流程设计为五个阶段:
- 建立语法树,来表示函数嵌套。
- 在时间上展平为一个个“时间节点事件”,完成时间上的对齐,体现音乐语义。
- 根据时间流对齐关系,进行排版,得到坐标、大小等信息。
- 渲染到屏幕上。
- 基于2的结果,编译为播放语义,用于播放
注意,播放和布局走的是两个分支。番茄简谱之所以有很多播放的问题,根源就是它将播放和布局结果深度耦合——它的后端只处理布局,播放完全依赖前端排版结果。
关于排版,核心在于横向怎么分配。我非常直觉地设计了模拟“弹簧”的布局算法,通过找到力平衡点实现最优布局。通过设计劲度系数和弹簧长度,还能实现空隙的等比例缩小。
实现#
第 1、2 都是古法编程完成的,3 的横向布局手写了一个非常朴素而差劲的优化器,然后便迎来了 AI 时代!好在地基和基本思路已定,AI只需要在框架中填补细节即可,进度突飞猛进。不过 AI 非常喜欢打补丁,依然需要我来审查代码。
能用 AI 提高效率,也离不开插件式架构的支撑。宗旨是只能写“插件”,不允许修改核心引擎代码。但若是引擎能力不足,便人工设计接口、扩展能力,再由 AI 接入该功能,非常完美的配合~
至于demo和教程框架这种不重要的东西,都是AI包办的啦……
同期工作与评价#
在完成 jpFun 绝大部分内容时,有一个人在 github 上 follow 了我。点进去一看——open-fanqie,是一个旨在复刻并修复番茄简谱,提供开源替代方案的项目。我认为维护番茄意义不大,唯有拓展、甚至替代才是出路。我在 issue 里打了打自己的广告并指出了番茄简谱的不足,招致了对方的刻薄回应,但也因此了解到了另一个项目:Lotus。
Lotus 的 MVP 推出得比我早一个月,算是同期工作。发现了很多“英雄所见略同”的地方:用弹簧建模横向排版、明确有“时间流”这样的抽象层。它的 README 里面还有文献,非常学术。不过在简略看了这个项目之后,我还是认为我的设计更胜一筹。首先,我的“插件”式架构设计无疑是最领先的,而它的依然“牵一发而动全身”。其次,它的语法偏向于 lilypond,写起来不直观。最后,我用ts实现,可以跑在任何地方;它用haskell,只能本地运行,还不能播放。即使是共通点“弹簧排版”,我的算法也只需要至多20轮迭代,而它的需要2000次。
Lotus 还是毕业设计、24年开始多人讨论,却比不过我一个闲时的项目,着实让我骄傲。阅读其论文,也没感受到什么认知和设计上的创新,更像是如实陈述实现。不知道是不是阅读了太多文献导致的思维固化,或者是着急赶毕业论文?
总结#
虽然这个任务从大三开始就盘桓在脑中,但真正开始设计是26年初 CodeX 刚刚问世的时候,当时对这个任务毫无头绪。趁着新人免费,我便想让最先进的AI试试,完全vibe,结果生成了几堆屎山,完全违背了我“解耦”的设计宗旨,只能全部删除,最后还是回归到人工。不过在与其交谈中,我逐渐明确了需求、具体目标、以及大致做法,如同摸黑时的微弱的明灯,让我得以迈出第一步。
更早一些,在25年开学选课的时候,我就已经开始思考如何设计语言。不过当时更是无从下手,于是毅然选择了《形式语言与自动机》这门课。不过最后好像也没什么用,顶多让我直到自己的语言不是图灵完备的。哪里需要什么编译原理、自动机?学过数据结构便足矣;因为领域陌生而徘徊不前,纯属是自己吓自己。本质上来说,设计语言就是字符串处理,当初的犹豫与棘手,只是没有拆分、明确任务而已。用最朴素的想法埋头去干、在做中不断总结和改进,才是通向成功的正道。