Skip to content

Commit d4ee481

Browse files
committed
feat(parser): 优化 ABC 解析器的时值计算逻辑
- 更新音符时值计算,确保根据 ABC 数字表示正确解析音符的时值。 - 增强注释,提供更清晰的说明,帮助理解音符时值的计算方式。 - 在 SVG 渲染中引入谱号信息的查找逻辑,确保音符渲染时考虑谱号的影响。 该变更提升了 ABC 解析器的准确性和可读性,增强了代码的可维护性。
1 parent e43b762 commit d4ee481

File tree

4 files changed

+464
-178
lines changed

4 files changed

+464
-178
lines changed

packages/simple-notation/src/data/parser/abc-parser.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1226,10 +1226,11 @@ export class AbcParser extends BaseParser<SNAbcInput> {
12261226
const durationStr = restStr.match(/^(\d+)/)?.[1];
12271227
const dotCount = (restStr.match(/\./g) || []).length;
12281228

1229-
const abcDurationMultiplier = durationStr
1230-
? parseInt(durationStr, 10)
1231-
: 1;
1232-
const noteValue = defaultNoteLength / abcDurationMultiplier;
1229+
// ABC 数字表示全音符的分母,例如 "z2" = 1/2(二分休止符),"z4" = 1/4(四分休止符)
1230+
// 如果没有数字,则使用默认 L: 值
1231+
const noteValue = durationStr
1232+
? 1 / parseInt(durationStr, 10) // 例如 "2" -> 1/2 = 0.5(二分休止符)
1233+
: defaultNoteLength; // 例如 L:1/4 -> 0.25(四分休止符)
12331234
const dottedNoteValue =
12341235
dotCount > 0
12351236
? noteValue * (1 + 0.5 * (1 - Math.pow(0.5, dotCount)))
@@ -1290,7 +1291,7 @@ export class AbcParser extends BaseParser<SNAbcInput> {
12901291
const octave = baseOctave + octaveOffset;
12911292

12921293
// 3. 解析时值并转换为 duration(ticks)
1293-
// ABC 中数字表示相对于 L: 字段的倍数
1294+
// ABC 中数字表示全音符的分母,如果没有数字则使用默认 L:
12941295
// 例如:如果 L: 是 1/4,"C4" = 1/4(四分音符),"C2" = 1/2(二分音符),"C" = 1/4(默认)
12951296
let duration: number;
12961297

@@ -1301,13 +1302,11 @@ export class AbcParser extends BaseParser<SNAbcInput> {
13011302
const defaultNoteLength = this.getDefaultNoteLength(trimmed);
13021303
const noteLengthValue = defaultNoteLength; // 相对于全音符,例如 1/4 = 0.25
13031304

1304-
// ABC 数字表示倍数,如果没有数字则为 1(即默认 L: 值)
1305-
const abcDurationMultiplier = durationStr
1306-
? parseInt(durationStr, 10)
1307-
: 1;
1308-
1309-
// 计算实际音符时值(相对于全音符)
1310-
const noteValue = noteLengthValue / abcDurationMultiplier;
1305+
// ABC 数字表示全音符的分母,例如 "G2" = 1/2(二分音符),"G4" = 1/4(四分音符)
1306+
// 如果没有数字,则使用默认 L: 值
1307+
const noteValue = durationStr
1308+
? 1 / parseInt(durationStr, 10) // 例如 "2" -> 1/2 = 0.5(二分音符)
1309+
: noteLengthValue; // 例如 L:1/4 -> 0.25(四分音符)
13111310

13121311
// 处理附点(每个点表示延长 1/2)
13131312
const dotCount = (trimmed.match(/\./g) || []).length;
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
import type { SNPitch } from '@core/model/music';
2+
// import { SNAccidental } from '@core/model/music'; // 暂时未使用,将在后续版本中添加变音记号支持
3+
import type { SNVoiceMetaClef } from '@data/model/parser';
4+
5+
/**
6+
* 乐谱计算器
7+
*
8+
* 负责计算不同类型乐谱(五线谱、吉他谱、口琴谱等)中音符的位置和渲染属性
9+
* 布局层不需要关心渲染层渲染为什么乐谱,只需要调用这个模块获取计算结果
10+
*/
11+
export class StaffCalculator {
12+
/**
13+
* 五线谱配置
14+
*/
15+
static readonly STAFF_CONFIG = {
16+
lineCount: 5, // 五线谱有5条线
17+
lineGap: 7.5, // 线间距(像素)
18+
staffHeight: 30, // 五线谱总高度(4个线间距)
19+
staffTop: 6, // 五线谱顶部 y 坐标偏移
20+
};
21+
22+
/**
23+
* 不同谱号的参考音高(第一条线的音高)
24+
* 五线谱从下往上编号,第一条线(最下面)是第0条线
25+
*/
26+
static readonly CLEF_REFERENCE_NOTES: Record<
27+
SNVoiceMetaClef,
28+
{ letter: string; octave: number }
29+
> = {
30+
treble: { letter: 'E', octave: 3 }, // 高音谱号:第一条线是 E3(ABC记谱法中大写字母基准八度为3)
31+
bass: { letter: 'G', octave: 2 }, // 低音谱号:第一条线是 G2
32+
alto: { letter: 'C', octave: 4 }, // 中音谱号:第一条线是 C4
33+
tenor: { letter: 'C', octave: 4 }, // 次中音谱号:第一条线是 C4
34+
};
35+
36+
/**
37+
* 音名字母到半音数的映射(C=0, D=2, E=4, F=5, G=7, A=9, B=11)
38+
*/
39+
private static readonly LETTER_TO_SEMITONE: Record<string, number> = {
40+
C: 0,
41+
D: 2,
42+
E: 4,
43+
F: 5,
44+
G: 7,
45+
A: 9,
46+
B: 11,
47+
};
48+
49+
/**
50+
* 计算音符在五线谱上的 y 坐标
51+
*
52+
* @param pitch 音高信息
53+
* @param clef 谱号(默认为高音谱号)
54+
* @param staffTop 五线谱顶部 y 坐标(默认使用配置值)
55+
* @param staffHeight 五线谱高度(默认使用配置值)
56+
* @returns 音符符头中心的 y 坐标
57+
*/
58+
static calculateNoteY(
59+
pitch: SNPitch,
60+
clef: SNVoiceMetaClef = 'treble',
61+
staffTop: number = StaffCalculator.STAFF_CONFIG.staffTop,
62+
staffHeight: number = StaffCalculator.STAFF_CONFIG.staffHeight,
63+
): number {
64+
// 获取谱号的参考音高
65+
const referenceNote = StaffCalculator.CLEF_REFERENCE_NOTES[clef];
66+
67+
// 注意:变音记号的处理暂时未实现,将在后续版本中添加
68+
69+
// 第一条线(最下面)的 y 坐标
70+
const lineGap = staffHeight / 4; // 线间距
71+
const firstLineY = staffTop + staffHeight; // 第一条线在五线谱底部
72+
73+
// 在五线谱中,每个线或间对应一个自然音阶的音符位置
74+
// 五线谱的自然音阶排列:E-F-G-A-B-C-D-E-F-G-A-B-C-D...
75+
// 从E到F是1个半音,从F到G是1个半音,从G到A是1个半音,等等
76+
// 但在五线谱上,从一条线到下一个间是1个位置(0.5个线间距),从一条线到下一条线是2个位置(1个线间距)
77+
//
78+
// 对于ABC记谱法(E3作为第一条线):
79+
// - 第一条线:E3
80+
// - 下加一线:C3(E3下方,但只向下0.5个线间距)
81+
// - 第一间:F3(E3上方0.5个线间距)
82+
// - 第二条线:G3(E3上方1个线间距)
83+
//
84+
// 因此,我们需要根据音符在自然音阶中的位置来计算,而不是直接使用MIDI半音差
85+
//
86+
// 计算音符在自然音阶中相对于参考音高的位置差
87+
// 从E3开始,向上:E3->F3->G3->A3->B3->C4->D4->E4->F4->G4...
88+
// 向下:E3->D3->C3->B2->A2->G2->F2->E2...
89+
const refLetterIndex = ['C', 'D', 'E', 'F', 'G', 'A', 'B'].indexOf(
90+
referenceNote.letter,
91+
);
92+
const noteLetterIndex = ['C', 'D', 'E', 'F', 'G', 'A', 'B'].indexOf(
93+
pitch.letter.toUpperCase(),
94+
);
95+
96+
// 计算八度差和字母差
97+
const octaveDiff = pitch.octave - referenceNote.octave;
98+
const letterDiff = noteLetterIndex - refLetterIndex;
99+
100+
// 计算在自然音阶中的位置差(从参考音高开始,向上或向下移动多少个自然音阶位置)
101+
// 每个自然音阶位置对应半个线间距(0.5 * lineGap)
102+
// 例如:从E3到G3,向上移动2个位置(E3->F3->G3),对应1个线间距
103+
const positionDiff = octaveDiff * 7 + letterDiff;
104+
105+
// 计算y坐标:positionDiff为正数时,音符在参考音高上方,y坐标更小
106+
const y = firstLineY - positionDiff * (lineGap / 2);
107+
108+
return y;
109+
}
110+
111+
/**
112+
* 判断音符是否需要符干
113+
*
114+
* @param duration 音符时值(ticks)
115+
* @param ticksPerWhole 全音符的 ticks 数(默认 48)
116+
* @returns 是否需要符干
117+
*/
118+
static needsStem(duration: number, ticksPerWhole: number = 48): boolean {
119+
// 全音符(duration = ticksPerWhole)不需要符干
120+
// 二分音符及更短的需要符干
121+
return duration < ticksPerWhole;
122+
}
123+
124+
/**
125+
* 判断音符是实心还是空心
126+
*
127+
* @param duration 音符时值(ticks)
128+
* @param ticksPerWhole 全音符的 ticks 数(默认 48)
129+
* @returns 是否为实心(true=实心,false=空心)
130+
*/
131+
static isFilledNote(duration: number, ticksPerWhole: number = 48): boolean {
132+
// 全音符和二分音符是空心的,四分音符及更短的是实心的
133+
const halfNoteTicks = ticksPerWhole / 2;
134+
return duration < halfNoteTicks;
135+
}
136+
137+
/**
138+
* 判断符干方向
139+
*
140+
* @param noteY 音符的 y 坐标
141+
* @param clef 谱号(默认为高音谱号)
142+
* @param staffTop 五线谱顶部 y 坐标(默认使用配置值)
143+
* @param staffHeight 五线谱高度(默认使用配置值)
144+
* @returns 符干方向(true=向上,false=向下)
145+
*/
146+
static getStemDirection(
147+
noteY: number,
148+
_clef: SNVoiceMetaClef = 'treble',
149+
staffTop: number = StaffCalculator.STAFF_CONFIG.staffTop,
150+
staffHeight: number = StaffCalculator.STAFF_CONFIG.staffHeight,
151+
): boolean {
152+
// 计算中间线(第三条线)的 y 坐标
153+
const middleLineY = staffTop + staffHeight / 2; // 中间线在五线谱中间
154+
155+
// 音符在中间线及以上时,符干向下(stemUp = false)
156+
// 音符在中间线以下时,符干向上(stemUp = true)
157+
return noteY > middleLineY;
158+
}
159+
160+
/**
161+
* 计算需要绘制的辅助线(ledger lines)的 y 坐标
162+
*
163+
* @param noteY 音符的 y 坐标
164+
* @param staffTop 五线谱顶部 y 坐标(默认使用配置值)
165+
* @param staffHeight 五线谱高度(默认使用配置值)
166+
* @returns 辅助线的 y 坐标数组(从下往上排序)
167+
*/
168+
static getLedgerLines(
169+
noteY: number,
170+
staffTop: number = StaffCalculator.STAFF_CONFIG.staffTop,
171+
staffHeight: number = StaffCalculator.STAFF_CONFIG.staffHeight,
172+
): number[] {
173+
const lineGap = staffHeight / 4; // 线间距
174+
const firstLineY = staffTop + staffHeight; // 第一条线(最下面)的 y 坐标
175+
const lastLineY = staffTop; // 第五条线(最上面)的 y 坐标
176+
const ledgerLines: number[] = [];
177+
const tolerance = lineGap / 4; // 判断音符是否在线上的容差
178+
179+
// 检查音符符头是否在线上(而不是在线间)
180+
// 音符在线上时,y坐标应该接近某条线的位置(允许一定误差)
181+
const isOnLine = (y: number, lineY: number): boolean => {
182+
return Math.abs(y - lineY) < tolerance;
183+
};
184+
185+
// 检查音符是否低于第一线(需要下加线)
186+
if (noteY > firstLineY) {
187+
// 从第一条线下方开始,每个线间距绘制一条辅助线,直到音符符头所在的线
188+
let currentLineY = firstLineY + lineGap;
189+
// 继续向下绘制,直到音符符头所在的线或更下方
190+
while (currentLineY <= noteY + tolerance) {
191+
// 如果音符符头在这条线上,或者这条线在音符符头下方,都需要绘制
192+
if (
193+
isOnLine(noteY, currentLineY) ||
194+
currentLineY <= noteY + tolerance
195+
) {
196+
ledgerLines.push(currentLineY);
197+
}
198+
currentLineY += lineGap;
199+
}
200+
}
201+
202+
// 检查音符是否高于第五线(需要上加线)
203+
if (noteY < lastLineY) {
204+
// 从第五条线上方开始,每个线间距绘制一条辅助线,直到音符符头所在的线
205+
let currentLineY = lastLineY - lineGap;
206+
// 继续向上绘制,直到音符符头所在的线或更上方
207+
while (currentLineY >= noteY - tolerance) {
208+
// 如果音符符头在这条线上,或者这条线在音符符头上方,都需要绘制
209+
if (
210+
isOnLine(noteY, currentLineY) ||
211+
currentLineY >= noteY - tolerance
212+
) {
213+
ledgerLines.push(currentLineY);
214+
}
215+
currentLineY -= lineGap;
216+
}
217+
}
218+
219+
// 去重并按从下往上排序
220+
return Array.from(new Set(ledgerLines)).sort((a, b) => a - b);
221+
}
222+
223+
/**
224+
* 获取音符的渲染属性
225+
*
226+
* @param pitch 音高信息
227+
* @param duration 音符时值(ticks)
228+
* @param clef 谱号(默认为高音谱号)
229+
* @param ticksPerWhole 全音符的 ticks 数(默认 48)
230+
* @param staffTop 五线谱顶部 y 坐标(默认使用配置值)
231+
* @param staffHeight 五线谱高度(默认使用配置值)
232+
* @returns 音符渲染属性
233+
*/
234+
static getNoteRenderProps(
235+
pitch: SNPitch,
236+
duration: number,
237+
clef: SNVoiceMetaClef = 'treble',
238+
ticksPerWhole: number = 48,
239+
staffTop: number = StaffCalculator.STAFF_CONFIG.staffTop,
240+
staffHeight: number = StaffCalculator.STAFF_CONFIG.staffHeight,
241+
): {
242+
y: number;
243+
isFilled: boolean;
244+
needsStem: boolean;
245+
stemDirection: boolean; // true=向上,false=向下
246+
} {
247+
const y = StaffCalculator.calculateNoteY(
248+
pitch,
249+
clef,
250+
staffTop,
251+
staffHeight,
252+
);
253+
const isFilled = StaffCalculator.isFilledNote(duration, ticksPerWhole);
254+
const needsStem = StaffCalculator.needsStem(duration, ticksPerWhole);
255+
const stemDirection = StaffCalculator.getStemDirection(
256+
y,
257+
clef,
258+
staffTop,
259+
staffHeight,
260+
);
261+
262+
return {
263+
y,
264+
isFilled,
265+
needsStem,
266+
stemDirection,
267+
};
268+
}
269+
}

0 commit comments

Comments
 (0)