Skip to content

Commit 2a460d7

Browse files
authored
fix(exporter): ensure string and fret for all staff types (#2832)
1 parent 0e98485 commit 2a460d7

23 files changed

Lines changed: 1138 additions & 52 deletions

File tree

packages/alphatab/src/exporter/GpifWriter.ts

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Duration } from '@coderline/alphatab/model/Duration';
1919
import { DynamicValue } from '@coderline/alphatab/model/DynamicValue';
2020
import { FadeType } from '@coderline/alphatab/model/FadeType';
2121
import { type Fermata, FermataType } from '@coderline/alphatab/model/Fermata';
22+
import { FingeringAssigner } from '@coderline/alphatab/model/FingeringAssigner';
2223
import { Fingers } from '@coderline/alphatab/model/Fingers';
2324
import { GolpeType } from '@coderline/alphatab/model/GolpeType';
2425
import { GraceType } from '@coderline/alphatab/model/GraceType';
@@ -34,6 +35,7 @@ import type { Note } from '@coderline/alphatab/model/Note';
3435
import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode';
3536
import { NoteOrnament } from '@coderline/alphatab/model/NoteOrnament';
3637
import { Ottavia } from '@coderline/alphatab/model/Ottavia';
38+
import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper';
3739
import { PickStroke } from '@coderline/alphatab/model/PickStroke';
3840
import { Rasgueado } from '@coderline/alphatab/model/Rasgueado';
3941
import type { Score } from '@coderline/alphatab/model/Score';
@@ -43,6 +45,7 @@ import { SlideOutType } from '@coderline/alphatab/model/SlideOutType';
4345
import type { Staff } from '@coderline/alphatab/model/Staff';
4446
import type { Track } from '@coderline/alphatab/model/Track';
4547
import { TripletFeel } from '@coderline/alphatab/model/TripletFeel';
48+
import { Tuning } from '@coderline/alphatab/model/Tuning';
4649
import { VibratoType } from '@coderline/alphatab/model/VibratoType';
4750
import type { Voice } from '@coderline/alphatab/model/Voice';
4851
import { WahPedal } from '@coderline/alphatab/model/WahPedal';
@@ -63,11 +66,13 @@ export class GpifWriter {
6366
private static readonly _sampleRate = 44100;
6467

6568
private _rhythmIdLookup: Map<string, string> = new Map<string, string>();
69+
private _tuningByStaff: Map<Staff, number[]> = new Map<Staff, number[]>();
6670

6771
public writeXml(score: Score): string {
6872
const xmlDocument = new XmlDocument();
6973

7074
this._rhythmIdLookup = new Map<string, string>();
75+
this._tuningByStaff = new Map<Staff, number[]>();
7176

7277
this._writeDom(xmlDocument, score);
7378

@@ -107,13 +112,38 @@ export class GpifWriter {
107112

108113
for (const tracks of score.tracks) {
109114
for (const staff of tracks.staves) {
115+
const needsFingering = ModelUtils.staffNotesAreNotStringed(staff);
116+
const assignersByVoiceIndex = needsFingering ? new Map<number, FingeringAssigner>() : null;
117+
const stringedTuning = needsFingering ? this._tuningByStaff.get(staff)! : null;
118+
// Once the assigner sets note.string/note.fret, note.realValue
119+
// routes through staff.tuning — needs to match the tuning we
120+
// gave the assigner. Save + restore below leaves the input
121+
// model untouched.
122+
const savedTunings = staff.tuning;
123+
if (needsFingering && stringedTuning !== null && savedTunings.length === 0) {
124+
staff.stringTuning.tunings = stringedTuning.slice();
125+
}
126+
110127
for (const bar of staff.bars) {
111128
const activeVoices = this._writeBarNode(bars, bar);
112129

113130
for (const voice of activeVoices) {
131+
let assigner: FingeringAssigner | null = null;
132+
if (assignersByVoiceIndex !== null && stringedTuning !== null) {
133+
if (assignersByVoiceIndex.has(voice.index)) {
134+
assigner = assignersByVoiceIndex.get(voice.index)!;
135+
} else {
136+
assigner = new FingeringAssigner(stringedTuning, staff.capo, staff.transpositionPitch);
137+
assignersByVoiceIndex.set(voice.index, assigner);
138+
}
139+
}
140+
114141
this._writeVoiceNode(voices, voice);
115142

116143
for (const beat of voice.beats) {
144+
if (assigner !== null) {
145+
assigner.assign(beat);
146+
}
117147
this._writeBeatNode(beats, beat, rhythms);
118148

119149
for (const note of beat.notes) {
@@ -122,6 +152,7 @@ export class GpifWriter {
122152
}
123153
}
124154
}
155+
staff.stringTuning.tunings = savedTunings;
125156
}
126157
}
127158
}
@@ -282,7 +313,13 @@ export class GpifWriter {
282313
this._writeConcertPitch(properties, note);
283314
this._writeTransposedPitch(properties, note);
284315

285-
if (note.isStringed) {
316+
if (note.isPercussion) {
317+
const art = PercussionMapper.getArticulation(note);
318+
const midi = art !== null ? art.outputMidiNumber : 0;
319+
this._writeSimplePropertyNode(properties, 'String', 'String', (note.string - 1).toString());
320+
this._writeSimplePropertyNode(properties, 'Fret', 'Fret', midi.toString());
321+
this._writeSimplePropertyNode(properties, 'Midi', 'Number', midi.toString());
322+
} else if (note.isStringed) {
286323
this._writeSimplePropertyNode(properties, 'String', 'String', (note.string - 1).toString());
287324
this._writeSimplePropertyNode(properties, 'Fret', 'Fret', note.fret.toString());
288325
this._writeSimplePropertyNode(properties, 'Midi', 'Number', note.realValue.toString());
@@ -291,10 +328,6 @@ export class GpifWriter {
291328
}
292329
}
293330

294-
if (note.isPercussion) {
295-
this._writeSimplePropertyNode(properties, 'String', 'String', (note.string - 1).toString());
296-
}
297-
298331
if (note.isPiano) {
299332
this._writeSimplePropertyNode(properties, 'Octave', 'Number', note.octave.toString());
300333
this._writeSimplePropertyNode(properties, 'Tone', 'Step', note.tone.toString());
@@ -393,7 +426,7 @@ export class GpifWriter {
393426

394427
private _writeTransposedPitch(properties: XmlNode, note: Note) {
395428
if (note.isPercussion) {
396-
this._writePitch(properties, 'ConcertPitch', 'C', '-1', '');
429+
this._writePitch(properties, 'TransposedPitch', 'C', '-1', '');
397430
} else {
398431
this._writePitchForValue(
399432
properties,
@@ -998,7 +1031,7 @@ export class GpifWriter {
9981031
initialTempoAutomation.addElement('Bar').innerText = '0';
9991032
initialTempoAutomation.addElement('Position').innerText = '0';
10001033
initialTempoAutomation.addElement('Visible').innerText = 'true';
1001-
initialTempoAutomation.addElement('Value').innerText = `${score.tempo} 2`;
1034+
initialTempoAutomation.addElement('Value').innerText = `${score.tempo | 0} 2`;
10021035
if (score.tempoLabel) {
10031036
initialTempoAutomation.addElement('Text').innerText = score.tempoLabel;
10041037
}
@@ -1024,7 +1057,7 @@ export class GpifWriter {
10241057
tempoAutomation.addElement('Bar').innerText = mb.index.toString();
10251058
tempoAutomation.addElement('Position').innerText = automation.ratioPosition.toString();
10261059
tempoAutomation.addElement('Visible').innerText = automation.isVisible ? 'true' : 'false';
1027-
tempoAutomation.addElement('Value').innerText = `${automation.value} 2`;
1060+
tempoAutomation.addElement('Value').innerText = `${automation.value | 0} 2`;
10281061
if (automation.text) {
10291062
tempoAutomation.addElement('Text').innerText = automation.text;
10301063
}
@@ -1271,15 +1304,34 @@ export class GpifWriter {
12711304
this._writeSimplePropertyNode(properties, 'CapoFret', 'Fret', staff.capo.toString());
12721305
this._writeSimplePropertyNode(properties, 'FretCount', 'Fret', '24');
12731306

1274-
if (staff.tuning.length > 0) {
1307+
// GP7/8 requires every staff to carry a stringed tuning.
1308+
let tuning = staff.tuning;
1309+
let tuningName = staff.tuningName;
1310+
if (tuning.length === 0) {
1311+
if (staff.isPercussion) {
1312+
tuning = [0, 0, 0, 0, 0, 0];
1313+
tuningName = '';
1314+
} else if (ModelUtils.staffNotesAreNotStringed(staff)) {
1315+
const staffTuning =
1316+
staff.index === 0 ? Tuning.getDefaultTuningFor(6) : Tuning.getDefaultTuningFor(5);
1317+
tuning = staffTuning!.tunings;
1318+
tuningName = staffTuning!.name;
1319+
}
1320+
}
1321+
this._tuningByStaff.set(staff, tuning);
1322+
1323+
if (tuning.length > 0) {
12751324
const tuningProperty = properties.addElement('Property');
12761325
tuningProperty.attributes.set('name', 'Tuning');
1277-
tuningProperty.addElement('Pitches').innerText = staff.tuning.slice().reverse().join(' ');
1278-
tuningProperty.addElement('Label').setCData(staff.tuningName);
1279-
tuningProperty.addElement('LabelVisible').innerText = staff.tuningName ? 'true' : 'false';
1326+
tuningProperty.addElement('Pitches').innerText = tuning.slice().reverse().join(' ');
1327+
tuningProperty.addElement('Label').setCData(tuningName);
1328+
tuningProperty.addElement('LabelVisible').innerText = tuningName ? 'true' : 'false';
12801329
tuningProperty.addElement('Flat');
12811330

1282-
switch (staff.tuning.length) {
1331+
if (staff.isPercussion) {
1332+
tuningProperty.addElement('Instrument').innerText = 'Undefined';
1333+
} else {
1334+
switch (tuning.length) {
12831335
case 3:
12841336
tuningProperty.addElement('Instrument').innerText = 'Shamisen';
12851337
break;
@@ -1324,6 +1376,7 @@ export class GpifWriter {
13241376
default:
13251377
tuningProperty.addElement('Instrument').innerText = 'Guitar';
13261378
break;
1379+
}
13271380
}
13281381
}
13291382

packages/alphatab/src/importer/Gp3To5Importer.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { ModelUtils } from '@coderline/alphatab/model/ModelUtils';
3030
import { Note } from '@coderline/alphatab/model/Note';
3131
import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode';
3232
import { Ottavia } from '@coderline/alphatab/model/Ottavia';
33+
import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper';
3334
import { PickStroke } from '@coderline/alphatab/model/PickStroke';
3435
import { PlaybackInformation } from '@coderline/alphatab/model/PlaybackInformation';
3536
import { Rasgueado } from '@coderline/alphatab/model/Rasgueado';
@@ -1480,9 +1481,13 @@ export class Gp3To5Importer extends ScoreImporter {
14801481
}
14811482

14821483
if (bar.staff.isPercussion) {
1483-
newNote.percussionArticulation = Gp3To5Importer._gp5PercussionInstrumentMap.has(newNote.fret)
1484+
const midi = Gp3To5Importer._gp5PercussionInstrumentMap.has(newNote.fret)
14841485
? Gp3To5Importer._gp5PercussionInstrumentMap.get(newNote.fret)!
14851486
: newNote.fret;
1487+
const knownArticulation = PercussionMapper.getArticulationById(midi);
1488+
if (knownArticulation !== null) {
1489+
newNote.percussionArticulation = bar.staff.track.getOrRegisterPercussionArticulation(knownArticulation);
1490+
}
14861491
newNote.fret = Number.NaN;
14871492
}
14881493
if (swapAccidentals) {

packages/alphatab/src/importer/GpifParser.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { Duration } from '@coderline/alphatab/model/Duration';
2020
import { DynamicValue } from '@coderline/alphatab/model/DynamicValue';
2121
import { FadeType } from '@coderline/alphatab/model/FadeType';
2222
import { Fermata, FermataType } from '@coderline/alphatab/model/Fermata';
23+
import { FingeringAssigner } from '@coderline/alphatab/model/FingeringAssigner';
2324
import { Fingers } from '@coderline/alphatab/model/Fingers';
2425
import { GolpeType } from '@coderline/alphatab/model/GolpeType';
2526
import { GraceType } from '@coderline/alphatab/model/GraceType';
@@ -134,6 +135,9 @@ export class GpifParser {
134135
private _skipApplyLyrics: boolean = false;
135136
private _backingTrackPadding: number = 0;
136137

138+
/** Marks the input as a Guitar Pro 6 file. Also auto-detected from the GPIF header. */
139+
public isGp6: boolean = false;
140+
137141
private _doubleBars: Set<MasterBar> = new Set<MasterBar>();
138142
private _keySignatures: Map<number, [KeySignature, KeySignatureType]> = new Map<
139143
number,
@@ -174,6 +178,9 @@ export class GpifParser {
174178
this._parseDom(dom);
175179
this._buildModel();
176180
ModelUtils.consolidate(this.score);
181+
if (this.isGp6) {
182+
this._assignFingeringForGp6();
183+
}
177184
this.score.finish(settings);
178185
if (!this._skipApplyLyrics && this._lyricsByTrack.size > 0) {
179186
for (const [t, lyrics] of this._lyricsByTrack) {
@@ -197,6 +204,16 @@ export class GpifParser {
197204
// parse all children
198205
for (const n of root.childElements()) {
199206
switch (n.localName) {
207+
case 'GPVersion':
208+
if (n.innerText === '6') {
209+
this.isGp6 = true;
210+
}
211+
break;
212+
case 'Encoding':
213+
if (n.findChildElement('EncodingDescription')?.innerText === 'GP6') {
214+
this.isGp6 = true;
215+
}
216+
break;
200217
case 'Score':
201218
this._parseScoreNode(n);
202219
break;
@@ -2559,7 +2576,7 @@ export class GpifParser {
25592576
note.addBendPoint(bendDestination);
25602577
}
25612578

2562-
// map GP6 element and variation combos to midi numbers
2579+
// Temporary MIDI id; normalised to a track-local index in `_attachNoteToBeat`.
25632580
if (element !== -1 && variation !== -1) {
25642581
note.percussionArticulation = PercussionMapper.articulationFromElementVariation(element, variation);
25652582
}
@@ -2733,6 +2750,63 @@ export class GpifParser {
27332750
if (this._tappedNotes.has(noteId)) {
27342751
beat.tap = true;
27352752
}
2753+
// Normalise `percussionArticulation` to the track-local index.
2754+
if (staff.isPercussion && note.percussionArticulation >= 0) {
2755+
const trackArticulations = staff.track.percussionArticulations;
2756+
let known: InstrumentArticulation | null = null;
2757+
if (note.percussionArticulation < trackArticulations.length) {
2758+
known = trackArticulations[note.percussionArticulation];
2759+
} else {
2760+
known = PercussionMapper.getArticulationById(note.percussionArticulation);
2761+
}
2762+
if (known !== null) {
2763+
note.percussionArticulation = staff.track.getOrRegisterPercussionArticulation(known);
2764+
}
2765+
}
2766+
}
2767+
2768+
private _assignFingeringForGp6(): void {
2769+
for (const track of this.score.tracks) {
2770+
for (const staff of track.staves) {
2771+
const isPercussion = staff.isPercussion;
2772+
const isPitchedOnly =
2773+
!isPercussion &&
2774+
staff.stringTuning.tunings.length === 0 &&
2775+
ModelUtils.staffNotesAreNotStringed(staff);
2776+
if (!isPercussion && !isPitchedOnly) {
2777+
continue;
2778+
}
2779+
2780+
if (isPercussion) {
2781+
staff.stringTuning.tunings = [0, 0, 0, 0, 0, 0];
2782+
} else {
2783+
const fallback =
2784+
staff.index === 0 ? Tuning.getDefaultTuningFor(6) : Tuning.getDefaultTuningFor(5);
2785+
if (fallback !== null) {
2786+
staff.stringTuning.tunings = fallback.tunings.slice();
2787+
staff.stringTuning.name = fallback.name;
2788+
}
2789+
}
2790+
2791+
const assignersByVoiceIndex = new Map<number, FingeringAssigner>();
2792+
for (const bar of staff.bars) {
2793+
for (const voice of bar.voices) {
2794+
let assigner: FingeringAssigner | undefined = assignersByVoiceIndex.get(voice.index);
2795+
if (assigner === undefined) {
2796+
assigner = new FingeringAssigner(
2797+
staff.stringTuning.tunings,
2798+
staff.capo,
2799+
staff.transpositionPitch
2800+
);
2801+
assignersByVoiceIndex.set(voice.index, assigner);
2802+
}
2803+
for (const beat of voice.beats) {
2804+
assigner.assign(beat);
2805+
}
2806+
}
2807+
}
2808+
}
2809+
}
27362810
}
27372811

27382812
private _buildModel(): void {

packages/alphatab/src/importer/GpxImporter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ export class GpxImporter extends ScoreImporter {
6363
// the score information as XML we need to parse.
6464
Logger.debug(this.name, 'Start Parsing score.gpif');
6565
const gpifParser: GpifParser = new GpifParser();
66+
gpifParser.isGp6 = true;
6667
gpifParser.parseXml(xml, this.settings);
6768
Logger.debug(this.name, 'score.gpif parsed');
6869
const score: Score = gpifParser.score;

packages/alphatab/src/importer/MusicXmlImporter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2855,7 +2855,7 @@ export class MusicXmlImporter extends ScoreImporter {
28552855
} else if (note.beat.voice.bar.staff.isPercussion) {
28562856
const knownArticulation = PercussionMapper.getArticulationById(note.displayValue);
28572857
if (knownArticulation) {
2858-
note.percussionArticulation = knownArticulation.id;
2858+
note.percussionArticulation = track.getOrRegisterPercussionArticulation(knownArticulation);
28592859
}
28602860
}
28612861
}

0 commit comments

Comments
 (0)