跳转到内容

在项目中使用

jpFun 的核心编译 API 不依赖 UI 框架,可用于浏览器、Node.js 和静态站点构建。输入是 jpFun 源码,输出包括诊断、谱面布局和供播放使用的音乐时间数据。

编辑器组件、文件读取和音频调度由宿主应用实现,并通过公共 API 调用核心编译能力。

使用 npm 或项目现有的包管理器安装:

终端窗口
npm install jpfun
import { compileScore, renderLayoutPagesToSvg } from "jpfun";
const source = `1,/ 2/ 3//`;
const result = compileScore(source);
const pages = renderLayoutPagesToSvg(result.layout);

pages 是按页排列的 SVG 字符串数组。浏览器可以将其插入页面,Node.js 可以写入文件,静态站点可以在构建时嵌入 HTML。编译成功后,应用仍应检查 result.diagnostics 中的可恢复问题。

不使用打包器时,可以通过 classic script 加载浏览器全量包,API 暴露在全局变量 jpfun 上:

<div id="score"></div>
<script src="https://unpkg.com/jpfun/dist/jpfun.min.js"></script>
<script>
const { compileScore, renderLayoutPagesToSvg } = jpfun;
const compiled = compileScore("1 2 3 | 4 -");
document.getElementById("score").innerHTML =
renderLayoutPagesToSvg(compiled.layout).join("");
</script>

生产部署应固定 CDN 包版本,避免依赖更新改变编译或排版结果。

目标使用的入口
显示或导出谱面compileScore + renderLayoutPagesToSvg
绘制到浏览器画布compileScore + renderLayoutPagesToCanvas
生成播放计划compileScore + compilePlayback
MIDI 转为 jpFunjpfun/converter/midi
MusicXML 转为 jpFunjpfun/converter/musicxml
注册或管理乐谱符号symbols
编辑器即时语法反馈analyzeScoreSyntax
自定义绘制后端compileScore + paintLayout / Painter

渲染和播放是两条独立的输出路径:渲染消费布局,播放消费音乐时间数据。compileScore 提供两者所需的结果,但不会自动绘制或发声。

compileScore(source, options?): CompileScoreResult

source 是完整的 jpFun 源码。compileScore 会依次完成预处理、解析、时间固化和布局,并保留每个阶段对外有用的结果:

字段内容常见用途
lineStarts每个逻辑行在源码中的起始偏移把诊断位置换算成行列号
maskedSource注释、续行符和字符串外 CRLF 中的 CR 经等长空格替换后的源码按原始偏移检查有效源码字符
diagnostics解析、固化和布局阶段共享的诊断展示语法、参数和排版问题
ast完整 AST 根节点源码工具、自定义分析
lowering音乐时间、轨道和关系对象播放、MIDI 或时间分析
layout页面、对象位置和最终几何SVG、Canvas 或自定义渲染

maskedSource 是预处理阶段的中间结果,与输入源码等长,所有字符偏移仍对应原始源码。预览可以借它判断一次源码点击是否落在已被掩码的注释内。保存文档时应使用原始源码。

编译选项用于设置应用级默认值和替换引擎依赖:

const compiled = compileScore(source, {
variables: { fontsize: 16, font: "sans-serif" },
rowGap: 18,
});

variables 直接传给根 ParserContext,覆盖系统默认值;fontsize 使用 px 数值,也可以传入自定义变量。

  • rowGap 强制设置每行轨道之间的间距;缺省时按该行最大字号推导。
  • textMeasurer 替换默认文本测量器,适合需要匹配特定字体度量的应用。
  • functions 替换内置函数注册表。扩展默认能力时需要自行包含 defaultFunctions。
  • damping、maxIter、eps、crossPunish 和 globalC 用于调整横向布局求解器,通常不需要设置。

页面尺寸、边距和页码属于乐谱内容,通过源码中的 @page(...) 设置。

能够恢复的问题会进入 compiled.diagnostics,编译仍然返回可用的 AST 和布局。每条诊断都包含稳定的 code、说明文字 message、源码偏移 span 以及可选的结构化参数 params:

