Skip to content

Commit ec6e6dc

Browse files
committed
feat(layout): 增强小节元素构建以支持符杠组处理
- 引入 BeamGrouper 以识别和处理符杠组,优化音符的布局逻辑。 - 新增 createNoteGroup 函数,将符杠组内的音符包装为容器元素,简化渲染过程。 - 更新 StaffCalculator 以计算音符的符尾数量,并在渲染时考虑符杠组的影响。 - 在 ElementNode 中实现符杠的绘制逻辑,确保符杠组内的音符不单独绘制符尾。 该变更提升了小节元素的布局和渲染能力,确保符杠组的正确处理和视觉表现。
1 parent 508f325 commit ec6e6dc

File tree

4 files changed

+630
-1
lines changed

4 files changed

+630
-1
lines changed
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import type { SNParserNode } from '@data/node';
2+
import type { SNTimeUnit } from '@core/model/ticks';
3+
4+
/**
5+
* 符杠组信息
6+
*/
7+
export interface BeamGroup {
8+
/** 符杠组ID */
9+
id: string;
10+
/** 组内音符的索引 */
11+
noteIndices: number[];
12+
/** 符杠数量(取组内最多符尾的音符) */
13+
beamCount: number;
14+
}
15+
16+
/**
17+
* 符杠分组器
18+
*
19+
* 职责:识别小节内可以用符杠连接的音符组
20+
* 规则:
21+
* 1. 只有八分音符或更短的音符才需要符杠
22+
* 2. 连续的这类音符如果在同一拍内,应该连接在一起
23+
* 3. 一拍的长度取决于拍号
24+
*/
25+
export class BeamGrouper {
26+
/**
27+
* 识别小节内的符杠组
28+
*
29+
* @param elements - 小节内的所有元素(包括 note、rest、tie 等)
30+
* @param timeUnit - 时间单位配置
31+
* @returns 符杠组数组
32+
*/
33+
static groupBeams(
34+
elements: SNParserNode[],
35+
timeUnit: SNTimeUnit,
36+
): BeamGroup[] {
37+
const groups: BeamGroup[] = [];
38+
let currentGroup: number[] = [];
39+
let groupIdCounter = 0;
40+
41+
// 一拍的 ticks 数
42+
const ticksPerBeat = timeUnit.ticksPerBeat;
43+
// 四分音符的 ticks 数
44+
const quarterNoteTicks = timeUnit.ticksPerWhole / 4;
45+
46+
// 当前累计的 ticks(用于判断是否到达拍边界)
47+
let currentTicks = 0;
48+
// 当前拍的起始 ticks
49+
let beatStartTicks = 0;
50+
51+
for (let i = 0; i < elements.length; i++) {
52+
const element = elements[i];
53+
const duration = element.duration || 0;
54+
55+
// 只处理音符类型
56+
if (element.type !== 'note') {
57+
// 遇到非音符元素,结束当前组
58+
if (currentGroup.length >= 2) {
59+
groups.push(
60+
this.createBeamGroup(
61+
currentGroup,
62+
elements,
63+
groupIdCounter++,
64+
timeUnit,
65+
),
66+
);
67+
}
68+
currentGroup = [];
69+
currentTicks += duration;
70+
// 更新拍起始位置
71+
beatStartTicks = Math.floor(currentTicks / ticksPerBeat) * ticksPerBeat;
72+
continue;
73+
}
74+
75+
// 检查是否需要符杠(八分音符或更短)
76+
const needsBeam = duration > 0 && duration < quarterNoteTicks;
77+
78+
if (!needsBeam) {
79+
// 不需要符杠的音符,结束当前组
80+
if (currentGroup.length >= 2) {
81+
groups.push(
82+
this.createBeamGroup(
83+
currentGroup,
84+
elements,
85+
groupIdCounter++,
86+
timeUnit,
87+
),
88+
);
89+
}
90+
currentGroup = [];
91+
currentTicks += duration;
92+
// 更新拍起始位置
93+
beatStartTicks = Math.floor(currentTicks / ticksPerBeat) * ticksPerBeat;
94+
continue;
95+
}
96+
97+
// 检查当前音符的起始位置是否在新的一拍
98+
const currentBeatStartTicks =
99+
Math.floor(currentTicks / ticksPerBeat) * ticksPerBeat;
100+
101+
// 如果当前音符起始位置在新的一拍,结束当前组
102+
if (currentBeatStartTicks > beatStartTicks) {
103+
// 先结束当前组(如果有)
104+
if (currentGroup.length >= 2) {
105+
groups.push(
106+
this.createBeamGroup(
107+
currentGroup,
108+
elements,
109+
groupIdCounter++,
110+
timeUnit,
111+
),
112+
);
113+
}
114+
// 开始新组
115+
currentGroup = [];
116+
beatStartTicks = currentBeatStartTicks;
117+
}
118+
119+
// 将当前音符加入当前组
120+
currentGroup.push(i);
121+
122+
currentTicks += duration;
123+
}
124+
125+
// 处理最后一组
126+
if (currentGroup.length >= 2) {
127+
groups.push(
128+
this.createBeamGroup(
129+
currentGroup,
130+
elements,
131+
groupIdCounter++,
132+
timeUnit,
133+
),
134+
);
135+
}
136+
137+
return groups;
138+
}
139+
140+
/**
141+
* 创建符杠组对象
142+
*
143+
* @param noteIndices - 音符索引数组
144+
* @param elements - 所有元素
145+
* @param groupId - 组ID
146+
* @param timeUnit - 时间单位配置
147+
* @returns 符杠组对象
148+
*/
149+
private static createBeamGroup(
150+
noteIndices: number[],
151+
elements: SNParserNode[],
152+
groupId: number,
153+
timeUnit: SNTimeUnit,
154+
): BeamGroup {
155+
// 计算符杠数量(取组内最多符尾的音符)
156+
let maxBeamCount = 0;
157+
const quarterNoteTicks = timeUnit.ticksPerWhole / 4;
158+
159+
for (const index of noteIndices) {
160+
const element = elements[index];
161+
const duration = element.duration || 0;
162+
163+
if (duration <= 0) continue;
164+
165+
// 计算符尾数量
166+
let beamCount = 0;
167+
let currentDuration = quarterNoteTicks;
168+
while (currentDuration > duration && beamCount < 4) {
169+
currentDuration /= 2;
170+
beamCount++;
171+
}
172+
173+
maxBeamCount = Math.max(maxBeamCount, beamCount);
174+
}
175+
176+
return {
177+
id: `beam-${groupId}`,
178+
noteIndices,
179+
beamCount: maxBeamCount,
180+
};
181+
}
182+
}

