编辑器集成
playground 是核心库的应用层集成。编辑器负责维护文档状态、协调不同有效期的编译结果,并将几何与播放计划接到浏览器界面和设备;它不定义另一套记谱语义。
本文先说明数据依赖与结果生命周期,再介绍 playground 的具体功能实现。VS Code 部分提供扩展点的接入建议,相关插件尚未实现。
分析路径与结果有效期
Section titled “分析路径与结果有效期”编辑器功能分为词法层和语义层,分别使用解析器的两个入口,详见 解析。两条路径共用核心语法和函数声明,应用层只负责消费结果,不另写一套参数绑定或简写识别规则:
| 词法层 | 语义层 | |
|---|---|---|
| 入口 | analyzeScoreSyntax(source) | compileScore(source, options) |
| 触发 | 每次按键,同步 | 防抖(playground 是 180ms),与渲染共用 |
| 产出 | { syntax, diagnostics } | { lineStarts, maskedSource, diagnostics, ast, lowering, layout } |
| 出错 | 只记诊断,不抛 | 致命错误直接抛出,此时没有 AST |
| 服务 | 高亮、补全、自动格式化、括号、函数名悬浮 | 预览渲染、去糖悬浮、诊断面板 |
词法层随输入同步更新。语义层结果在文档修改后失效,直到下一次编译完成才可用。接入其他前端时,也应区分这两条路径:高亮、补全等功能使用当前词法结果,依赖 AST 的功能则在语义结果过期时暂停或使用词法信息作为替代。
| 状态 | 来源与有效期 |
|---|---|
| 源码与编辑选区 | 编辑器文档模型,是应用的输入状态 |
| token 与调用边界 | 当前源码的词法结果,不依赖成功排版 |
| AST、Lowering、布局与诊断 | 一次完整编译的结果,不与其他源码版本混用 |
| 源码与谱面映射 | 从同轮源码区间和最终几何建立,源码变化后立即失效 |
| 播放计划 | 从成功编译的 Lowering 按需生成,同一源码版本复用 |
| 缩放、面板、混音与设备状态 | 应用状态,不回写 AST 或改变核心记谱语义 |
结果有效期不仅影响预览更新,也决定能否执行定位、去糖替换和播放。旧谱面上的坐标不能与新源码的偏移混用;异步接入时,同样需要防止较早请求的结果覆盖较新的文档状态。
功能与数据依赖
Section titled “功能与数据依赖”| 功能 | 数据来源 | VS Code 对应 |
|---|---|---|
| 语法着色 | syntax.tokens | DocumentSemanticTokensProvider |
| 函数名补全 | defaultFunctions 的 def | CompletionItemProvider(触发字符 @) |
| 符号补全 | symbols.table.values() 的名称和说明 | CompletionItemProvider(触发字符 $) |
| 参数名补全 | syntax.calls + def.args + resolveArgType | 同上 |
| 当前参数提示 | syntax.calls + resolveArgDef | SignatureHelpProvider |
| 标签补全 | 参数类型 + syntax.tokens 里的 label | 同上 |
| 回车自动格式化 | syntax.tokens | OnTypeFormattingEditProvider(触发字符 \n) |
| 函数文档悬浮 | syntax.calls + def | HoverProvider |
| 去糖写法悬浮 | compileScore().ast | 同上 |
| 去糖替换 | node.toString(source) | CodeActionProvider(见下) |
| 音高变换 | transformNotes(source, compiled.lowering, operation) | CodeActionProvider + WorkspaceEdit |
| 标签跳转定义 | syntax.tokens + ASTLabelNode.target | DefinitionProvider |
| 标签重命名 | syntax.tokens | RenameProvider |
| 诊断 | compiled.diagnostics + 抛出的错误 | DiagnosticCollection |
| 谱面预览 | paintLayoutPages + RecordingPainter,按页重放到 SVG / Canvas | Webview |
| 源码 / 谱面双向定位 | object.ast.sourceSpan + box、attachment.sourceSpan + regions | Webview 消息 + TextEditor.selection |
| 分页、缩放与打印 | layout.pages + 前端页面组装 | Webview / Webview 打印 |
| 实际播放 | compilePlayback(lowering) + Web Audio 适配器 | Webview |
| MIDI 导出 | PlaybackPlan.events + SMF 适配器 | Webview 下载 |
| 注释/括号 | languageData | language-configuration.json |
playground 将源码与播放、谱面与诊断组织为两组视图。视图切换只改变展示和按需计算时机,不改变文档内容;桌面拆分布局与移动端上下布局共用这些状态。
布局选择、编辑器宽度、主题等偏好保存在 localStorage 中,存储不可用时退化为会话内状态。编译状态与统计来自当前结果,播放状态来自独立的计划和调度器。持久化失败或设备不可用不应阻断源码编辑与排版。
源码工具栏的音高菜单提供转绝对音高、转相对音高、升半音和降半音。有选区时只处理被选区完整覆盖的音符,支持多个选区;没有选区时处理整篇。互转保持实际音高;升降半音保持原来的绝对或相对表示,全部调号不变。休止符、占位符和打击音不参与变换,颤音等派生音继续按原调号生成。
transformNotes 由 note 模块提供,读取同一源码版本的 lowering,返回基于原始偏移的 { span, text }[]。函数形式与简写形式保留,显式调用只修改音高参数值;低八度逗号需要保护时使用引号或大括号,例如 @n(C3) 转成 @n("1,"),@div(C3, 1) 转成 @div({1,}, 1)。默认参数只在当前音符局部覆盖,不修改 @set。
playground 在执行前确认编译结果未过期,候选源码编译成功后用一次 CodeMirror 事务提交全部 edits,并隔离前后的撤销历史。光标和选区沿修改映射,文件关联不变;文档变化继续触发现有的脏状态、预览与播放失效流程。输入法组合期间不执行变换,失败时保留当前文档。
syntax.tokens 是扁平的 { kind, span } 数组,条目互不重叠,并按起点升序排列。这些信息可用于实现 VS Code 的 DocumentSemanticTokensProvider。建议按下表映射 token 类型:
| kind | playground CSS 变量 | VS Code semantic token type |
|---|---|---|
comment | --syntax-comment(斜体) | comment |
string | --syntax-string | string |
function | --syntax-function(粗) | function |
label | --syntax-variable | label |
property | --syntax-type | property |
number | --syntax-number | number |
boolean | --syntax-keyword | keyword |
length | --syntax-number | number |
atom | --syntax-number(粗) | 需自定义 type,或 number 加 modifier |
operator | --syntax-keyword | operator |
punctuation | --syntax-comment | 无标准类型,可省略 |
offset 对应原始源码位置。preprocessSource 将注释、续行转义和字符串外 CRLF 中的 CR 等长替换为空格,因此 span 可直接用于原文,无需额外的位置映射。
VS Code 的 semantic tokens 异步提供,首次打开文档时可能短暂没有颜色。可用一份只识别 % 注释和 @name 的最小 TextMate 语法提供初始高亮。完整高亮仍使用词法结果,以免重复维护语法定义。
playground 的补全由 jpfun-language.ts 中的 complete() 实现。它先确定光标所在的调用和参数角色,再生成候选;接受参数候选时则重新读取当前语法区间。候选过滤范围与实际替换范围不必相同。
调用定位与参数提示
Section titled “调用定位与参数提示”callAt(calls, pos) 从当前 syntax.calls 找到最内层调用:这些调用按起点升序排列,且同级不重叠,所以最后一个命中的就是目标。再由 argumentIndexAt 和参数的 nameSpan、equalsSpan、valueSpan 区分参数位置、名字和值。
当前参数提示 parameterDocAt 用共享的 resolveArgDef(def, name, index) 取得完整声明,补全用 resolveArgType(def, name, index) 查询类型,均不直接以 def.args[index] 推断绑定。namedOnly: true 的声明不占位置编号,因此 @voices / @vs 的位置参数都是 content,@tie 的位置参数都是 label;它们不会被误判成 connect 字符串或 height 长度。
提示按同一份声明展示类型、必填状态和默认值。普通参数显示位置编号,额外位置参数显示 extraArgType;仅命名参数明确标为“仅命名参数”,没有位置序号。当前参数提示、完整函数文档、候选文档和文档站函数参数表遵循同一规则。
按光标位置生成候选
Section titled “按光标位置生成候选”complete() 区分四类候选:
- 函数名:当前前缀以
@开头时列出函数,通过snippetCompletion插入@name(),把光标放进括号。VS Code 可使用SnippetString("name($0)")和triggerCharacters: ["@"]。 - 参数名:在可输入参数名的位置,从
def.args中列出其他参数尚未使用的具名选项。编辑已有名称时不把当前参数排除,进入等号右侧后则停止提供参数名。 - 标签值:只有当前参数类型为
label,且光标处于位置参数或具名参数的值中,才加入文档中已声明的标签。@x形式的labeltoken 是声明,@tie(x)中的x是引用;编辑参数名本身时不混入标签候选。 - 符号名:当前前缀以
$开头时,从symbols.table.values()提供已注册符号名称和说明,直接插入$name。符号注册需在加载编辑器语言模块前完成;候选列表只构建一次,后续输入由 CodeMirror 过滤。
content 需要额外区分“开始输入一个参数”与“正在输入内容”:仅在参数起点的空白或名称前缀处,提供匹配的仅命名选项。已经输入音符、内容块或其他内容时,外层参数名补全返回 null,不能把内容误当成名字。@、$ 前缀和嵌套调用仍走各自的补全逻辑。
下表中的 ▮ 表示光标,不是源码的一部分:
| 光标位置 | 候选行为 |
|---|---|
@voices(@voice(1), con▮) | 提供仅命名参数 connect |
@voices(1 ▮)、@voices({▮}) | 不提供外层参数名 |
@voices(@voice(1), connect=▮) | 已进入字符串值,不再提供参数名 |
@voices(@voice(@n(▮))) | 根据最内层 @n 的声明提供候选 |
1@a 2@b @tie(a, ▮) | 保留标签候选,也可选择尚未使用的 height 参数 |
1@a 2@b @tie(a, b, hei▮ght=1em) | 只提供匹配的参数名,不加入标签候选 |
接受补全与区间映射
Section titled “接受补全与区间映射”候选始终按光标前的前缀过滤,而不是按整个待替换名称过滤。所有参数候选共用一个 applyParameterCompletion 回调;接受时,它从最新的 syntaxField 重新查找调用和 nameSpan,不捕获生成候选时的旧区间:
- 新参数还没有
nameSpan,就在补全区间插入name=。 - 已有命名参数则替换完整
nameSpan,保留原来的等号、两侧空白和值。
例如在 @voices(@voice(1), con▮nect = "[-]") 中选择 connect,只规范化整个名称,保留 = "[-]",不会留下名称后缀或再插入等号。在名称前、名称中或名称末尾接受补全,都遵循相同规则。
回调使用 CodeMirror 的 insertCompletionText 构造事务,并添加 pickedCompletion 标记,而不是手写单光标替换。这样既支持匹配的多光标编辑,也能在候选被缓存、输入导致位置映射后,按最新名称范围完成替换。内容位置的候选不设置可复用的 validFor,以便继续输入时重新判断是否仍处于参数名边界。
编辑器顶部的「自动格式化」复选框默认开启,取消勾选后回车只执行普通换行。开启时,按 Enter 正常换行并保留缩进,然后只为刚离开的上一行补空格;行中回车不会格式化移到下一行的内容。粘贴、导入和保存不会触发此功能。
格式化只补缺失的空格,不压缩已有空白、不改变行首缩进,也不修改字符串或注释内部:
| 输入 | 回车后上一行 |
|---|---|
6#3 | 6 #3 |
1^3 | 1 ^ 3 |
1/2 | 1/ 2(减时线与附点仍紧贴前面的音符) |
1,,2 | 1,, 2(低八度逗号属于音符,不拆开) |
@up(1,3) | @up(1, 3)(参数逗号只在后面补空格) |
@div(6#3,1) | @div(6 #3, 1)(内容参数内同样处理) |
@tempo(120) | 不变(函数名、括号保持紧凑) |
V{}:、V[]:、V|: | 不变(完整声明 token 不拆开) |
@set(voices.connect="[1-2]{3-}") | 不变(字符串中的括号和范围不格式化) |
实现位于 playground 的 insertFormattedNewline,复用 syntaxField 中已缓存的 syntax.tokens:按源码顺序扫描相邻的 atom / operator 边界及参数逗号 punctuation,不另写词法规则,也不依赖 AST 或修改 core。CodeMirror 的 insertNewlineAndIndent 负责换行、缩进和多光标;补空格与换行合并为一次 transaction,因此一次撤销即可还原。无需补空格时直接提交原换行 transaction,避免重复更新状态。
定位文档与语法糖节点
Section titled “定位文档与语法糖节点”悬浮内容按以下顺序查找:
- 词法层:光标落在
call.nameSpan上时,显示函数简介、别名、参数信息及def.details中的 Markdown 详细说明(示例、简写与特性)。这部分使用当前词法结果,不依赖 AST。 - 语义层:词法层未命中且 AST 未过期时,沿
sourceSpan和children递归查找命中路径上最深的、带def的ASTFunctionNode,先显示node.toString(source)生成的去糖等价写法,再显示文档。
1/ 的 / -> @div(@n(1, , 0, #000), 1)1^3^5 的 ^ -> @up(@n(1,...), @n(3,...), @n(5,...)) // 扁平化后的真实结果2>3 的 > -> @grace(@n(3,...), @n(2,...), side=pre)| -> @bar(0, 22px)去糖行只用于语法糖。若节点的 sourceSpan 与 syntax.calls 中某个条目的起止位置完全相同,则它是源码中的显式调用,去糖行与原文相同,无需重复显示。
仅检查源码切片是否以 @name 开头无法区分这两种情况:语法糖可能扩展 span,例如 @note(1) ^ @note(3) 产生的 UpFunction 切片仍以 @note 开头。
替换范围与生效配置
Section titled “替换范围与生效配置”去糖替换使用命中节点的 node.sourceSpan 和 node.toString(source),不是只替换鼠标下的 token。对 V{}:、V[]:、V|:,核心词法结果将整个声明标记识别为一个 operator,后接的 N: 无论同行还是换行都继续独立着色;语义结果则让这些声明落在同一个 voices 节点的源码范围内。因此悬浮在声明上会显示整个连续声部块的去糖结果,替换也覆盖声明及成员,而非单独的 V 标记。
VoicesFunction.toString 将生效的连接设置显式写为末尾的 connect 参数:继承的预设被固定到输出中,局部声明输出自己的范围,只有 V|: 时也保留 connect=""。这些范围只影响视觉连接,输出仍是一个含全部直接声部的 @voices,不会生成分组函数或新音轨层级。编辑器不重新计算范围,也不依赖替换位置仍有原来的 @set 连接设置。
高亮和回车格式化只需要核心 token,不必等待这个语义节点出现;去糖悬浮则必须等待当前版本的 AST。用户尚未完成 V 声明或后续声部时,继续使用词法容错结果,不拿旧块执行替换。
去糖行单独显示,使用 --syntax-function 颜色和等宽字体;行尾提供「替换」按钮。等价写法不截断、不折行,超长内容在行内横向滚动。悬浮框整体有最大宽高限制,正文过长时由外层滚动;「替换」按钮在滚动时保持可见。
playground 自行控制 CodeMirror 悬浮框的关闭时机:悬浮框优先放在当前行上方,保留 4px 间距;鼠标离开 token 后延迟 250ms 关闭,进入悬浮框或其外扩 8px 区域时取消关闭。点击编辑器后,在鼠标再次移动前不会原地弹出悬浮框。这些数值由 playground 的交互设置决定。
VS Code 接入:Hover 使用
MarkdownString,不能放置交互式按钮。可在 Markdown 中提供command:链接(需要isTrusted),也可用CodeActionProvider提供「替换为函数调用」的 refactor。后者更符合 VS Code 的操作习惯,也可针对选区批量提供替换。
标签跳转与重命名
Section titled “标签跳转与重命名”Ctrl/Cmd + 点击标签引用时,光标跳到被标注对象,便于查看引用对应的音符或其他对象。按住修饰键时,可跳转的标签显示下划线和手型。只有引用支持跳转,声明、音符和注释中的同名文字不响应。
光标位于声明或引用上时,按 F2 可重命名该组标签。编辑器通过 EditorState.allowMultipleSelections 选中该组的所有出现位置,输入时同步修改,不显示额外弹窗。
跳转和重命名使用相同的作用域规则,与 parseArgWithType 的 label 分支一致:
一个引用绑定到所属函数调用之前最近的同名声明。
同一个标签名可以重复使用:
1@x 2@y @tie(x,y) 3@x 4@y @tie(x,y)第一条延音线的 x 指向 1,因为 3@x 位于该调用之后;第二条的 x 指向 3,因为两个声明都在调用之前,而 3@x 更近。
因此,F2 按绑定到同一个声明的引用分组。跳转和重命名可共用声明查找逻辑:跳转查找引用绑定的声明,重命名收集绑定到该声明的全部引用。
词法层比较「声明起点 < 该调用的 span.start」,核心比较「被标注对象起点 < funcStart」。对有效源码,两者等价。只有函数调用位于对象与其标签之间时才可能产生差异,例如 1 @tie(x) @x,而该写法会触发 E_UNKNOWN_LABEL。
查找声明只使用词法层结果,随输入同步更新。只有从声明定位到被标注对象时,才需要读取 AST 中的 ASTLabelNode.target。编译失败或文档刚修改、AST 不可用时,跳转目标改为声明 @x 本身。F2 不依赖 AST。
定位被标注对象使用 target,不使用 parent。ASTLabelNode.parent 表示 AST 中的容器关系,可能在 ASTBraceNode 等容器构造时被改写;target 始终指向被标注节点。
与谱面的联动
Section titled “与谱面的联动”跳转后,右侧谱面对目标音符显示与普通点击相同的波纹强调。playground 在跳转的 transaction 中加入 labelJumped effect,由 updateListener 识别后通知外层。
通知不依赖 click 事件,因为跳转在 mousedown 阶段拦截事件并调用 preventDefault,以阻止 CodeMirror 默认的「Ctrl+点击添加多光标」行为。此后浏览器不一定再生成 click。
VS Code 接入:
DefinitionProvider返回被标注对象的Location,修饰键下划线、F12和悬浮预览由编辑器提供。重命名使用RenameProvider,将同组标签的 range 作为WorkspaceEdit返回;可通过prepareRename将范围限定在名字部分,不包含@。
- 非致命诊断包含警告和已处理、允许继续编译的错误,记录在
compiled.diagnostics中,编译仍会完成。 - 致命错误由
compileScore抛出,捕获到的Diagnostic单独展示。单行页面溢出在布局入口转换为带 span 的E_PAGE_OVERFLOW。其他非Diagnostic异常没有 span,前端用一条「排版中断」文本行表示。 Diagnostic提供code、message、span(offset)和toLineCol(lineStarts)。没有编辑器时可用lineStarts转换行列;playground 直接使用doc.lineAt(offset),VS Code 使用document.positionAt(offset)。后两种方式也适用于致命错误,此时无法取得compileScore的lineStarts。- 空 span(
start === end)在显示时扩展为 1 个字符,使波浪线可见。
诊断标签显示总数和最高严重级别,面板显示错误与警告计数。带 span 的诊断行显示 行:列,点击后切回源码、选中对应区间并滚动到视口中央;无 span 的诊断行只显示文本。
analyzeScoreSyntax 也返回 diagnostics,例如未闭合调用、位置参数排在命名参数之后等。前端可自行决定是否在输入过程中展示这些诊断。playground 目前只展示编译路径的诊断,减少输入未完成时的错误提示。
只有致命错误会自动切换到诊断面板,下一次编译成功后切回预览。非致命诊断即使是 error 级别,也只更新标签徽标,谱面继续显示。compiled.diagnostics 中的 ErrorDiagnostic 都允许编译继续,因此不能仅凭错误级别决定是否切换面板。
播放诊断也区分是否阻止继续运行,但不会自动切换右侧谱面。PlaybackPlan.diagnostics 与排版诊断合并进入统一列表;warning 同时在播放页显示数量,不阻止播放。compilePlayback 抛出的带 span 的 Diagnostic 会禁用走带,并同时显示在诊断列表和播放页。只有用户点击播放页摘要时才切到诊断页。
远程脚本、AudioContext 和 MIDI 下载失败没有源码位置,只在播放页显示,不生成源码波浪线。
播放与 MIDI 导出
Section titled “播放与 MIDI 导出”实时播放和 MIDI 导出共用 PlaybackPlan,但输出适配器不同:浏览器将事件转成声音,MIDI 将事件编码成文件。事件、增时线和连音的核心语义见 播放,这里说明前端如何消费这些数据。
计划生命周期
Section titled “计划生命周期”首次切到播放标签,或用户点击 MIDI 导出时,playground 使用当前成功编译结果中的 lowering 调用 compilePlayback,生成 PlaybackPlan。同一源码版本复用计划。
播放标签保持打开时,源码变化会立即停止声音并使旧计划失效,下一次 compileScore 成功后重建计划。其他情况下,普通编辑和排版不会触发播放编译。
浏览器实时播放
Section titled “浏览器实时播放”tiny-synth.ts 按需加载远程 tinySynth,打开播放页时提供 128 个 GM 风格的旋律音色,点击播放时才创建并恢复 AudioContext。
适配器沿 Tempo 事件将 QN 换算成秒,按 noteId 配对 NoteOn/NoteOff,得到待调度的音符。调度器每 100ms 补足约 1 秒的绝对 AudioContext 时间窗口。暂停时停止已排程节点;恢复播放、跳转或播放中调整速度倍率、移调、音色覆盖时,从当前位置重建窗口。
PlaybackPlan.tracks[index]、混音设置与 tinySynth 的 channel[index] 一一对应。这里的 channel 标识浏览器内的混音输出,MIDI 通道编号由导出适配器另行分配。音量、Mute 和 Solo 直接作用于原声部的输出增益,无需重建计划。
播放秒数通过 scoreMap 映射回原谱 QN。右侧 SVG/Canvas 共用 overlay,以半透明矩形标示当前时间列,并行声部合并成一个区域。跨行或跨页时,只在列变化时滚动。暂停保留矩形,停止或源码变化时清除矩形。
播放标签激活时,点击右侧有正时值的谱面对象会将进度移到该对象的记谱位置,不跳回源码。
X:同一事件,两种输出
Section titled “X:同一事件,两种输出”X 与 9 等价,谱面均显示 X。它们产生真实的 NoteOn/NoteOff:NoteOn 携带 percussion: true、midi: 37 和原声部身份,NoteOff 通过 noteId 配对。37 在这里是 GM Side Stick 的打击键号;0/Z 和 8 仍不产生音符事件。
| 项目 | 浏览器播放 | MIDI 导出 |
|---|---|---|
| 声音来源 | tinySynth 额外注册的单一木击近似音色 | 接收端 GM 音源的 Side Stick,实际听感由音源决定 |
| 音色与键号 | 内部音色槽 128,固定频率的短促波形;不加入乐器下拉框 | 打击键 37,不导出内部音色槽 128 |
| 通道与归属 | 保留原声部的 tinySynth channel,共用该声部增益 | 所有打击音汇入额外的打击 MTrk,使用 MIDI channel 10(零基编号 9) |
| 增时线 | X - - 只触发一次短包络,调度参数 last 固定为 25ms,尾音自然衰减 | 保留延长后的 NoteOff 时刻,不把记谱时值改成 25ms |
| 音色覆盖与移调 | 忽略旋律 program、乐器选择和移调,打击音不变 | 不向鼓通道写入旋律 Program Change,键号保持 37 |
| 原声部音量 | 通过原声部 gain 控制,Mute/Solo 同样生效 | 音量折算到每个打击 NoteOn 的 velocity;Mute/Solo 不参与导出 |
例如,X - - 1 中,X 在 QN=0 起音,NoteOff 在 QN=3,旋律音 1 也从 QN=3 开始。浏览器只在开头敲击一次,之后仍按三拍推进进度;MIDI 则保存完整的三拍事件对。打击音的物理衰减与记谱占用时值是两件事,增时线不会让短促音效反复响起。
前端为 tinySynth 补充了一个固定频率、快速衰减的合成音色,近似表现 Side Stick 的短促敲击,调用现有的 play(),复用调度、混音和 stopAll()。从 X 的延续区间中途 seek 或恢复播放时,不重新触发已经过去的敲击;回到起音位置则正常触发。
MIDI 文件与设置边界
Section titled “MIDI 文件与设置边界”midi-export.ts 使用同一事件计划,按需加载远程 midi.js,导出 format 1 的 Standard MIDI File,固定为 480 PPQ:
- conductor 轨保存 Tempo、拍号和曲末时长标记,因此尾部休止不会丢失;
- 含旋律音的原声部各有一个
MTrk,保存旋律音符、Program Change 和音量 CC7;旋律通道在每个 port 中避开 channel 10,超过 15 个旋律通道时沿用 MIDI Port 元事件扩展; - 按需新增一条打击
MTrk,NoteOn/NoteOff 都按noteId路由到 channel 10。它只是导出文件的轨道,不会增加PlaybackPlan.tracks或播放页的声部数量。
多个原声部的 X 共用 MIDI channel 10,不能分别向这个共享通道写不同的 CC7。导出器将鼓通道的 CC7 固定为 127,再按各原声部音量缩放打击力度;音量为零时整对省略 NoteOn/NoteOff,不产生空鼓轨。MTrk 是文件中的轨道块,MIDI channel 是事件中的通道编号,两者独立分配。
播放页的设置是否影响导出,取决于设置本身:
| 设置 | 浏览器播放 | MIDI 导出 |
|---|---|---|
| 按谱面 / 乐器覆盖 | 普通音使用谱面 program,或整声部覆盖;X 除外 | 同样决定旋律 Program Change;打击轨不写 program |
| 声部音量 | 修改原声部输出增益 | 旋律写 CC7;打击音缩放 velocity |
| Mute / Solo | 决定各声部是否听得到 | 忽略,不因静音或独奏筛除音符 |
| 速度倍率 | 缩放播放时间,X 的短包络参数不变 | 忽略,保留计划中的 Tempo 和记谱时值 |
| 移调 | 只改变旋律音高 | 忽略,保留计划中的音符键号 |
SMF 拍号分母只能是 2 的幂,分子必须在 1..255,三字节 Tempo 也有范围限制;超出范围时导出失败。音符键号和力度则由适配器整数化并限制到设备可接受的范围,不应把前述限制理解为所有数值都严格原样输出。
打击乐目前支持播放和导出。MIDI/MusicXML 导入器仍跳过鼓通道,导出的 X 再导入后会丢失。
tinySynth 与 midi.js 均从 https://madderscientist.github.io/noteDigger/lib/ 按需加载,不进入 npm/Vite bundle。网络加载失败只影响对应播放或 MIDI 操作,源码编辑、排版和图像导出继续可用。
compileScore 返回的 layout 可直接传入两个渲染后端:
renderLayoutPagesToSvg(layout, { padding, background }) // 返回分页 SVG 字符串数组renderLayoutPagesToCanvas(layout, contexts) // 画到每页的 2D contextSVG 路径直接使用最终坐标,不生成 <defs>/<use>,以简化渲染结构。SVG 内容与缩放无关,缩放时只修改 style.width/height,无需重新生成。
renderLayoutPagesToCanvas 在内部按页执行 translate(-bounds.x, -bounds.y)。前端负责创建数量匹配的 context,并处理画布尺寸、devicePixelRatio 和缩放。
playground 使用「全量布局与指令缓存、按需创建绘图表面」:paintLayoutPages 一次将全曲绘制指令路由到各页 RecordingPainter,保留跨页元素和绘制层级;页面进入视口后,再重放到 SvgPainter 或 CanvasPainter。这减少 SVG 序列化、DOM 和 Canvas 像素缓冲开销,不减少全曲解析、布局和指令生成。
页面生命周期由 preview-pages.ts 统一管理:所有页保留尺寸占位,IntersectionObserver 以滚动容器为根、上下各预留 600px 缓冲区,绘制更新合并到一个 animation frame。离屏表面移除,Canvas 同时缩至 1 × 1 释放像素缓冲,指令缓存保留;源码和播放定位可临时绘制目标页,再由观察器接管。
图片导出复用同一源码版本的编译结果和完整分页指令缓存,不重新排版、调用布局对象的 paint 或读取预览 DOM。SVG 逐页序列化,PNG/JPEG 按导出 PPI 在独立 Canvas 上重放并编码,不受预览缩放和 devicePixelRatio 影响;导出不清空共享指令。位图导出持有启动时的页面快照,期间编辑不会混入新版本。源码已变化时,先编译更新,失败则中止导出。
VS Code 可使用 Webview 展示预览。jpfun 是纯 ESM,不依赖 DOM,因此既可在扩展主进程编译,通过 postMessage 将 SVG 字符串发送给 Webview,也可全部放在 Webview 中运行。在扩展主进程编译时,诊断与预览可以复用同一份编译结果。
playground 将每个 layout.pages[i].bounds 展示为独立纸张,纵向排列,保留固定页间距。工具栏提供:
- 即时切换 SVG / Canvas 后端;
- 缩放菜单中的「适合宽度」和 50%~200% 常用倍率,内部缩放范围为 25%~300%;
- 默认适宽模式,按钮以
适合宽度:53%的形式显示实时倍率;容器或页面宽度变化时自动调整,手动选择倍率或使用Ctrl+滚轮后退出适宽模式,只显示百分比; - 普通滚轮滚动页面,
Ctrl+滚轮以指针位置为锚点缩放。
多页缩放锚点保存为「具体页面 + 页面内归一化坐标」,避免将页间距计入缩放比例。缩放立即更新所有占位尺寸并恢复锚点;SVG 只更新已挂载根元素的 CSS 尺寸,Canvas 通过同一帧调度按最终缩放和 devicePixelRatio 重绘视口附近页。
发生致命错误时,当前编译结果被清空,之后缩放不会重新显示上一次成功的谱面。
源码与谱面定位
Section titled “源码与谱面定位”源码与谱面共用一份不依赖渲染后端的映射:可见 Temporal 使用 ast.sourceSpan + box,最终 PlacedAttachment 使用 sourceSpan + regions。
除顶层 layout.objects 外,前端还从 lowering.astToTemporal 收集已完成布局的折叠成员,因此 grace/up 内部音符与各层复合体均可分别命中。regions 来自最终 attachment geometry,不包含试测轮的几何结果。自动 beam 的 span 取首末端点及其生效 div 作用域的并集。
函数包装只有在以下条件全部满足时,才会并入对象范围:仅有一个可见后代,且父 AST 本身既没有可见 Temporal,也没有独立 attachment。因此,1/ 的音符和减时线都对应完整的 1/;grace 操作符不会覆盖内部音符各自的 span;@box 仍通过自身 attachment 定位。
从源码定位谱面
Section titled “从源码定位谱面”playground 只在鼠标点击源码或标签跳转时同步右侧谱面,键盘移动光标不触发同步。点击注释、空白或没有可见输出的声明时,清除强调,不选择附近对象。
文档修改后立即丢弃上一轮导航映射,防抖编译完成后再恢复,避免旧谱面使用新文档中已失效的 span。命中后只修改 previewScroll.scrollLeft/scrollTop,不通过 scrollIntoView 移动整个工作台;目标中心显示双层圆形波纹。
从谱面定位源码或播放位置
Section titled “从谱面定位源码或播放位置”点击右侧谱面时,行为由左侧当前标签决定:
- 源码标签激活时,第一次 click 将光标放到 span 起点;500ms 内同一位置的第二次 click 选中完整 span。第二次点击的选区处理在 document 捕获层完成,因此即使布局变化使点击落在其他 DOM 元素上,也能保留原 range。
- 播放标签激活时,点击改为 seek 到对象的 score time,不改变编辑器选区。
命中测试先扣除纸张元素的 CSS 边框,再根据当前纸张的 bounds,将 SVG/Canvas 内容盒像素还原为全局布局坐标。随后在 region 外扩 6px 的范围内,选择面积最小、距离最近的目标。细 beam、tie 等关系图形因此优先于覆盖它们的大盒子,SVG 和 Canvas 无需分别维护 DOM source marker。
打印复用当前源码版本的编译结果与分页指令缓存,只有结果失效时才重新编译;将全部页面重放为白底 SVG,建立独立打印树。动态 @page size 与布局纸张尺寸一致;工作台在打印媒体中隐藏,每个 SVG 后强制分页。打印结束后,清空临时节点和样式。
若重新编译失败,打印树保持为空,不打印过期谱面。
playground 通过 EditorState.languageData 声明:
commentTokens: { line: "%" }closeBrackets: { brackets: ["(", "{", "[", "\""] }对应 VS Code 的 language-configuration.json(comments.lineComment、brackets、autoClosingPairs)。
playground 使用 Tab 接受补全,回车插入换行。VS Code 默认允许 Tab 和回车接受补全;要保持相同交互,可在 [jpfun] 作用域下设置 editor.acceptSuggestionOnEnter: "off"。
playground 还提供行号、括号匹配、多光标、搜索、Alt+Z 切换折行、选中空白可视化和 Ctrl+Enter 手动排版。接入 VS Code 时,可复用编辑器已有的通用编辑功能。
以下功能目前未实现:
- 查找引用:尚不能从音符列出所有引用它的位置。
F2已计算绑定到同一个声明的引用,但没有提供引用列表 UI。 - 语义高亮:尚未对未绑定标签、未知函数名等进行错误着色。这需要遍历 AST,仅靠 token 不足以判断。
- 全文格式化和代码折叠:已有回车时为上一行补空格的功能,但不提供完整文档的格式化命令。
- 增量解析:每次按键仍会重新扫描全文词法信息。若需要支持更大规模的乐谱,可先考虑按行或大括号边界进行区间重扫。节点复用还需处理两个问题:
span使用绝对 offset,且 AST 会改写 grammar 阶段的 span 对象。
@div(1)/这类语法糖作用于同名显式调用的写法,节点 span 没有扩展到尾部字符,仍为[0,7),因此悬浮在/上时没有响应。- 文档修改后到下一次编译完成前,去糖悬浮不可用;词法层的函数文档悬浮不受影响。