import { ErrorDiagnostic, compileScore } from "jpfun";
const compiled = compileScore(source);
for (const diagnostic of compiled.diagnostics) {
const range = diagnostic.toLineCol(compiled.lineStarts);
console.log({
severity: diagnostic instanceof ErrorDiagnostic ? "error" : "warning",
code: diagnostic.code,
message: diagnostic.message,
params: diagnostic.params,
range,
});
}

需要自定义多语言提示时,可在编译前替换静态格式化函数 Diagnostic.format(code, message, params)。toLineCol 返回从 1 开始的行号和列号,适合没有文本编辑器模型的环境。CodeMirror 可以直接使用 doc.lineAt(diagnostic.span.start),VS Code 则可使用 document.positionAt(...),不必再做一次行起点查找。

无法恢复的解析错误或非法编译选项会由 compileScore 抛出,因此处理用户输入时还应包住调用。抛出的值若是 Diagnostic,其 span 仍可用于定位;其他异常应当作为内部错误展示,而不要假定它带有源码位置。

try {
const compiled = compileScore(source);
showScore(compiled.layout);
showDiagnostics(compiled.diagnostics);
} catch (error) {
showCompileError(error);
}

SVG 接口直接返回每页对应的字符串,适合服务端生成、静态站点构建和浏览器预览:

import { compileScore, renderLayoutPagesToSvg } from "jpfun";
const { layout } = compileScore(source);
const pages = renderLayoutPagesToSvg(layout, { background: "#fff" });

Canvas 接口消费同一份 layout。调用方负责为每页创建画布,并设置 CSS 尺寸和设备像素比:

import {
compileScore,
layoutPageBounds,
renderLayoutPagesToCanvas,
} from "jpfun";
const compiled = compileScore(source);
const pixelRatio = window.devicePixelRatio || 1;
const canvases = layoutPageBounds(compiled.layout).map(page => {
const canvas = document.createElement("canvas");
canvas.width = Math.ceil(page.w * pixelRatio);
canvas.height = Math.ceil(page.h * pixelRatio);
canvas.style.width = `${page.w}px`;
canvas.style.height = `${page.h}px`;
const context = canvas.getContext("2d")!;
context.scale(pixelRatio, pixelRatio);
return { canvas, context };
});
renderLayoutPagesToCanvas(
compiled.layout,
canvases.map(item => item.context),
);

传入的 context 数量必须与布局页数一致。SVG 和 Canvas 不会重新解析源码;切换后端时可以复用原来的编译结果。更详细的分页、绘制顺序和自定义 Painter 接口见渲染后端。

布局和播放并列消费 lowering。compileScore 不会主动展开反复或生成播放事件;只有需要播放、导出 MIDI 或分析实际演奏顺序时,才调用 compilePlayback:

import { compilePlayback, compileScore } from "jpfun";
const compiled = compileScore(source);
const plan = compilePlayback(compiled.lowering);
console.log(plan.events);
console.log(plan.durationSeconds);

PlaybackPlan 包含按演奏时间排序的速度、拍号、音色、NoteOn 和 NoteOff 事件。编译器已经完成反复展开、完整延音、速度效果合并和装饰音展开;应用按这些最终事件调度声音,并通过 scoreMap 将播放位置映射回原谱。

performanceDuration 是总演奏时值,单位 QN;durationSeconds 是沿 Tempo 积分得到的秒数。休止符参与时值和速度效果计算,tracks 仅包含最终实际发声的轨道。编译期区间与音段留在核心内部,Web Audio、Web MIDI 和 MIDI 文件适配器共同消费 events。

应用负责音频设备、实时调度和 MIDI 字节编码。可恢复问题保存在 plan.diagnostics 中;阻止编译的问题会抛出带源码位置的诊断,应用应分别处理这两种结果。

异常庞大的反复结构可以通过 { maxFlowSteps } 显式提高默认的 65,536 列访问预算;超过预算会抛出错误,而不会返回截断的计划。时间映射、区间协议与事件结构见播放,浏览器中的计划缓存和设备适配见编辑器集成。

midiJsonToJpFun 接收 midi.js 的 JSON() 结果并生成可继续编辑的 jpFun 源码。MIDI 字节解析属于应用边界,jpFun 不会替你读取 .mid 文件:

