diff --git a/packages/alphatab/src/exporter/GpifWriter.ts b/packages/alphatab/src/exporter/GpifWriter.ts index 047c44128..b0367c85a 100644 --- a/packages/alphatab/src/exporter/GpifWriter.ts +++ b/packages/alphatab/src/exporter/GpifWriter.ts @@ -1,5 +1,6 @@ import { GpifSoundMapper } from '@coderline/alphatab/exporter/GpifSoundMapper'; import { VersionInfo } from '@coderline/alphatab/generated/VersionInfo'; +import { Logger } from '@coderline/alphatab/Logger'; import { GeneralMidi } from '@coderline/alphatab/midi/GeneralMidi'; import { MidiFileGenerator } from '@coderline/alphatab/midi/MidiFileGenerator'; import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; @@ -107,9 +108,9 @@ export class GpifWriter { for (const tracks of score.tracks) { for (const staff of tracks.staves) { for (const bar of staff.bars) { - this._writeBarNode(bars, bar); + const activeVoices = this._writeBarNode(bars, bar); - for (const voice of bar.voices) { + for (const voice of activeVoices) { this._writeVoiceNode(voices, voice); for (const beat of voice.beats) { @@ -1831,11 +1832,46 @@ export class GpifWriter { fermataNode.addElement('Offset').innerText = `${numerator}/${denominator}`; } - private _writeBarNode(parent: XmlNode, bar: Bar) { + private _writeBarNode(parent: XmlNode, bar: Bar): Voice[] { const barNode = parent.addElement('Bar'); barNode.attributes.set('id', bar.id.toString()); - barNode.addElement('Voices').innerText = bar.voices.map(v => (v.isEmpty ? '-1' : v.id.toString())).join(' '); + // Guitar Pro's format requires exactly 4 voice slots per bar. Preserve + // voice positions when they fit (index < 4); non-empty voices at index + // >= 4 (produced e.g. by MusicXML with sparse voice numbers) get placed + // into the first free slot. Empty voices at index < 4 keep their slot + // as '-1' so their beats are never written (avoids orphan nodes). + const activeVoices: Voice[] = []; + const slots: string[] = ['-1', '-1', '-1', '-1']; + const overflowVoices: Voice[] = []; + for (let i = 0; i < bar.voices.length; i++) { + const v = bar.voices[i]; + if (i < 4) { + if (!v.isEmpty) { + slots[i] = v.id.toString(); + activeVoices.push(v); + } + } else if (!v.isEmpty) { + overflowVoices.push(v); + } + } + let dropped = 0; + for (const v of overflowVoices) { + const freeSlot = slots.indexOf('-1'); + if (freeSlot >= 0) { + slots[freeSlot] = v.id.toString(); + activeVoices.push(v); + } else { + dropped++; + } + } + if (dropped > 0) { + Logger.warning( + 'GpifWriter', + `Bar ${bar.id} has ${activeVoices.length + dropped} non-empty voices; Guitar Pro supports max 4. Dropping ${dropped}.` + ); + } + barNode.addElement('Voices').innerText = slots.join(' '); barNode.addElement('Clef').innerText = Clef[bar.clef]; if (bar.clefOttava !== Ottavia.Regular) { barNode.addElement('Ottavia').innerText = Ottavia[bar.clefOttava].substr(1); @@ -1843,6 +1879,7 @@ export class GpifWriter { if (bar.simileMark !== SimileMark.None) { barNode.addElement('SimileMark').innerText = SimileMark[bar.simileMark]; } + return activeVoices; } private _writeVoiceNode(parent: XmlNode, voice: Voice) { diff --git a/packages/alphatab/src/importer/Gp3To5Importer.ts b/packages/alphatab/src/importer/Gp3To5Importer.ts index 922d99d45..d34b991cd 100644 --- a/packages/alphatab/src/importer/Gp3To5Importer.ts +++ b/packages/alphatab/src/importer/Gp3To5Importer.ts @@ -1,11 +1,12 @@ -import { GeneralMidi } from '@coderline/alphatab/midi/GeneralMidi'; - import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter'; import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; - import { IOHelper } from '@coderline/alphatab/io/IOHelper'; -import { OverflowError, type IReadable } from '@coderline/alphatab/io/IReadable'; +import { type IReadable, OverflowError } from '@coderline/alphatab/io/IReadable'; +import type { IWriteable } from '@coderline/alphatab/io/IWriteable'; +import { Logger } from '@coderline/alphatab/Logger'; +import { GeneralMidi } from '@coderline/alphatab/midi/GeneralMidi'; import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; +import { AccidentalType } from '@coderline/alphatab/model/AccidentalType'; import { Automation, AutomationType } from '@coderline/alphatab/model/Automation'; import { Bar, BarLineStyle } from '@coderline/alphatab/model/Bar'; import { Beat, BeatBeamingMode } from '@coderline/alphatab/model/Beat'; @@ -14,8 +15,10 @@ import { BrushType } from '@coderline/alphatab/model/BrushType'; import { Chord } from '@coderline/alphatab/model/Chord'; import { Clef } from '@coderline/alphatab/model/Clef'; import { Color } from '@coderline/alphatab/model/Color'; +import { Direction } from '@coderline/alphatab/model/Direction'; import { Duration } from '@coderline/alphatab/model/Duration'; import { DynamicValue } from '@coderline/alphatab/model/DynamicValue'; +import { FadeType } from '@coderline/alphatab/model/FadeType'; import type { Fingers } from '@coderline/alphatab/model/Fingers'; import { GraceType } from '@coderline/alphatab/model/GraceType'; import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; @@ -23,29 +26,23 @@ import type { KeySignature } from '@coderline/alphatab/model/KeySignature'; import type { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; import { Lyrics } from '@coderline/alphatab/model/Lyrics'; import { MasterBar } from '@coderline/alphatab/model/MasterBar'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { Note } from '@coderline/alphatab/model/Note'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; +import { Ottavia } from '@coderline/alphatab/model/Ottavia'; import { PickStroke } from '@coderline/alphatab/model/PickStroke'; import { PlaybackInformation } from '@coderline/alphatab/model/PlaybackInformation'; +import { Rasgueado } from '@coderline/alphatab/model/Rasgueado'; import { Score, ScoreSubElement } from '@coderline/alphatab/model/Score'; import { Section } from '@coderline/alphatab/model/Section'; import { SlideInType } from '@coderline/alphatab/model/SlideInType'; import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; import type { Staff } from '@coderline/alphatab/model/Staff'; import { Track } from '@coderline/alphatab/model/Track'; +import { TremoloPickingEffect } from '@coderline/alphatab/model/TremoloPickingEffect'; import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { Voice } from '@coderline/alphatab/model/Voice'; - -import type { IWriteable } from '@coderline/alphatab/io/IWriteable'; -import { Logger } from '@coderline/alphatab/Logger'; -import { AccidentalType } from '@coderline/alphatab/model/AccidentalType'; -import { Direction } from '@coderline/alphatab/model/Direction'; -import { FadeType } from '@coderline/alphatab/model/FadeType'; -import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; -import { Ottavia } from '@coderline/alphatab/model/Ottavia'; -import { Rasgueado } from '@coderline/alphatab/model/Rasgueado'; -import { TremoloPickingEffect } from '@coderline/alphatab/model/TremoloPickingEffect'; import { WahPedal } from '@coderline/alphatab/model/WahPedal'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; @@ -807,6 +804,33 @@ export class Gp3To5Importer extends ScoreImporter { if (beatCount === 0) { return; } + + // + // fast path where we know we read the full voice + // - its the first voice to read + // - we are multivoice and know we can read all voices in full extend + const currentVoiceCount = bar.index === 0 ? 1 : bar.previousBar!.voices.length; + if (bar.voices.length === 0 || currentVoiceCount === 2) { + this._readAndChainVoice(track, bar, beatCount); + return; + } + + // + // fallback to detection of empty voices we can skip. + // GP5 empty second voices are always a single beat with the isEmpty flag, + // so a beat count above 1 is guaranteed to be real content. + if (beatCount === 1 && this._skipEmptyVoice()) { + return; + } + + // + // secondary fallback: the first filled second voice for this staff. + // we have to backfill and read fully. + ModelUtils.backfillStaffVoices(bar.staff, 2); + this._readAndChainVoice(track, bar, beatCount); + } + + private _readAndChainVoice(track: Track, bar: Bar, beatCount: number) { const newVoice: Voice = new Voice(); bar.addVoice(newVoice); @@ -816,6 +840,38 @@ export class Gp3To5Importer extends ScoreImporter { } } + /** + * Attempts to skip a fully empty voice. + * @returns true if we detected an empty voice, false if the beat was not empty and a full voice has to be read. + */ + private _skipEmptyVoice(): boolean { + const startOfVoice = this.data.position; + + // aligned with readBeat, find out if we are having an empty beat. + const flags = this.data.readByte(); + let isEmpty = false; + if ((flags & 0x40) !== 0) { + const type = this.data.readByte(); + isEmpty = (type & 0x02) === 0; + } + + // NOTE: we don't check any of the other flags on an empty voice, we assume no effects are set to be read. + + // seek back to start of voice for a full read; costs a duplicate read of these bytes. + if (!isEmpty) { + this.data.position = startOfVoice; + return false; + } + + // read the remaining bytes of the empty beat to avoid input stream desync (aligned with readBeat). + this.data.skip(2); // duration + stringFlags + if (this._versionNumber >= 500) { + this.data.skip(2); // flags2 + } + + return true; + } + public readBeat(track: Track, bar: Bar, voice: Voice): void { const newBeat: Beat = new Beat(); const flags: number = this.data.readByte(); diff --git a/packages/alphatab/src/importer/GpifParser.ts b/packages/alphatab/src/importer/GpifParser.ts index 816086db9..eda3fee6e 100644 --- a/packages/alphatab/src/importer/GpifParser.ts +++ b/packages/alphatab/src/importer/GpifParser.ts @@ -1,7 +1,13 @@ +import { BeatCloner } from '@coderline/alphatab/generated/model/BeatCloner'; +import { NoteCloner } from '@coderline/alphatab/generated/model/NoteCloner'; import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError'; +import { Logger } from '@coderline/alphatab/Logger'; +import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; import { AccentuationType } from '@coderline/alphatab/model/AccentuationType'; import { Automation, AutomationType, SyncPointData } from '@coderline/alphatab/model/Automation'; +import { BackingTrack } from '@coderline/alphatab/model/BackingTrack'; import { Bar, BarLineStyle, SustainPedalMarker, SustainPedalMarkerType } from '@coderline/alphatab/model/Bar'; +import { BarreShape } from '@coderline/alphatab/model/BarreShape'; import { Beat, BeatBeamingMode } from '@coderline/alphatab/model/Beat'; import { BendPoint } from '@coderline/alphatab/model/BendPoint'; import { BrushType } from '@coderline/alphatab/model/BrushType'; @@ -9,19 +15,29 @@ import { Chord } from '@coderline/alphatab/model/Chord'; import { Clef } from '@coderline/alphatab/model/Clef'; import { Color } from '@coderline/alphatab/model/Color'; 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'; +import { FadeType } from '@coderline/alphatab/model/FadeType'; import { Fermata, FermataType } from '@coderline/alphatab/model/Fermata'; import { Fingers } from '@coderline/alphatab/model/Fingers'; +import { GolpeType } from '@coderline/alphatab/model/GolpeType'; import { GraceType } from '@coderline/alphatab/model/GraceType'; import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; +import { InstrumentArticulation, TechniqueSymbolPlacement } from '@coderline/alphatab/model/InstrumentArticulation'; import { KeySignature } from '@coderline/alphatab/model/KeySignature'; import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; import { Lyrics } from '@coderline/alphatab/model/Lyrics'; import { BeamingRules, MasterBar } from '@coderline/alphatab/model/MasterBar'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; import { Note } from '@coderline/alphatab/model/Note'; +import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; +import { NoteOrnament } from '@coderline/alphatab/model/NoteOrnament'; import { Ottavia } from '@coderline/alphatab/model/Ottavia'; +import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; import { PickStroke } from '@coderline/alphatab/model/PickStroke'; +import { Rasgueado } from '@coderline/alphatab/model/Rasgueado'; import { Score } from '@coderline/alphatab/model/Score'; import { Section } from '@coderline/alphatab/model/Section'; import { SimileMark } from '@coderline/alphatab/model/SimileMark'; @@ -29,32 +45,15 @@ import { SlideInType } from '@coderline/alphatab/model/SlideInType'; import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; import type { Staff } from '@coderline/alphatab/model/Staff'; import { Track } from '@coderline/alphatab/model/Track'; +import { TremoloPickingEffect } from '@coderline/alphatab/model/TremoloPickingEffect'; import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; +import { Tuning } from '@coderline/alphatab/model/Tuning'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { Voice } from '@coderline/alphatab/model/Voice'; -import type { Settings } from '@coderline/alphatab/Settings'; -import { XmlDocument } from '@coderline/alphatab/xml/XmlDocument'; - -import { BeatCloner } from '@coderline/alphatab/generated/model/BeatCloner'; -import { NoteCloner } from '@coderline/alphatab/generated/model/NoteCloner'; -import { Logger } from '@coderline/alphatab/Logger'; -import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; -import { BackingTrack } from '@coderline/alphatab/model/BackingTrack'; -import { BarreShape } from '@coderline/alphatab/model/BarreShape'; -import { Direction } from '@coderline/alphatab/model/Direction'; -import { FadeType } from '@coderline/alphatab/model/FadeType'; -import { GolpeType } from '@coderline/alphatab/model/GolpeType'; -import { InstrumentArticulation, TechniqueSymbolPlacement } from '@coderline/alphatab/model/InstrumentArticulation'; -import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; -import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; -import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; -import { NoteOrnament } from '@coderline/alphatab/model/NoteOrnament'; -import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; -import { Rasgueado } from '@coderline/alphatab/model/Rasgueado'; -import { Tuning } from '@coderline/alphatab/model/Tuning'; -import { TremoloPickingEffect } from '@coderline/alphatab/model/TremoloPickingEffect'; import { WahPedal } from '@coderline/alphatab/model/WahPedal'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; +import type { Settings } from '@coderline/alphatab/Settings'; +import { XmlDocument } from '@coderline/alphatab/xml/XmlDocument'; import { type XmlNode, XmlNodeType } from '@coderline/alphatab/xml/XmlNode'; /** @@ -127,7 +126,7 @@ export class GpifParser { private _rhythmById!: Map; private _noteById!: Map; private _notesOfBeat!: Map; - private _tappedNotes!: Map; + private _tappedNotes!: Set; private _lyricsByTrack!: Map; private _soundsByTrack!: Map>; private _hasAnacrusis: boolean = false; @@ -160,7 +159,7 @@ export class GpifParser { this._rhythmById = new Map(); this._notesOfBeat = new Map(); this._noteById = new Map(); - this._tappedNotes = new Map(); + this._tappedNotes = new Set(); this._lyricsByTrack = new Map(); this._soundsByTrack = new Map>(); this._skipApplyLyrics = false; @@ -2380,7 +2379,7 @@ export class GpifParser { variation = GpifParser._parseIntSafe(c.findChildElement('Variation')?.innerText, 0); break; case 'Tapped': - this._tappedNotes.set(noteId, true); + this._tappedNotes.add(noteId); break; case 'HarmonicType': const htype = c.findChildElement('HType'); @@ -2668,6 +2667,74 @@ export class GpifParser { this._rhythmById.set(rhythmId, rhythm); } + private _keyFor(trackId: string, base: [KeySignature, KeySignatureType]): [KeySignature, KeySignatureType] { + if (!this._transposeKeySignaturePerTrack.has(trackId)) { + return base; + } + return [ModelUtils.transposeKey(base[0], this._transposeKeySignaturePerTrack.get(trackId)!), base[1]]; + } + + private _equalizeVoiceCount(staff: Staff, bar: Bar, staffVoiceLimits: Map): void { + const previousMax = staffVoiceLimits.get(staff) ?? 0; + const target = Math.max(previousMax, bar.voices.length, 1); + if (target > previousMax) { + staffVoiceLimits.set(staff, target); + ModelUtils.backfillStaffVoices(staff, target); + } + while (bar.voices.length < target) { + ModelUtils.appendPlaceholderVoice(bar); + } + } + + private _attachVoiceToBar(bar: Bar, voiceId: string, staff: Staff): void { + const voice: Voice = this._voiceById.get(voiceId)!; + bar.addVoice(voice); + const beatIds = this._beatsOfVoice.get(voiceId); + if (!beatIds) { + return; + } + for (const beatId of beatIds) { + if (beatId !== GpifParser._invalidId) { + this._attachBeatToVoice(voice, beatId, staff); + } + } + } + + // Beat objects in the parser scratch are shared across bars in gp6, so + // every attachment gets a fresh clone before being wired into the model. + private _attachBeatToVoice(voice: Voice, beatId: string, staff: Staff): void { + const beat: Beat = BeatCloner.clone(this._beatById.get(beatId)!); + voice.addBeat(beat); + const rhythmId: string = this._rhythmOfBeat.get(beatId)!; + const rhythm: GpifRhythm = this._rhythmById.get(rhythmId)!; + beat.duration = rhythm.value; + beat.dots = rhythm.dots; + beat.tupletNumerator = rhythm.tupletNumerator; + beat.tupletDenominator = rhythm.tupletDenominator; + const noteIds = this._notesOfBeat.get(beatId); + if (!noteIds) { + return; + } + for (const noteId of noteIds) { + if (noteId !== GpifParser._invalidId) { + this._attachNoteToBeat(beat, noteId, staff); + } + } + } + + private _attachNoteToBeat(beat: Beat, noteId: string, staff: Staff): void { + const note = NoteCloner.clone(this._noteById.get(noteId)!); + if (staff.isPercussion) { + note.fret = Number.NaN; + } else { + note.percussionArticulation = Number.NaN; + } + beat.addNote(note); + if (this._tappedNotes.has(noteId)) { + beat.tap = true; + } + } + private _buildModel(): void { // build score for (let i: number = 0, j: number = this._masterBars.length; i < j; i++) { @@ -2693,6 +2760,9 @@ export class GpifParser { this.score.addTrack(track); trackIndexToTrackId.push(trackId); } + + const staffVoiceLimits = new Map(); + // process all masterbars let keySignature: [KeySignature, KeySignatureType]; @@ -2701,16 +2771,7 @@ export class GpifParser { let staffIndex = 0; let trackIndex = 0; - keySignature = [KeySignature.C, KeySignatureType.Major]; - if (this._transposeKeySignaturePerTrack.has(trackIndexToTrackId[0])) { - keySignature = [ - ModelUtils.transposeKey( - keySignature[0], - this._transposeKeySignaturePerTrack.get(trackIndexToTrackId[0])! - ), - keySignature[1] - ]; - } + keySignature = this._keyFor(trackIndexToTrackId[0], [KeySignature.C, KeySignatureType.Major]); for ( let barIndex: number = 0; @@ -2718,110 +2779,56 @@ export class GpifParser { barIndex++ ) { const barId: string = barIds[barIndex]; - if (barId !== GpifParser._invalidId) { - const bar: Bar = this._barsById.get(barId)!; - const track: Track = this.score.tracks[trackIndex]; - const staff: Staff = track.staves[staffIndex]; - staff.addBar(bar); - - const masterBarIndex = staff.bars.length - 1; - if (this._keySignatures.has(masterBarIndex)) { - keySignature = this._keySignatures.get(masterBarIndex)!; - if (this._transposeKeySignaturePerTrack.has(trackIndexToTrackId[trackIndex])) { - keySignature = [ - ModelUtils.transposeKey( - keySignature[0], - this._transposeKeySignaturePerTrack.get(trackIndexToTrackId[trackIndex])! - ), - keySignature[1] - ]; - } - } + if (barId === GpifParser._invalidId) { + trackIndex++; + continue; + } + const bar: Bar = this._barsById.get(barId)!; + const track: Track = this.score.tracks[trackIndex]; + const staff: Staff = track.staves[staffIndex]; + staff.addBar(bar); + + const masterBarIndex = staff.bars.length - 1; + if (this._keySignatures.has(masterBarIndex)) { + keySignature = this._keyFor( + trackIndexToTrackId[trackIndex], + this._keySignatures.get(masterBarIndex)! + ); + } - bar.keySignature = keySignature[0]; - bar.keySignatureType = keySignature[1]; + bar.keySignature = keySignature[0]; + bar.keySignatureType = keySignature[1]; - if (this._doubleBars.has(bar.masterBar)) { - bar.barLineRight = BarLineStyle.LightLight; - } + if (this._doubleBars.has(bar.masterBar)) { + bar.barLineRight = BarLineStyle.LightLight; + } - if (this._voicesOfBar.has(barId)) { - // add voices to bars - for (const voiceId of this._voicesOfBar.get(barId)!) { - if (voiceId !== GpifParser._invalidId) { - const voice: Voice = this._voiceById.get(voiceId)!; - bar.addVoice(voice); - if (this._beatsOfVoice.has(voiceId)) { - // add beats to voices - for (const beatId of this._beatsOfVoice.get(voiceId)!) { - if (beatId !== GpifParser._invalidId) { - // important! we clone the beat because beats get reused - // in gp6, our model needs to have unique beats. - const beat: Beat = BeatCloner.clone(this._beatById.get(beatId)!); - voice.addBeat(beat); - const rhythmId: string = this._rhythmOfBeat.get(beatId)!; - const rhythm: GpifRhythm = this._rhythmById.get(rhythmId)!; - // set beat duration - beat.duration = rhythm.value; - beat.dots = rhythm.dots; - beat.tupletNumerator = rhythm.tupletNumerator; - beat.tupletDenominator = rhythm.tupletDenominator; - // add notes to beat - if (this._notesOfBeat.has(beatId)) { - for (const noteId of this._notesOfBeat.get(beatId)!) { - if (noteId !== GpifParser._invalidId) { - const note = NoteCloner.clone(this._noteById.get(noteId)!); - if (staff.isPercussion) { - note.fret = Number.NaN; - } else { - note.percussionArticulation = Number.NaN; - } - beat.addNote(note); - if (this._tappedNotes.has(noteId)) { - beat.tap = true; - } - } - } - } - } - } - } - } else { - // invalid voice -> empty voice - const voice: Voice = new Voice(); - bar.addVoice(voice); - const beat: Beat = new Beat(); - beat.isEmpty = true; - beat.duration = Duration.Quarter; - voice.addBeat(beat); + const voiceIds = this._voicesOfBar.get(barId); + if (voiceIds) { + let pendingPlaceholders = 0; + for (const voiceId of voiceIds) { + if (voiceId === GpifParser._invalidId) { + pendingPlaceholders++; + } else { + while (pendingPlaceholders > 0) { + ModelUtils.appendPlaceholderVoice(bar); + pendingPlaceholders--; } + this._attachVoiceToBar(bar, voiceId, staff); } } - // stave is full? -> next track - if (staffIndex === track.staves.length - 1) { - trackIndex++; - staffIndex = 0; - } else { - staffIndex++; - } - - keySignature = [KeySignature.C, KeySignatureType.Major]; - if ( - trackIndex < trackIndexToTrackId.length && - this._transposeKeySignaturePerTrack.has(trackIndexToTrackId[trackIndex]) - ) { - keySignature = [ - ModelUtils.transposeKey( - keySignature[0], - this._transposeKeySignaturePerTrack.get(trackIndexToTrackId[trackIndex])! - ), - keySignature[1] - ]; - } - } else { - // no bar for track + } + this._equalizeVoiceCount(staff, bar, staffVoiceLimits); + staffIndex++; + if (staffIndex >= track.staves.length) { trackIndex++; + staffIndex = 0; } + + keySignature = + trackIndex < trackIndexToTrackId.length + ? this._keyFor(trackIndexToTrackId[trackIndex], [KeySignature.C, KeySignatureType.Major]) + : [KeySignature.C, KeySignatureType.Major]; } } diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 996002638..0217d0780 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -200,6 +200,19 @@ class TrackInfo { } } +/** + * Tracks how raw MusicXML voice numbers on a staff map into alphaTab's local + * (dense, 0-based) voice slots. Collapses MuseScore's sparse `staff*4+local` + * convention (staff 2 → voices 5..8) into dense per-staff indices. + * @internal + */ +class StaffVoicePacking { + // raw MusicXML voice string -> assigned local voice index on this staff + public readonly mapping: Map = new Map(); + // raw voice strings kept in ascending numeric order (localIndex == index in this array) + public readonly sortedRawVoices: string[] = []; +} + /** * @internal */ @@ -208,6 +221,7 @@ export class MusicXmlImporter extends ScoreImporter { private _idToTrackInfo: Map = new Map(); private _indexToTrackInfo: Map = new Map(); private _staffToContext: Map = new Map(); + private _staffVoicePacking: Map = new Map(); private _currentBarNumberDisplayPart?: BarNumberDisplay; private _currentBarNumberDisplayBar?: BarNumberDisplay; @@ -2402,23 +2416,54 @@ export class MusicXmlImporter extends ScoreImporter { return staff.bars[masterBar.index]; } - private _getOrCreateVoice(bar: Bar, voiceIndex: number): Voice { - let voicesCreated = false; - while (bar.voices.length <= voiceIndex) { - bar.addVoice(new Voice()); - voicesCreated = true; + private _resolveAndPlaceVoice(staff: Staff, rawVoice: string, bar: Bar): Voice { + let packing: StaffVoicePacking; + if (this._staffVoicePacking.has(staff)) { + packing = this._staffVoicePacking.get(staff)!; + } else { + packing = new StaffVoicePacking(); + this._staffVoicePacking.set(staff, packing); } - // ensure voices on all bars - if (voicesCreated) { - for (const b of bar.staff.bars) { - while (b.voices.length <= voiceIndex) { - b.addVoice(new Voice()); - } + if (packing.mapping.has(rawVoice)) { + return bar.voices[packing.mapping.get(rawVoice)!]; + } + + let newVoiceNumber = Number.parseInt(rawVoice, 10); + if (Number.isNaN(newVoiceNumber)) { + Logger.warning('MusicXML', 'Voices need to be specified as numbers'); + newVoiceNumber = 0; + } + + // find sorted-insertion position + let insertPos = packing.sortedRawVoices.length; + for (let i = 0; i < packing.sortedRawVoices.length; i++) { + const existing = Number.parseInt(packing.sortedRawVoices[i], 10); + const existingNumber = Number.isNaN(existing) ? 0 : existing; + if (existingNumber > newVoiceNumber) { + insertPos = i; + break; + } + } + + packing.sortedRawVoices.splice(insertPos, 0, rawVoice); + + // insert a new Voice at insertPos in every bar of the staff, and re-index + for (const b of staff.bars) { + b.voices.splice(insertPos, 0, new Voice()); + for (let i = insertPos; i < b.voices.length; i++) { + b.voices[i].index = i; + b.voices[i].bar = b; } } - return bar.voices[voiceIndex]; + // refresh mapping + packing.mapping.clear(); + for (let i = 0; i < packing.sortedRawVoices.length; i++) { + packing.mapping.set(packing.sortedRawVoices[i], i); + } + + return bar.voices[insertPos]; } private _parseNote(element: XmlNode, masterBar: MasterBar, track: Track) { @@ -2433,7 +2478,7 @@ export class MusicXmlImporter extends ScoreImporter { let isChord = false; let staffIndex = 0; - let voiceIndex = 0; + let voiceRaw: string = '1'; let durationInTicks = -1; let beatDuration: Duration | null = null; @@ -2478,7 +2523,7 @@ export class MusicXmlImporter extends ScoreImporter { } const bar = this._getOrCreateBar(staff, masterBar); - const voice = this._getOrCreateVoice(bar, voiceIndex); + const voice = this._resolveAndPlaceVoice(staff, voiceRaw, bar); const actualMusicalPosition = voice.beats.length === 0 ? 0 : voice.beats[voice.beats.length - 1].displayEnd; @@ -2664,15 +2709,11 @@ export class MusicXmlImporter extends ScoreImporter { // case 'footnote': Ignored // case 'level': Ignored - case 'voice': - voiceIndex = Number.parseInt(c.innerText, 10); - if (Number.isNaN(voiceIndex)) { - Logger.warning('MusicXML', 'Voices need to be specified as numbers'); - voiceIndex = 0; - } else { - voiceIndex = voiceIndex - 1; - } + case 'voice': { + const trimmed = c.innerText.trim(); + voiceRaw = trimmed.length > 0 ? trimmed : '1'; break; + } case 'type': beatDuration = this._parseBeatDuration(c); break; diff --git a/packages/alphatab/src/model/GraceGroup.ts b/packages/alphatab/src/model/GraceGroup.ts index e9f04cd4b..e287b4648 100644 --- a/packages/alphatab/src/model/GraceGroup.ts +++ b/packages/alphatab/src/model/GraceGroup.ts @@ -33,7 +33,7 @@ export class GraceGroup { public finish() { if (this.beats.length > 0) { - this.id = `${this.beats[0].absoluteDisplayStart}_${this.beats[0].voice.index}`; + this.id = `${this.beats[0].voice.bar.id}_${this.beats[0].voice.index}_${this.beats[0].absoluteDisplayStart}`; } } } diff --git a/packages/alphatab/src/model/ModelUtils.ts b/packages/alphatab/src/model/ModelUtils.ts index eb914357f..18e103bc0 100644 --- a/packages/alphatab/src/model/ModelUtils.ts +++ b/packages/alphatab/src/model/ModelUtils.ts @@ -8,6 +8,7 @@ import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; import { MasterBar } from '@coderline/alphatab/model/MasterBar'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; import { HeaderFooterStyle, type Score, ScoreStyle, type ScoreSubElement } from '@coderline/alphatab/model/Score'; +import type { Staff } from '@coderline/alphatab/model/Staff'; import type { Track } from '@coderline/alphatab/model/Track'; import { Voice } from '@coderline/alphatab/model/Voice'; import type { Settings } from '@coderline/alphatab/Settings'; @@ -550,6 +551,24 @@ export class ModelUtils { return headerFooterStyle; } + public static backfillStaffVoices(staff: Staff, targetVoiceCount: number): void { + for (let bi = 0; bi < staff.bars.length - 1; bi++) { + const priorBar = staff.bars[bi]; + while (priorBar.voices.length < targetVoiceCount) { + ModelUtils.appendPlaceholderVoice(priorBar); + } + } + } + + public static appendPlaceholderVoice(bar: Bar): void { + const voice: Voice = new Voice(); + bar.addVoice(voice); + const beat: Beat = new Beat(); + beat.isEmpty = true; + beat.duration = Duration.Quarter; + voice.addBeat(beat); + } + /** * Performs some general consolidations of inconsistencies on the given score like * missing bars, beats, duplicated midi channels etc @@ -627,12 +646,7 @@ export class ModelUtils { } for (let i = 0; i < voiceCount; i++) { - const v = new Voice(); - bar.addVoice(v); - - const emptyBeat: Beat = new Beat(); - emptyBeat.isEmpty = true; - v.addBeat(emptyBeat); + ModelUtils.appendPlaceholderVoice(bar); } } } @@ -1130,4 +1144,18 @@ export class ModelUtils { ? ModelUtils._minorKeySignatureTonicDegrees[ksi] : ModelUtils._majorKeySignatureTonicDegrees[ksi]; } + + public static staffNotesAreNotStringed(staff: Staff) { + // hunt for first actual note + for (const bar of staff.bars) { + for (const voice of bar.voices) { + for (const beat of voice.beats) { + for (const note of beat.notes) { + return !note.isStringed; + } + } + } + } + return true; + } } diff --git a/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png b/packages/alphatab/test-data/musicxml-samples/BeetAnGeSample.png index a1bf6585f..a1a40b690 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/Dichterliebe01.png b/packages/alphatab/test-data/musicxml-samples/Dichterliebe01.png index 0d41cc8ca..190a52f3c 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 99cc4ab25..77e45cafc 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-samples/MozaChloSample.png b/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png index a26a93625..17a2c50dd 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png and b/packages/alphatab/test-data/musicxml-samples/MozaChloSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png b/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png index 6368a87dc..e81a56bf3 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png and b/packages/alphatab/test-data/musicxml-samples/MozaVeilSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png b/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png index 771749a71..83f634f12 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png and b/packages/alphatab/test-data/musicxml-samples/MozartPianoSonata.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png b/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png index 003206d40..f35a3dda8 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png and b/packages/alphatab/test-data/musicxml-samples/SchbAvMaSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/Telemann.png b/packages/alphatab/test-data/musicxml-samples/Telemann.png index 7495e73a4..2239dd9b4 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Telemann.png and b/packages/alphatab/test-data/musicxml-samples/Telemann.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/02e-Rests-NoType.png b/packages/alphatab/test-data/musicxml-testsuite/02e-Rests-NoType.png index 8a2568bd3..526d8b67b 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/02e-Rests-NoType.png and b/packages/alphatab/test-data/musicxml-testsuite/02e-Rests-NoType.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/11b-TimeSignatures-NoTime.png b/packages/alphatab/test-data/musicxml-testsuite/11b-TimeSignatures-NoTime.png index 72bcc66c8..0564a36dd 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/11b-TimeSignatures-NoTime.png and b/packages/alphatab/test-data/musicxml-testsuite/11b-TimeSignatures-NoTime.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/23f-Tuplets-DurationButNoBracket.png b/packages/alphatab/test-data/musicxml-testsuite/23f-Tuplets-DurationButNoBracket.png index 627ee51f4..d90c78124 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/23f-Tuplets-DurationButNoBracket.png and b/packages/alphatab/test-data/musicxml-testsuite/23f-Tuplets-DurationButNoBracket.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png b/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png index 2bdac1a42..e97d84c8e 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png and b/packages/alphatab/test-data/musicxml-testsuite/24h-GraceNote-Simultaneous.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/41c-StaffGroups.png b/packages/alphatab/test-data/musicxml-testsuite/41c-StaffGroups.png index c867b41d4..692e1f625 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/41c-StaffGroups.png and b/packages/alphatab/test-data/musicxml-testsuite/41c-StaffGroups.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/42b-MultiVoice-MidMeasureClefChange.png b/packages/alphatab/test-data/musicxml-testsuite/42b-MultiVoice-MidMeasureClefChange.png index 64b44b953..de961bd10 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/42b-MultiVoice-MidMeasureClefChange.png and b/packages/alphatab/test-data/musicxml-testsuite/42b-MultiVoice-MidMeasureClefChange.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/43a-PianoStaff.png b/packages/alphatab/test-data/musicxml-testsuite/43a-PianoStaff.png index a122e6e00..787e28e64 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/43a-PianoStaff.png and b/packages/alphatab/test-data/musicxml-testsuite/43a-PianoStaff.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/43b-MultiStaff-DifferentKeys.png b/packages/alphatab/test-data/musicxml-testsuite/43b-MultiStaff-DifferentKeys.png index 212d73082..b0f3db638 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/43b-MultiStaff-DifferentKeys.png and b/packages/alphatab/test-data/musicxml-testsuite/43b-MultiStaff-DifferentKeys.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/43c-MultiStaff-DifferentKeysAfterBackup.png b/packages/alphatab/test-data/musicxml-testsuite/43c-MultiStaff-DifferentKeysAfterBackup.png index fac6fe6a6..afe43659a 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/43c-MultiStaff-DifferentKeysAfterBackup.png and b/packages/alphatab/test-data/musicxml-testsuite/43c-MultiStaff-DifferentKeysAfterBackup.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/43d-MultiStaff-StaffChange.png b/packages/alphatab/test-data/musicxml-testsuite/43d-MultiStaff-StaffChange.png index 9f209cbff..c464021ba 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/43d-MultiStaff-StaffChange.png and b/packages/alphatab/test-data/musicxml-testsuite/43d-MultiStaff-StaffChange.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png b/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png index a0f758edd..eb5009841 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png and b/packages/alphatab/test-data/musicxml-testsuite/43e-Multistaff-ClefDynamics.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/43f-MultiStaff-Lyrics.png b/packages/alphatab/test-data/musicxml-testsuite/43f-MultiStaff-Lyrics.png index 04982c222..4b1c2f105 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/43f-MultiStaff-Lyrics.png and b/packages/alphatab/test-data/musicxml-testsuite/43f-MultiStaff-Lyrics.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/43g-MultiStaff-PartSymbol.png b/packages/alphatab/test-data/musicxml-testsuite/43g-MultiStaff-PartSymbol.png index 93eabf63e..a6942b22f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/43g-MultiStaff-PartSymbol.png and b/packages/alphatab/test-data/musicxml-testsuite/43g-MultiStaff-PartSymbol.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61c-Lyrics-Pianostaff.png b/packages/alphatab/test-data/musicxml-testsuite/61c-Lyrics-Pianostaff.png index b267f1a84..149a3778b 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61c-Lyrics-Pianostaff.png and b/packages/alphatab/test-data/musicxml-testsuite/61c-Lyrics-Pianostaff.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/71d-ChordsFrets-Multistaff.png b/packages/alphatab/test-data/musicxml-testsuite/71d-ChordsFrets-Multistaff.png index 2b9765c72..e86a7e09f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/71d-ChordsFrets-Multistaff.png and b/packages/alphatab/test-data/musicxml-testsuite/71d-ChordsFrets-Multistaff.png differ diff --git a/packages/alphatab/test-data/musicxml3/track-volume-balance.png b/packages/alphatab/test-data/musicxml3/track-volume-balance.png index 417c438d2..ebf63e7ce 100644 Binary files a/packages/alphatab/test-data/musicxml3/track-volume-balance.png and b/packages/alphatab/test-data/musicxml3/track-volume-balance.png differ diff --git a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hidden-dots.png b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hidden-dots.png index 2480948f3..8dec14c07 100644 Binary files a/packages/alphatab/test-data/visual-tests/effects-and-annotations/hidden-dots.png and b/packages/alphatab/test-data/visual-tests/effects-and-annotations/hidden-dots.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/colors.png b/packages/alphatab/test-data/visual-tests/general/colors.png index ed5e99b4d..75f28ef37 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/colors.png and b/packages/alphatab/test-data/visual-tests/general/colors.png differ diff --git a/packages/alphatab/test/importer/__snapshots__/MusicXmlImporter.test.ts.snap b/packages/alphatab/test/importer/__snapshots__/MusicXmlImporter.test.ts.snap index 88982a96c..ec9111e9a 100644 --- a/packages/alphatab/test/importer/__snapshots__/MusicXmlImporter.test.ts.snap +++ b/packages/alphatab/test/importer/__snapshots__/MusicXmlImporter.test.ts.snap @@ -31,7 +31,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 0, + "id" => 1, "beats" => [ Map { "__kind" => "Beat", @@ -60,6 +60,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 0, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 1, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], }, ], @@ -600,7 +613,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 0, + "id" => 1, "beats" => [ Map { "__kind" => "Beat", @@ -696,6 +709,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 0, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 4, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], }, ], @@ -1397,7 +1423,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 0, + "id" => 1, "beats" => [ Map { "__kind" => "Beat", @@ -1478,6 +1504,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 0, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 28, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], }, Map { @@ -1486,7 +1525,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 3, + "id" => 5, "beats" => [ Map { "__kind" => "Beat", @@ -1557,6 +1596,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 6, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 29, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], }, ], @@ -1571,7 +1623,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 1, + "id" => 3, "beats" => [ Map { "__kind" => "Beat", @@ -1603,7 +1655,7 @@ Map { }, Map { "__kind" => "Voice", - "id" => 2, + "id" => 4, "beats" => [ Map { "__kind" => "Beat", @@ -1634,6 +1686,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 2, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 30, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], }, Map { @@ -1642,7 +1707,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 4, + "id" => 7, "beats" => [ Map { "__kind" => "Beat", @@ -1674,7 +1739,7 @@ Map { }, Map { "__kind" => "Voice", - "id" => 5, + "id" => 8, "beats" => [ Map { "__kind" => "Beat", @@ -1705,6 +1770,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 9, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 31, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], }, ], @@ -1729,7 +1807,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 6, + "id" => 11, "beats" => [ Map { "__kind" => "Beat", @@ -1772,7 +1850,7 @@ Map { }, Map { "__kind" => "Voice", - "id" => 7, + "id" => 12, "beats" => [ Map { "__kind" => "Beat", @@ -1804,7 +1882,7 @@ Map { }, Map { "__kind" => "Voice", - "id" => 8, + "id" => 13, "beats" => [ Map { "__kind" => "Beat", @@ -1835,6 +1913,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 10, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 32, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], "keysignature" => 4, }, @@ -1844,7 +1935,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 9, + "id" => 14, "beats" => [ Map { "__kind" => "Beat", @@ -1877,7 +1968,7 @@ Map { }, Map { "__kind" => "Voice", - "id" => 10, + "id" => 15, "beats" => [ Map { "__kind" => "Beat", @@ -1909,7 +2000,7 @@ Map { }, Map { "__kind" => "Voice", - "id" => 11, + "id" => 16, "beats" => [ Map { "__kind" => "Beat", @@ -1940,6 +2031,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 17, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 33, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], "keysignature" => 4, }, @@ -1993,7 +2097,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 0, + "id" => 2, "beats" => [ Map { "__kind" => "Beat", @@ -2107,6 +2211,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 0, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 47, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], "keysignature" => 4, }, @@ -2123,7 +2240,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 1, + "id" => 3, "beats" => [ Map { "__kind" => "Beat", @@ -2358,48 +2475,9 @@ Map { }, ], }, - Map { - "__kind" => "Voice", - "id" => 2, - "beats" => [ - Map { - "__kind" => "Beat", - "id" => 47, - "isempty" => true, - "displayduration" => 960, - "playbackduration" => 960, - }, - ], - }, - Map { - "__kind" => "Voice", - "id" => 3, - "beats" => [ - Map { - "__kind" => "Beat", - "id" => 48, - "isempty" => true, - "displayduration" => 960, - "playbackduration" => 960, - }, - ], - }, Map { "__kind" => "Voice", "id" => 4, - "beats" => [ - Map { - "__kind" => "Beat", - "id" => 49, - "isempty" => true, - "displayduration" => 960, - "playbackduration" => 960, - }, - ], - }, - Map { - "__kind" => "Voice", - "id" => 5, "beats" => [ Map { "__kind" => "Beat", @@ -2570,6 +2648,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 1, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 48, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], "keysignature" => 4, }, @@ -2768,6 +2859,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 5, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 49, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], }, ], @@ -2782,7 +2886,7 @@ Map { "voices" => [ Map { "__kind" => "Voice", - "id" => 7, + "id" => 8, "beats" => [ Map { "__kind" => "Beat", @@ -2986,6 +3090,19 @@ Map { }, ], }, + Map { + "__kind" => "Voice", + "id" => 7, + "beats" => [ + Map { + "__kind" => "Beat", + "id" => 50, + "isempty" => true, + "displayduration" => 960, + "playbackduration" => 960, + }, + ], + }, ], }, ],