Skip to content

Commit a61804a

Browse files
authored
fix(musicxml gp-exporter): optimized handling of more than 4 voices (#2831)
1 parent 5faabb2 commit a61804a

33 files changed

Lines changed: 519 additions & 233 deletions

‎packages/alphatab/src/exporter/GpifWriter.ts‎

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { GpifSoundMapper } from '@coderline/alphatab/exporter/GpifSoundMapper';
22
import { VersionInfo } from '@coderline/alphatab/generated/VersionInfo';
3+
import { Logger } from '@coderline/alphatab/Logger';
34
import { GeneralMidi } from '@coderline/alphatab/midi/GeneralMidi';
45
import { MidiFileGenerator } from '@coderline/alphatab/midi/MidiFileGenerator';
56
import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils';
@@ -107,9 +108,9 @@ export class GpifWriter {
107108
for (const tracks of score.tracks) {
108109
for (const staff of tracks.staves) {
109110
for (const bar of staff.bars) {
110-
this._writeBarNode(bars, bar);
111+
const activeVoices = this._writeBarNode(bars, bar);
111112

112-
for (const voice of bar.voices) {
113+
for (const voice of activeVoices) {
113114
this._writeVoiceNode(voices, voice);
114115

115116
for (const beat of voice.beats) {
@@ -1831,18 +1832,54 @@ export class GpifWriter {
18311832
fermataNode.addElement('Offset').innerText = `${numerator}/${denominator}`;
18321833
}
18331834

1834-
private _writeBarNode(parent: XmlNode, bar: Bar) {
1835+
private _writeBarNode(parent: XmlNode, bar: Bar): Voice[] {
18351836
const barNode = parent.addElement('Bar');
18361837
barNode.attributes.set('id', bar.id.toString());
18371838

1838-
barNode.addElement('Voices').innerText = bar.voices.map(v => (v.isEmpty ? '-1' : v.id.toString())).join(' ');
1839+
// Guitar Pro's format requires exactly 4 voice slots per bar. Preserve
1840+
// voice positions when they fit (index < 4); non-empty voices at index
1841+
// >= 4 (produced e.g. by MusicXML with sparse voice numbers) get placed
1842+
// into the first free slot. Empty voices at index < 4 keep their slot
1843+
// as '-1' so their beats are never written (avoids orphan <Beat> nodes).
1844+
const activeVoices: Voice[] = [];
1845+
const slots: string[] = ['-1', '-1', '-1', '-1'];
1846+
const overflowVoices: Voice[] = [];
1847+
for (let i = 0; i < bar.voices.length; i++) {
1848+
const v = bar.voices[i];
1849+
if (i < 4) {
1850+
if (!v.isEmpty) {
1851+
slots[i] = v.id.toString();
1852+
activeVoices.push(v);
1853+
}
1854+
} else if (!v.isEmpty) {
1855+
overflowVoices.push(v);
1856+
}
1857+
}
1858+
let dropped = 0;
1859+
for (const v of overflowVoices) {
1860+
const freeSlot = slots.indexOf('-1');
1861+
if (freeSlot >= 0) {
1862+
slots[freeSlot] = v.id.toString();
1863+
activeVoices.push(v);
1864+
} else {
1865+
dropped++;
1866+
}
1867+
}
1868+
if (dropped > 0) {
1869+
Logger.warning(
1870+
'GpifWriter',
1871+
`Bar ${bar.id} has ${activeVoices.length + dropped} non-empty voices; Guitar Pro supports max 4. Dropping ${dropped}.`
1872+
);
1873+
}
1874+
barNode.addElement('Voices').innerText = slots.join(' ');
18391875
barNode.addElement('Clef').innerText = Clef[bar.clef];
18401876
if (bar.clefOttava !== Ottavia.Regular) {
18411877
barNode.addElement('Ottavia').innerText = Ottavia[bar.clefOttava].substr(1);
18421878
}
18431879
if (bar.simileMark !== SimileMark.None) {
18441880
barNode.addElement('SimileMark').innerText = SimileMark[bar.simileMark];
18451881
}
1882+
return activeVoices;
18461883
}
18471884

18481885
private _writeVoiceNode(parent: XmlNode, voice: Voice) {

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

Lines changed: 70 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { GeneralMidi } from '@coderline/alphatab/midi/GeneralMidi';
2-
31
import { ScoreImporter } from '@coderline/alphatab/importer/ScoreImporter';
42
import { UnsupportedFormatError } from '@coderline/alphatab/importer/UnsupportedFormatError';
5-
63
import { IOHelper } from '@coderline/alphatab/io/IOHelper';
7-
import { OverflowError, type IReadable } from '@coderline/alphatab/io/IReadable';
4+
import { type IReadable, OverflowError } from '@coderline/alphatab/io/IReadable';
5+
import type { IWriteable } from '@coderline/alphatab/io/IWriteable';
6+
import { Logger } from '@coderline/alphatab/Logger';
7+
import { GeneralMidi } from '@coderline/alphatab/midi/GeneralMidi';
88
import { AccentuationType } from '@coderline/alphatab/model/AccentuationType';
9+
import { AccidentalType } from '@coderline/alphatab/model/AccidentalType';
910
import { Automation, AutomationType } from '@coderline/alphatab/model/Automation';
1011
import { Bar, BarLineStyle } from '@coderline/alphatab/model/Bar';
1112
import { Beat, BeatBeamingMode } from '@coderline/alphatab/model/Beat';
@@ -14,38 +15,34 @@ import { BrushType } from '@coderline/alphatab/model/BrushType';
1415
import { Chord } from '@coderline/alphatab/model/Chord';
1516
import { Clef } from '@coderline/alphatab/model/Clef';
1617
import { Color } from '@coderline/alphatab/model/Color';
18+
import { Direction } from '@coderline/alphatab/model/Direction';
1719
import { Duration } from '@coderline/alphatab/model/Duration';
1820
import { DynamicValue } from '@coderline/alphatab/model/DynamicValue';
21+
import { FadeType } from '@coderline/alphatab/model/FadeType';
1922
import type { Fingers } from '@coderline/alphatab/model/Fingers';
2023
import { GraceType } from '@coderline/alphatab/model/GraceType';
2124
import { HarmonicType } from '@coderline/alphatab/model/HarmonicType';
2225
import type { KeySignature } from '@coderline/alphatab/model/KeySignature';
2326
import type { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType';
2427
import { Lyrics } from '@coderline/alphatab/model/Lyrics';
2528
import { MasterBar } from '@coderline/alphatab/model/MasterBar';
29+
import { ModelUtils } from '@coderline/alphatab/model/ModelUtils';
2630
import { Note } from '@coderline/alphatab/model/Note';
2731
import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode';
32+
import { Ottavia } from '@coderline/alphatab/model/Ottavia';
2833
import { PickStroke } from '@coderline/alphatab/model/PickStroke';
2934
import { PlaybackInformation } from '@coderline/alphatab/model/PlaybackInformation';
35+
import { Rasgueado } from '@coderline/alphatab/model/Rasgueado';
3036
import { Score, ScoreSubElement } from '@coderline/alphatab/model/Score';
3137
import { Section } from '@coderline/alphatab/model/Section';
3238
import { SlideInType } from '@coderline/alphatab/model/SlideInType';
3339
import { SlideOutType } from '@coderline/alphatab/model/SlideOutType';
3440
import type { Staff } from '@coderline/alphatab/model/Staff';
3541
import { Track } from '@coderline/alphatab/model/Track';
42+
import { TremoloPickingEffect } from '@coderline/alphatab/model/TremoloPickingEffect';
3643
import { TripletFeel } from '@coderline/alphatab/model/TripletFeel';
3744
import { VibratoType } from '@coderline/alphatab/model/VibratoType';
3845
import { Voice } from '@coderline/alphatab/model/Voice';
39-
40-
import type { IWriteable } from '@coderline/alphatab/io/IWriteable';
41-
import { Logger } from '@coderline/alphatab/Logger';
42-
import { AccidentalType } from '@coderline/alphatab/model/AccidentalType';
43-
import { Direction } from '@coderline/alphatab/model/Direction';
44-
import { FadeType } from '@coderline/alphatab/model/FadeType';
45-
import { ModelUtils } from '@coderline/alphatab/model/ModelUtils';
46-
import { Ottavia } from '@coderline/alphatab/model/Ottavia';
47-
import { Rasgueado } from '@coderline/alphatab/model/Rasgueado';
48-
import { TremoloPickingEffect } from '@coderline/alphatab/model/TremoloPickingEffect';
4946
import { WahPedal } from '@coderline/alphatab/model/WahPedal';
5047
import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection';
5148

@@ -807,6 +804,33 @@ export class Gp3To5Importer extends ScoreImporter {
807804
if (beatCount === 0) {
808805
return;
809806
}
807+
808+
//
809+
// fast path where we know we read the full voice
810+
// - its the first voice to read
811+
// - we are multivoice and know we can read all voices in full extend
812+
const currentVoiceCount = bar.index === 0 ? 1 : bar.previousBar!.voices.length;
813+
if (bar.voices.length === 0 || currentVoiceCount === 2) {
814+
this._readAndChainVoice(track, bar, beatCount);
815+
return;
816+
}
817+
818+
//
819+
// fallback to detection of empty voices we can skip.
820+
// GP5 empty second voices are always a single beat with the isEmpty flag,
821+
// so a beat count above 1 is guaranteed to be real content.
822+
if (beatCount === 1 && this._skipEmptyVoice()) {
823+
return;
824+
}
825+
826+
//
827+
// secondary fallback: the first filled second voice for this staff.
828+
// we have to backfill and read fully.
829+
ModelUtils.backfillStaffVoices(bar.staff, 2);
830+
this._readAndChainVoice(track, bar, beatCount);
831+
}
832+
833+
private _readAndChainVoice(track: Track, bar: Bar, beatCount: number) {
810834
const newVoice: Voice = new Voice();
811835
bar.addVoice(newVoice);
812836

@@ -816,6 +840,38 @@ export class Gp3To5Importer extends ScoreImporter {
816840
}
817841
}
818842

843+
/**
844+
* Attempts to skip a fully empty voice.
845+
* @returns true if we detected an empty voice, false if the beat was not empty and a full voice has to be read.
846+
*/
847+
private _skipEmptyVoice(): boolean {
848+
const startOfVoice = this.data.position;
849+
850+
// aligned with readBeat, find out if we are having an empty beat.
851+
const flags = this.data.readByte();
852+
let isEmpty = false;
853+
if ((flags & 0x40) !== 0) {
854+
const type = this.data.readByte();
855+
isEmpty = (type & 0x02) === 0;
856+
}
857+
858+
// NOTE: we don't check any of the other flags on an empty voice, we assume no effects are set to be read.
859+
860+
// seek back to start of voice for a full read; costs a duplicate read of these bytes.
861+
if (!isEmpty) {
862+
this.data.position = startOfVoice;
863+
return false;
864+
}
865+
866+
// read the remaining bytes of the empty beat to avoid input stream desync (aligned with readBeat).
867+
this.data.skip(2); // duration + stringFlags
868+
if (this._versionNumber >= 500) {
869+
this.data.skip(2); // flags2
870+
}
871+
872+
return true;
873+
}
874+
819875
public readBeat(track: Track, bar: Bar, voice: Voice): void {
820876
const newBeat: Beat = new Beat();
821877
const flags: number = this.data.readByte();

0 commit comments

Comments
 (0)