import { midiJsonToJpFun } from "jpfun/converter/midi";
const source = midiJsonToJpFun(parsedMidiJson, {
title: "My Score",
pitchMode: "absolute",
alignRate: 4,
barsPerLine: 0,
});
  • pitchMode 默认为 "absolute";使用 "relative" 可生成以 C 为基准的数字谱。
  • alignRate 控制二进制时值的自适应量化精度。
  • barsPerLine 默认为 0,表示依据真实排版宽度自动换行;正整数表示每行固定小节数。
  • title 覆盖 MIDI 文件头中的名称。

转换器会保留音符、和弦、重叠声部、轨道名、速度、拍号和 MIDI program,并识别常见的 3:2 四分、八分与十六分三连音。鼓通道以及控制器、弯音等目前不会进入生成的源码。

musicXmlToJpFun 接收已经解析好的 XML 根元素。浏览器可以直接使用原生 DOMParser:

import { musicXmlToJpFun } from "jpfun/converter/musicxml";
const xml = await file.text();
const document = new DOMParser().parseFromString(xml, "application/xml");
const parseError = document.querySelector("parsererror");
if (parseError) throw new SyntaxError(parseError.textContent || "Invalid MusicXML");
const { script, diagnostics } = musicXmlToJpFun(document.documentElement, {
pitchMode: "absolute",
barsPerLine: 4,
});

返回对象包含源码 script 和结构化诊断数组 diagnostics。原先直接使用字符串返回值的调用方需要改为读取 .script。转换失败仍抛异常,不返回部分结果。

每条 MusicXmlDiagnostic 包含稳定编号 code、级别 severity: "warning"、说明 message 和原始 MusicXML 位置 location:

编号报告内容
W_MUSICXML_PERCUSSION_SKIPPEDchannel 10 音符被跳过
W_MUSICXML_APPROXIMATED无固定音高音符按显示音高转换、后倚音改为前倚音、渐强线按起音端点和固定力度变化近似
W_MUSICXML_UNSUPPORTED_ELEMENT和声及记号容器中被舍弃的踏板、连奏线、演奏记号、装饰音记号和力度等
W_MUSICXML_UNATTACHED_CONTENT倚音、文字或力度没有可附着目标
W_MUSICXML_UNRESOLVED_RELATION连音线、滑音线、渐强线或房子未配对、被覆盖、未闭合或无法生成端点

location.element 是去掉命名空间前缀的元素名;可取得的位置还包含 partId、从 1 开始的声部内小节序号 measureIndex、原始字符串编号 measureNumber、staff 和 voice。这些位置属于原始 XML,不是生成源码的偏移;最小 DOM 接口不提供 XML 行列号。诊断按转换阶段的处理顺序输出,多次转换的顺序一致,不保证与 XML 全文顺序相同。

没有已覆盖的问题时 diagnostics 为 [],这不代表完全无损转换。默认值补齐、纯雕版坐标和未覆盖的 MusicXML 特性不在诊断保证范围内;程序应按 code 分类,不解析 message 文本。

文字、力度和楔形线严格匹配所属 part 和 staff;省略 voice 时,力度和楔形线作用于该 staff 的匹配声部,文字只附着一次。目标不存在时报告诊断,不会改写其他 staff 或 voice。连音组标记只有被最终事件的节奏状态保留才算确认,和弦成员上的等价重复声明不会误报。

同一音符的所有 notations 容器都会检查。速度和力度标记只有实际进入生成源码才算输出,曲末等未输出的标记会报告无法附着。琶音若在和弦合并及声部拆分后无法表达,例如跨 staff 分组被拆成单音事件,会报告未支持的内容。

双音 <slide> 和 <glissando> 按 part、元素种类和 number 配对为 @gliss,可跨声部;同时刻或跨谱面行的端点报告未解决关系并省略。波浪线或带文字的滑音线近似为直箭头并报告 W_MUSICXML_APPROXIMATED。单音 articulations 中的 scoop/plop/doit/falloff 分别转换为前上行、前下行、后上行、后下行 @slide;曲线外观近似为直箭头。这些记号只影响视觉,不增加滑音播放。

在 Node.js 中可使用任意兼容 DOM 实现,并传入满足 MusicXmlElement 接口的根元素。转换器支持 partwise/timewise、part/staff/voice、休止与和弦、倚音、连音、连音组、歌词、速度、调号、拍号、力度、反复与房子,以及基本的分页和谱头信息。

