Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 117 additions & 26 deletions packages/alphatab/src/importer/MusicXmlImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1279,29 +1279,7 @@ export class MusicXmlImporter extends ScoreImporter {
}
}

if (element.attributes.has('coda')) {
masterBar.addDirection(Direction.TargetCoda);
}

if (element.attributes.has('tocoda')) {
masterBar.addDirection(Direction.JumpDaCoda);
}

if (element.attributes.has('dacapo')) {
masterBar.addDirection(Direction.JumpDaCapo);
}

if (element.attributes.has('dalsegno')) {
masterBar.addDirection(Direction.JumpDalSegno);
}

if (element.attributes.has('fine')) {
masterBar.addDirection(Direction.TargetFine);
}

if (element.attributes.has('segno')) {
masterBar.addDirection(Direction.TargetSegno);
}
this._parseSoundDirections(element, masterBar);

// damper-pedal="" Ignored -> Handled via pedal direction
// dynamics="" Ignored -> Handled via dynamics direction
Expand Down Expand Up @@ -1337,6 +1315,101 @@ export class MusicXmlImporter extends ScoreImporter {
this._nextBeatAutomations.push(automation);
}
}

/**
* Applies the jump and marker attributes of a `<sound>` (measure or direction level) as directions.
* @returns true if any direction was applied.
*/
private _parseSoundDirections(element: XmlNode, masterBar: MasterBar): boolean {
let hasDirections = false;
if (element.attributes.has('coda')) {
masterBar.addDirection(Direction.TargetCoda);
hasDirections = true;
}

if (element.attributes.has('tocoda')) {
masterBar.addDirection(Direction.JumpDaCoda);
hasDirections = true;
}

// yes-no typed, "no" means no jump
if (element.getAttribute('dacapo', 'no') !== 'no') {
masterBar.addDirection(Direction.JumpDaCapo);
hasDirections = true;
}

if (element.attributes.has('dalsegno')) {
masterBar.addDirection(Direction.JumpDalSegno);
hasDirections = true;
}

if (element.attributes.has('fine')) {
masterBar.addDirection(Direction.TargetFine);
hasDirections = true;
}

if (element.attributes.has('segno')) {
masterBar.addDirection(Direction.TargetSegno);
hasDirections = true;
}

return hasDirections;
}

/**
* The texts (normalized via {@link _normalizeDirectionLabel}) with which `<words>` print the directions
* of the `<sound>` attributes. Such words are only the visual counterpart of the direction which renders its
* own label, they are not added as additional beat text. Any other words next to a jump are kept as text.
* Covers the default labels of the MuseScore export and the spelled-out forms, but no double segno/coda
* or numbered forms as the directions are not mapped to their double variants.
*/
private static readonly _soundDirectionLabels: Map<string, string[]> = new Map<string, string[]>([
['dacapo', ['dc', 'dacapo', 'dcalfine', 'dacapoalfine', 'dcalcoda', 'dacapoalcoda']],
[
'dalsegno',
[
'ds',
'dalsegno',
'delsegno',
'dsalfine',
'dalsegnoalfine',
'delsegnoalfine',
'dsalcoda',
'dalsegnoalcoda',
'delsegnoalcoda'
]
],
['tocoda', ['tocoda', 'dacoda']],
['fine', ['fine']],
['coda', ['coda']],
['segno', ['segno']]
]);

/**
* Lower-cases the text and removes dots and whitespace ("D. C. al Coda" -> "dcalcoda").
*/
private static _normalizeDirectionLabel(text: string): string {
const lower = text.toLowerCase();
let normalized = '';
for (let i = 0; i < lower.length; i++) {
const c = lower.charAt(i);
if (c !== '.' && c !== ' ' && c !== '\t' && c !== '\r' && c !== '\n' && c !== '\u00a0') {
normalized += c;
}
}
return normalized;
}

private static _isSoundDirectionLabel(words: string, sound: XmlNode): boolean {
const normalized = MusicXmlImporter._normalizeDirectionLabel(words);
for (const [attribute, labels] of MusicXmlImporter._soundDirectionLabels) {
if (sound.attributes.has(attribute) && labels.indexOf(normalized) >= 0) {
return true;
}
}
return false;
}

