Skip to content

Commit 7c099a3

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. A marking is now shown on the next note of the staff (and voice) it is written for via the new Beat.showDynamics, and carried forward in time to all staves of its part for playback, like MuseScore and Dorico do by default. Fixes #2979
1 parent 1dd1db1 commit 7c099a3

34 files changed

Lines changed: 359 additions & 10 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;

‎packages/alphatab/src/importer/MusicXmlImporter.ts‎

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,12 @@ interface MusicXmlHarmonicGroup {
9797
* @internal
9898
*/
9999
class StaffContext {
100-
public currentDynamics = DynamicValue.F;
100+
/**
101+
* The dynamics of a direction, shown on the next note of the staff (and of the voice, if given).
102+
*/
103+
public pendingDynamics: DynamicValue | null = null;
104+
public pendingDynamicsVoice: string = '';
105+
public lastDynamics: DynamicValue | null = null;
101106
public transpose: number = 0;
102107
public isExplicitlyBeamed = false;
103108
}
@@ -283,7 +288,6 @@ export class MusicXmlImporter extends ScoreImporter {
283288
* Whether the exporter writes the stop of octave shifts before the last shifted note instead of after it.
284289
*/
285290
private _octaveShiftEndsBeforeLastNote: boolean = false;
286-
private _currentDynamics = DynamicValue.F;
287291

288292
public get name(): string {
289293
return 'MusicXML';
@@ -303,6 +307,7 @@ export class MusicXmlImporter extends ScoreImporter {
303307

304308
this._parseDom(dom);
305309
this._applySpans();
310+
this._applyDynamics();
306311
ModelUtils.consolidate(this._score);
307312
this._score.finish(this.settings);
308313
this._score.rebuildRepeatGroups();
@@ -2263,7 +2268,9 @@ export class MusicXmlImporter extends ScoreImporter {
22632268
case 'dynamics':
22642269
const newDynamics = this._parseDynamics(direction);
22652270
if (newDynamics !== null) {
2266-
this._currentDynamics = newDynamics;
2271+
const context = this._getStaffContext(staff);
2272+
context.pendingDynamics = newDynamics;
2273+
context.pendingDynamicsVoice = voice;
22672274
this._score.stylesheet.hideDynamics = false;
22682275
}
22692276
break;
@@ -2653,6 +2660,15 @@ export class MusicXmlImporter extends ScoreImporter {
26532660
beat.addNote(note!);
26542661
} else {
26552662
beat = this._createBeat(staff, masterBar, voiceRaw, note);
2663+
const context = this._getStaffContext(staff);
2664+
if (
2665+
note !== null &&
2666+
context.pendingDynamics !== null &&
2667+
(context.pendingDynamicsVoice.length === 0 || context.pendingDynamicsVoice === voiceRaw)
2668+
) {
2669+
this._markDynamics(context, beat, context.pendingDynamics!, false);
2670+
context.pendingDynamics = null;
2671+
}
26562672
}
26572673

26582674
if (note !== null) {
@@ -2995,7 +3011,6 @@ export class MusicXmlImporter extends ScoreImporter {
29953011
// need an empty placeholder beat for the gap
29963012
if (gap > 0) {
29973013
const placeholder = new Beat();
2998-
placeholder.dynamics = this._currentDynamics;
29993014
placeholder.isEmpty = true;
30003015
placeholder.duration = Duration.TwoHundredFiftySixth; // smallest we have
30013016
placeholder.overrideDisplayDuration = gap;
@@ -3013,7 +3028,6 @@ export class MusicXmlImporter extends ScoreImporter {
30133028
this._beats.push(newBeat);
30143029
this._harmonicGroups.clear();
30153030
newBeat.isEmpty = false;
3016-
newBeat.dynamics = this._currentDynamics;
30173031

30183032
const automations = this._nextBeatAutomations;
30193033
this._nextBeatAutomations = null;
@@ -3516,7 +3530,6 @@ export class MusicXmlImporter extends ScoreImporter {
35163530
}
35173531

35183532
const placeholder = new Beat();
3519-
placeholder.dynamics = this._currentDynamics;
35203533
placeholder.isEmpty = false;
35213534
placeholder.duration = preferredDuration;
35223535
placeholder.overrideDisplayDuration = preferredDurationTicks;
@@ -3710,8 +3723,8 @@ export class MusicXmlImporter extends ScoreImporter {
37103723
case 'dynamics':
37113724
const dynamics = this._parseDynamics(c);
37123725
if (dynamics !== null) {
3713-
beat.dynamics = dynamics;
3714-
this._currentDynamics = dynamics;
3726+
this._markDynamics(this._getStaffContext(beat.voice.bar.staff), beat, dynamics, true);
3727+
this._score.stylesheet.hideDynamics = false;
37153728
}
37163729
break;
37173730
case 'fermata':
@@ -3747,6 +3760,83 @@ export class MusicXmlImporter extends ScoreImporter {
37473760
}
37483761
}
37493762

3763+
/**
3764+
* A note-attached marking repeating the previous one of the staff is not shown, as some exporters (e.g. Guitar Pro)
3765+
* write the dynamics on every note.
3766+
*/
3767+
private _markDynamics(context: StaffContext, beat: Beat, value: DynamicValue, isNoteAttached: boolean) {
3768+
beat.dynamics = value;
3769+
beat.showDynamics = !isNoteAttached || context.lastDynamics !== value;
3770+
context.lastDynamics = value;
3771+
}
3772+
3773+
/**
3774+
* Carries the dynamics markings forward in time to all beats of their part, e.g. a single marking for both hands
3775+
* of a piano. Of markings at the same position, the one on the own staff wins.
3776+
*/
3777+
private _applyDynamics() {
3778+
if (this._score.stylesheet.hideDynamics) {
3779+
return;
3780+
}
3781+
for (const track of this._score.tracks) {
3782+
const markings: Beat[] = [];
3783+
for (const staff of track.staves) {
3784+
for (const bar of staff.bars) {
3785+
for (const voice of bar.voices) {
3786+
for (const beat of voice.beats) {
3787+
if (beat.showDynamics !== undefined) {
3788+
markings.push(beat);
3789+
}
3790+
}
3791+
}
3792+
}
3793+
}
3794+
3795+
for (const staff of track.staves) {
3796+
const ordered = markings.slice();
3797+
ordered.sort((a, b) => {
3798+
const position = MusicXmlImporter._comparePosition(a, b);
3799+
if (position !== 0) {
3800+
return position;
3801+
}
3802+
return (a.voice.bar.staff === staff ? 1 : 0) - (b.voice.bar.staff === staff ? 1 : 0);
3803+
});
3804+
3805+
// per voice: index of the next marking and the dynamics in effect
3806+
const nextMarking: number[] = [];
3807+
const current: DynamicValue[] = [];
3808+
for (const bar of staff.bars) {
3809+
for (const voice of bar.voices) {
3810+
while (nextMarking.length <= voice.index) {
3811+
nextMarking.push(0);
3812+
current.push(DynamicValue.F);
3813+
}
3814+
for (const beat of voice.beats) {
3815+
let next = nextMarking[voice.index];
3816+
while (
3817+
next < ordered.length &&
3818+
MusicXmlImporter._comparePosition(ordered[next], beat) <= 0
3819+
) {
3820+
current[voice.index] = ordered[next].dynamics;
3821+
next++;
3822+
}
3823+
nextMarking[voice.index] = next;
3824+
if (beat.showDynamics === undefined) {
3825+
beat.dynamics = current[voice.index];
3826+
beat.showDynamics = false;
3827+
}
3828+
}
3829+
}
3830+
}
3831+
}
3832+
}
3833+
}
3834+
3835+
private static _comparePosition(a: Beat, b: Beat): number {
3836+
const bar = a.voice.bar.index - b.voice.bar.index;
3837+
return bar !== 0 ? bar : a.displayStart - b.displayStart;
3838+
}
3839+
37503840
private _getStaffContext(staff: Staff) {
37513841
if (!this._staffToContext.has(staff)) {
37523842
const context = new StaffContext();

‎packages/alphatab/src/model/Beat.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -673,6 +673,16 @@ export class Beat {
673673
*/
674674
public dynamics: DynamicValue = DynamicValue.F;
675675

676+
/**
677+
* Whether a dynamics marking with the {@link dynamics} of this beat is shown.
678+
* @remarks
679+
* If not set, a marking is shown where the dynamics change.
680+
* Formats encoding the written markings (e.g. MusicXML) set it, as the dynamics in effect can differ
681+
* from the shown markings: e.g. a single marking on a piano applies to both staves.
682+
* @since 1.9.0
683+
*/
684+
public showDynamics?: boolean;
685+
676686
/**
677687
* Gets or sets a value indicating whether the beam direction should be inverted.
678688
*/
@@ -1185,6 +1195,9 @@ export class Beat {
11851195
const cloneBeat: Beat = BeatCloner.clone(this);
11861196
cloneBeat.id = Beat._globalBeatId++;
11871197
cloneBeat.pickStroke = PickStroke.None;
1198+
if (cloneBeat.showDynamics === true) {
1199+
cloneBeat.showDynamics = false;
1200+
}
11881201
for (let i: number = 0, j: number = cloneBeat.notes.length; i < j; i++) {
11891202
const cloneNote: Note = cloneBeat.notes[i];
11901203
const note: Note = this.notes[i];

‎packages/alphatab/src/rendering/effects/DynamicsEffectInfo.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ function shouldCreateDynamicsGlyph(beat: Beat): boolean {
2323
return false;
2424
}
2525

26+
if (beat.showDynamics !== undefined) {
27+
return beat.showDynamics!;
28+
}
29+
2630
const previousBeat = getPreviousDynamicsBeat(beat);
2731

2832
let show: boolean = (beat.voice.index === 0 && !previousBeat) || beat.dynamics !== previousBeat?.dynamics;
-2.52 KB
Loading
-3.24 KB
Loading
-3.72 KB
Loading
-1.61 KB
Loading
242 Bytes
Loading

0 commit comments

Comments
 (0)