Skip to content

Commit 7d92562

Browse files
committed
fix(importer): apply MusicXML dynamics per part and show them only where written
Dynamics were kept as one importer-wide state assigned to the next created beat, leaking across staves and parts. Markings are now placed by position, apply to all staves of their part for playback like MuseScore and Dorico do, and are drawn only on the staff and voice they are written for via the new Beat.showDynamics. Fixes #2979
1 parent 1dd1db1 commit 7d92562

32 files changed

Lines changed: 552 additions & 20 deletions

‎packages/alphatab/src/generated/model/BeatCloner.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export class BeatCloner {
6666
clone.overrideDisplayDuration = original.overrideDisplayDuration;
6767
clone.golpe = original.golpe;
6868
clone.dynamics = original.dynamics;
69+
clone.showDynamics = original.showDynamics;
6970
clone.invertBeamDirection = original.invertBeamDirection;
7071
clone.preferredBeamDirection = original.preferredBeamDirection;
7172
clone.isEffectSlurOrigin = original.isEffectSlurOrigin;

‎packages/alphatab/src/generated/model/BeatSerializer.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export class BeatSerializer {
9494
o.set("overridedisplayduration", obj.overrideDisplayDuration);
9595
o.set("golpe", obj.golpe as number);
9696
o.set("dynamics", obj.dynamics as number);
97+
o.set("showdynamics", obj.showDynamics);
9798
o.set("invertbeamdirection", obj.invertBeamDirection);
9899
o.set("preferredbeamdirection", obj.preferredBeamDirection as number | null);
99100
o.set("beamingmode", obj.beamingMode as number);
@@ -256,6 +257,9 @@ export class BeatSerializer {
256257
case "dynamics":
257258
obj.dynamics = JsonHelper.parseEnum<DynamicValue>(v, DynamicValue)!;
258259
return true;
260+
case "showdynamics":
261+
obj.showDynamics = v as boolean | undefined;
262+
return true;
259263
case "invertbeamdirection":
260264
obj.invertBeamDirection = v! as boolean;
261265
return true;
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import type { MusicXmlSpanPlacement } from '@coderline/alphatab/importer/MusicXmlSpans';
2+
import type { Beat } from '@coderline/alphatab/model/Beat';
3+
import { DynamicValue } from '@coderline/alphatab/model/DynamicValue';
4+
import { GraceType } from '@coderline/alphatab/model/GraceType';
5+
import type { Staff } from '@coderline/alphatab/model/Staff';
6+
import type { Track } from '@coderline/alphatab/model/Track';
7+
8+
/**
9+
* A dynamics marking written in a part.
10+
* @internal
11+
* @record
12+
*/
13+
export interface MusicXmlDynamicsMarking {
14+
value: DynamicValue;
15+
/**
16+
* Where the marking is written, the offset is the part of the `<offset>` affecting the sound.
17+
*/
18+
placement: MusicXmlSpanPlacement;
19+
/**
20+
* The index of the staff the marking is shown on.
21+
*/
22+
displayStaffIndex: number;
23+
/**
24+
* Whether the marking is written in the `<notations>` of a note rather than as a `<direction>`.
25+
*/
26+
isNoteAttached: boolean;
27+
}
28+
29+
/**
30+
* Applies the dynamics markings of a part to the model.
31+
*
32+
* - A marking is in effect for the beats of all staves and voices of the part starting at or after it
33+
* (e.g. a single marking for both hands of a piano). Of the markings at the same position, the one of the own staff wins.
34+
* - A marking is shown once, on the first note of its staff (and voice) it is in effect for. A marking attached to a
35+
* note is not shown if it repeats the marking before it on the staff (Guitar Pro writes the dynamics on every note).
36+
* @internal
37+
*/
38+
export class MusicXmlDynamicsApplier {
39+
private _track: Track;
40+
private _beatSequence: Map<Beat, number>;
41+
private _voiceIndexOf: (staff: Staff, voice: string) => number;
42+
43+
/**
44+
* @param beatSequence The index of every beat in document order (see {@link MusicXmlSpanPosition.sequence}).
45+
* @param voiceIndexOf Resolves a raw MusicXML voice to the index of the voice on the staff, -1 if not existing.
46+
*/
47+
public constructor(
48+
track: Track,
49+
beatSequence: Map<Beat, number>,
50+
voiceIndexOf: (staff: Staff, voice: string) => number
51+
) {
52+
this._track = track;
53+
this._beatSequence = beatSequence;
54+
this._voiceIndexOf = voiceIndexOf;
55+
}
56+
57+
public apply(markings: MusicXmlDynamicsMarking[]) {
58+
// of markings at the same position, the later one in the document wins
59+
const sorted: MusicXmlDynamicsMarking[] = [];
60+
for (const marking of markings) {
61+
let index = sorted.length;
62+
while (index > 0 && MusicXmlDynamicsApplier._compareMarkings(sorted[index - 1], marking) > 0) {
63+
index--;
64+
}
65+
sorted.splice(index, 0, marking);
66+
}
67+
68+
const shown: (Beat | null)[] = [];
69+
const repeated: boolean[] = [];
70+
for (let i = 0; i < sorted.length; i++) {
71+
shown.push(null);
72+
repeated.push(false);
73+
}
74+
75+
for (const staff of this._track.staves) {
76+
let voiceCount = 0;
77+
for (const bar of staff.bars) {
78+
voiceCount = Math.max(voiceCount, bar.voices.length);
79+
}
80+
81+
for (let v = 0; v < voiceCount; v++) {
82+
let next = 0;
83+
let current = -1;
84+
let previousNote = -1;
85+
for (const bar of staff.bars) {
86+
if (v >= bar.voices.length) {
87+
continue;
88+
}
89+
for (const beat of bar.voices[v].beats) {
90+
while (next < sorted.length && this._isInEffect(sorted[next], beat)) {
91+
if (
92+
current === -1 ||
93+
!MusicXmlDynamicsApplier._keepsOwnStaff(sorted[current], sorted[next], staff.index)
94+
) {
95+
current = next;
96+
}
97+
next++;
98+
}
99+
100+
beat.dynamics = current === -1 ? DynamicValue.F : sorted[current].value;
101+
beat.showDynamics = false;
102+
if (current !== -1 && this._canShow(sorted[current], staff, v, beat)) {
103+
const best = shown[current];
104+
if (best === null || this._isBetterShown(sorted[current], beat, best!)) {
105+
shown[current] = beat;
106+
repeated[current] =
107+
sorted[current].isNoteAttached &&
108+
previousNote !== -1 &&
109+
sorted[previousNote].displayStaffIndex === staff.index &&
110+
sorted[previousNote].value === sorted[current].value;
111+
}
112+
}
113+
if (!beat.isRest && !beat.isEmpty) {
114+
previousNote = current;
115+
}
116+
}
117+
}
118+
}
119+
}
120+
121+
for (let i = 0; i < shown.length; i++) {
122+
if (shown[i] !== null && !repeated[i]) {
123+
shown[i]!.showDynamics = true;
124+
}
125+
}
126+
}
127+
128+
/**
129+
* Whether the marking `current` stays in effect when the marking `next` at the same position is reached:
130+
* the marking written on the staff wins over the one of another staff.
131+
*/
132+
private static _keepsOwnStaff(
133+
current: MusicXmlDynamicsMarking,
134+
next: MusicXmlDynamicsMarking,
135+
staffIndex: number
136+
): boolean {
137+
return (
138+
MusicXmlDynamicsApplier._compareStart(current, next) === 0 &&
139+
current.placement.staffIndex === staffIndex &&
140+
next.placement.staffIndex !== staffIndex
141+
);
142+
}
143+
144+
private _canShow(marking: MusicXmlDynamicsMarking, staff: Staff, voiceIndex: number, beat: Beat): boolean {
145+
if (staff.index !== marking.displayStaffIndex || beat.isRest || beat.isEmpty) {
146+
return false;
147+
}
148+
if (marking.placement.voice.length > 0) {
149+
const markingVoice = this._voiceIndexOf(staff, marking.placement.voice);
150+
return markingVoice === -1 || markingVoice === voiceIndex;
151+
}
152+
return true;
153+
}
154+
155+
/**
156+
* Whether the beat shows the marking rather than the beat found before. Of the first notes at the same position,
157+
* the one following the marking in the document wins, otherwise the one of the lowest voice.
158+
*/
159+
private _isBetterShown(marking: MusicXmlDynamicsMarking, beat: Beat, best: Beat): boolean {
160+
const compare = MusicXmlDynamicsApplier._compare(
161+
beat.voice.bar.index,
162+
beat.displayStart,
163+
best.voice.bar.index,
164+
best.displayStart
165+
);
166+
if (compare !== 0) {
167+
return compare < 0;
168+
}
169+
return this._sequenceOf(beat) === marking.placement.position.sequence;
170+
}
171+
172+
private _isInEffect(marking: MusicXmlDynamicsMarking, beat: Beat): boolean {
173+
const compare = MusicXmlDynamicsApplier._compare(
174+
marking.placement.position.barIndex,
175+
MusicXmlDynamicsApplier._ticks(marking),
176+
beat.voice.bar.index,
177+
beat.displayStart
178+
);
179+
if (compare !== 0) {
180+
return compare < 0;
181+
}
182+
// beats without duration (grace notes) at the same position are ordered as in the document
183+
return beat.graceType === GraceType.None || this._sequenceOf(beat) >= marking.placement.position.sequence;
184+
}
185+
186+
private _sequenceOf(beat: Beat): number {
187+
return this._beatSequence.has(beat) ? this._beatSequence.get(beat)! : -1;
188+
}
189+
190+
/**
191+
* The position the marking is in effect from. A sound offset only moves the marking before the cursor it is
192+
* written at, a positive one is ignored as exporters use it to nudge markings into the note they belong to (e.g. Finale).
193+
*/
194+
private static _ticks(marking: MusicXmlDynamicsMarking): number {
195+
const position = marking.placement.position;
196+
return Math.max(0, position.ticks + Math.min(0, position.offset));
197+
}
198+
199+
private static _compareStart(a: MusicXmlDynamicsMarking, b: MusicXmlDynamicsMarking): number {
200+
return MusicXmlDynamicsApplier._compare(
201+
a.placement.position.barIndex,
202+
MusicXmlDynamicsApplier._ticks(a),
203+
b.placement.position.barIndex,
204+
MusicXmlDynamicsApplier._ticks(b)
205+
);
206+
}
207+
208+
private static _compareMarkings(a: MusicXmlDynamicsMarking, b: MusicXmlDynamicsMarking): number {
209+
const compare = MusicXmlDynamicsApplier._compareStart(a, b);
210+
return compare !== 0 ? compare : a.placement.position.sequence - b.placement.position.sequence;
211+
}
212+
213+
private static _compare(barIndexA: number, ticksA: number, barIndexB: number, ticksB: number): number {
214+
return barIndexA !== barIndexB ? barIndexA - barIndexB : ticksA - ticksB;
215+
}
216+
}

0 commit comments

Comments
 (0)