压缩的 .mxl 不在支持范围内;应先解压,或者从制谱软件导出 .musicxml。

两个转换器既可以从 jpfun 根入口导入,也各自提供独立子路径。应用始终需要某个转换器时,优先静态导入对应子路径;这样入口依赖更明确,也避免把另一个转换器带进同一模块图。

对于“点击导入后才需要”的功能,使用动态 import() 可以让 Vite、Rollup、webpack 等构建工具把转换器生成独立 chunk:

async function importScore(file: File) {
if (/\.musicxml$/i.test(file.name)) {
const { musicXmlToJpFun } = await import("jpfun/converter/musicxml");
const document = new DOMParser().parseFromString(
await file.text(),
"application/xml",
);
return musicXmlToJpFun(document.documentElement).script;
}
if (/\.midi?$/i.test(file.name)) {
const { midiJsonToJpFun } = await import("jpfun/converter/midi");
const parsedMidiJson = await parseMidiFile(file);
return midiJsonToJpFun(parsedMidiJson);
}
return file.text();
}

分包由应用构建工具完成。import("jpfun") 会以整个根入口作为异步边界;分别延迟加载 MIDI 和 MusicXML 时,应使用上面的两个子路径。未配置打包器的原生 ESM 环境仍会按模块加载,但不会自动生成可部署的 chunk 文件。

@symbol(简写 $name)的符号表由顶层导出的 symbols 对象管理。不同乐器的专属符号包可以独立于 core 维护,并在应用启动或切换乐器时按需注册:

import { compileScore, symbols, type SymbolDefinition } from "jpfun";
const dieyinSymbol: SymbolDefinition = {
name: "dizi.dy",
description: "竹笛叠音:本音前带出上方二度",
text: { content: "又", fontFamily: "serif" },
emitPlayback: emitter =>
emitter.affectFollowing(symbols.ornament({ body: [1, 0] })),
};
// 注册符号包或单个符号;同名时直接覆盖已有定义(含内置符号)
symbols.register(dieyinSymbol);
const compiled = compileScore("1 ^ $dizi.dy");

symbols 提供以下属性与方法:

成员作用
symbols.core最初内置符号的只读备份列表(readonly SymbolDefinition[])
symbols.table当前注册表的只读视图(ReadonlyMap<string, SymbolDefinition>),键为小写名称;不复制 Map,只读限制由 TypeScript 类型提供
symbols.register(...definitions)注册一个或多个符号定义;名称忽略大小写,同名直接覆盖并同步更新 pattern
symbols.clear()清空全部已注册符号(包括内置符号)
symbols.reset()清空并恢复为 symbols.core 初始状态
symbols.ornament(pattern)按调内音级偏移序列生成装饰音 PlaybackTransform
symbols.bounds(shapes)计算 SymbolShape[] 在符号自身坐标系中的固有包围盒,用于自定义 anchor 或组合图形

所有符号平铺在同一命名层级中,名称前缀(如 dizi.dy、guitar.p)或是否覆盖内置短名(如用吉他指法覆盖 $p)由符号包与宿主自行决定。切换曲谱或测试清理时,调用 symbols.reset() 即可回退到最初的内置符号表。@symbol 的函数文档 SymbolNode.def.details 会随当前已注册符号动态更新。

analyzeScoreSyntax 为编辑器提供轻量语法分析,识别函数调用和 token,并尽量容忍尚未输入完整的代码:

import { analyzeScoreSyntax } from "jpfun";
const { syntax, diagnostics } = analyzeScoreSyntax(source);

它提供语法高亮、函数名悬浮、补全和输入过程诊断所需的结构,具体的编辑器交互仍由应用实现。需要 AST、播放或渲染时使用 compileScore。两条路径共享同一套语法定义,但承担不同的容错边界,具体配合方式见编辑器集成。

实现自定义函数、编译阶段或渲染后端时,需要进一步理解 AST、Lowering 和 Layout 的契约。下一篇架构总览介绍这些表示之间的关系与职责边界。

仓库中的文档网站在构建时调用核心库生成谱例;在线编辑器展示浏览器中编译、诊断、预览、播放和导出的完整集成,其应用层设计见编辑器集成。