packages/simple-notation/src/layout/builder/build-measure-elements.ts

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { ScoreConfig } from '@manager/config';
44
import { getTimeUnitFromNode, measureDuration } from '@core/utils/time-unit';
55
import { buildElementChildren } from './build-element-children';
66
import { calculateNodeHeight } from './calculate-height';
7+
import { BeamGrouper } from './beam-grouper';
78

89
/**
910
* 构建 Measure 内部的元素(叶子节点)
@@ -51,13 +52,59 @@ export function buildMeasureElements(
5152
const ticksForRatio =
5253
totalElementsTicks > 0 ? totalElementsTicks : measureTotalTicks;
5354

55+
// 识别符杠组(beam groups)
56+
const beamGroups = BeamGrouper.groupBeams(elements, timeUnit);
57+
58+
// 创建索引到符杠组的映射
59+
const indexToBeamGroup = new Map<number, (typeof beamGroups)[0]>();
60+
const processedIndices = new Set<number>();
61+
62+
for (const group of beamGroups) {
63+
group.noteIndices.forEach((noteIndex) => {
64+
indexToBeamGroup.set(noteIndex, group);
65+
});
66+
}
67+
5468
// 计算每个元素的起始位置和宽度(基于tick比例)
5569
let currentTickOffset = 0;
5670
for (let i = 0; i < elements.length; i++) {
71+
// 如果这个索引已经被处理过(作为符杠组的一部分),跳过
72+
if (processedIndices.has(i)) continue;
73+
5774
const dataElement = elements[i];
5875
const elementDuration = dataElement.duration || 0;
5976

60-
// 转换元素
77+
// 检查是否属于符杠组
78+
const beamGroup = indexToBeamGroup.get(i);
79+
80+
if (beamGroup && beamGroup.noteIndices[0] === i) {
81+
// 这是符杠组的第一个音符,创建音符组容器
82+
const noteGroupElement = createNoteGroup(
83+
beamGroup,
84+
elements,
85+
scoreConfig,
86+
parentNode,
87+
currentTickOffset,
88+
ticksForRatio,
89+
usableWidth,
90+
horizontalPadding,
91+
);
92+
93+
if (noteGroupElement) {
94+
// 标记这些索引已经被处理
95+
beamGroup.noteIndices.forEach((idx) => processedIndices.add(idx));
96+
97+
// 更新 tick 偏移量(整个组的 duration)
98+
const groupDuration = beamGroup.noteIndices.reduce(
99+
(sum, idx) => sum + (elements[idx].duration || 0),
100+
0,
101+
);
102+
currentTickOffset += groupDuration;
103+
}
104+
continue;
105+
}
106+
107+
// 不属于符杠组的普通元素,单独处理
61108
const layoutElement = transformMeasureElement(
62109
dataElement,
63110
scoreConfig,
@@ -143,6 +190,132 @@ export function buildMeasureElements(
143190
}
144191
}
145192

193+
/**
194+
* 创建音符组(Note Group)
195+
*
196+
* 将符杠组内的多个音符包装为一个容器元素,方便渲染层统一处理
197+
*
198+
* @param beamGroup - 符杠组信息
199+
* @param elements - 所有元素
200+
* @param scoreConfig - 乐谱配置
201+
* @param parentNode - 父布局节点(小节)
202+
* @param tickOffset - 当前的 tick 偏移量
203+
* @param ticksForRatio - 用于计算比例的 ticks 总数
204+
* @param usableWidth - 可用宽度
205+
* @param horizontalPadding - 水平padding
206+
* @returns 音符组布局元素
207+
*/
208+
function createNoteGroup(
209+
beamGroup: { id: string; noteIndices: number[]; beamCount: number },
210+
elements: SNParserNode[],
211+
scoreConfig: ScoreConfig,
212+
parentNode: SNLayoutElement,
213+
tickOffset: number,
214+
ticksForRatio: number,
215+
usableWidth: number,
216+
horizontalPadding: number,
217+
): SNLayoutElement | null {
218+
// 创建音符组容器
219+
const noteGroupElement = new SNLayoutElement(
220+
`layout-note-group-${beamGroup.id}`,
221+
);
222+
223+
// 创建一个虚拟的数据节点来表示音符组
224+
const noteGroupData = {
225+
id: beamGroup.id,
226+
type: 'note-group',
227+
beamCount: beamGroup.beamCount,
228+
children: beamGroup.noteIndices.map((idx) => elements[idx]),
229+
};
230+
noteGroupElement.data = noteGroupData as any;
231+
232+
// 计算音符组的总 duration
233+
const groupDuration = beamGroup.noteIndices.reduce(
234+
(sum, idx) => sum + (elements[idx].duration || 0),
235+
0,
236+
);
237+
238+
// 计算音符组在小节内的位置和宽度
239+
const startRatio = tickOffset / ticksForRatio;
240+
const durationRatio = groupDuration / ticksForRatio;
241+
const groupX = horizontalPadding + startRatio * usableWidth;
242+
const groupWidth = durationRatio * usableWidth;
243+
244+
// 设置音符组的布局
245+
noteGroupElement.updateLayout({
246+
x: groupX,
247+
y: 0,
248+
width: Math.max(20, groupWidth),
249+
height: 0,
250+
});
251+
252+
// 添加到父节点
253+
parentNode.addChildren(noteGroupElement);
254+
255+
// 在音符组内部创建各个音符的布局
256+
let innerTickOffset = 0;
257+
for (let i = 0; i < beamGroup.noteIndices.length; i++) {
258+
const noteIndex = beamGroup.noteIndices[i];
259+
const noteElement = elements[noteIndex];
260+
const noteDuration = noteElement.duration || 0;
261+
262+
// 创建音符的布局元素
263+
const noteLayoutElement = transformMeasureElement(
264+
noteElement,
265+
scoreConfig,
266+
noteGroupElement,
267+
);
268+
269+
if (noteLayoutElement && noteLayoutElement.layout) {
270+
// 标记这个音符属于符杠组,渲染时不应该绘制单独的符尾
271+
(noteLayoutElement as any).beamGroup = {
272+
groupId: beamGroup.id,
273+
groupIndex: i,
274+
totalInGroup: beamGroup.noteIndices.length,
275+
beamCount: beamGroup.beamCount,
276+
};
277+
278+
// 计算音符在音符组内的相对位置
279+
const noteStartRatio = innerTickOffset / groupDuration;
280+
const noteDurationRatio = noteDuration / groupDuration;
281+
const noteX = noteStartRatio * groupWidth;
282+
const noteWidth = noteDurationRatio * groupWidth;
283+
284+
noteLayoutElement.layout.x = noteX;
285+
noteLayoutElement.layout.width = Math.max(10, noteWidth);
286+
287+
// Y坐标使用父节点(小节)的padding.top
288+
if (parentNode.layout) {
289+
const parentPadding = parentNode.layout.padding ?? {
290+
top: 0,
291+
right: 0,
292+
bottom: 0,
293+
left: 0,
294+
};
295+
noteLayoutElement.layout.y = parentPadding.top;
296+
}
297+
298+
// 处理音符的 children(歌词等)
299+
if (noteElement.children?.length) {
300+
buildElementChildren(
301+
noteElement.children,
302+
noteLayoutElement,
303+
noteLayoutElement.layout.x,
304+
noteLayoutElement.layout.width,
305+
scoreConfig,
306+
);
307+
}
308+
}
309+
310+
innerTickOffset += noteDuration;
311+
}
312+
313+
// 更新父节点高度
314+
calculateNodeHeight(parentNode);
315+
316+
return noteGroupElement;
317+
}
318+
146319
/**
147320
* 转换 Measure 内部的元素(Note/Rest/Lyric等)
148321
* @param element - 数据层元素节点

0 commit comments

Comments
 (0)