private _parseSwing(element: XmlNode, masterBar: MasterBar) {
let first = 0;
let second = 0;
Expand Down Expand Up @@ -2072,6 +2145,10 @@ export class MusicXmlImporter extends ScoreImporter {
// let voiceIndex = -1;
let staffIndex = -1;
let tempo = -1;
let sound: XmlNode | null = null;
let hasSoundDirections = false;
// all words of the direction, also split ones like "D.S. al " + "Coda"
let allWords = '';

for (const c of element.childElements()) {
switch (c.localName) {
Expand All @@ -2082,6 +2159,11 @@ export class MusicXmlImporter extends ScoreImporter {
if (types.length > 0) {
directionTypes.push(types[types.length - 1]);
}
for (const t of types) {
if (t.localName === 'words') {
allWords += t.innerText;
}
}
break;
}
case 'offset':
Expand All @@ -2099,6 +2181,8 @@ export class MusicXmlImporter extends ScoreImporter {
if (c.attributes.has('tempo')) {
tempo = Number.parseFloat(c.attributes.get('tempo')!);
}
sound = c;
hasSoundDirections = this._parseSoundDirections(c, masterBar);
break;
// case 'listening': Ignored
}
Expand Down Expand Up @@ -2144,11 +2228,17 @@ export class MusicXmlImporter extends ScoreImporter {
masterBar.section = new Section();
masterBar.section.marker = direction.innerText;
break;
// <sound> jump attributes are the authoritative directions, the symbols are only their visual counterpart
// (e.g. a coda symbol printed next to "To Coda")
case 'segno':
masterBar.addDirection(Direction.TargetSegno);
if (!hasSoundDirections) {
masterBar.addDirection(Direction.TargetSegno);
}
break;
case 'coda':
masterBar.addDirection(Direction.TargetCoda);
if (!hasSoundDirections) {
masterBar.addDirection(Direction.TargetCoda);
}
break;
case 'words':
previousWords = direction.innerText;
Expand Down Expand Up @@ -2227,7 +2317,8 @@ export class MusicXmlImporter extends ScoreImporter {
}
}

if (previousWords) {
// words printing the label of a <sound> direction are not repeated as text, the direction renders it
if (previousWords && !(hasSoundDirections && MusicXmlImporter._isSoundDirectionLabel(allWords, sound!))) {
this._nextBeatText = previousWords;
}
}
Expand Down
134 changes: 134 additions & 0 deletions packages/alphatab/test/importer/MusicXmlImporter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { BendType } from '@coderline/alphatab/model/BendType';
import { Direction } from '@coderline/alphatab/model/Direction';
import { Fingers } from '@coderline/alphatab/model/Fingers';
import type { Note } from '@coderline/alphatab/model/Note';
import { JsonConverter } from '@coderline/alphatab/model/JsonConverter';
Expand Down Expand Up @@ -630,4 +631,137 @@ describe('MusicXmlImporterTests', () => {
expect(tempoChanges[0].tempo).toBe(60);
});
});

describe('sound-directions', () => {
function loadMeasures(measures: string[]): Score {
let xml = `<?xml version="1.0" encoding="UTF-8"?>
<score-partwise version="4.0">
<part-list><score-part id="P1"><part-name>Music</part-name></score-part></part-list>
<part id="P1">`;
for (let i = 0; i < measures.length; i++) {
xml += `<measure number="${i + 1}">`;
if (i === 0) {
xml +=
'<attributes><divisions>1</divisions><time><beats>4</beats><beat-type>4</beat-type></time><clef><sign>G</sign><line>2</line></clef></attributes>';
}
xml += measures[i];
xml +=
'<note><pitch><step>C</step><octave>4</octave></pitch><duration>4</duration><voice>1</voice><type>whole</type></note>';
xml += '</measure>';
}
xml += '</part></score-partwise>';
return MusicXmlImporterTestHelper.prepareImporterWithBytes(IOHelper.stringToBytes(xml)).readScore();
}

function direction(content: string): string {
return `<direction placement="above">${content}</direction>`;
}

function expectDirections(score: Score, barIndex: number, expected: Direction[]) {
const actual = score.masterBars[barIndex].directions;
expect(actual === null ? 0 : actual.size).toBe(expected.length);
for (const d of expected) {
expect(actual!.has(d)).toBe(true);
}
}

function beatText(score: Score, barIndex: number): string | null {
return score.tracks[0].staves[0].bars[barIndex].voices[0].beats[0].text;
}

it('direction-level', () => {
const score = loadMeasures([
'',
direction('<direction-type><words>To Coda</words></direction-type><sound tocoda="coda1"/>'),
direction('<direction-type><words>D.C. al Coda</words></direction-type><sound dacapo="yes"/>'),
direction('<direction-type><coda/></direction-type><sound coda="coda1"/>')
]);
expectDirections(score, 0, []);
expectDirections(score, 1, [Direction.JumpDaCoda]);
expectDirections(score, 2, [Direction.JumpDaCapo]);
expectDirections(score, 3, [Direction.TargetCoda]);
expect(beatText(score, 1)).toBeNull();
expect(beatText(score, 2)).toBeNull();
});

it('measure-level', () => {
const score = loadMeasures([
'',
`${direction('<direction-type><words>To Coda</words></direction-type>')}<sound tocoda="coda1"/>`,
`${direction('<direction-type><words>D.C. al Coda</words></direction-type>')}<sound dacapo="yes"/>`,
`${direction('<direction-type><coda/></direction-type>')}<sound coda="coda1"/>`
]);
expectDirections(score, 0, []);
expectDirections(score, 1, [Direction.JumpDaCoda]);
expectDirections(score, 2, [Direction.JumpDaCapo]);
expectDirections(score, 3, [Direction.TargetCoda]);
});

it('segno-fine-dalsegno', () => {
const score = loadMeasures([
direction('<direction-type><segno/></direction-type><sound segno="segno1"/>'),
direction('<direction-type><words>Fine</words></direction-type><sound fine="yes"/>'),
direction('<direction-type><words>D.S. al Fine</words></direction-type><sound dalsegno="segno1"/>')
]);
expectDirections(score, 0, [Direction.TargetSegno]);
expectDirections(score, 1, [Direction.TargetFine]);
expectDirections(score, 2, [Direction.JumpDalSegno]);
expect(beatText(score, 1)).toBeNull();
expect(beatText(score, 2)).toBeNull();
});

it('coda-symbol-with-tocoda', () => {
const score = loadMeasures([
direction(
'<direction-type><words>To Coda</words></direction-type><direction-type><coda/></direction-type><sound tocoda="coda1"/>'
)
]);
expectDirections(score, 0, [Direction.JumpDaCoda]);
expect(beatText(score, 0)).toBeNull();
});

it('unknown-words-kept', () => {
const score = loadMeasures([
direction('<direction-type><words>Andante</words></direction-type><sound dacapo="yes"/>'),
direction('<direction-type><words>Fine</words></direction-type><sound dacapo="yes"/>')
]);
expectDirections(score, 0, [Direction.JumpDaCapo]);
expectDirections(score, 1, [Direction.JumpDaCapo]);
expect(beatText(score, 0)).toBe('Andante');
expect(beatText(score, 1)).toBe('Fine');
});

it('label-variants', () => {
const score = loadMeasures([
direction('<direction-type><words>D. C. al Fine</words></direction-type><sound dacapo="yes"/>'),
direction(
'<direction-type><words>D.S. al </words><words>Coda</words></direction-type><sound dalsegno="segno1"/>'
),
direction('<direction-type><words>To\u00a0Coda</words></direction-type><sound tocoda="coda1"/>')
]);
expectDirections(score, 0, [Direction.JumpDaCapo]);
expectDirections(score, 1, [Direction.JumpDalSegno]);
expectDirections(score, 2, [Direction.JumpDaCoda]);
expect(beatText(score, 0)).toBeNull();
expect(beatText(score, 1)).toBeNull();
expect(beatText(score, 2)).toBeNull();
});

it('dacapo-no', () => {
const score = loadMeasures([
direction('<direction-type><words>Andante</words></direction-type><sound dacapo="no"/>')
]);
expectDirections(score, 0, []);
expect(beatText(score, 0)).toBe('Andante');
});

it('display-only-symbols', () => {
const score = loadMeasures([
direction('<direction-type><segno/></direction-type>'),
direction('<direction-type><coda/></direction-type>')
]);
expectDirections(score, 0, [Direction.TargetSegno]);
expectDirections(score, 1, [Direction.TargetCoda]);
});
});
});
Loading