diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index d0e417eb1..169b46420 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -1,3 +1,12 @@ +import { MusicXmlNoteLinks, MusicXmlNoteLinkType } from '@coderline/alphatab/importer/MusicXmlNoteLinks'; +import { MusicXmlSpanApplier } from '@coderline/alphatab/importer/MusicXmlSpanApplier'; +import { + type MusicXmlSpanEvent, + MusicXmlSpanKind, + type MusicXmlSpanPlacement, + MusicXmlSpanReader, + MusicXmlSpanTracker +} from '@coderline/alphatab/importer/MusicXmlSpans'; import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; import { IOHelper } from '@coderline/alphatab/io/IOHelper'; @@ -6,13 +15,12 @@ import { GeneralMidi } from '@coderline/alphatab/midi/GeneralMidi'; import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; import { Automation, AutomationType } from '@coderline/alphatab/model/Automation'; -import { Bar, BarLineStyle, SustainPedalMarkerType, SustainPedalMarker } from '@coderline/alphatab/model/Bar'; +import { Bar, BarLineStyle } from '@coderline/alphatab/model/Bar'; import { Beat, BeatBeamingMode } from '@coderline/alphatab/model/Beat'; import { BendPoint } from '@coderline/alphatab/model/BendPoint'; import { BrushType } from '@coderline/alphatab/model/BrushType'; import { Chord } from '@coderline/alphatab/model/Chord'; import { Clef } from '@coderline/alphatab/model/Clef'; -import { CrescendoType } from '@coderline/alphatab/model/CrescendoType'; import { Direction } from '@coderline/alphatab/model/Direction'; import { Duration } from '@coderline/alphatab/model/Duration'; import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; @@ -35,8 +43,6 @@ import { PickStroke } from '@coderline/alphatab/model/PickStroke'; import { BarNumberDisplay } from '@coderline/alphatab/model/RenderStylesheet'; import { Score } from '@coderline/alphatab/model/Score'; import { Section } from '@coderline/alphatab/model/Section'; -import { SimileMark } from '@coderline/alphatab/model/SimileMark'; -import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; import { Staff } from '@coderline/alphatab/model/Staff'; import { Track } from '@coderline/alphatab/model/Track'; import { TremoloPickingEffect, TremoloPickingStyle } from '@coderline/alphatab/model/TremoloPickingEffect'; @@ -54,20 +60,9 @@ import { ZipReader } from '@coderline/alphatab/zip/ZipReader'; * @internal */ class StaffContext { - public slurStarts!: Map; public currentDynamics = DynamicValue.F; - public tieStarts!: Set; - public tieStartIds!: Map; - public slideOrigins: Map = new Map(); public transpose: number = 0; public isExplicitlyBeamed = false; - - constructor() { - this.tieStarts = new Set(); - this.tieStartIds = new Map(); - this.slideOrigins = new Map(); - this.slurStarts = new Map(); - } } /** @@ -118,6 +113,16 @@ class TrackInfo { private _instrumentIdToArticulationIndex: Map = new Map(); + /** + * The spans of directions and measure styles (e.g. let ring), span numbers are unique within a part. + */ + public spans: MusicXmlSpanTracker = new MusicXmlSpanTracker(); + public noteLinks: MusicXmlNoteLinks = new MusicXmlNoteLinks(); + /** + * The alternate endings started but not stopped yet. + */ + public openEndings: number = 0; + private _lyricsLine = 0; private _lyricsLines: Map = new Map(); @@ -233,6 +238,10 @@ export class MusicXmlImporter extends ScoreImporter { private _nextBarNumber: number = 1; private _divisionsPerQuarterNote: number = 1; + /** + * Whether the exporter writes the stop of octave shifts before the last shifted note instead of after it. + */ + private _octaveShiftEndsBeforeLastNote: boolean = false; private _currentDynamics = DynamicValue.F; public get name(): string { @@ -252,6 +261,7 @@ export class MusicXmlImporter extends ScoreImporter { this._score.stylesheet.hideDynamics = true; this._parseDom(dom); + this._applySpans(); ModelUtils.consolidate(this._score); this._score.finish(this.settings); this._score.rebuildRepeatGroups(); @@ -259,6 +269,33 @@ export class MusicXmlImporter extends ScoreImporter { return this._score; } + /** + * Applies the spans of all parts once all beats are known: a span can cover beats which appear before + * its start or after its stop in the document (other voices or staves). Must run before the model is finished + * as the note effects are linked there. + */ + private _applySpans() { + const voiceIndexOf = (staff: Staff, voice: string) => { + if (this._staffVoicePacking.has(staff)) { + const mapping = this._staffVoicePacking.get(staff)!.mapping; + if (mapping.has(voice)) { + return mapping.get(voice)!; + } + } + return -1; + }; + for (const info of this._indexToTrackInfo.values()) { + new MusicXmlSpanApplier( + info.track, + this._score.masterBars, + this._beats, + voiceIndexOf, + this._octaveShiftEndsBeforeLastNote + ).apply(info.spans.finish()); + info.noteLinks.apply(); + } + } + private _extractMusicXml(): string { const zip = new ZipReader(this.data, this.settings.importer.maxDecodingBufferSize); let entries: ZipEntry[]; @@ -598,7 +635,18 @@ export class MusicXmlImporter extends ScoreImporter { this._score.tab += ` (${c.attributes.get('type')})`; } break; - // case 'software': Ignored + case 'software': + // Finale and Sibelius write the end of octave shifts before the last shifted note + // (see MusicXML test suite 33da, MuseScore import) + const software = c.innerText.toLowerCase(); + if ( + software.indexOf('finale') >= 0 || + software.indexOf('sibelius') >= 0 || + software.indexOf('dolet') >= 0 + ) { + this._octaveShiftEndsBeforeLastNote = true; + } + break; case 'encoding-description': this._score.notices += MusicXmlImporter._sanitizeDisplay(c.innerText); break; @@ -1000,7 +1048,9 @@ export class MusicXmlImporter extends ScoreImporter { this._musicalPosition = 0; this._lastBeat = null; - masterBar.alternateEndings = this._nextMasterBarRepeatEnding; + // bars within an alternate ending + const info = this._indexToTrackInfo.get(track.index)!; + masterBar.alternateEndings = masterBar.alternateEndings | info.openEndings; const barLines: XmlNode[] = []; @@ -1046,8 +1096,6 @@ export class MusicXmlImporter extends ScoreImporter { this._parseBarLine(barLine, masterBar, track); } - this._applySimileMarks(masterBar, track); - // initial empty staff and voice (if no other elements created something already) const staff = this._getOrCreateStaff(track, 0); const bar = this._getOrCreateBar(staff, masterBar); @@ -1099,54 +1147,6 @@ export class MusicXmlImporter extends ScoreImporter { } } - private _applySimileMarks(masterBar: MasterBar, track: Track) { - if (this._simileMarkAllStaves !== null) { - for (const s of track.staves) { - const bar = this._getOrCreateBar(s, masterBar); - bar.simileMark = this._simileMarkAllStaves!; - if (bar.simileMark !== SimileMark.None) { - this._clearBar(bar); - } - } - - if (this._simileMarkAllStaves === SimileMark.FirstOfDouble) { - this._simileMarkAllStaves = SimileMark.SecondOfDouble; - } else { - this._simileMarkAllStaves = null; - } - } - - if (this._simileMarkPerStaff !== null) { - const keys = Array.from(this._simileMarkPerStaff!.keys()); - for (const i of keys) { - const s = this._getOrCreateStaff(track, i); - const bar = this._getOrCreateBar(s, masterBar); - bar.simileMark = this._simileMarkPerStaff!.get(i)!; - - if (bar.simileMark !== SimileMark.None) { - this._clearBar(bar); - } - - if (bar.simileMark === SimileMark.FirstOfDouble) { - this._simileMarkPerStaff!.set(i, SimileMark.SecondOfDouble); - } else { - this._simileMarkPerStaff!.delete(i); - } - } - if (this._simileMarkPerStaff.size === 0) { - this._simileMarkPerStaff = null; - } - } - } - - private _clearBar(bar: Bar) { - for (const v of bar.voices) { - const emptyBeat: Beat = new Beat(); - emptyBeat.isEmpty = true; - v.addBeat(emptyBeat); - } - } - private _parseBarLine(element: XmlNode, masterBar: MasterBar, track: Track) { for (const c of element.childElements()) { switch (c.localName) { @@ -1160,7 +1160,7 @@ export class MusicXmlImporter extends ScoreImporter { // case 'coda': Ignored (use directions) // case 'fermata': Ignored (on barline, they exist on beat notations) case 'ending': - this._parseEnding(c, masterBar); + this._parseEnding(c, masterBar, track); break; case 'repeat': this._parseRepeat(c, masterBar); @@ -1182,8 +1182,7 @@ export class MusicXmlImporter extends ScoreImporter { } } - private _nextMasterBarRepeatEnding: number = 0; - private _parseEnding(element: XmlNode, masterBar: MasterBar): void { + private _parseEnding(element: XmlNode, masterBar: MasterBar, track: Track): void { const numbers = element .getAttribute('number') .split(',') @@ -1194,19 +1193,18 @@ export class MusicXmlImporter extends ScoreImporter { flags = flags | ((0x01 << (num - 1)) & 0xff); } - masterBar.alternateEndings = flags; - + // the endings of the master bar are the union of all parts + masterBar.alternateEndings = masterBar.alternateEndings | flags; + const info = this._indexToTrackInfo.get(track.index)!; switch (element.getAttribute('type', '')) { case 'start': - this._nextMasterBarRepeatEnding = this._nextMasterBarRepeatEnding | flags; + info.openEndings = info.openEndings | flags; break; case 'stop': case 'discontinue': - this._nextMasterBarRepeatEnding = this._nextMasterBarRepeatEnding & ~flags; - break; - case 'continue': - // keep + info.openEndings = info.openEndings & ~flags; break; + // case 'continue': keep } } @@ -1458,11 +1456,33 @@ export class MusicXmlImporter extends ScoreImporter { private _nextBeatAutomations: Automation[] | null = null; private _nextBeatChord: Chord | null = null; - private _nextBeatCrescendo: CrescendoType | null = null; - private _nextBeatLetRing: boolean = false; - private _nextBeatPalmMute: boolean = false; - private _nextBeatOttavia: Ottavia | null = null; private _nextBeatText: string | null = null; + /** + * Where the pending beat text is written, it can be the label of a following line (e.g. music21). + */ + private _nextBeatTextTrackIndex: number = -1; + private _nextBeatTextBarIndex: number = -1; + private _nextBeatTextTicks: number = -1; + /** + * All beats in document order (see {@link MusicXmlSpanPosition.sequence}). + */ + private _beats: Beat[] = []; + + /** + * The placement of a span element at the current cursor. + */ + private _placement(masterBar: MasterBar, staffIndex: number, voice: string, offset: number): MusicXmlSpanPlacement { + return { + staffIndex: staffIndex, + voice: voice, + position: { + barIndex: masterBar.index, + ticks: this._musicalPosition, + offset: offset, + sequence: this._beats.length + } + }; + } private _parseSoundMidiInstrument(element: XmlNode, _masterBar: MasterBar) { let automation: Automation; @@ -1812,7 +1832,7 @@ export class MusicXmlImporter extends ScoreImporter { // case 'for-part': not supported // case 'directive': Ignored case 'measure-style': - this._parseMeasureStyle(c, track, false); + this._parseMeasureStyle(c, masterBar, track, false); break; } } @@ -1836,69 +1856,24 @@ export class MusicXmlImporter extends ScoreImporter { // case 'for-part': not supported // case 'directive': Ignored case 'measure-style': - this._parseMeasureStyle(c, track, true); + this._parseMeasureStyle(c, masterBar, track, true); break; } } } } - private _simileMarkAllStaves: SimileMark | null = null; - private _simileMarkPerStaff: Map | null = null; - private _isBeatSlash: boolean = false; - private _parseMeasureStyle(element: XmlNode, _track: Track, midBar: boolean) { - for (const c of element.childElements()) { - switch (c.localName) { - // case 'multiple-rest': Ignored, when multibar rests are enabled for rendering this info shouldn't matter. - case 'measure-repeat': - if (!midBar) { - let simileMark: SimileMark | null = null; - switch (c.getAttribute('type')) { - case 'start': - switch (Number.parseInt(c.getAttribute('slashes', '1'), 10)) { - case 1: - simileMark = SimileMark.Simple; - break; - case 2: - simileMark = SimileMark.FirstOfDouble; - break; - default: - // not supported - break; - } - break; - case 'stop': - simileMark = null; - break; - } - - if (element.attributes.has('number')) { - this._simileMarkPerStaff = this._simileMarkPerStaff ?? new Map(); - const staff = Number.parseInt(element.attributes.get('number')!, 10) - 1; - if (simileMark == null) { - this._simileMarkPerStaff!.delete(staff); - } else { - this._simileMarkPerStaff!.set(staff, simileMark!); - } - } else { - this._simileMarkAllStaves = simileMark; - } - } - - break; - // case 'beat-repeat': Not supported - case 'slash': - // use-stems: not supported - switch (c.getAttribute('type')) { - case 'start': - this._isBeatSlash = true; - break; - case 'stop': - this._isBeatSlash = false; - break; - } - break; - } + private _parseMeasureStyle(element: XmlNode, masterBar: MasterBar, track: Track, midBar: boolean) { + const events: MusicXmlSpanEvent[] = []; + MusicXmlSpanReader.readMeasureStyle(element, midBar, events); + if (events.length > 0) { + // the number of the measure style is the staff it applies to + const staffIndex = element.attributes.has('number') + ? Number.parseInt(element.attributes.get('number')!, 10) - 1 + : -1; + this._indexToTrackInfo + .get(track.index)! + .spans.process(events, this._placement(masterBar, staffIndex, '', 0)); } } @@ -2142,7 +2117,8 @@ export class MusicXmlImporter extends ScoreImporter { private _parseDirection(element: XmlNode, masterBar: MasterBar, track: Track) { const directionTypes: XmlNode[] = []; let offset: number | null = null; - // let voiceIndex = -1; + let offsetAffectsSound = false; + let voice = ''; let staffIndex = -1; let tempo = -1; let sound: XmlNode | null = null; @@ -2168,11 +2144,12 @@ export class MusicXmlImporter extends ScoreImporter { } case 'offset': offset = Number.parseFloat(c.innerText); + offsetAffectsSound = c.getAttribute('sound', 'no') === 'yes'; break; // case 'footnote': Ignored // case 'level': Ignored case 'voice': - // voiceIndex = parseInt(c.innerText) - 1; + voice = c.innerText.trim(); break; case 'staff': staffIndex = Number.parseInt(c.innerText, 10) - 1; @@ -2188,7 +2165,8 @@ export class MusicXmlImporter extends ScoreImporter { } } - let staff: Staff | null = null; + // the staff of directions without staff, e.g. for pedal markers + let staff: Staff; if (staffIndex >= 0) { staff = this._getOrCreateStaff(track, staffIndex); } else if (this._lastBeat !== null) { @@ -2196,24 +2174,19 @@ export class MusicXmlImporter extends ScoreImporter { } else { staff = this._getOrCreateStaff(track, 0); } + this._getOrCreateBar(staff, masterBar); - const bar = staff ? this._getOrCreateBar(staff, masterBar) : null; - - const getRatioPosition = () => { - let timelyPosition = this._musicalPosition; - if (offset !== null) { - timelyPosition += offset!; - } - - const totalDuration = masterBar.calculateDuration(false); - return timelyPosition / totalDuration; - }; + const offsetTicks = offset !== null ? this._musicXmlDivisionsToAlphaTabTicks(offset!) : 0; + const totalDuration = masterBar.calculateDuration(false); + // the offset always affects the display, the sound only if specified + const displayRatioPosition = (this._musicalPosition + offsetTicks) / totalDuration; + const soundRatioPosition = offsetAffectsSound ? displayRatioPosition : this._musicalPosition / totalDuration; if (tempo > 0) { const tempoAutomation = new Automation(); tempoAutomation.type = AutomationType.Tempo; tempoAutomation.value = tempo; - tempoAutomation.ratioPosition = getRatioPosition(); + tempoAutomation.ratioPosition = soundRatioPosition; if (!this._hasSameTempo(masterBar, tempoAutomation)) { masterBar.tempoAutomations.push(tempoAutomation); @@ -2221,6 +2194,8 @@ export class MusicXmlImporter extends ScoreImporter { } let previousWords: string = ''; + const spanEvents: MusicXmlSpanEvent[] = []; + const pedalEvents: MusicXmlSpanEvent[] = []; for (const direction of directionTypes) { switch (direction.localName) { @@ -2245,18 +2220,7 @@ export class MusicXmlImporter extends ScoreImporter { break; // case 'symbol': Not supported case 'wedge': - switch (direction.getAttribute('type')) { - case 'crescendo': - this._nextBeatCrescendo = CrescendoType.Crescendo; - break; - case 'diminuendo': - this._nextBeatCrescendo = CrescendoType.Decrescendo; - break; - // case 'continue': Ignore - case 'stop': - this._nextBeatCrescendo = null; - break; - } + MusicXmlSpanReader.readWedge(direction, spanEvents); break; case 'dynamics': const newDynamics = this._parseDynamics(direction); @@ -2266,41 +2230,23 @@ export class MusicXmlImporter extends ScoreImporter { } break; case 'dashes': - const type = direction.getAttribute('type', 'start'); - switch (previousWords) { - case 'LetRing': - this._nextBeatLetRing = type === 'start' || type === 'continue'; - break; - case 'P.M.': - this._nextBeatPalmMute = type === 'start' || type === 'continue'; - break; + case 'bracket': + if (this._parseLine(direction, previousWords, track, masterBar.index, spanEvents)) { + // the words are the label of the line, unknown lines keep their words as text + previousWords = ''; } - previousWords = ''; break; - // case 'bracket': Ignored case 'pedal': - const pedal = this._parsePedal(direction); - if (pedal && bar) { - pedal.ratioPosition = getRatioPosition(); - - // up or holds without a previous down/hold? - const canHaveUp = - bar.sustainPedals.length > 0 && - bar.sustainPedals[bar.sustainPedals.length - 1].pedalType !== SustainPedalMarkerType.Up; - - if (pedal.pedalType !== SustainPedalMarkerType.Up || canHaveUp) { - bar.sustainPedals.push(pedal); - } - } + MusicXmlSpanReader.readPedal(direction, pedalEvents); break; case 'metronome': // is the authoritative playback tempo, the metronome is only its visual counterpart if (tempo <= 0) { - this._parseMetronome(direction, masterBar, getRatioPosition()); + this._parseMetronome(direction, masterBar, displayRatioPosition); } break; case 'octave-shift': - this._nextBeatOttavia = this._parseOctaveShift(direction); + MusicXmlSpanReader.readOctaveShift(direction, spanEvents); break; // case 'harp-pedals': Not supported // case 'damp': Not supported @@ -2317,44 +2263,55 @@ export class MusicXmlImporter extends ScoreImporter { } } + // directions without staff apply to all staves of the part, pedal markers are placed on a single staff + const spans = this._indexToTrackInfo.get(track.index)!.spans; + if (spanEvents.length > 0) { + spans.process(spanEvents, this._placement(masterBar, staffIndex, voice, offsetTicks)); + } + if (pedalEvents.length > 0) { + spans.process(pedalEvents, this._placement(masterBar, staff.index, voice, offsetTicks)); + } + // 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; + this._nextBeatTextTrackIndex = track.index; + this._nextBeatTextBarIndex = masterBar.index; + this._nextBeatTextTicks = this._musicalPosition; } } - private _parseOctaveShift(element: XmlNode): Ottavia | null { - const type = element.getAttribute('type'); - const size = Number.parseInt(element.getAttribute('size', '8'), 10); - switch (size) { - case 15: - switch (type) { - case 'up': - return Ottavia._15mb; - case 'down': - return Ottavia._15ma; - case 'stop': - return Ottavia.Regular; - case 'continue': - return this._nextBeatOttavia; - } - break; - case 8: - switch (type) { - case 'up': - return Ottavia._8vb; - case 'down': - return Ottavia._8va; - case 'stop': - return Ottavia.Regular; - case 'continue': - return this._nextBeatOttavia; - } - break; + /** + * Reads a `` or `` line. + * @returns Whether the words were used as the label of the line. + */ + private _parseLine( + element: XmlNode, + words: string, + track: Track, + barIndex: number, + spanEvents: MusicXmlSpanEvent[] + ): boolean { + // the label can be a separate direction at the same position (e.g. music21) + const usesPendingText = + words.length === 0 && + this._nextBeatText !== null && + this._nextBeatTextTrackIndex === track.index && + this._nextBeatTextBarIndex === barIndex && + this._nextBeatTextTicks === this._musicalPosition && + element.getAttribute('type', 'start') === 'start'; + const label = usesPendingText ? this._nextBeatText! : words; + if (MusicXmlSpanReader.lineKind(label) === MusicXmlSpanKind.None) { + MusicXmlSpanReader.readLine(element, '', spanEvents); + return false; } - - return null; + MusicXmlSpanReader.readLine(element, label, spanEvents); + if (usesPendingText) { + this._nextBeatText = null; + } + return true; } + private _parseMetronome(element: XmlNode, masterBar: MasterBar, ratioPosition: number) { let unit: Duration | null = null; let dots = 0; @@ -2400,28 +2357,6 @@ export class MusicXmlImporter extends ScoreImporter { return false; } - private _parsePedal(element: XmlNode): SustainPedalMarker | null { - const marker = new SustainPedalMarker(); - switch (element.getAttribute('type')) { - case 'start': - marker.pedalType = SustainPedalMarkerType.Down; - break; - case 'stop': - marker.pedalType = SustainPedalMarkerType.Up; - break; - // case 'sostenuto': Not supported - // case 'change': Not supported - case 'continue': - marker.pedalType = SustainPedalMarkerType.Hold; - break; - // case 'discontinue': Not supported - // case 'resume': Not supported - default: - return null; - } - return marker; - } - private _parseDynamics(element: XmlNode) { for (const c of element.childElements()) { // we are having the same enum names as MusicXML uses as tagnames @@ -2731,6 +2666,12 @@ export class MusicXmlImporter extends ScoreImporter { durationInTicks = this._parseDuration(c); break; // case 'tie': Ignored -> "tie" is sound, "tied" is notation + case 'tied': + // not valid at this place but written for let ring by TuxGuitar + if (note) { + this._parseTied(c, note, voiceRaw); + } + break; // case 'instrument': handled in pass 1 // case 'footnote': Ignored @@ -2797,7 +2738,7 @@ export class MusicXmlImporter extends ScoreImporter { } break; case 'notations': - this._parseNotations(c, note, beat); + this._parseNotations(c, note, beat, voiceRaw); break; case 'lyric': this._parseLyric(c, beat, track); @@ -2908,11 +2849,9 @@ export class MusicXmlImporter extends ScoreImporter { } const newBeat = new Beat(); + this._beats.push(newBeat); newBeat.isEmpty = false; newBeat.dynamics = this._currentDynamics; - if (this._isBeatSlash) { - newBeat.slashed = true; - } const automations = this._nextBeatAutomations; this._nextBeatAutomations = null; @@ -2931,20 +2870,6 @@ export class MusicXmlImporter extends ScoreImporter { } } - const crescendo = this._nextBeatCrescendo; - // Don't reset until 'stop' this._nextBeatCrescendo = null; - if (crescendo !== null) { - newBeat.crescendo = crescendo; - } - - const ottavia = this._nextBeatOttavia; - // Don't set until 'stop' - if (ottavia !== null) { - newBeat.ottava = ottavia; - } - - newBeat.isLetRing = this._nextBeatLetRing; - newBeat.isPalmMute = this._nextBeatPalmMute; if (this._nextBeatText) { newBeat.text = this._nextBeatText; this._nextBeatText = null; @@ -2977,6 +2902,10 @@ export class MusicXmlImporter extends ScoreImporter { return; } + // the note was attached to its beat before the string was known, register it now for the lookups by string + // (e.g. hammer-on destinations, let ring ending on the same string) + note.beat.noteStringLookup.set(note.string, note); + // dead notes are commonly written without fret (e.g. Guitar Pro 5), the string still defines the tab position if (!note.isStringed && note.isDead && note.beat.voice.bar.staff.tuning.length > 0) { note.fret = Math.max(0, this._calculatePitchedNoteValue(note) - note.stringTuning); @@ -3567,35 +3496,35 @@ export class MusicXmlImporter extends ScoreImporter { } } - private _parseNotations(element: XmlNode, note: Note | null, beat: Beat) { + private _parseNotations(element: XmlNode, note: Note | null, beat: Beat, voice: string) { for (const c of element.childElements()) { switch (c.localName) { // case 'footnote': Ignored // case 'level': Ignored case 'tied': if (note) { - this._parseTied(c, note, beat.voice.bar.staff); + this._parseTied(c, note, voice); } break; case 'slur': if (note) { - this._parseSlur(c, note); + this._parseNoteLink(c, MusicXmlNoteLinkType.Slur, note, voice); } break; // case 'tuplet': Handled via time-modification case 'glissando': if (note) { - this._parseGlissando(c, note); + this._parseNoteLink(c, MusicXmlNoteLinkType.Glissando, note, voice); } break; case 'slide': if (note) { - this._parseSlide(c, note); + this._parseNoteLink(c, MusicXmlNoteLinkType.Slide, note, voice); } break; case 'ornaments': if (note) { - this._parseOrnaments(c, note); + this._parseOrnaments(c, note, voice); } break; case 'technical': @@ -3626,56 +3555,33 @@ export class MusicXmlImporter extends ScoreImporter { } } - private _getStaffContext(staff: Staff) { - if (!this._staffToContext.has(staff)) { - const context = new StaffContext(); - this._staffToContext.set(staff, context); - return context; - } - return this._staffToContext.get(staff)!; + private _noteLinks(note: Note): MusicXmlNoteLinks { + return this._indexToTrackInfo.get(note.beat.voice.bar.staff.track.index)!.noteLinks; } - private _parseGlissando(element: XmlNode, note: Note) { - const type = element.getAttribute('type'); + /** + * Reads an element linking notes (e.g. ``, ``) identified by its number. + */ + private _parseNoteLink(element: XmlNode, type: MusicXmlNoteLinkType, note: Note, voice: string) { const number = element.getAttribute('number', '1'); - - const context = this._getStaffContext(note.beat.voice.bar.staff); - - switch (type) { + switch (element.getAttribute('type')) { case 'start': - context.slideOrigins.set(number, note); + this._noteLinks(note).start(type, number, note, voice, 0); break; case 'stop': - if (context.slideOrigins.has(number)) { - const origin = context.slideOrigins.get(number)!; - origin.slideTarget = note; - note.slideOrigin = origin; - origin.slideOutType = SlideOutType.Shift; // TODO: wavy lines - } + this._noteLinks(note).stop(type, number, note, voice); break; + // case 'continue': no meaning for the link } } - private _parseSlur(element: XmlNode, note: Note) { - const slurNumber: string = element.getAttribute('number', '1'); - - const context = this._getStaffContext(note.beat.voice.bar.staff); - - switch (element.getAttribute('type')) { - case 'start': - context.slurStarts.set(slurNumber, note); - break; - case 'stop': - if (context.slurStarts.has(slurNumber)) { - note.isSlurDestination = true; - const slurStart = context.slurStarts.get(slurNumber)!; - slurStart.slurDestination = note; - note.slurOrigin = slurStart; - - context.slurStarts.delete(slurNumber); - } - break; + private _getStaffContext(staff: Staff) { + if (!this._staffToContext.has(staff)) { + const context = new StaffContext(); + this._staffToContext.set(staff, context); + return context; } + return this._staffToContext.get(staff)!; } private _parseArpeggiate(element: XmlNode, beat: Beat) { @@ -3797,7 +3703,8 @@ export class MusicXmlImporter extends ScoreImporter { break; case 'hammer-on': case 'pull-off': - if (note) { + // only the start is the origin, the destination (stop) is resolved by the model + if (note && c.getAttribute('type', 'start') === 'start') { note.isHammerPullOrigin = true; } break; @@ -3936,20 +3843,30 @@ export class MusicXmlImporter extends ScoreImporter { return Fingers.Unknown; } - private _currentTrillStep: number = -1; + private _parseOrnaments(element: XmlNode, note: Note, voice: string): void { + // a trill mark defines the meaning of the wavy line, without it the line is a vibrato + let trillStep = -1; + for (const c of element.childElements()) { + if (c.localName === 'trill-mark') { + switch (c.getAttribute('trill-step', 'whole')) { + case 'half': + trillStep = 1; + break; + case 'unison': + trillStep = 0; + break; + default: + trillStep = 2; + break; + } + } + } - private _parseOrnaments(element: XmlNode, note: Note): void { - let currentTrillStep = -1; + const links = this._noteLinks(note); + let hasLine = false; for (const c of element.childElements()) { switch (c.localName) { - case 'trill-mark': - currentTrillStep = Number.parseInt(c.getAttribute('trill-step', '2'), 10); - if (note.isStringed) { - note.trillValue = note.stringTuning + currentTrillStep; - } else if (!note.isPercussion) { - note.trillValue = note.calculateRealValue(false, false) + currentTrillStep; - } - break; + // case 'trill-mark': handled above case 'turn': note.ornament = NoteOrnament.Turn; break; @@ -3962,20 +3879,19 @@ export class MusicXmlImporter extends ScoreImporter { // case 'inverted-vertical-turn': Not supported // case 'shake': Not supported case 'wavy-line': - if (currentTrillStep > 0) { - if (c.getAttribute('type') === 'start') { - this._currentTrillStep = currentTrillStep; - } - } else if (this._currentTrillStep > 0) { - if (c.getAttribute('type') === 'stop') { - this._currentTrillStep = -1; - } else if (note.isStringed) { - note.trillValue = note.stringTuning + this._currentTrillStep; - } else if (!note.isPercussion) { - note.trillValue = note.calculateRealValue(false, false) + this._currentTrillStep; - } - } else { - note.vibrato = VibratoType.Slight; + const number = c.getAttribute('number', '1'); + switch (c.getAttribute('type')) { + case 'start': + links.start(MusicXmlNoteLinkType.WavyLine, number, note, voice, trillStep); + hasLine = true; + break; + case 'continue': + links.continue(MusicXmlNoteLinkType.WavyLine, number, note, voice, trillStep); + hasLine = true; + break; + case 'stop': + links.stop(MusicXmlNoteLinkType.WavyLine, number, note, voice); + break; } break; case 'mordent': @@ -4002,74 +3918,28 @@ export class MusicXmlImporter extends ScoreImporter { // case 'other-element': Not supported } } - } - - private _parseSlide(element: XmlNode, note: Note) { - const type = element.getAttribute('type'); - const number = element.getAttribute('number', '1'); - const context = this._getStaffContext(note.beat.voice.bar.staff); + // a trill on this note only + if (trillStep >= 0 && !hasLine) { + links.single(MusicXmlNoteLinkType.WavyLine, note, trillStep); + } + } - switch (type) { + private _parseTied(element: XmlNode, note: Note, voice: string): void { + // ties are identified by the pitch, the number is rarely given (see MusicXML spec) + const pitch = `${this._calculatePitchedNoteValue(note)}`; + switch (element.getAttribute('type')) { case 'start': - context.slideOrigins.set(number, note); + this._noteLinks(note).start(MusicXmlNoteLinkType.Tie, pitch, note, voice, 0); break; case 'stop': - if (context.slideOrigins.has(number)) { - const origin = context.slideOrigins.get(number)!; - origin.slideTarget = note; - note.slideOrigin = origin; - origin.slideOutType = SlideOutType.Shift; - } + this._noteLinks(note).stop(MusicXmlNoteLinkType.Tie, pitch, note, voice); break; - } - } - - private _parseTied(element: XmlNode, note: Note, staff: Staff): void { - const type = element.getAttribute('type'); - const number = element.getAttribute('number', ''); - - const context = this._getStaffContext(staff); - - if (type === 'start') { - if (number) { - // start without end - if (context.tieStartIds.has(number)) { - const unclosed = context.tieStartIds.get(number)!; - context.tieStarts.delete(unclosed); - } - - context.tieStartIds.set(number, note); - } - - context.tieStarts.add(note); - } else if (type === 'stop' && !note.isTieDestination) { - let tieOrigin: Note | null = null; - if (number) { - if (!context.tieStartIds.has(number)) { - return; - } - - tieOrigin = context.tieStartIds.get(number)!; - context.tieStartIds.delete(number); - context.tieStarts.delete(note); - } else { - const realValue = this._calculatePitchedNoteValue(note); - for (const t of context.tieStarts) { - if (this._calculatePitchedNoteValue(t) === realValue) { - tieOrigin = t; - context.tieStarts.delete(tieOrigin); - break; - } - } - } - - if (!tieOrigin) { - return; - } - - note.isTieDestination = true; - note.tieOrigin = tieOrigin; + // an undamped note (e.g. MuseScore l.v., TuxGuitar let ring) + case 'let-ring': + note.isLetRing = true; + break; + // case 'continue': no meaning for the playback } } diff --git a/packages/alphatab/src/importer/MusicXmlNoteLinks.ts b/packages/alphatab/src/importer/MusicXmlNoteLinks.ts new file mode 100644 index 000000000..2cc2c5e2f --- /dev/null +++ b/packages/alphatab/src/importer/MusicXmlNoteLinks.ts @@ -0,0 +1,288 @@ +import { Logger } from '@coderline/alphatab/Logger'; +import type { Beat } from '@coderline/alphatab/model/Beat'; +import { GraceType } from '@coderline/alphatab/model/GraceType'; +import type { Note } from '@coderline/alphatab/model/Note'; +import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; +import { VibratoType } from '@coderline/alphatab/model/VibratoType'; + +/** + * @internal + */ +export enum MusicXmlNoteLinkType { + Tie = 0, + Slur = 1, + Slide = 2, + Glissando = 3, + /** + * A trill or vibrato line from the origin to the destination note within the voice. + * The value is the trill step, -1 for a vibrato line (wavy line without trill mark). + */ + WavyLine = 4 +} + +/** + * @internal + * @record + */ +export interface MusicXmlNoteLink { + type: MusicXmlNoteLinkType; + /** + * Identifies concurrent links of the same type: the MusicXML number-level, the pitch for ties. + */ + key: string; + voice: string; + value: number; + origin: Note; + destination: Note | null; +} + +/** + * Pairs the MusicXML elements linking notes of a part (e.g. ``, ``, ``) and applies them. + * Links are within a staff, the model resolves the origin of a link before its destination. + * @internal + */ +export class MusicXmlNoteLinks { + private _open: MusicXmlNoteLink[] = []; + private _links: MusicXmlNoteLink[] = []; + + /** + * @param voice The raw MusicXML voice of the note. + */ + public start(type: MusicXmlNoteLinkType, key: string, origin: Note, voice: string, value: number) { + // the same link started again without stop (notes of the same chord can start multiple links) + for (let i = 0; i < this._open.length; i++) { + const open = this._open[i]; + if ( + open.type === type && + open.key === key && + open.voice === voice && + open.origin.beat.voice.bar.staff === origin.beat.voice.bar.staff && + open.origin.beat !== origin.beat + ) { + this._open.splice(i, 1); + this._unclosed(open); + break; + } + } + this._open.push({ type: type, key: key, voice: voice, value: value, origin: origin, destination: null }); + } + + /** + * A continuation (e.g. across system breaks), starts the link if it was not started. + */ + public continue(type: MusicXmlNoteLinkType, key: string, note: Note, voice: string, value: number) { + for (const open of this._open) { + if ( + open.type === type && + open.key === key && + open.origin.beat.voice.bar.staff === note.beat.voice.bar.staff + ) { + return; + } + } + this.start(type, key, note, voice, value); + } + + public stop(type: MusicXmlNoteLinkType, key: string, destination: Note, voice: string) { + let best = -1; + let bestScore = -1; + for (let i = 0; i < this._open.length; i++) { + const open = this._open[i]; + if ( + open.type !== type || + open.key !== key || + open.origin.beat.voice.bar.staff !== destination.beat.voice.bar.staff + ) { + continue; + } + // unisons on different strings + const score = (open.voice === voice ? 2 : 0) + (open.origin.string === destination.string ? 1 : 0); + // ties pair in order (e.g. chords), others with the most recent start + if (score > bestScore || (score === bestScore && type !== MusicXmlNoteLinkType.Tie)) { + best = i; + bestScore = score; + } + } + if (best !== -1) { + const link = this._open[best]; + this._open.splice(best, 1); + link.destination = destination; + this._links.push(link); + } + } + + /** + * Adds a link on a single note (e.g. a trill mark without line). + */ + public single(type: MusicXmlNoteLinkType, note: Note, value: number) { + this._links.push({ type: type, key: '', voice: '', value: value, origin: note, destination: note }); + } + + public apply() { + for (const open of this._open) { + this._unclosed(open); + } + this._open = []; + + for (const link of this._links) { + const origin = link.origin; + switch (link.type) { + case MusicXmlNoteLinkType.Tie: + const destination = MusicXmlNoteLinks._tieDestination(origin, link.destination); + if (destination !== null && MusicXmlNoteLinks._isLinkable(origin, destination!)) { + destination!.isTieDestination = true; + destination!.tieOrigin = origin; + } + break; + case MusicXmlNoteLinkType.Slur: + if (MusicXmlNoteLinks._isLinkable(origin, link.destination!)) { + MusicXmlNoteLinks._applySlur(origin, link.destination!); + } + break; + case MusicXmlNoteLinkType.Slide: + case MusicXmlNoteLinkType.Glissando: + // glissandos are shift slides, wavy lines are not supported + if (MusicXmlNoteLinks._isLinkable(origin, link.destination!)) { + origin.slideTarget = link.destination!; + link.destination!.slideOrigin = origin; + origin.slideOutType = SlideOutType.Shift; + } + break; + case MusicXmlNoteLinkType.WavyLine: + MusicXmlNoteLinks._applyLine(link); + break; + } + } + this._links = []; + } + + private _unclosed(link: MusicXmlNoteLink) { + switch (link.type) { + // a tie without end is tied to the next note (resolved when applied, e.g. MuseScore import) + case MusicXmlNoteLinkType.Tie: + this._links.push(link); + break; + // a line without end covers its start note + case MusicXmlNoteLinkType.WavyLine: + link.destination = link.origin; + this._links.push(link); + break; + // other links without end have no meaning + } + } + + private static _applySlur(origin: Note, destination: Note) { + // hammer-ons and pull-offs are drawn with their own slur, exporters write both (e.g. MuseScore) + if ( + origin.isHammerPullOrigin && + destination.string === origin.string && + MusicXmlNoteLinks._nextBeat(origin.beat, true) === destination.beat + ) { + return; + } + destination.isSlurDestination = true; + origin.slurDestination = destination; + destination.slurOrigin = origin; + } + + /** + * Applies a line to all notes from the origin to the destination within the voice. + */ + private static _applyLine(link: MusicXmlNoteLink) { + // a line ending in another voice covers its start note + const last = + link.destination!.beat.voice.index === link.origin.beat.voice.index + ? link.destination!.beat + : link.origin.beat; + let beat: Beat | null = link.origin.beat; + while (beat !== null && beat!.voice.bar.index <= last.voice.bar.index) { + for (const note of beat!.notes) { + if (note.isPercussion) { + continue; + } + if (link.value >= 0) { + note.trillValue = note.calculateRealValue(false, false) + link.value; + } else if (note.vibrato === VibratoType.None) { + note.vibrato = VibratoType.Slight; + } + } + beat = beat === last ? null : MusicXmlNoteLinks._nextBeat(beat!, false); + } + } + + /** + * A tie connects a note with the same pitch on the next beat of the voice. + * Ties without (or with an invalid) end are tied to that note if it exists. + */ + private static _tieDestination(origin: Note, destination: Note | null): Note | null { + const next = MusicXmlNoteLinks._nextBeat(origin.beat, origin.beat.graceType === GraceType.None); + if (destination !== null) { + // ties across voices are kept as written + if (destination!.beat.voice.index !== origin.beat.voice.index || destination!.beat === next) { + return destination; + } + } + if (next === null) { + return null; + } + + let candidate: Note | null = null; + for (const note of next!.notes) { + if (!note.isTieDestination && note.octave === origin.octave && note.tone === origin.tone) { + if (note.string === origin.string) { + return note; + } + if (candidate === null) { + candidate = note; + } + } + } + return candidate; + } + + /** + * The next beat in the voice, also across bars. + */ + private static _nextBeat(beat: Beat, skipGrace: boolean): Beat | null { + const voice = beat.voice; + let bar = voice.bar; + let beats = voice.beats; + let index = beats.indexOf(beat) + 1; + while (true) { + while (index < beats.length) { + if (!skipGrace || beats[index].graceType === GraceType.None) { + return beats[index]; + } + index++; + } + const staff = bar.staff; + if (bar.index + 1 >= staff.bars.length || voice.index >= staff.bars[bar.index + 1].voices.length) { + return null; + } + bar = staff.bars[bar.index + 1]; + beats = bar.voices[voice.index].beats; + index = 0; + } + } + + /** + * The model resolves the origin of a link before its destination (bar by bar, voice by voice). + */ + private static _isLinkable(origin: Note, destination: Note): boolean { + const originBeat = origin.beat; + const destinationBeat = destination.beat; + let isLinkable: boolean; + if (originBeat.voice.bar.index !== destinationBeat.voice.bar.index) { + isLinkable = originBeat.voice.bar.index < destinationBeat.voice.bar.index; + } else if (originBeat.voice.index !== destinationBeat.voice.index) { + isLinkable = originBeat.voice.index < destinationBeat.voice.index; + } else { + const beats = originBeat.voice.beats; + isLinkable = beats.indexOf(originBeat) < beats.indexOf(destinationBeat); + } + if (!isLinkable) { + Logger.warning('MusicXML', 'Ignoring link to a note before its origin'); + } + return isLinkable; + } +} diff --git a/packages/alphatab/src/importer/MusicXmlSpanApplier.ts b/packages/alphatab/src/importer/MusicXmlSpanApplier.ts new file mode 100644 index 000000000..092eaee37 --- /dev/null +++ b/packages/alphatab/src/importer/MusicXmlSpanApplier.ts @@ -0,0 +1,393 @@ +import { + type MusicXmlSpan, + MusicXmlSpanKind, + type MusicXmlSpanPlacement +} from '@coderline/alphatab/importer/MusicXmlSpans'; +import { type Bar, SustainPedalMarker, SustainPedalMarkerType } from '@coderline/alphatab/model/Bar'; +import { Beat } from '@coderline/alphatab/model/Beat'; +import { CrescendoType } from '@coderline/alphatab/model/CrescendoType'; +import { GraceType } from '@coderline/alphatab/model/GraceType'; +import type { MasterBar } from '@coderline/alphatab/model/MasterBar'; +import { Ottavia } from '@coderline/alphatab/model/Ottavia'; +import { SimileMark } from '@coderline/alphatab/model/SimileMark'; +import type { Staff } from '@coderline/alphatab/model/Staff'; +import type { Track } from '@coderline/alphatab/model/Track'; + +/** + * A boundary of a span, normalized to the bar it refers to. + * @internal + * @record + */ +interface MusicXmlSpanBound { + barIndex: number; + /** + * The ticks of the cursor the element is written at. + */ + ticks: number; + /** + * The position including the offset of the element. + */ + exactBarIndex: number; + exactTicks: number; + sequence: number; +} + +/** + * Applies the spans of a part to the model. + * + * A beat is covered by a span if: + * - the beat is next to the cursor the start or stop is written at, and on the covered side of it. Exporters write + * stops after the last covered note (e.g. MuseScore), or nudge them with an offset into the next note (e.g. Finale). + * - otherwise (e.g. other voices) if its middle is within the span, including the offsets. + * - beats without duration (grace notes) at a boundary are ordered as in the document. + * @internal + */ +export class MusicXmlSpanApplier { + private _track: Track; + private _masterBars: MasterBar[]; + private _beats: Beat[]; + private _beatSequence: Map = new Map(); + private _voiceIndexOf: (staff: Staff, voice: string) => number; + private _octaveStopInLastNote: boolean; + private _barLengths: number[] = []; + private _pedalBars: Bar[] = []; + + /** + * @param beats All beats in document order (see {@link MusicXmlSpanPosition.sequence}). + * @param voiceIndexOf Resolves a raw MusicXML voice to the index of the voice on the staff, -1 if not existing. + * @param octaveStopInLastNote Whether octave shift stops are written within the last shifted note (e.g. Finale). + */ + public constructor( + track: Track, + masterBars: MasterBar[], + beats: Beat[], + voiceIndexOf: (staff: Staff, voice: string) => number, + octaveStopInLastNote: boolean + ) { + this._track = track; + this._masterBars = masterBars; + this._beats = beats; + for (let i = 0; i < beats.length; i++) { + if (beats[i].voice.bar.staff.track === track) { + this._beatSequence.set(beats[i], i); + } + } + this._voiceIndexOf = voiceIndexOf; + this._octaveStopInLastNote = octaveStopInLastNote; + + // bars can be fuller or emptier than their time signature (e.g. anacrusis, cadenza) + for (const masterBar of masterBars) { + let length = 0; + for (const staff of track.staves) { + if (masterBar.index < staff.bars.length) { + for (const voice of staff.bars[masterBar.index].voices) { + if (voice.beats.length > 0) { + length = Math.max(length, voice.beats[voice.beats.length - 1].displayEnd); + } + } + } + } + this._barLengths.push(length > 0 ? length : masterBar.calculateDuration(false)); + } + } + + public apply(spans: MusicXmlSpan[]) { + // later spans override the values of earlier ones + const ordered: MusicXmlSpan[] = []; + for (const span of spans) { + let index = ordered.length; + while (index > 0 && this._compareStart(ordered[index - 1], span) > 0) { + index--; + } + ordered.splice(index, 0, span); + } + + for (const span of ordered) { + switch (span.kind) { + case MusicXmlSpanKind.SustainPedal: + this._applyPedal(span); + break; + case MusicXmlSpanKind.SimileSimple: + case MusicXmlSpanKind.SimileDouble: + this._applySimile(span); + break; + default: + this._applyBeats(span); + break; + } + } + + for (const bar of this._pedalBars) { + // a change lifts before it retakes + bar.sustainPedals.sort((a, b) => + a.ratioPosition !== b.ratioPosition + ? a.ratioPosition - b.ratioPosition + : MusicXmlSpanApplier._pedalOrder(a) - MusicXmlSpanApplier._pedalOrder(b) + ); + } + } + + private _applyBeats(span: MusicXmlSpan) { + const start = this._bound(span.start!); + let writtenBeat: Beat | null = null; + if (span.kind === MusicXmlSpanKind.Crescendo || span.kind === MusicXmlSpanKind.Decrescendo) { + // dynamics are shown once per staff, on the voice they are written at + writtenBeat = this._writtenBeat(span.start!); + } + + for (const staff of this._track.staves) { + // a direction without staff in a part with multiple staves applies to all of them (e.g. notation and tablature) + if (span.start!.staffIndex !== -1 && staff.index !== span.start!.staffIndex) { + continue; + } + + let voiceIndex = -1; + if (span.start!.voice.length > 0) { + voiceIndex = this._voiceIndexOf(staff, span.start!.voice); + if (voiceIndex === -1) { + continue; + } + } else if (writtenBeat !== null && writtenBeat!.voice.bar.staff === staff) { + voiceIndex = writtenBeat!.voice.index; + } + + let end: MusicXmlSpanBound | null = span.end !== null ? this._bound(span.end!) : null; + if (end !== null && this._octaveStopInLastNote && MusicXmlSpanApplier._isOttava(span.kind)) { + end = this._endOfBeatAt(staff, voiceIndex, end!); + } + + const lastBar = end === null ? staff.bars.length - 1 : Math.min(end!.barIndex, staff.bars.length - 1); + for (let barIndex = start.barIndex; barIndex <= lastBar; barIndex++) { + const voices = staff.bars[barIndex].voices; + for (let v = 0; v < voices.length; v++) { + if (voiceIndex === -1 || v === voiceIndex) { + for (const beat of voices[v].beats) { + if (this._covers(start, end, barIndex, beat)) { + MusicXmlSpanApplier._applyBeat(span.kind, beat); + } + } + } + } + } + } + } + + private static _applyBeat(kind: MusicXmlSpanKind, beat: Beat) { + switch (kind) { + case MusicXmlSpanKind.LetRing: + for (const note of beat.notes) { + note.isLetRing = true; + } + break; + case MusicXmlSpanKind.PalmMute: + for (const note of beat.notes) { + note.isPalmMute = true; + } + break; + case MusicXmlSpanKind.Crescendo: + beat.crescendo = CrescendoType.Crescendo; + break; + case MusicXmlSpanKind.Decrescendo: + beat.crescendo = CrescendoType.Decrescendo; + break; + case MusicXmlSpanKind.Ottava8va: + beat.ottava = Ottavia._8va; + break; + case MusicXmlSpanKind.Ottava8vb: + beat.ottava = Ottavia._8vb; + break; + case MusicXmlSpanKind.Ottava15ma: + beat.ottava = Ottavia._15ma; + break; + case MusicXmlSpanKind.Ottava15mb: + beat.ottava = Ottavia._15mb; + break; + case MusicXmlSpanKind.Slash: + beat.slashed = true; + break; + } + } + + private _covers(start: MusicXmlSpanBound, end: MusicXmlSpanBound | null, barIndex: number, beat: Beat): boolean { + const sequence = this._beatSequence.has(beat) ? this._beatSequence.get(beat)! : -1; + if (beat.displayEnd <= beat.displayStart) { + return ( + MusicXmlSpanApplier._isAtOrAfter(start, barIndex, beat, sequence) && + (end === null || !MusicXmlSpanApplier._isAtOrAfter(end!, barIndex, beat, sequence)) + ); + } + + const middle = (beat.displayStart + beat.displayEnd) / 2; + const isAfterStart = MusicXmlSpanApplier._isBoundary(start, barIndex, beat) + ? MusicXmlSpanApplier._isAtOrAfter(start, barIndex, beat, sequence) + : MusicXmlSpanApplier._compare(start.exactBarIndex, start.exactTicks, barIndex, middle) < 0; + if (!isAfterStart || end === null) { + return isAfterStart; + } + return MusicXmlSpanApplier._isBoundary(end!, barIndex, beat) + ? !MusicXmlSpanApplier._isAtOrAfter(end!, barIndex, beat, sequence) + : MusicXmlSpanApplier._compare(end!.exactBarIndex, end!.exactTicks, barIndex, middle) > 0; + } + + private static _isBoundary(bound: MusicXmlSpanBound, barIndex: number, beat: Beat): boolean { + return bound.barIndex === barIndex && (bound.ticks === beat.displayStart || bound.ticks === beat.displayEnd); + } + + private static _isAtOrAfter(bound: MusicXmlSpanBound, barIndex: number, beat: Beat, sequence: number): boolean { + const compare = MusicXmlSpanApplier._compare(bound.barIndex, bound.ticks, barIndex, beat.displayStart); + if (compare !== 0) { + return compare < 0; + } + // beats without duration (grace notes) at the same position are ordered as in the document + return beat.graceType === GraceType.None || sequence < 0 || sequence >= bound.sequence; + } + + private static _compare(barIndexA: number, ticksA: number, barIndexB: number, ticksB: number): number { + return barIndexA !== barIndexB ? barIndexA - barIndexB : ticksA - ticksB; + } + + private static _isOttava(kind: MusicXmlSpanKind): boolean { + return ( + kind === MusicXmlSpanKind.Ottava8va || + kind === MusicXmlSpanKind.Ottava8vb || + kind === MusicXmlSpanKind.Ottava15ma || + kind === MusicXmlSpanKind.Ottava15mb + ); + } + + /** + * The end of the beats containing the exact position (stops written within the last covered note). + */ + private _endOfBeatAt(staff: Staff, voiceIndex: number, bound: MusicXmlSpanBound): MusicXmlSpanBound { + if (bound.exactBarIndex >= staff.bars.length) { + return bound; + } + let end = -1; + const voices = staff.bars[bound.exactBarIndex].voices; + for (let v = 0; v < voices.length; v++) { + if (voiceIndex === -1 || v === voiceIndex) { + for (const beat of voices[v].beats) { + if (beat.displayStart <= bound.exactTicks && bound.exactTicks < beat.displayEnd) { + end = Math.max(end, beat.displayEnd); + } + } + } + } + if (end === -1) { + return bound; + } + return { + barIndex: bound.exactBarIndex, + ticks: end, + exactBarIndex: bound.exactBarIndex, + exactTicks: end, + sequence: bound.sequence + }; + } + + /** + * The beat the element is written at: the beat following it in the document. + */ + private _writtenBeat(placement: MusicXmlSpanPlacement): Beat | null { + const sequence = placement.position.sequence; + if (sequence >= this._beats.length) { + return null; + } + const beat = this._beats[sequence]; + return beat.voice.bar.staff.track === this._track ? beat : null; + } + + private _applyPedal(span: MusicXmlSpan) { + // pedal markers are placed on a single staff at their exact position + const staffIndex = span.start!.staffIndex === -1 ? 0 : span.start!.staffIndex; + if (staffIndex >= this._track.staves.length) { + return; + } + const staff = this._track.staves[staffIndex]; + this._addPedalMarker(staff, this._bound(span.start!), SustainPedalMarkerType.Down); + if (span.end !== null) { + this._addPedalMarker(staff, this._bound(span.end!), SustainPedalMarkerType.Up); + } + } + + private _addPedalMarker(staff: Staff, bound: MusicXmlSpanBound, type: SustainPedalMarkerType) { + if (bound.exactBarIndex >= staff.bars.length) { + return; + } + const bar = staff.bars[bound.exactBarIndex]; + const marker = new SustainPedalMarker(); + marker.pedalType = type; + marker.ratioPosition = + bound.exactTicks / + Math.max( + this._masterBars[bound.exactBarIndex].calculateDuration(false), + this._barLengths[bound.exactBarIndex] + ); + bar.sustainPedals.push(marker); + if (this._pedalBars.indexOf(bar) === -1) { + this._pedalBars.push(bar); + } + } + + private static _pedalOrder(marker: SustainPedalMarker): number { + return marker.pedalType === SustainPedalMarkerType.Up ? 0 : 1; + } + + private _applySimile(span: MusicXmlSpan) { + const startBar = span.start!.position.barIndex; + for (const staff of this._track.staves) { + if (span.start!.staffIndex !== -1 && staff.index !== span.start!.staffIndex) { + continue; + } + const endBar = + span.end === null ? staff.bars.length : Math.min(span.end!.position.barIndex, staff.bars.length); + for (let barIndex = startBar; barIndex < endBar; barIndex++) { + const bar = staff.bars[barIndex]; + if (span.kind === MusicXmlSpanKind.SimileSimple) { + bar.simileMark = SimileMark.Simple; + } else { + bar.simileMark = + (barIndex - startBar) % 2 === 0 ? SimileMark.FirstOfDouble : SimileMark.SecondOfDouble; + } + // the repeated content is played from the repeated bar + for (const voice of bar.voices) { + const emptyBeat = new Beat(); + emptyBeat.isEmpty = true; + voice.addBeat(emptyBeat); + } + } + } + } + + private _compareStart(a: MusicXmlSpan, b: MusicXmlSpan): number { + const pa = a.start!.position; + const pb = b.start!.position; + return MusicXmlSpanApplier._compare(pa.barIndex, pa.ticks, pb.barIndex, pb.ticks); + } + + /** + * Normalizes the placement to the bar it refers to (e.g. offsets crossing bar lines). + */ + private _bound(placement: MusicXmlSpanPlacement): MusicXmlSpanBound { + const position = placement.position; + const bound: MusicXmlSpanBound = { + barIndex: position.barIndex, + ticks: position.ticks, + exactBarIndex: position.barIndex, + exactTicks: position.ticks + position.offset, + sequence: position.sequence + }; + while (bound.exactTicks < 0 && bound.exactBarIndex > 0) { + bound.exactBarIndex--; + bound.exactTicks += this._barLengths[bound.exactBarIndex]; + } + while ( + bound.exactBarIndex < this._barLengths.length - 1 && + bound.exactTicks > this._barLengths[bound.exactBarIndex] + ) { + bound.exactTicks -= this._barLengths[bound.exactBarIndex]; + bound.exactBarIndex++; + } + return bound; + } +} diff --git a/packages/alphatab/src/importer/MusicXmlSpans.ts b/packages/alphatab/src/importer/MusicXmlSpans.ts new file mode 100644 index 000000000..00abc5895 --- /dev/null +++ b/packages/alphatab/src/importer/MusicXmlSpans.ts @@ -0,0 +1,466 @@ +import { Logger } from '@coderline/alphatab/Logger'; +import type { XmlNode } from '@coderline/alphatab/xml/XmlNode'; + +/** + * The MusicXML element encoding a span. Each element has its own number-level slots to pair starts and stops. + * @internal + */ +export enum MusicXmlSpanElement { + Dashes = 0, + Bracket = 1, + Wedge = 2, + OctaveShift = 3, + Pedal = 4, + Slash = 5, + MeasureRepeat = 6 +} + +/** + * @internal + */ +export enum MusicXmlSpanAction { + Start = 0, + Stop = 1, + /** + * Continuation across system breaks, starts the span if it was not started (e.g. partial exports). + */ + Continue = 2 +} + +/** + * The meaning of a span in the alphaTab model. + * @internal + */ +export enum MusicXmlSpanKind { + /** + * No effect on the model (e.g. unknown words on a line), the span is still paired to keep the numbers consistent. + */ + None = 0, + LetRing = 1, + PalmMute = 2, + Crescendo = 3, + Decrescendo = 4, + Ottava8va = 5, + Ottava8vb = 6, + Ottava15ma = 7, + Ottava15mb = 8, + SustainPedal = 9, + Slash = 10, + SimileSimple = 11, + SimileDouble = 12 +} + +/** + * A musical position within a part. + * @internal + * @record + */ +export interface MusicXmlSpanPosition { + /** + * The index of the master bar. + */ + barIndex: number; + /** + * The display ticks of the cursor (between the notes the element is written at), relative to the start of the bar. + */ + ticks: number; + /** + * The `` in display ticks, the element is shown at the cursor plus the offset. + */ + offset: number; + /** + * The number of beats created before this position in the document, the index of the following beat. + */ + sequence: number; +} + +/** + * Where a span element is written. + * @internal + * @record + */ +export interface MusicXmlSpanPlacement { + /** + * The index of the staff, -1 if not specified. + */ + staffIndex: number; + /** + * The raw MusicXML voice, empty if not specified. + */ + voice: string; + position: MusicXmlSpanPosition; +} + +/** + * A span start, stop or continuation read from the MusicXML. + * @internal + * @record + */ +export interface MusicXmlSpanEvent { + element: MusicXmlSpanElement; + action: MusicXmlSpanAction; + kind: MusicXmlSpanKind; + /** + * The MusicXML number-level identifying concurrent spans of the same element. + */ + number: string; +} + +/** + * A paired span. Open while only the start is known, a parked stop while only the end is known. + * @internal + * @record + */ +export interface MusicXmlSpan { + element: MusicXmlSpanElement; + kind: MusicXmlSpanKind; + number: string; + start: MusicXmlSpanPlacement | null; + /** + * The exclusive end, null if the span is not closed. + */ + end: MusicXmlSpanPlacement | null; +} + +/** + * Pairs the span starts and stops of a part. + * + * - The `number` identifies concurrent spans of the same element within the part (MusicXML number-level). + * Exporters reuse numbers across staves (e.g. MuseScore), hence spans on different staves never match. + * Lines were identified by their words before numbers were respected, hence a line of the same kind is preferred. + * - Start and stop refer to the score order, not the document order: a stop can appear before its start + * (e.g. in another voice after a ``). Such stops are parked until their start appears. + * @internal + */ +export class MusicXmlSpanTracker { + private _open: MusicXmlSpan[] = []; + private _parkedStops: MusicXmlSpan[] = []; + private _closed: MusicXmlSpan[] = []; + + /** + * Processes the span events of a single element (e.g. direction) written at the given placement. + */ + public process(events: MusicXmlSpanEvent[], placement: MusicXmlSpanPlacement) { + // an element ending one span and starting the next one, ends the first one first + for (const e of events) { + if (e.action === MusicXmlSpanAction.Stop) { + this._stop(e, placement); + } + } + for (const e of events) { + if ( + e.action === MusicXmlSpanAction.Start || + (e.action === MusicXmlSpanAction.Continue && + e.kind !== MusicXmlSpanKind.None && + MusicXmlSpanTracker._bestMatch(this._open, e, placement) === -1) + ) { + this._start(e, placement); + } + } + } + + /** + * Completes the tracking at the end of the part. + * @returns The spans with an effect on the model, unclosed spans run to the end of the part. + */ + public finish(): MusicXmlSpan[] { + for (const s of this._open) { + if (s.kind !== MusicXmlSpanKind.None) { + Logger.warning('MusicXML', `Span ${s.number} is not closed, it continues until the end of the part`); + this._closed.push(s); + } + } + const spans = this._closed; + this._open = []; + this._parkedStops = []; + this._closed = []; + return spans; + } + + private _stop(e: MusicXmlSpanEvent, placement: MusicXmlSpanPlacement) { + const index = MusicXmlSpanTracker._bestMatch(this._open, e, placement); + if (index === -1) { + this._parkedStops.push({ element: e.element, kind: e.kind, number: e.number, start: null, end: placement }); + return; + } + const span = this._open[index]; + this._open.splice(index, 1); + span.end = placement; + if (span.kind === MusicXmlSpanKind.None) { + span.kind = e.kind; + } + this._close(span); + } + + private _start(e: MusicXmlSpanEvent, placement: MusicXmlSpanPlacement) { + // the stop of this span appeared earlier in the document + const parked = MusicXmlSpanTracker._bestMatch(this._parkedStops, e, placement); + if (parked !== -1) { + const span = this._parkedStops[parked]; + this._parkedStops.splice(parked, 1); + if (e.kind !== MusicXmlSpanKind.None) { + span.kind = e.kind; + } + span.start = placement; + this._close(span); + return; + } + + // the same span started again without stop: the previous one ends here + const restarted = MusicXmlSpanTracker._bestMatch(this._open, e, placement); + if (restarted !== -1 && MusicXmlSpanTracker._isSame(this._open[restarted], e, placement)) { + const span = this._open[restarted]; + this._open.splice(restarted, 1); + span.end = placement; + this._close(span); + } + + this._open.push({ element: e.element, kind: e.kind, number: e.number, start: placement, end: null }); + } + + private _close(span: MusicXmlSpan) { + if (span.kind !== MusicXmlSpanKind.None) { + this._closed.push(span); + } + } + + /** + * The best matching span for the event, -1 if none matches. Parked stops only match if they are after the start. + */ + private static _bestMatch(spans: MusicXmlSpan[], e: MusicXmlSpanEvent, placement: MusicXmlSpanPlacement): number { + let best = -1; + let bestScore = -1; + for (let i = 0; i < spans.length; i++) { + const span = spans[i]; + if (span.element !== e.element || span.number !== e.number) { + continue; + } + const other = span.start !== null ? span.start! : span.end!; + if (other.staffIndex !== -1 && placement.staffIndex !== -1 && other.staffIndex !== placement.staffIndex) { + continue; + } + if (span.start === null && !MusicXmlSpanTracker._isBefore(placement.position, span.end!.position)) { + continue; + } + const score = MusicXmlSpanTracker._score(span, other, e, placement); + // the most recent open span, the earliest parked stop + if (score > bestScore || (score === bestScore && span.start !== null)) { + best = i; + bestScore = score; + } + } + return best; + } + + private static _score( + span: MusicXmlSpan, + other: MusicXmlSpanPlacement, + e: MusicXmlSpanEvent, + placement: MusicXmlSpanPlacement + ): number { + let score = 0; + if ( + MusicXmlSpanTracker._isIdentifiedByWords(span.element) && + e.kind !== MusicXmlSpanKind.None && + span.kind === e.kind + ) { + score += 4; + } + if (other.staffIndex === placement.staffIndex) { + score += 2; + } + if (other.voice === placement.voice) { + score += 1; + } + return score; + } + + private static _isSame(span: MusicXmlSpan, e: MusicXmlSpanEvent, placement: MusicXmlSpanPlacement): boolean { + return ( + span.start!.staffIndex === placement.staffIndex && + span.start!.voice === placement.voice && + (!MusicXmlSpanTracker._isIdentifiedByWords(span.element) || span.kind === e.kind) + ); + } + + private static _isIdentifiedByWords(element: MusicXmlSpanElement): boolean { + return element === MusicXmlSpanElement.Dashes || element === MusicXmlSpanElement.Bracket; + } + + private static _isBefore(a: MusicXmlSpanPosition, b: MusicXmlSpanPosition): boolean { + return a.barIndex !== b.barIndex ? a.barIndex < b.barIndex : a.ticks < b.ticks; + } +} + +/** + * Reads the MusicXML elements encoding spans. + * @internal + */ +export class MusicXmlSpanReader { + /** + * Reads a `` or `` element. + * @param words The `` labelling the line, they define its meaning. + */ + public static readLine(element: XmlNode, words: string, events: MusicXmlSpanEvent[]) { + const lineElement = element.localName === 'bracket' ? MusicXmlSpanElement.Bracket : MusicXmlSpanElement.Dashes; + const type = element.getAttribute('type', 'start'); + if (type !== 'start' && type !== 'stop' && type !== 'continue') { + return; + } + const action = + type === 'stop' + ? MusicXmlSpanAction.Stop + : type === 'continue' + ? MusicXmlSpanAction.Continue + : MusicXmlSpanAction.Start; + MusicXmlSpanReader._push(events, lineElement, action, MusicXmlSpanReader.lineKind(words), element); + } + + /** + * The meaning of a line labelled with the given words (e.g. alphaTab "LetRing", MuseScore "let ring"). + */ + public static lineKind(words: string): MusicXmlSpanKind { + switch (words.replace(/[^a-zA-Z0-9]/g, '').toLowerCase()) { + case 'letring': + return MusicXmlSpanKind.LetRing; + case 'pm': + case 'palmmute': + return MusicXmlSpanKind.PalmMute; + default: + return MusicXmlSpanKind.None; + } + } + + public static readWedge(element: XmlNode, events: MusicXmlSpanEvent[]) { + switch (element.getAttribute('type')) { + case 'crescendo': + MusicXmlSpanReader._start(events, MusicXmlSpanElement.Wedge, MusicXmlSpanKind.Crescendo, element); + break; + case 'diminuendo': + MusicXmlSpanReader._start(events, MusicXmlSpanElement.Wedge, MusicXmlSpanKind.Decrescendo, element); + break; + case 'stop': + MusicXmlSpanReader._stop(events, MusicXmlSpanElement.Wedge, element); + break; + // case 'continue': no meaning for the time range + } + } + + public static readOctaveShift(element: XmlNode, events: MusicXmlSpanEvent[]) { + const type = element.getAttribute('type'); + if (type === 'stop') { + MusicXmlSpanReader._stop(events, MusicXmlSpanElement.OctaveShift, element); + } else if (type === 'down' || type === 'up') { + // the type is the direction the notes are displayed shifted to, other sizes are not supported but paired + let kind = MusicXmlSpanKind.None; + switch (element.getAttribute('size', '8')) { + case '8': + kind = type === 'down' ? MusicXmlSpanKind.Ottava8va : MusicXmlSpanKind.Ottava8vb; + break; + case '15': + kind = type === 'down' ? MusicXmlSpanKind.Ottava15ma : MusicXmlSpanKind.Ottava15mb; + break; + } + MusicXmlSpanReader._start(events, MusicXmlSpanElement.OctaveShift, kind, element); + } + // case 'continue': no meaning for the time range + } + + public static readPedal(element: XmlNode, events: MusicXmlSpanEvent[]) { + switch (element.getAttribute('type')) { + // lift and retake + case 'change': + MusicXmlSpanReader._stop(events, MusicXmlSpanElement.Pedal, element); + MusicXmlSpanReader._start(events, MusicXmlSpanElement.Pedal, MusicXmlSpanKind.SustainPedal, element); + break; + case 'start': + // a line continuing after a discontinue + case 'resume': + MusicXmlSpanReader._start(events, MusicXmlSpanElement.Pedal, MusicXmlSpanKind.SustainPedal, element); + break; + case 'stop': + // a line ending without explicit lift + case 'discontinue': + MusicXmlSpanReader._stop(events, MusicXmlSpanElement.Pedal, element); + break; + case 'continue': + MusicXmlSpanReader._push( + events, + MusicXmlSpanElement.Pedal, + MusicXmlSpanAction.Continue, + MusicXmlSpanKind.SustainPedal, + element + ); + break; + // case 'sostenuto': Not supported + } + } + + /** + * Reads a `` element. + * @param midBar Whether the measure style changes within the bar, measure repeats are only supported at the bar start. + */ + public static readMeasureStyle(element: XmlNode, midBar: boolean, events: MusicXmlSpanEvent[]) { + for (const c of element.childElements()) { + switch (c.localName) { + // case 'multiple-rest': Ignored, when multibar rests are enabled for rendering this info shouldn't matter. + case 'measure-repeat': + if (midBar) { + break; + } + switch (c.getAttribute('type')) { + case 'start': + const slashes = c.getAttribute('slashes', '1'); + const kind = + slashes === '1' + ? MusicXmlSpanKind.SimileSimple + : slashes === '2' + ? MusicXmlSpanKind.SimileDouble + : MusicXmlSpanKind.None; + MusicXmlSpanReader._start(events, MusicXmlSpanElement.MeasureRepeat, kind, c); + break; + // the first measure not repeating anymore + case 'stop': + MusicXmlSpanReader._stop(events, MusicXmlSpanElement.MeasureRepeat, c); + break; + } + break; + // case 'beat-repeat': Not supported + case 'slash': + // use-stems: not supported + switch (c.getAttribute('type')) { + case 'start': + MusicXmlSpanReader._start(events, MusicXmlSpanElement.Slash, MusicXmlSpanKind.Slash, c); + break; + case 'stop': + MusicXmlSpanReader._stop(events, MusicXmlSpanElement.Slash, c); + break; + } + break; + } + } + } + + private static _start( + events: MusicXmlSpanEvent[], + element: MusicXmlSpanElement, + kind: MusicXmlSpanKind, + node: XmlNode + ) { + MusicXmlSpanReader._push(events, element, MusicXmlSpanAction.Start, kind, node); + } + + private static _stop(events: MusicXmlSpanEvent[], element: MusicXmlSpanElement, node: XmlNode) { + MusicXmlSpanReader._push(events, element, MusicXmlSpanAction.Stop, MusicXmlSpanKind.None, node); + } + + private static _push( + events: MusicXmlSpanEvent[], + element: MusicXmlSpanElement, + action: MusicXmlSpanAction, + kind: MusicXmlSpanKind, + node: XmlNode + ) { + events.push({ element: element, action: action, kind: kind, number: node.getAttribute('number', '1') }); + } +} diff --git a/packages/alphatab/src/model/Bar.ts b/packages/alphatab/src/model/Bar.ts index ef6e8d305..309deb8f6 100644 --- a/packages/alphatab/src/model/Bar.ts +++ b/packages/alphatab/src/model/Bar.ts @@ -571,9 +571,10 @@ export class Bar { } } - const isDown = previousMarker !== null && previousMarker.pedalType !== SustainPedalMarkerType.Up; - for (const marker of sustainPedals) { + // a press while the pedal is already down continues the pedal + const isDown = previousMarker !== null && previousMarker.pedalType !== SustainPedalMarkerType.Up; + if (previousMarker && previousMarker.pedalType !== SustainPedalMarkerType.Up) { //duplicate or out-of-order markers if (previousMarker.bar === this && marker.ratioPosition <= previousMarker.ratioPosition) { diff --git a/packages/alphatab/src/rendering/glyphs/SustainPedalGlyph.ts b/packages/alphatab/src/rendering/glyphs/SustainPedalGlyph.ts index fb96867d0..4a2edf68c 100644 --- a/packages/alphatab/src/rendering/glyphs/SustainPedalGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/SustainPedalGlyph.ts @@ -32,7 +32,10 @@ export class SustainPedalGlyph extends EffectGlyph { while (markerIndex < markers.length) { let marker: SustainPedalMarker | null = markers[markerIndex]; while (marker != null) { - const markerX = cx + this.renderer.getRatioPositionX(marker.ratioPosition); + const markerX = + cx + + this.renderer.getRatioPositionX(marker.ratioPosition) + + this._changeOffset(marker, textWidth, starSize); // real own marker let linePadding = 0; @@ -47,7 +50,10 @@ export class SustainPedalGlyph extends EffectGlyph { // line to next marker or end-of-bar if (marker.nextPedalMarker) { if (marker.nextPedalMarker.bar === marker.bar) { - let nextX = cx + this.renderer.getRatioPositionX(marker.nextPedalMarker.ratioPosition); + let nextX = + cx + + this.renderer.getRatioPositionX(marker.nextPedalMarker.ratioPosition) + + this._changeOffset(marker.nextPedalMarker, textWidth, starSize); switch (marker.nextPedalMarker.pedalType) { case SustainPedalMarkerType.Down: @@ -90,4 +96,30 @@ export class SustainPedalGlyph extends EffectGlyph { } } } + + /** + * On a pedal change (lift and retake at the same position) the symbols are placed next to each other. + */ + private _changeOffset(marker: SustainPedalMarker, textWidth: number, starSize: number): number { + const markers = marker.bar.sustainPedals; + const index = markers.indexOf(marker); + const padding = this.renderer.smuflMetrics.sustainPedalLinePadding / 2; + if ( + marker.pedalType === SustainPedalMarkerType.Up && + index + 1 < markers.length && + markers[index + 1].pedalType === SustainPedalMarkerType.Down && + markers[index + 1].ratioPosition === marker.ratioPosition + ) { + return -(starSize / 2 + padding); + } + if ( + marker.pedalType === SustainPedalMarkerType.Down && + index > 0 && + markers[index - 1].pedalType === SustainPedalMarkerType.Up && + markers[index - 1].ratioPosition === marker.ratioPosition + ) { + return textWidth / 2 + padding; + } + return 0; + } } diff --git a/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png b/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png index c8ee983d9..0d7cb515d 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png and b/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png b/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png index 2c28b71a9..b0bd13dd6 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png and b/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png b/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png index 4ed343c6c..1c3b2644d 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png and b/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png b/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png index ddad88851..7256b7b19 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png and b/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png b/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png index 1bfaf2f78..89b76bf00 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png and b/packages/alphatab/test-data/musicxml-samples/MahlFaGe4Sample.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png b/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png index 7ed62a4b6..da63c21d7 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png and b/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png b/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png index 607206f52..62fff0e28 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png and b/packages/alphatab/test-data/musicxml-testsuite/31b-Directions-Order.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png b/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png index 4abeab8a3..d7c60801c 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png and b/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/33a-Spanners.png b/packages/alphatab/test-data/musicxml-testsuite/33a-Spanners.png index b0ee41ec4..e1410d3ff 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/33a-Spanners.png and b/packages/alphatab/test-data/musicxml-testsuite/33a-Spanners.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/33da-Spanners-OctaveShifts-before.png b/packages/alphatab/test-data/musicxml-testsuite/33da-Spanners-OctaveShifts-before.png index 8926760a2..65e3d6e2c 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/33da-Spanners-OctaveShifts-before.png and b/packages/alphatab/test-data/musicxml-testsuite/33da-Spanners-OctaveShifts-before.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/33f-Trill-EndingOnGraceNote.png b/packages/alphatab/test-data/musicxml-testsuite/33f-Trill-EndingOnGraceNote.png index 164199bfd..9aa28c823 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/33f-Trill-EndingOnGraceNote.png and b/packages/alphatab/test-data/musicxml-testsuite/33f-Trill-EndingOnGraceNote.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/33i-Ties-NotEnded.png b/packages/alphatab/test-data/musicxml-testsuite/33i-Ties-NotEnded.png index 7c92a4faa..f02d77e37 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/33i-Ties-NotEnded.png and b/packages/alphatab/test-data/musicxml-testsuite/33i-Ties-NotEnded.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/34a-Print-Object-Spanners.png b/packages/alphatab/test-data/musicxml-testsuite/34a-Print-Object-Spanners.png index 1480cd62c..4c796444c 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/34a-Print-Object-Spanners.png and b/packages/alphatab/test-data/musicxml-testsuite/34a-Print-Object-Spanners.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png b/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png index 9e5eb0a0f..02a3c503c 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png and b/packages/alphatab/test-data/musicxml-testsuite/34b-Colors.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png b/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png index 7390e1528..568ba7dfb 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png and b/packages/alphatab/test-data/musicxml-testsuite/34c-Font-Size.png differ diff --git a/packages/alphatab/test-data/musicxml3/chord-diagram.png b/packages/alphatab/test-data/musicxml3/chord-diagram.png index 024a28c91..f8e211869 100644 Binary files a/packages/alphatab/test-data/musicxml3/chord-diagram.png and b/packages/alphatab/test-data/musicxml3/chord-diagram.png differ diff --git a/packages/alphatab/test/importer/MusicXmlImporter.test.ts b/packages/alphatab/test/importer/MusicXmlImporter.test.ts index fd4cc3c1d..b6c81bcda 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -1,19 +1,25 @@ -import { describe, expect, it } from 'vitest'; +import { IOHelper } from '@coderline/alphatab/io/IOHelper'; +import { AlphaSynthMidiFileHandler } from '@coderline/alphatab/midi/AlphaSynthMidiFileHandler'; +import { MidiFile } from '@coderline/alphatab/midi/MidiFile'; +import { MidiFileGenerator } from '@coderline/alphatab/midi/MidiFileGenerator'; +import { SustainPedalMarkerType } from '@coderline/alphatab/model/Bar'; +import type { Beat } from '@coderline/alphatab/model/Beat'; import { BendType } from '@coderline/alphatab/model/BendType'; +import { CrescendoType } from '@coderline/alphatab/model/CrescendoType'; 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'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { Ottavia } from '@coderline/alphatab/model/Ottavia'; +import type { Note } from '@coderline/alphatab/model/Note'; import { BarNumberDisplay } from '@coderline/alphatab/model/RenderStylesheet'; import type { Score } from '@coderline/alphatab/model/Score'; -import { MusicXmlImporterTestHelper } from 'test/importer/MusicXmlImporterTestHelper'; -import { IOHelper } from '@coderline/alphatab/io/IOHelper'; -import { MidiFileGenerator } from '@coderline/alphatab/midi/MidiFileGenerator'; -import { MidiFile } from '@coderline/alphatab/midi/MidiFile'; -import { AlphaSynthMidiFileHandler } from '@coderline/alphatab/midi/AlphaSynthMidiFileHandler'; +import { SimileMark } from '@coderline/alphatab/model/SimileMark'; +import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { Settings } from '@coderline/alphatab/Settings'; -import { FlatMidiEventGenerator, FlatTempoEvent } from 'test/audio/FlatMidiEventGenerator'; +import { FlatMidiEventGenerator, FlatNoteEvent, FlatTempoEvent } from 'test/audio/FlatMidiEventGenerator'; +import { MusicXmlImporterTestHelper } from 'test/importer/MusicXmlImporterTestHelper'; +import { describe, expect, it } from 'vitest'; describe('MusicXmlImporterTests', () => { it('track-volume', async () => { @@ -555,6 +561,40 @@ describe('MusicXmlImporterTests', () => { ); }); + it('hammer-pull', () => { + // 5h7p5 5 on the low E string + const note = (step: string, fret: number, technical: string) => + `${step}211quarter` + + `${technical}6${fret}`; + const xml = ` + + Guitar + + 1TAB5 + 6 + E2 + A2 + D3 + G3 + B3 + E4 + + + ${note('A', 5, 'H')} + ${note('B', 7, 'P')} + ${note('A', 5, '')} + ${note('A', 5, '')} + +`; + const score = MusicXmlImporterTestHelper.prepareImporterWithBytes(IOHelper.stringToBytes(xml)).readScore(); + const notes = score.tracks[0].staves[0].bars[0].voices[0].beats.map(b => b.notes[0]); + + expect(notes.map(n => n.isHammerPullOrigin)).toEqual([true, true, false, false]); + expect(notes[0].hammerPullDestination).toBe(notes[1]); + expect(notes[1].hammerPullDestination).toBe(notes[2]); + expect(notes[3].isHammerPullDestination).toBe(false); + }); + describe('barnumberdisplay', async () => { async function testPartwise(filename: string, display: BarNumberDisplay) { const score = await MusicXmlImporterTestHelper.loadFile(`test-data/musicxml4/${filename}`); @@ -805,4 +845,931 @@ describe('MusicXmlImporterTests', () => { expectDirections(score, 1, [Direction.TargetCoda]); }); }); + + describe('spans', () => { + const tuning = ['E2', 'A2', 'D3', 'G3', 'B3', 'E4']; + + // parts of 4/4 measures on a single tablature staff, or on the given number of notation staves + function load(parts: string[][], staves: number = 1, divisions: number = 1, software: string = ''): Score { + let attributes = `${divisions}`; + if (staves === 1) { + attributes += 'TAB56'; + for (let i = 0; i < tuning.length; i++) { + attributes += `${tuning[i][0]}${tuning[i][1]}`; + } + attributes += ''; + } else { + attributes += `${staves}G2F4`; + } + attributes += ''; + + let xml = ''; + if (software.length > 0) { + xml += `${software}`; + } + xml += ''; + for (let i = 0; i < parts.length; i++) { + xml += `P${i + 1}`; + } + xml += ''; + for (let i = 0; i < parts.length; i++) { + xml += ``; + for (let j = 0; j < parts[i].length; j++) { + xml += `${j === 0 ? attributes : ''}${parts[i][j]}`; + } + xml += ''; + } + xml += ''; + return MusicXmlImporterTestHelper.prepareImporterWithBytes(IOHelper.stringToBytes(xml)).readScore(); + } + + // a quarter note on the tablature + function tab(pitch: string, stringNumber: number, fret: number = 0, notations: string = ''): string { + return ( + `${pitch[0]}${pitch[1]}` + + '11quarter' + + `${stringNumber}${fret}${notations}` + ); + } + + // a quarter (or longer) note on a notation staff + function note( + pitch: string, + voice: number = 1, + staff: number = 0, + notations: string = '', + duration: number = 1, + type: string = 'quarter' + ): string { + return ( + `${pitch[0]}${pitch[1]}` + + `${duration}${voice}${type}` + + `${staff > 0 ? `${staff}` : ''}${notations.length > 0 ? `${notations}` : ''}` + ); + } + + function chordNote(pitch: string, stringNumber: number, fret: number, notations: string = ''): string { + return tab(pitch, stringNumber, fret, notations).replace('', ''); + } + + function openStrings(count: number): string { + let notes = ''; + for (let i = 0; i < count; i++) { + notes += tab('E2', 6); + } + return notes; + } + + function quarters(count: number, pitch: string = 'C4', voice: number = 1, staff: number = 0): string { + let notes = ''; + for (let i = 0; i < count; i++) { + notes += note(pitch, voice, staff); + } + return notes; + } + + function direction(directionTypes: string, extra: string = ''): string { + return `${directionTypes}${extra}`; + } + + function dashes(words: string, type: string, number: string = '1', extra: string = ''): string { + const label = words.length > 0 ? `${words}` : ''; + return direction( + `${label}`, + extra + ); + } + + function notesOf( + score: Score, + bar: number = 0, + staff: number = 0, + voice: number = 0, + track: number = 0 + ): Note[] { + const notes: Note[] = []; + for (const b of score.tracks[track].staves[staff].bars[bar].voices[voice].beats) { + for (const n of b.notes) { + notes.push(n); + } + } + return notes; + } + + function beatsOf( + score: Score, + bar: number = 0, + staff: number = 0, + voice: number = 0, + track: number = 0 + ): Beat[] { + return score.tracks[track].staves[staff].bars[bar].voices[voice].beats; + } + + function noteLengths(score: Score): number[] { + const handler = new FlatMidiEventGenerator(); + new MidiFileGenerator(score, new Settings(), handler).generate(); + const lengths: number[] = []; + for (const e of handler.midiEvents) { + if (e instanceof FlatNoteEvent) { + lengths.push(e.length); + } + } + return lengths; + } + + describe('lines', () => { + it('let-ring', () => { + const score = load([ + [ + dashes('LetRing', 'start') + + tab('E2', 6) + + tab('A2', 5) + + tab('D3', 4) + + tab('G3', 3) + + dashes('LetRing', 'stop') + ] + ]); + expect(notesOf(score).map(n => n.isLetRing)).toEqual([true, true, true, true]); + expect(noteLengths(score)).toEqual([3840, 2880, 1920, 960]); + + const serialized = JsonConverter.jsObjectToScore(JsonConverter.scoreToJsObject(score), new Settings()); + expect(notesOf(serialized).map(n => n.isLetRing)).toEqual([true, true, true, true]); + expect(beatsOf(serialized).map(b => b.isLetRing)).toEqual([true, true, true, true]); + }); + + it('let-ring-same-string', () => { + // ringing ends when the string is struck again + const score = load([ + [ + dashes('LetRing', 'start') + + tab('E2', 6, 0) + + tab('G2', 6, 3) + + tab('A2', 5, 0) + + tab('B2', 5, 2) + + dashes('LetRing', 'stop') + ] + ]); + expect(noteLengths(score)).toEqual([960, 2880, 960, 960]); + }); + + it('palm-mute', () => { + const score = load([ + [dashes('P.M.', 'start') + openStrings(2) + dashes('P.M.', 'stop') + openStrings(2)] + ]); + expect(notesOf(score).map(n => n.isPalmMute)).toEqual([true, true, false, false]); + }); + + it('musescore-brackets', () => { + // MuseScore writes let ring and palm mute as words with brackets + const bracket = (type: string, words: string) => + direction( + `${words.length > 0 ? `${words}` : ''}` + + `` + ); + const score = load([ + [bracket('start', 'let ring') + openStrings(2) + bracket('stop', '') + openStrings(2)], + [bracket('start', 'P.M.') + openStrings(3) + bracket('stop', '') + openStrings(1)] + ]); + expect(notesOf(score, 0).map(n => n.isLetRing)).toEqual([true, true, false, false]); + expect(notesOf(score, 0, 0, 0, 1).map(n => n.isPalmMute)).toEqual([true, true, true, false]); + expect(beatsOf(score)[0].text).toBeNull(); + }); + + it('words-in-separate-direction', () => { + // e.g. music21 writes the label as separate direction + const score = load([ + [ + direction('let ring') + + dashes('', 'start') + + openStrings(2) + + dashes('', 'stop') + + openStrings(2) + ] + ]); + expect(notesOf(score).map(n => n.isLetRing)).toEqual([true, true, false, false]); + expect(beatsOf(score)[0].text).toBeNull(); + }); + + it('stop-without-words', () => { + const score = load([ + [dashes('LetRing', 'start') + openStrings(2) + dashes('', 'stop') + openStrings(2), openStrings(4)] + ]); + expect(notesOf(score, 0).map(n => n.isLetRing)).toEqual([true, true, false, false]); + expect(notesOf(score, 1).map(n => n.isLetRing)).toEqual([false, false, false, false]); + }); + + it('across-bars-and-chords', () => { + const score = load([ + [ + openStrings(2) + + dashes('LetRing', 'start') + + tab('E2', 6) + + tab('E2', 6) + + chordNote('A2', 5, 0), + openStrings(1) + dashes('', 'stop') + openStrings(3) + ] + ]); + expect(notesOf(score, 0).map(n => n.isLetRing)).toEqual([false, false, true, true, true]); + expect(notesOf(score, 1).map(n => n.isLetRing)).toEqual([true, false, false, false]); + }); + + it('numbers', () => { + // overlapping let ring (1) and palm mute (2), stopped without words + const score = load([ + [ + dashes('LetRing', 'start', '1') + + tab('E2', 6) + + dashes('P.M.', 'start', '2') + + tab('E2', 6) + + dashes('', 'stop', '1') + + tab('E2', 6) + + dashes('', 'stop', '2') + + tab('E2', 6), + // one direction starting a new span with the number of the span it stops + dashes('LetRing', 'start', '1') + + openStrings(2) + + direction( + 'P.M.' + + '' + + '' + ) + + openStrings(2) + + dashes('', 'stop', '1') + ] + ]); + expect(notesOf(score, 0).map(n => n.isLetRing)).toEqual([true, true, false, false]); + expect(notesOf(score, 0).map(n => n.isPalmMute)).toEqual([false, true, true, false]); + expect(notesOf(score, 1).map(n => n.isLetRing)).toEqual([true, true, false, false]); + expect(notesOf(score, 1).map(n => n.isPalmMute)).toEqual([false, false, true, true]); + }); + + it('words-identify-same-number', () => { + // let ring and palm mute with the same number, identified by the words on their stops + const score = load([ + [ + dashes('LetRing', 'start', '1') + + tab('E2', 6) + + dashes('P.M.', 'start', '1') + + tab('E2', 6) + + dashes('LetRing', 'stop', '1') + + tab('E2', 6) + + dashes('P.M.', 'stop', '1') + + tab('E2', 6) + ] + ]); + expect(notesOf(score).map(n => n.isLetRing)).toEqual([true, true, false, false]); + expect(notesOf(score).map(n => n.isPalmMute)).toEqual([false, true, true, false]); + }); + + it('restart', () => { + // a start without stop is ended by the next start + const score = load([ + [ + dashes('LetRing', 'start') + + openStrings(2) + + dashes('LetRing', 'start') + + openStrings(1) + + dashes('', 'stop') + + openStrings(1) + ] + ]); + expect(notesOf(score).map(n => n.isLetRing)).toEqual([true, true, true, false]); + }); + + it('stop-and-start-without-open-span', () => { + // a stray stop in the direction starting a span must not end the new span + const score = load([ + [ + direction( + '' + + 'LetRing' + + '' + ) + + openStrings(2) + + dashes('', 'stop') + + openStrings(2) + ] + ]); + expect(notesOf(score).map(n => n.isLetRing)).toEqual([true, true, false, false]); + }); + + it('staves-and-voices', () => { + // the stop (voice 1) appears in the document before the start (voice 2), staff 2 has no span + const staff1: string = '1'; + const score = load( + [ + [ + note('C5', 1, 1) + + note('C5', 1, 1) + + dashes('', 'stop', '1', staff1) + + note('C5', 1, 1) + + note('C5', 1, 1) + + '4' + + note('A4', 2, 1) + + dashes('LetRing', 'start', '1', staff1) + + quarters(3, 'A4', 2, 1) + + '4' + + quarters(4, 'C3', 5, 2) + ] + ], + 2 + ); + expect(notesOf(score, 0, 0, 0).map(n => n.isLetRing)).toEqual([false, true, false, false]); + expect(notesOf(score, 0, 0, 1).map(n => n.isLetRing)).toEqual([false, true, false, false]); + expect(notesOf(score, 0, 1, 0).map(n => n.isLetRing)).toEqual([false, false, false, false]); + }); + + it('direction-voice', () => { + const score = load( + [ + [ + dashes('LetRing', 'start', '1', '21') + + quarters(4, 'C5', 1, 1) + + '4' + + quarters(4, 'A4', 2, 1) + + dashes('', 'stop', '1', '21') + ] + ], + 2 + ); + expect(notesOf(score, 0, 0, 0).map(n => n.isLetRing)).toEqual([false, false, false, false]); + expect(notesOf(score, 0, 0, 1).map(n => n.isLetRing)).toEqual([true, true, true, true]); + }); + + it('same-staff-preference', () => { + // exporters reuse numbers on different staves (e.g. MuseScore) + const score = load( + [ + [ + dashes('LetRing', 'start', '1', '1') + + quarters(2, 'C5', 1, 1) + + dashes('', 'stop', '1', '1') + + quarters(2, 'C5', 1, 1) + + '4' + + dashes('LetRing', 'start', '1', '2') + + quarters(3, 'C3', 5, 2) + + dashes('', 'stop', '1', '2') + + quarters(1, 'C3', 5, 2) + ] + ], + 2 + ); + expect(notesOf(score, 0, 0).map(n => n.isLetRing)).toEqual([true, true, false, false]); + expect(notesOf(score, 0, 1).map(n => n.isLetRing)).toEqual([true, true, true, false]); + }); + + it('stop-before-start-with-number-on-other-staff', () => { + // staff 1 has an open span with the same number while the stop of the staff 2 span is read before its start + const score = load( + [ + [ + dashes('LetRing', 'start', '1', '1') + + quarters(4, 'C5', 1, 1) + + '4' + + quarters(2, 'C3', 5, 2) + + dashes('', 'stop', '1', '2') + + quarters(2, 'C3', 5, 2) + + '4' + + quarters(1, 'E3', 6, 2) + + dashes('LetRing', 'start', '1', '2') + + quarters(3, 'E3', 6, 2) + + dashes('', 'stop', '1', '1') + ] + ], + 2 + ); + expect(notesOf(score, 0, 0).map(n => n.isLetRing)).toEqual([true, true, true, true]); + expect(notesOf(score, 0, 1, 0).map(n => n.isLetRing)).toEqual([false, true, false, false]); + expect(notesOf(score, 0, 1, 1).map(n => n.isLetRing)).toEqual([false, true, false, false]); + }); + + it('offsets', () => { + // the offset nudges the stop visually (e.g. Finale), the written voice is covered as written, + // other voices are covered by the exact position + const parts = (offset: number) => [ + [ + dashes('LetRing', 'start', '1', '1') + + note('C5', 1, 1, '', 4) + + note('C5', 1, 1, '', 4) + + dashes('', 'stop', '1', `${offset}1`) + + note('C5', 1, 1, '', 4) + + note('C5', 1, 1, '', 4) + + '16' + + note('A4', 2, 1, '', 4) + + note('A4', 2, 1, '', 8, 'half') + + note('A4', 2, 1, '', 4) + ] + ]; + let score = load(parts(3), 2, 4); + expect(notesOf(score, 0, 0, 0).map(n => n.isLetRing)).toEqual([true, true, false, false]); + expect(notesOf(score, 0, 0, 1).map(n => n.isLetRing)).toEqual([true, true, false]); + + score = load(parts(0), 2, 4); + expect(notesOf(score, 0, 0, 1).map(n => n.isLetRing)).toEqual([true, false, false]); + }); + + it('overfull-bar', () => { + // a stop at the end of a bar longer than its time signature stays in the bar + const score = load([ + [dashes('LetRing', 'start') + openStrings(5) + dashes('', 'stop'), openStrings(4)] + ]); + expect(notesOf(score, 0).map(n => n.isLetRing)).toEqual([true, true, true, true, true]); + expect(notesOf(score, 1).map(n => n.isLetRing)).toEqual([false, false, false, false]); + }); + + it('unclosed-per-part', () => { + const score = load([ + [dashes('LetRing', 'start') + openStrings(4), openStrings(4)], + [openStrings(4), openStrings(4)] + ]); + expect(notesOf(score, 1).map(n => n.isLetRing)).toEqual([true, true, true, true]); + expect(notesOf(score, 0, 0, 0, 1).map(n => n.isLetRing)).toEqual([false, false, false, false]); + }); + + it('continue-and-unknown-words', () => { + const score = load([ + [ + dashes('LetRing', 'continue') + + openStrings(2) + + dashes('', 'stop') + + dashes('cresc.', 'start') + + openStrings(2) + + dashes('', 'stop') + ] + ]); + expect(notesOf(score).map(n => n.isLetRing)).toEqual([true, true, false, false]); + expect(notesOf(score).map(n => n.isPalmMute)).toEqual([false, false, false, false]); + // lines without meaning keep their label + expect(beatsOf(score)[2].text).toBe('cresc.'); + }); + + it('tied-let-ring', () => { + const score = load([ + [ + tab('E2', 6, 0, '') + + // TuxGuitar writes the tie outside the notations + tab('A2', 5, 0).replace('', '') + + openStrings(2) + ] + ]); + expect(notesOf(score).map(n => n.isLetRing)).toEqual([true, true, false, false]); + }); + }); + + describe('wedges', () => { + it('staff-and-restart', () => { + const wedge = (type: string, staff: number) => + direction(``, `${staff}`); + const score = load( + [ + [ + wedge('crescendo', 1) + + quarters(2, 'C5', 1, 1) + + // a new wedge with the same number ends the previous one + wedge('diminuendo', 1) + + quarters(2, 'C5', 1, 1) + + '4' + + quarters(4, 'C3', 5, 2) + + // the open wedge of staff 1 does not apply to staff 2 + wedge('stop', 1) + ] + ], + 2 + ); + expect(beatsOf(score, 0, 0).map(b => b.crescendo)).toEqual([ + CrescendoType.Crescendo, + CrescendoType.Crescendo, + CrescendoType.Decrescendo, + CrescendoType.Decrescendo + ]); + expect(beatsOf(score, 0, 1).map(b => b.crescendo)).toEqual([ + CrescendoType.None, + CrescendoType.None, + CrescendoType.None, + CrescendoType.None + ]); + }); + + it('directions-in-order', () => { + // directions separated by a forward are processed in their order + const wedge = (type: string) => direction(``); + const score = load( + [ + [ + wedge('crescendo') + + quarters(2) + + wedge('diminuendo') + + '1' + + wedge('stop') + + quarters(1), + quarters(4) + ] + ], + 2 + ); + expect(beatsOf(score, 0).map(b => b.crescendo)).toEqual([ + CrescendoType.Crescendo, + CrescendoType.Crescendo, + CrescendoType.Decrescendo, + CrescendoType.None + ]); + expect(beatsOf(score, 1).map(b => b.crescendo)).toEqual([ + CrescendoType.None, + CrescendoType.None, + CrescendoType.None, + CrescendoType.None + ]); + }); + + it('written-voice', () => { + // a wedge is shown on the voice of the following note, other directions do not change it + const score = load( + [ + [ + direction('') + + dashes('LetRing', 'start', '1', '2') + + quarters(4, 'C5', 1, 1) + + '4' + + quarters(4, 'A4', 2, 1) + + direction('') + ] + ], + 2 + ); + expect(beatsOf(score, 0, 0, 0).map(b => b.crescendo)).toEqual([ + CrescendoType.Crescendo, + CrescendoType.Crescendo, + CrescendoType.Crescendo, + CrescendoType.Crescendo + ]); + expect(beatsOf(score, 0, 0, 1).map(b => b.crescendo)).toEqual([ + CrescendoType.None, + CrescendoType.None, + CrescendoType.None, + CrescendoType.None + ]); + }); + + it('grace-notes', () => { + // the wedge starts before and ends after the grace notes at the same position + const grace = (pitch: string) => + `${pitch[0]}${pitch[1]}116th`; + const score = load( + [ + [ + direction('') + + grace('G5') + + grace('F5') + + direction('') + + note('C5', 1, 0, '', 4, 'whole') + ] + ], + 2 + ); + expect(beatsOf(score).map(b => b.crescendo)).toEqual([ + CrescendoType.Decrescendo, + CrescendoType.Decrescendo, + CrescendoType.None + ]); + }); + }); + + describe('octave-shifts', () => { + const shift = (type: string, size: number, number: string, extra: string) => + direction( + ``, + extra + ); + + it('staff-and-numbers', () => { + const score = load( + [ + [ + shift('down', 8, '1', '1') + + quarters(1, 'C6', 1, 1) + + // unsupported sizes are paired but have no effect + shift('down', 22, '2', '1') + + quarters(1, 'C6', 1, 1) + + shift('stop', 22, '2', '1') + + quarters(1, 'C6', 1, 1) + + shift('stop', 8, '1', '1') + + quarters(1, 'C6', 1, 1) + + '4' + + quarters(4, 'C3', 5, 2) + ] + ], + 2 + ); + expect(beatsOf(score, 0, 0).map(b => b.ottava)).toEqual([ + Ottavia._8va, + Ottavia._8va, + Ottavia._8va, + Ottavia.Regular + ]); + expect(beatsOf(score, 0, 1).map(b => b.ottava)).toEqual([ + Ottavia.Regular, + Ottavia.Regular, + Ottavia.Regular, + Ottavia.Regular + ]); + }); + + it('stop-conventions', () => { + // Finale writes the stop before the last shifted note, MuseScore after it (see MusicXML test suite 33da/33db) + const shifted = (software: string) => + load( + [ + [ + shift('down', 8, '1', '') + + note('C6', 1, 0, '', 4) + + note('C6', 1, 0, '', 4) + + shift('stop', 8, '1', '1') + + note('C6', 1, 0, '', 4) + + note('C6', 1, 0, '', 4) + ] + ], + 2, + 4, + software + ); + expect(beatsOf(shifted('Finale 2011 for Windows')).map(b => b.ottava)).toEqual([ + Ottavia._8va, + Ottavia._8va, + Ottavia._8va, + Ottavia.Regular + ]); + expect(beatsOf(shifted('MuseScore 4.3.0')).map(b => b.ottava)).toEqual([ + Ottavia._8va, + Ottavia._8va, + Ottavia.Regular, + Ottavia.Regular + ]); + }); + }); + + describe('pedals', () => { + const pedal = (type: string, extra: string) => + direction(``, extra); + + it('across-bars', () => { + const score = load( + [[pedal('start', '') + quarters(4), quarters(2) + pedal('stop', '') + quarters(2)]], + 2 + ); + const bars = score.tracks[0].staves[0].bars; + expect(bars[0].sustainPedals.map(p => p.pedalType)).toEqual([SustainPedalMarkerType.Down]); + expect(bars[1].sustainPedals.map(p => p.pedalType)).toEqual([SustainPedalMarkerType.Up]); + expect(bars[1].sustainPedals[0].ratioPosition).toBe(0.5); + }); + + it('change', () => { + // a change in a bar after a held bar lifts and retakes the pedal + const score = load( + [ + [ + pedal('start', '') + quarters(4), + quarters(2) + pedal('change', '') + quarters(2) + pedal('stop', '') + ] + ], + 2 + ); + const bar = score.tracks[0].staves[0].bars[1]; + expect(bar.sustainPedals.map(p => p.pedalType)).toEqual([ + SustainPedalMarkerType.Up, + SustainPedalMarkerType.Down, + SustainPedalMarkerType.Up + ]); + expect(bar.sustainPedals.map(p => p.ratioPosition)).toEqual([0.5, 0.5, 1]); + }); + + it('overfull-bar', () => { + const score = load([[pedal('start', '') + quarters(5) + pedal('stop', '')]], 2); + expect(score.tracks[0].staves[0].bars[0].sustainPedals.map(p => p.ratioPosition)).toEqual([0, 1]); + }); + + it('offset', () => { + // the offset is given in divisions + const score = load( + [ + [ + quarters(1) + + pedal('start', '1') + + quarters(1) + + pedal('stop', '') + + quarters(2) + ] + ], + 2 + ); + expect(score.tracks[0].staves[0].bars[0].sustainPedals.map(p => p.ratioPosition)).toEqual([0.5, 0.5]); + }); + }); + + describe('wavy-lines', () => { + it('trill-line', () => { + const score = load( + [ + [ + note( + 'C5', + 1, + 1, + '' + ) + + note('C5', 1, 1) + + note('C5', 1, 1, '') + + note('C5', 1, 1) + + '4' + + quarters(4, 'A4', 2, 1) + ] + ], + 2 + ); + expect(notesOf(score, 0, 0, 0).map(n => n.trillValue)).toEqual([73, 73, 73, -1]); + expect(notesOf(score, 0, 0, 1).map(n => n.isTrill)).toEqual([false, false, false, false]); + }); + + it('trill-on-tablature', () => { + const score = load([[tab('A2', 6, 5, '') + openStrings(3)]]); + const trill = notesOf(score)[0]; + expect(trill.trillValue).toBe(47); + expect(trill.trillFret).toBe(7); + }); + + it('vibrato-line', () => { + const score = load( + [ + [ + note('C5', 1, 0, '') + + note('C5') + + note('C5', 1, 0, '') + + quarters(1, 'C5') + ] + ], + 2 + ); + expect(notesOf(score).map(n => n.vibrato)).toEqual([ + VibratoType.Slight, + VibratoType.Slight, + VibratoType.Slight, + VibratoType.None + ]); + }); + }); + + describe('measure-styles', () => { + const style = (content: string) => `${content}`; + + it('slash-per-part', () => { + const score = load( + [ + [style('') + quarters(4), quarters(4)], + [quarters(4), quarters(4)] + ], + 2 + ); + expect(beatsOf(score, 1).map(b => b.slashed)).toEqual([true, true, true, true]); + expect(beatsOf(score, 0, 0, 0, 1).map(b => b.slashed)).toEqual([false, false, false, false]); + }); + + it('measure-repeat', () => { + const score = load( + [ + [ + quarters(4), + style('') + quarters(4), + quarters(4), + style('') + quarters(4) + ] + ], + 2 + ); + expect(score.tracks[0].staves[0].bars.map(b => b.simileMark)).toEqual([ + SimileMark.None, + SimileMark.Simple, + SimileMark.Simple, + SimileMark.None + ]); + }); + }); + + it('endings-of-all-parts', () => { + const start = ''; + const stop = + ''; + // a later part without endings keeps the endings of the first part + const score = load( + [ + [quarters(4), start + quarters(4) + stop, quarters(4)], + [quarters(4), quarters(4), quarters(4)] + ], + 2 + ); + expect(score.masterBars.map(m => m.alternateEndings)).toEqual([0, 1, 0]); + }); + + describe('note-links', () => { + it('ties', () => { + const score = load( + [ + [ + // a tie without end is tied to the next note + note('C5', 1, 0, '') + + note('C5') + + // a stop of a note which is not the next one is ignored + note('C5', 1, 0, '') + + note('C5', 1, 0, '') + ] + ], + 2 + ); + const notes = notesOf(score); + expect(notes[1].tieOrigin).toBe(notes[0]); + expect(notes[2].isTieDestination).toBe(false); + expect(notes[3].tieOrigin).toBe(notes[2]); + }); + + it('ties-on-unisons', () => { + const score = load([ + [ + tab('E4', 1, 0, '') + + chordNote('E4', 2, 5, '') + + tab('E4', 1, 0, '') + + chordNote('E4', 2, 5, '') + + openStrings(2) + ] + ]); + const notes = notesOf(score); + expect(notes[2].tieOrigin).toBe(notes[0]); + expect(notes[3].tieOrigin).toBe(notes[1]); + }); + + it('slides-and-glissandos', () => { + const score = load( + [ + [ + note( + 'C5', + 1, + 0, + '', + 1 + ) + + note('E5', 1, 0, '') + + note('G5', 1, 0, '') + + // a stray stop does not link to a previous start + note('C5', 1, 0, '') + ] + ], + 2 + ); + const notes = notesOf(score); + expect(notes[1].slideOrigin).toBe(notes[0]); + expect(notes[2].slideOrigin).toBe(notes[0]); + expect(notes[3].slideOrigin).toBeNull(); + }); + + it('slur-on-hammer-on', () => { + // the hammer-on is drawn as slur, the slur written along is not added + const score = load([ + [ + tab( + 'A2', + 6, + 5, + 'H' + ) + + tab('B2', 6, 7, '') + + openStrings(2) + ] + ]); + const notes = notesOf(score); + expect(notes[0].isHammerPullOrigin).toBe(true); + expect(notes[1].isSlurDestination).toBe(false); + }); + }); + + it('tempo-offset', () => { + // the offset is given in divisions and only affects the sound if specified + const tempoPosition = (sound: string) => { + const score = load( + [ + [ + quarters(1) + + direction( + 'Andante', + `1` + ) + + quarters(3) + ] + ], + 2 + ); + return score.masterBars[0].tempoAutomations.filter(a => a.value === 90)[0].ratioPosition; + }; + expect(tempoPosition('')).toBe(0.25); + expect(tempoPosition(' sound="yes"')).toBe(0.5); + }); + }); }); diff --git a/packages/alphatab/test/model/Bar.test.ts b/packages/alphatab/test/model/Bar.test.ts new file mode 100644 index 000000000..a287e4ff6 --- /dev/null +++ b/packages/alphatab/test/model/Bar.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; +import { SustainPedalMarkerType } from '@coderline/alphatab/model/Bar'; +import { Settings } from '@coderline/alphatab/Settings'; + +describe('BarTests', () => { + it('sustain-pedal-retake-after-held-bar', () => { + // the pedal is held into the second bar, lifted and pressed again + const score = ScoreLoader.loadAlphaTex('C4.4 {spd} C4 C4 C4 | C4 C4 {spu} C4 {spd} C4', new Settings()); + const markers = score.tracks[0].staves[0].bars[1].sustainPedals; + expect(markers.map(m => m.pedalType)).toEqual([SustainPedalMarkerType.Up, SustainPedalMarkerType.Down]); + expect(markers[0].previousPedalMarker).toBe(score.tracks[0].staves[0].bars[0].sustainPedals[0]); + expect(markers[1].previousPedalMarker).toBeNull(); + }); +});