From 916d050efb70106ab756a64ba683bc27922edd98 Mon Sep 17 00:00:00 2001 From: danielku15 Date: Wed, 7 Oct 2026 13:00:19 +0200 Subject: [PATCH 1/2] test(importer): cover MusicXML jump attributes on direction-level sound Refs #2859 --- .../test/importer/MusicXmlImporter.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/packages/alphatab/test/importer/MusicXmlImporter.test.ts b/packages/alphatab/test/importer/MusicXmlImporter.test.ts index 07f97ec4e..24f37bf33 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -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'; @@ -630,4 +631,137 @@ describe('MusicXmlImporterTests', () => { expect(tempoChanges[0].tempo).toBe(60); }); }); + + describe('sound-directions', () => { + function loadMeasures(measures: string[]): Score { + let xml = ` + + Music + `; + for (let i = 0; i < measures.length; i++) { + xml += ``; + if (i === 0) { + xml += + '1G2'; + } + xml += measures[i]; + xml += + 'C441whole'; + xml += ''; + } + xml += ''; + return MusicXmlImporterTestHelper.prepareImporterWithBytes(IOHelper.stringToBytes(xml)).readScore(); + } + + function direction(content: string): string { + return `${content}`; + } + + 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('To Coda'), + direction('D.C. al Coda'), + direction('') + ]); + 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('To Coda')}`, + `${direction('D.C. al Coda')}`, + `${direction('')}` + ]); + 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('Fine'), + direction('D.S. al Fine') + ]); + 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( + 'To Coda' + ) + ]); + expectDirections(score, 0, [Direction.JumpDaCoda]); + expect(beatText(score, 0)).toBeNull(); + }); + + it('unknown-words-kept', () => { + const score = loadMeasures([ + direction('Andante'), + direction('Fine') + ]); + 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('D. C. al Fine'), + direction( + 'D.S. al Coda' + ), + direction('To\u00a0Coda') + ]); + 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('Andante') + ]); + expectDirections(score, 0, []); + expect(beatText(score, 0)).toBe('Andante'); + }); + + it('display-only-symbols', () => { + const score = loadMeasures([ + direction(''), + direction('') + ]); + expectDirections(score, 0, [Direction.TargetSegno]); + expectDirections(score, 1, [Direction.TargetCoda]); + }); + }); }); From 25b74966ff9c075b5c7e514dbc51c560f052704c Mon Sep 17 00:00:00 2001 From: danielku15 Date: Wed, 7 Oct 2026 13:00:19 +0200 Subject: [PATCH 2/2] fix(importer): read MusicXML jumps from inside Jump and marker attributes (coda, tocoda, dacapo, dalsegno, fine, segno) were only read from a measure-level , while notation programs write them on the of a . Both positions now share one mapping. Within such a direction the defines the semantic direction: / symbols are only its visual counterpart, and that are a known label of the jump (e.g. "To Coda", "D.C. al Fine") are not repeated as beat text since the direction renders its own label. Other words are kept. dacapo="no" no longer counts as a jump. Fixes #2859 --- .../alphatab/src/importer/MusicXmlImporter.ts | 143 ++++++++++++++---- 1 file changed, 117 insertions(+), 26 deletions(-) diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 779da2cd0..9af55e8af 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -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 @@ -1337,6 +1315,101 @@ export class MusicXmlImporter extends ScoreImporter { this._nextBeatAutomations.push(automation); } } + + /** + * Applies the jump and marker attributes of a `` (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 `` print the directions + * of the `` 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 = new Map([ + ['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; @@ -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) { @@ -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': @@ -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 } @@ -2144,11 +2228,17 @@ export class MusicXmlImporter extends ScoreImporter { masterBar.section = new Section(); masterBar.section.marker = direction.innerText; break; + // 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; @@ -2227,7 +2317,8 @@ export class MusicXmlImporter extends ScoreImporter { } } - if (previousWords) { + // words printing the label of a direction are not repeated as text, the direction renders it + if (previousWords && !(hasSoundDirections && MusicXmlImporter._isSoundDirectionLabel(allWords, sound!))) { this._nextBeatText = previousWords; } }