文档状态: ✅ 已完成
创建日期: 2026-01-29
用途: 定义简谱各元素的精确渲染规则
适用于: 简谱渲染引擎 - 绘制模块
const RENDER_CONSTANTS = {
// ===== 间距配置 =====
/** 四分音符水平间距(像素) */
QUARTER_NOTE_SPACING: 50,
/** 小节左右内边距(像素) */
MEASURE_PADDING: 20,
/** 行间距(像素) */
SYSTEM_SPACING: 100,
/** 声部间距(像素) */
VOICE_SPACING: 60,
// ===== 字体配置 =====
/** 音符数字字体大小(像素) */
NOTE_FONT_SIZE: 24,
/** 歌词字体大小(像素) */
LYRIC_FONT_SIZE: 14,
/** 升降号字体大小(像素) */
ACCIDENTAL_FONT_SIZE: 12,
/** 音符字体 */
NOTE_FONT: 'Arial, "Noto Sans SC", sans-serif',
/** 歌词字体 */
LYRIC_FONT: '"Microsoft YaHei", "Noto Sans SC", sans-serif',
// ===== 尺寸配置 =====
/** 高低音点半径(像素) */
OCTAVE_DOT_RADIUS: 2.5,
/** 附点半径(像素) */
DURATION_DOT_RADIUS: 2,
/** 减时线高度/粗细(像素) */
UNDERLINE_HEIGHT: 1.5,
/** 减时线间距(像素) */
UNDERLINE_GAP: 3,
/** 增时线高度/粗细(像素) */
EXTENSION_LINE_HEIGHT: 1.5,
/** 小节线粗细(像素) */
BARLINE_WIDTH: 1,
// ===== 颜色配置 =====
/** 音符颜色 */
NOTE_COLOR: '#000000',
/** 线条颜色 */
LINE_COLOR: '#000000',
/** 歌词颜色 */
LYRIC_COLOR: '#333333',
};
音符区域高度分配:
┌─────────────────────────────┐
│ 升降号区域 (12px) │ ← accidental
├─────────────────────────────┤
│ 高音点区域 (8px) │ ← octave dots (high)
├─────────────────────────────┤
│ │
│ 音符数字 (24px) │ ← note number
│ │
├─────────────────────────────┤
│ 低音点区域 (8px) │ ← octave dots (low)
├─────────────────────────────┤
│ 减时线区域 (12px) │ ← underlines
├─────────────────────────────┤
│ 歌词区域 (20px) │ ← lyrics
└─────────────────────────────┘
总高度约:84px(单声部无歌词约64px)
interface NoteRenderSpec {
/** 数字内容:1-7 或 0(休止符) */
text: string;
/** 字体大小 */
fontSize: number;
/** 字体粗细 */
fontWeight: 'normal' | 'bold';
/** 水平对齐 */
textAnchor: 'middle';
/** 垂直对齐基线 */
dominantBaseline: 'central';
}
function drawNoteNumber(
x: number,
y: number,
pitch: number,
config: RenderConfig
): SVGTextElement {
const text = document.createElementNS(SVG_NS, 'text');
text.setAttribute('x', String(x));
text.setAttribute('y', String(y));
text.setAttribute('font-size', String(config.noteFontSize));
text.setAttribute('font-family', config.noteFont);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'central');
text.setAttribute('fill', config.noteColor);
text.setAttribute('class', 'vf-numbered-note-head');
text.textContent = String(pitch);
return text;
}
/**
* 计算音符显示宽度
* 基于时值计算,保证固定时间比例
*/
function calculateNoteWidth(realValue: number, quarterSpacing: number): number {
return realValue * quarterSpacing;
}
// 示例:
// 四分音符(1.0) → 50px
// 八分音符(0.5) → 25px
// 二分音符(2.0) → 100px
// 全音符(4.0) → 200px
增时线是简谱渲染的核心难点!
增时线规则:
1. 只有时值 >= 1.0(四分音符及以上)才可能有增时线
2. 数量 = Math.floor(realValue) - 1
3. 每条增时线代表1个四分音符的时值
4. 增时线应该按时值均匀分布在占用的空间中
| 音符类型 | 时值(realValue) | 增时线数量 | 视觉表示 |
|---|---|---|---|
| 四分音符 | 1.0 | 0 | 5 |
| 附点四分 | 1.5 | 0 | 5· |
| 二分音符 | 2.0 | 1 | 5 — |
| 附点二分 | 3.0 | 2 | 5· — — |
| 全音符 | 4.0 | 3 | 5 — — — |
function calcExtensionLineCount(realValue: number): number {
if (realValue < 1.0) return 0;
return Math.floor(realValue) - 1;
}
关键:增时线要按四分音符间距均匀分布!
interface ExtensionLinePosition {
/** 增时线起始X坐标 */
x: number;
/** 增时线Y坐标(与音符数字基线平齐) */
y: number;
/** 增时线长度 */
width: number;
}
/**
* 计算增时线位置
* @param noteX 音符中心X坐标
* @param noteY 音符中心Y坐标
* @param lineIndex 增时线索引(0开始)
* @param quarterSpacing 四分音符间距
*/
function calcExtensionLinePosition(
noteX: number,
noteY: number,
lineIndex: number,
quarterSpacing: number
): ExtensionLinePosition {
// 每条增时线占据1个四分音符的空间
// 第1条增时线在音符后的第1个四分音符位置
// 第2条增时线在音符后的第2个四分音符位置...
const lineStartX = noteX + (lineIndex + 1) * quarterSpacing;
const lineWidth = quarterSpacing * 0.7; // 占空间的70%,居中
const lineX = lineStartX - lineWidth / 2;
return {
x: lineX,
y: noteY,
width: lineWidth,
};
}
二分音符(2拍):
┌─────────┬─────────┐
│ 5 │ — │
│ 第1拍 │ 第2拍 │
└─────────┴─────────┘
音符 增时线
全音符(4拍):
┌─────────┬─────────┬─────────┬─────────┐
│ 5 │ — │ — │ — │
│ 第1拍 │ 第2拍 │ 第3拍 │ 第4拍 │
└─────────┴─────────┴─────────┴─────────┘
音符 增时线1 增时线2 增时线3
function drawExtensionLine(
x: number,
y: number,
width: number,
config: RenderConfig
): SVGRectElement {
const line = document.createElementNS(SVG_NS, 'rect');
line.setAttribute('x', String(x));
line.setAttribute('y', String(y - config.extensionLineHeight / 2));
line.setAttribute('width', String(width));
line.setAttribute('height', String(config.extensionLineHeight));
line.setAttribute('fill', config.lineColor);
line.setAttribute('class', 'vf-extension-line');
return line;
}
附点二分音符(3拍):
┌───────────────┬─────────┬─────────┐
│ 5· │ — │ — │
│ 1.5拍 │ 第2拍 │ 第3拍 │
└───────────────┴─────────┴─────────┘
附点四分音符占1.5拍,后面2条增时线各占1拍
计算方式:
- realValue = 3.0
- 增时线数量 = Math.floor(3.0) - 1 = 2
- 第1条增时线位置:noteX + quarterSpacing * 1.5(从附点结束位置开始)
- 第2条增时线位置:noteX + quarterSpacing * 2.5
多声部时,增时线要与其他声部的音符垂直对齐:
声部1: 5 — — — (全音符)
声部2: 1 2 3 4 (四个四分音符)
↑ ↑ ↑ ↑
X坐标必须对齐
规则:先进行多声部对齐计算,再绘制增时线
| 音符类型 | 时值(realValue) | 减时线数量 | 视觉表示 |
|---|---|---|---|
| 四分音符 | 1.0 | 0 | 5 |
| 八分音符 | 0.5 | 1 | 5 + 1条线 |
| 十六分音符 | 0.25 | 2 | 5 + 2条线 |
| 三十二分音符 | 0.125 | 3 | 5 + 3条线 |
function calcUnderlineCount(realValue: number): number {
if (realValue >= 1.0) return 0;
return Math.round(Math.log2(1 / realValue));
}
interface UnderlineSpec {
/** 减时线宽度 */
width: number;
/** 减时线高度(粗细) */
height: number;
/** 第一条线距离音符底部的距离 */
topOffset: number;
/** 多条线之间的间距 */
gap: number;
}
const UNDERLINE_SPEC: UnderlineSpec = {
width: 16, // 略宽于数字宽度
height: 1.5, // 线条粗细
topOffset: 4, // 距音符底部4px
gap: 3, // 多条线间距3px
};
八分音符(1条减时线):
5
───
十六分音符(2条减时线):
5
───
───
三十二分音符(3条减时线):
5
───
───
───
function drawUnderlines(
noteX: number,
noteBottomY: number,
count: number,
config: RenderConfig
): SVGGElement {
const group = document.createElementNS(SVG_NS, 'g');
group.setAttribute('class', 'vf-underlines');
const width = UNDERLINE_SPEC.width;
const startX = noteX - width / 2;
for (let i = 0; i < count; i++) {
const y = noteBottomY + UNDERLINE_SPEC.topOffset +
i * (UNDERLINE_SPEC.height + UNDERLINE_SPEC.gap);
const line = document.createElementNS(SVG_NS, 'rect');
line.setAttribute('x', String(startX));
line.setAttribute('y', String(y));
line.setAttribute('width', String(width));
line.setAttribute('height', String(UNDERLINE_SPEC.height));
line.setAttribute('fill', config.lineColor);
line.setAttribute('class', 'vf-underline');
group.appendChild(line);
}
return group;
}
连续的相同时值音符,减时线应该连接:
分离绘制: 连接绘制(推荐):
1 2 3 4 1 2 3 4
_ _ _ _ ─────────────
规则:
- 同一拍内的连续短音符,减时线连接
- 跨拍的音符,减时线可以分开
- 当前实现先使用分离绘制,后续优化为连接绘制
高音点:在音符数字上方
低音点:在音符数字下方
高两个八度: ··
5
高一个八度: ·
5
中音: 5
低一个八度: 5
·
低两个八度: 5
··
interface OctaveDotSpec {
/** 点的半径 */
radius: number;
/** 第一个点距离数字的距离 */
offset: number;
/** 多个点之间的间距 */
gap: number;
}
const OCTAVE_DOT_SPEC: OctaveDotSpec = {
radius: 2.5,
offset: 6, // 距离数字6px
gap: 5, // 点间距5px
};
function drawOctaveDots(
noteX: number,
noteY: number,
octaveOffset: number,
config: RenderConfig
): SVGGElement {
const group = document.createElementNS(SVG_NS, 'g');
group.setAttribute('class', 'vf-octave-dots');
const count = Math.abs(octaveOffset);
const isHigh = octaveOffset > 0;
// 高音点在上方,低音点在下方
const baseY = isHigh
? noteY - config.noteFontSize / 2 - OCTAVE_DOT_SPEC.offset
: noteY + config.noteFontSize / 2 + OCTAVE_DOT_SPEC.offset;
for (let i = 0; i < count; i++) {
const dotY = isHigh
? baseY - i * OCTAVE_DOT_SPEC.gap
: baseY + i * OCTAVE_DOT_SPEC.gap;
const dot = document.createElementNS(SVG_NS, 'circle');
dot.setAttribute('cx', String(noteX));
dot.setAttribute('cy', String(dotY));
dot.setAttribute('r', String(OCTAVE_DOT_SPEC.radius));
dot.setAttribute('fill', config.noteColor);
dot.setAttribute('class', isHigh ? 'vf-high-dot' : 'vf-low-dot');
group.appendChild(dot);
}
return group;
}
附点位置:在音符数字的右侧,垂直居中
单附点: 5·
双附点: 5··
三附点: 5···(极少见)
interface DurationDotSpec {
/** 点的半径 */
radius: number;
/** 第一个点距离数字右边的距离 */
offset: number;
/** 多个点之间的间距 */
gap: number;
}
const DURATION_DOT_SPEC: DurationDotSpec = {
radius: 2,
offset: 4, // 距离数字右边4px
gap: 4, // 点间距4px
};
function drawDurationDots(
noteX: number,
noteY: number,
dotCount: number,
config: RenderConfig
): SVGGElement {
const group = document.createElementNS(SVG_NS, 'g');
group.setAttribute('class', 'vf-duration-dots');
// 数字宽度约为字体大小的0.6
const digitWidth = config.noteFontSize * 0.6;
const startX = noteX + digitWidth / 2 + DURATION_DOT_SPEC.offset;
for (let i = 0; i < dotCount; i++) {
const dotX = startX + i * (DURATION_DOT_SPEC.radius * 2 + DURATION_DOT_SPEC.gap);
const dot = document.createElementNS(SVG_NS, 'circle');
dot.setAttribute('cx', String(dotX));
dot.setAttribute('cy', String(noteY));
dot.setAttribute('r', String(DURATION_DOT_SPEC.radius));
dot.setAttribute('fill', config.noteColor);
dot.setAttribute('class', 'vf-duration-dot');
group.appendChild(dot);
}
return group;
}
升降号位置:在音符数字的左上方
升号: #
5
降号: ♭
5
还原号: ♮
5
| 类型 | Unicode | 显示 |
|---|---|---|
| 升号 | # 或 \u266F |
# 或 ♯ |
| 降号 | \u266D |
♭ |
| 还原号 | \u266E |
♮ |
| 重升 | \u00D7 或 ## |
× 或 ## |
| 重降 | \u266D\u266D |
♭♭ |
interface AccidentalSpec {
/** 字体大小(相对于音符字体) */
fontSizeRatio: number;
/** 水平偏移(向左) */
offsetX: number;
/** 垂直偏移(向上) */
offsetY: number;
}
const ACCIDENTAL_SPEC: AccidentalSpec = {
fontSizeRatio: 0.5, // 音符字体的50%
offsetX: 8, // 向左偏移8px
offsetY: 8, // 向上偏移8px
};
const ACCIDENTAL_SYMBOLS: Record<string, string> = {
'sharp': '#',
'flat': '♭',
'natural': '♮',
'double-sharp': '×',
'double-flat': '♭♭',
};
function drawAccidental(
noteX: number,
noteY: number,
accidental: string,
config: RenderConfig
): SVGTextElement {
const text = document.createElementNS(SVG_NS, 'text');
const fontSize = config.noteFontSize * ACCIDENTAL_SPEC.fontSizeRatio;
const x = noteX - config.noteFontSize * 0.3 - ACCIDENTAL_SPEC.offsetX;
const y = noteY - config.noteFontSize * 0.3 - ACCIDENTAL_SPEC.offsetY;
text.setAttribute('x', String(x));
text.setAttribute('y', String(y));
text.setAttribute('font-size', String(fontSize));
text.setAttribute('font-family', config.noteFont);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('fill', config.noteColor);
text.setAttribute('class', 'vf-accidental');
text.textContent = ACCIDENTAL_SYMBOLS[accidental] || accidental;
return text;
}
休止符用数字"0"表示,位置与普通音符相同
四分休止符: 0
八分休止符: 0
─
二分休止符: 0 —(不画增时线,用空白表示)
全休止符: 0 — — —(不画增时线,用空白表示)
function renderRest(
x: number,
y: number,
realValue: number,
config: RenderConfig
): SVGGElement {
const group = document.createElementNS(SVG_NS, 'g');
group.setAttribute('class', 'vf-rest');
// 绘制数字"0"
const text = drawNoteNumber(x, y, 0, config);
group.appendChild(text);
// 休止符的减时线正常绘制
if (realValue < 1.0) {
const underlineCount = calcUnderlineCount(realValue);
const underlines = drawUnderlines(x, y + config.noteFontSize / 2, underlineCount, config);
group.appendChild(underlines);
}
// 休止符不绘制增时线!
// 长休止符通过占据空间来表示
return group;
}
| 类型 | 样式 | 说明 |
|---|---|---|
| single | │ |
普通单线 |
| double | ║ |
双线(段落结束) |
| repeat-start | ║: |
反复开始 |
| repeat-end | :║ |
反复结束 |
| final | ║█ |
终止线 |
interface BarlineSpec {
/** 单线粗细 */
thinWidth: number;
/** 粗线粗细 */
thickWidth: number;
/** 双线间距 */
doubleGap: number;
/** 反复点半径 */
repeatDotRadius: number;
/** 反复点间距 */
repeatDotGap: number;
}
const BARLINE_SPEC: BarlineSpec = {
thinWidth: 1,
thickWidth: 3,
doubleGap: 3,
repeatDotRadius: 2,
repeatDotGap: 8,
};
function drawBarline(
x: number,
topY: number,
bottomY: number,
type: string,
config: RenderConfig
): SVGGElement {
const group = document.createElementNS(SVG_NS, 'g');
group.setAttribute('class', `vf-barline vf-barline-${type}`);
const height = bottomY - topY;
switch (type) {
case 'single':
group.appendChild(createLine(x, topY, x, bottomY, BARLINE_SPEC.thinWidth));
break;
case 'double':
group.appendChild(createLine(x - BARLINE_SPEC.doubleGap, topY,
x - BARLINE_SPEC.doubleGap, bottomY,
BARLINE_SPEC.thinWidth));
group.appendChild(createLine(x, topY, x, bottomY, BARLINE_SPEC.thinWidth));
break;
case 'final':
group.appendChild(createLine(x - BARLINE_SPEC.doubleGap - BARLINE_SPEC.thickWidth,
topY,
x - BARLINE_SPEC.doubleGap - BARLINE_SPEC.thickWidth,
bottomY,
BARLINE_SPEC.thinWidth));
group.appendChild(createRect(x - BARLINE_SPEC.thickWidth, topY,
BARLINE_SPEC.thickWidth, height));
break;
// ... 其他类型
}
return group;
}
歌词位置:在音符下方,与音符水平对齐
单行歌词:
5 3 2 1
小 星 星 亮
多遍歌词(垂直排列):
5 3 2 1
小 星 星 亮 ← 第1遍
一 闪 一 闪 ← 第2遍
interface LyricSpec {
/** 字体大小 */
fontSize: number;
/** 距离音符底部的距离 */
topOffset: number;
/** 多遍歌词行间距 */
lineHeight: number;
}
const LYRIC_SPEC: LyricSpec = {
fontSize: 14,
topOffset: 25, // 距离音符底部25px
lineHeight: 18, // 行间距18px
};
<!-- 歌词元素必须包含以下属性,用于业务层高亮匹配 -->
<text
class="vf-lyric lyric{noteId}"
lyricIndex="1"
data-note-id="{noteId}"
>
歌词文字
</text>
function drawLyric(
noteX: number,
noteBottomY: number,
text: string,
lyricIndex: number,
noteId: string,
config: RenderConfig
): SVGTextElement {
const textEl = document.createElementNS(SVG_NS, 'text');
const y = noteBottomY + LYRIC_SPEC.topOffset +
(lyricIndex - 1) * LYRIC_SPEC.lineHeight;
textEl.setAttribute('x', String(noteX));
textEl.setAttribute('y', String(y));
textEl.setAttribute('font-size', String(LYRIC_SPEC.fontSize));
textEl.setAttribute('font-family', config.lyricFont);
textEl.setAttribute('text-anchor', 'middle');
textEl.setAttribute('fill', config.lyricColor);
textEl.setAttribute('class', `vf-lyric lyric${noteId}`);
textEl.setAttribute('lyricIndex', String(lyricIndex));
textEl.setAttribute('data-note-id', noteId);
textEl.textContent = text;
return textEl;
}
/**
* 计算小节宽度(固定时间比例)
* 公式:(拍数 / 单位拍 × 4) × 四分音符间距 + 左右padding
*/
function calculateMeasureWidth(
beats: number,
beatType: number,
quarterSpacing: number,
padding: number
): number {
// 小节总时值(以四分音符为单位)
const totalBeats = beats / (beatType / 4);
// 内容宽度
const contentWidth = totalBeats * quarterSpacing;
// 总宽度
return contentWidth + padding * 2;
}
// 示例:
// 4/4拍: (4 / 1) × 50 + 20 × 2 = 240px
// 3/4拍: (3 / 1) × 50 + 20 × 2 = 190px
// 6/8拍: (6 / 2) × 50 + 20 × 2 = 190px
/**
* 计算音符X坐标(固定时间比例)
* 公式:小节X + padding + 时间戳 × 四分音符间距
*/
function calculateNoteX(
measureX: number,
timestamp: number,
padding: number,
quarterSpacing: number
): number {
return measureX + padding + timestamp * quarterSpacing;
}
interface LineBreakSpec {
/** 最大行宽 */
maxWidth: number;
/** 最小小节数/行 */
minMeasuresPerLine: number;
/** 最大小节数/行 */
maxMeasuresPerLine: number;
}
function calculateLineBreaks(
measures: JianpuMeasure[],
maxWidth: number
): number[][] {
const lines: number[][] = [];
let currentLine: number[] = [];
let currentWidth = 0;
measures.forEach((measure, index) => {
if (currentWidth + measure.width > maxWidth && currentLine.length > 0) {
lines.push(currentLine);
currentLine = [];
currentWidth = 0;
}
currentLine.push(index);
currentWidth += measure.width;
});
if (currentLine.length > 0) {
lines.push(currentLine);
}
return lines;
}
/**
* 计算声部Y坐标
*/
function calculateVoiceY(
baseY: number,
voiceIndex: number,
voiceCount: number,
voiceSpacing: number
): number {
// 多声部垂直居中分布
const totalHeight = (voiceCount - 1) * voiceSpacing;
const startY = baseY - totalHeight / 2;
return startY + voiceIndex * voiceSpacing;
}
文档版本: v1.0
最后更新: 2026-01-29
维护者: 简谱渲染引擎开发团队