diff --git a/packages/alphatab/scripts/Serializer.setProperty.ts b/packages/alphatab/scripts/Serializer.setProperty.ts index f216d996a..6ebde70ef 100644 --- a/packages/alphatab/scripts/Serializer.setProperty.ts +++ b/packages/alphatab/scripts/Serializer.setProperty.ts @@ -95,47 +95,67 @@ function generateSetPropertyBody(serializable: TypeSchema, importer: (name: stri caseStatements.push(ts.factory.createReturnStatement(ts.factory.createTrue())); } else if (prop.type.isArray) { const arrayItemType = prop.type.arrayItemType!; - const collectionAddMethod = - (prop.jsDocTags.filter(t => t.tagName.text === 'json_add').map(t => t.comment ?? '')[0] as string) ?? - `${fieldName}.push`; - - // obj.fieldName = []; - // for(const i of value) { - // obj.addFieldName(Type.FromJson(i)); - // } - // or - // for(const __li of value) { - // obj.fieldName.push(Type.FromJson(__li)); - // } - - const itemSerializer = `${arrayItemType.typeAsString}Serializer`; - importer(itemSerializer, findSerializerModule(arrayItemType)); - importer(arrayItemType.typeAsString, arrayItemType.modulePath); - - const loopItems = [ - createNodeFromSource( - `obj.${fieldName} = [];`, + if (arrayItemType.isEnumType) { + importer(arrayItemType.typeAsString, arrayItemType.modulePath); + importer('JsonHelper', '@coderline/alphatab/io/JsonHelper'); + const parseEnumArray = createNodeFromSource( + `obj.${fieldName} = (v as number[]).map(i => JsonHelper.parseEnum<${arrayItemType.typeAsString}>(i, ${arrayItemType.typeAsString})!);`, ts.SyntaxKind.ExpressionStatement - ), - createNodeFromSource( - `for(const o of (v as (Map | null)[])) { - const i = new ${arrayItemType.typeAsString}(); - ${itemSerializer}.fromJson(i, o); - obj.${collectionAddMethod}(i) - }`, - ts.SyntaxKind.ForOfStatement - ) - ]; - - if (prop.type.isNullable || prop.type.isOptional) { - caseStatements.push( - ts.factory.createIfStatement( - ts.factory.createIdentifier('v'), - ts.factory.createBlock(loopItems, true) - ) ); + if (prop.type.isNullable || prop.type.isOptional) { + caseStatements.push( + ts.factory.createIfStatement( + ts.factory.createIdentifier('v'), + ts.factory.createBlock([parseEnumArray], true) + ) + ); + } else { + caseStatements.push(parseEnumArray); + } } else { - caseStatements.push(...loopItems); + const collectionAddMethod = + (prop.jsDocTags.filter(t => t.tagName.text === 'json_add').map(t => t.comment ?? '')[ + 0 + ] as string) ?? `${fieldName}.push`; + + // obj.fieldName = []; + // for(const i of value) { + // obj.addFieldName(Type.FromJson(i)); + // } + // or + // for(const __li of value) { + // obj.fieldName.push(Type.FromJson(__li)); + // } + + const itemSerializer = `${arrayItemType.typeAsString}Serializer`; + importer(itemSerializer, findSerializerModule(arrayItemType)); + importer(arrayItemType.typeAsString, arrayItemType.modulePath); + + const loopItems = [ + createNodeFromSource( + `obj.${fieldName} = [];`, + ts.SyntaxKind.ExpressionStatement + ), + createNodeFromSource( + `for(const o of (v as (Map | null)[])) { + const i = new ${arrayItemType.typeAsString}(); + ${itemSerializer}.fromJson(i, o); + obj.${collectionAddMethod}(i) + }`, + ts.SyntaxKind.ForOfStatement + ) + ]; + + if (prop.type.isNullable || prop.type.isOptional) { + caseStatements.push( + ts.factory.createIfStatement( + ts.factory.createIdentifier('v'), + ts.factory.createBlock(loopItems, true) + ) + ); + } else { + caseStatements.push(...loopItems); + } } caseStatements.push(ts.factory.createReturnStatement(ts.factory.createTrue())); } else if (prop.type.isMap) { diff --git a/packages/alphatab/scripts/Serializer.toJson.ts b/packages/alphatab/scripts/Serializer.toJson.ts index 2d7c301b3..9132c3e4d 100644 --- a/packages/alphatab/scripts/Serializer.toJson.ts +++ b/packages/alphatab/scripts/Serializer.toJson.ts @@ -76,7 +76,35 @@ function generateToJsonBody(serializable: TypeSchema, importer: (name: string, m } } else if (prop.type.isArray) { const arrayItemType = prop.type.arrayItemType!; - if (arrayItemType.isOwnType && !arrayItemType.isEnumType) { + if (arrayItemType.isEnumType) { + const serializeStatement = createNodeFromSource( + ` + o.set(${JSON.stringify(jsonName)}, obj.${fieldName}); + `, + ts.SyntaxKind.ExpressionStatement + ); + if (prop.type.isNullable) { + propertyStatements.push( + createNodeFromSource( + `if(obj.${fieldName} !== null) { + o.set(${JSON.stringify(jsonName)}, obj.${fieldName}); + }`, + ts.SyntaxKind.IfStatement + ) + ); + } else if (prop.type.isOptional) { + propertyStatements.push( + createNodeFromSource( + `if(obj.${fieldName} !== undefined) { + o.set(${JSON.stringify(jsonName)}, obj.${fieldName}); + }`, + ts.SyntaxKind.IfStatement + ) + ); + } else { + propertyStatements.push(serializeStatement); + } + } else if (arrayItemType.isOwnType) { const itemSerializer = `${arrayItemType.typeAsString}Serializer`; importer(itemSerializer, findSerializerModule(arrayItemType)); if (prop.type.isNullable) { diff --git a/packages/alphatab/src/exporter/AlphaTexExporter.ts b/packages/alphatab/src/exporter/AlphaTexExporter.ts index 57d290dff..ebe732691 100644 --- a/packages/alphatab/src/exporter/AlphaTexExporter.ts +++ b/packages/alphatab/src/exporter/AlphaTexExporter.ts @@ -512,7 +512,12 @@ export class AlphaTexExporter extends ScoreExporter { if (data.bars.length === 0) { const bar: AlphaTexBarNode = { nodeType: AlphaTexNodeType.Bar, - metaData: this._handler.buildBarMetaDataNodes(data, undefined, 0, false), + metaData: this._handler.buildBarMetaDataNodes( + data, + undefined, + 0, + false + ), beats: [], pipe: undefined }; @@ -533,7 +538,12 @@ export class AlphaTexExporter extends ScoreExporter { private _bar(score: AlphaTexScoreNode, data: Bar, voiceIndex: number, isMultiVoice: boolean) { const bar: AlphaTexBarNode = { nodeType: AlphaTexNodeType.Bar, - metaData: this._handler.buildBarMetaDataNodes(data.staff, data, voiceIndex, isMultiVoice), + metaData: this._handler.buildBarMetaDataNodes( + data.staff, + data, + voiceIndex, + isMultiVoice + ), beats: [], pipe: undefined }; @@ -677,7 +687,10 @@ export class AlphaTexExporter extends ScoreExporter { } else if (data.isPiano) { note.noteValue = { nodeType: AlphaTexNodeType.Ident, - text: Tuning.getTextForTuning(data.realValueWithoutHarmonic, true) + text: Tuning.getTextForTuning( + data.realValueWithoutHarmonic, + true + ) } as AlphaTexIdentifier; } else if (data.isStringed) { note.noteValue = { diff --git a/packages/alphatab/src/exporter/GpifWriter.ts b/packages/alphatab/src/exporter/GpifWriter.ts index ed1fcc3a5..25554ea70 100644 --- a/packages/alphatab/src/exporter/GpifWriter.ts +++ b/packages/alphatab/src/exporter/GpifWriter.ts @@ -45,7 +45,7 @@ import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; import type { Staff } from '@coderline/alphatab/model/Staff'; import type { Track } from '@coderline/alphatab/model/Track'; import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; -import { Tuning } from '@coderline/alphatab/model/Tuning'; +import { Tuning, TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import type { Voice } from '@coderline/alphatab/model/Voice'; import { WahPedal } from '@coderline/alphatab/model/WahPedal'; @@ -1300,6 +1300,7 @@ export class GpifWriter { private _writeStaffNode(parent: XmlNode, staff: Staff) { const staffNode = parent.addElement('Staff'); const properties = staffNode.addElement('Properties'); + const tuningAccidentalMode = this._getTuningAccidentalMode(staff); this._writeSimplePropertyNode(properties, 'CapoFret', 'Fret', staff.capo.toString()); this._writeSimplePropertyNode(properties, 'FretCount', 'Fret', '24'); @@ -1326,7 +1327,9 @@ export class GpifWriter { tuningProperty.addElement('Pitches').innerText = tuning.slice().reverse().join(' '); tuningProperty.addElement('Label').setCData(tuningName); tuningProperty.addElement('LabelVisible').innerText = tuningName ? 'true' : 'false'; - tuningProperty.addElement('Flat'); + if (tuningAccidentalMode !== TuningAccidentalMode.Sharp) { + tuningProperty.addElement('Flat'); + } if (staff.isPercussion) { tuningProperty.addElement('Instrument').innerText = 'Undefined'; @@ -1388,12 +1391,38 @@ export class GpifWriter { staff.tuning.map(_ => '0').join('') ); - this._writeSimplePropertyNode(properties, 'TuningFlat', 'Enable', null); + if (tuningAccidentalMode !== TuningAccidentalMode.Sharp) { + this._writeSimplePropertyNode(properties, 'TuningFlat', 'Enable', null); + } this._writeDiagramCollection(properties, staff, 'DiagramCollection'); this._writeDiagramCollection(properties, staff, 'DiagramWorkingSet'); } + private _getTuningAccidentalMode(staff: Staff): TuningAccidentalMode | undefined { + if (staff.stringTuning.accidentalModes === undefined) { + return undefined; + } + let mode: TuningAccidentalMode | null = null; + for (let i = 0; i < staff.tuning.length; i++) { + const flatName = Tuning.getTextForTuning(staff.tuning[i], false, TuningAccidentalMode.Flat); + const sharpName = Tuning.getTextForTuning(staff.tuning[i], false, TuningAccidentalMode.Sharp); + if (flatName === sharpName) { + continue; + } + + const stringMode = staff.stringTuning.getAccidentalMode(i); + if (mode === null) { + mode = stringMode; + } else if (mode !== stringMode) { + // GPIF has one alteration preference for the whole tuning. + // Keep the legacy flat output when the model contains mixed modes. + return TuningAccidentalMode.Flat; + } + } + return mode ?? undefined; + } + private _writeDiagramCollection(properties: XmlNode, staff: Staff, name: string) { const diagramCollectionProperty = properties.addElement('Property'); diagramCollectionProperty.attributes.set('name', name); diff --git a/packages/alphatab/src/generated/model/TuningSerializer.ts b/packages/alphatab/src/generated/model/TuningSerializer.ts index 037ea0ed7..b1809cde3 100644 --- a/packages/alphatab/src/generated/model/TuningSerializer.ts +++ b/packages/alphatab/src/generated/model/TuningSerializer.ts @@ -5,6 +5,7 @@ // import { Tuning } from "@coderline/alphatab/model/Tuning"; import { JsonHelper } from "@coderline/alphatab/io/JsonHelper"; +import { TuningAccidentalMode } from "@coderline/alphatab/model/Tuning"; /** * @internal */ @@ -23,6 +24,9 @@ export class TuningSerializer { o.set("isstandard", obj.isStandard); o.set("name", obj.name); o.set("tunings", obj.tunings); + if (obj.accidentalModes !== undefined) { + o.set("accidentalmodes", obj.accidentalModes); + } return o; } public static setProperty(obj: Tuning, property: string, v: unknown): boolean { @@ -36,6 +40,11 @@ export class TuningSerializer { case "tunings": obj.tunings = v! as number[]; return true; + case "accidentalmodes": + if (v) { + obj.accidentalModes = (v as number[]).map(i => JsonHelper.parseEnum(i, TuningAccidentalMode)!); + } + return true; } return false; } diff --git a/packages/alphatab/src/importer/AlphaTexImporter.ts b/packages/alphatab/src/importer/AlphaTexImporter.ts index 853b8cce1..9d78a54fa 100644 --- a/packages/alphatab/src/importer/AlphaTexImporter.ts +++ b/packages/alphatab/src/importer/AlphaTexImporter.ts @@ -785,6 +785,7 @@ export class AlphaTexImporter extends ScoreImporter implements IAlphaTexImporter staff.isPercussion = true; staff.stringTuning.reset(); staff.stringTuning.tunings = [0, 0, 0, 0, 0, 0]; + staff.stringTuning.accidentalModes = undefined; if (!this._state.staffHasExplicitDisplayTransposition.has(staff)) { staff.displayTranspositionPitch = 0; } diff --git a/packages/alphatab/src/importer/GpifParser.ts b/packages/alphatab/src/importer/GpifParser.ts index 624284d74..946ae0bce 100644 --- a/packages/alphatab/src/importer/GpifParser.ts +++ b/packages/alphatab/src/importer/GpifParser.ts @@ -48,7 +48,7 @@ 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 { Tuning, TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { Voice } from '@coderline/alphatab/model/Voice'; import { WahPedal } from '@coderline/alphatab/model/WahPedal'; @@ -84,6 +84,12 @@ class GpifSound { public bank: number = 0; } +interface GpifTuningData { + tunings: number[]; + accidentalMode?: TuningAccidentalMode; + label: string; +} + /** * This class can parse a score.gpif xml file into the model structure * @internal @@ -623,6 +629,7 @@ export class GpifParser { const track: Track = new Track(); track.ensureStaveCount(1); const trackId: string = node.getAttribute('id'); + let trackTuning: GpifTuningData | null = null; for (const c of node.childElements()) { switch (c.localName) { @@ -664,7 +671,7 @@ export class GpifParser { this._parseLyrics(trackId, c); break; case 'Properties': - this._parseTrackProperties(track, c); + trackTuning = this._parseTrackProperties(track, c) ?? trackTuning; break; case 'GeneralMidi': case 'MidiConnection': @@ -696,6 +703,19 @@ export class GpifParser { break; } } + + if (trackTuning) { + for (const staff of track.staves) { + if (staff.stringTuning.tunings.length === 0) { + staff.stringTuning.tunings = trackTuning.tunings.slice(); + staff.stringTuning.accidentalModes = this._getTuningAccidentalModes(trackTuning); + if (trackTuning.label.length > 0) { + staff.stringTuning.name = trackTuning.label; + } + } + } + } + this._tracksById.set(trackId, track); } @@ -962,38 +982,36 @@ export class GpifParser { } private _parseStaffProperties(staff: Staff, node: XmlNode): void { - for (const c of node.childElements()) { + const properties = Array.from(node.childElements()); + const tuningProperty = properties.find(c => c.localName === 'Property' && c.getAttribute('name') === 'Tuning'); + const tuningFlatProperty = properties.find( + c => c.localName === 'Property' && c.getAttribute('name') === 'TuningFlat' + ); + + for (const c of properties) { + if (c === tuningProperty || c === tuningFlatProperty) { + continue; + } switch (c.localName) { case 'Property': this._parseStaffProperty(staff, c); break; } } + + if (tuningProperty) { + const tuning = this._parseTuningProperty(tuningProperty, tuningFlatProperty); + staff.stringTuning.tunings = tuning.tunings; + staff.stringTuning.accidentalModes = this._getTuningAccidentalModes(tuning); + if (tuning.label.length > 0) { + staff.stringTuning.name = tuning.label; + } + } } private _parseStaffProperty(staff: Staff, node: XmlNode): void { const propertyName: string = node.getAttribute('name'); switch (propertyName) { - case 'Tuning': - for (const c of node.childElements()) { - switch (c.localName) { - case 'Pitches': - const tuningParts: string[] = GpifParser._splitSafe( - node.findChildElement('Pitches')?.innerText - ); - const tuning = new Array(tuningParts.length); - for (let i: number = 0; i < tuning.length; i++) { - tuning[tuning.length - 1 - i] = GpifParser._parseIntSafe(tuningParts[i], 0); - } - staff.stringTuning.tunings = tuning; - break; - case 'Label': - staff.stringTuning.name = c.innerText; - break; - } - } - - break; case 'DiagramCollection': case 'ChordCollection': this._parseDiagramCollectionForStaff(staff, node); @@ -1150,29 +1168,30 @@ export class GpifParser { } } - private _parseTrackProperties(track: Track, node: XmlNode): void { - for (const c of node.childElements()) { + private _parseTrackProperties(track: Track, node: XmlNode): GpifTuningData | null { + const properties = Array.from(node.childElements()); + const tuningProperty = properties.find(c => c.localName === 'Property' && c.getAttribute('name') === 'Tuning'); + const tuningFlatProperty = properties.find( + c => c.localName === 'Property' && c.getAttribute('name') === 'TuningFlat' + ); + + for (const c of properties) { + if (c === tuningProperty || c === tuningFlatProperty) { + continue; + } switch (c.localName) { case 'Property': this._parseTrackProperty(track, c); break; } } + + return tuningProperty ? this._parseTuningProperty(tuningProperty, tuningFlatProperty) : null; } private _parseTrackProperty(track: Track, node: XmlNode): void { const propertyName: string = node.getAttribute('name'); switch (propertyName) { - case 'Tuning': - const tuningParts: string[] = GpifParser._splitSafe(node.findChildElement('Pitches')?.innerText); - const tuning = new Array(tuningParts.length); - for (let i: number = 0; i < tuning.length; i++) { - tuning[tuning.length - 1 - i] = GpifParser._parseIntSafe(tuningParts[i], 0); - } - for (const staff of track.staves) { - staff.stringTuning.tunings = tuning; - } - break; case 'DiagramCollection': case 'ChordCollection': this._parseDiagramCollectionForTrack(track, node); @@ -1186,6 +1205,40 @@ export class GpifParser { } } + private _parseTuningProperty(node: XmlNode, tuningFlatProperty: XmlNode | undefined): GpifTuningData { + const tuningParts: string[] = GpifParser._splitSafe(node.findChildElement('Pitches')?.innerText); + const tunings = new Array(tuningParts.length); + for (let i: number = 0; i < tunings.length; i++) { + tunings[tunings.length - 1 - i] = GpifParser._parseIntSafe(tuningParts[i], 0); + } + + const isFlat = + node.findChildElement('Flat') !== null || + (tuningFlatProperty !== undefined && tuningFlatProperty.findChildElement('Enable') !== null); + return { + tunings, + accidentalMode: isFlat ? TuningAccidentalMode.Flat : undefined, + label: node.findChildElement('Label')?.innerText ?? '' + }; + } + + private _getTuningAccidentalModes(tuning: GpifTuningData): TuningAccidentalMode[] | undefined { + const accidentalMode = tuning.accidentalMode; + if (accidentalMode === undefined) { + return undefined; + } + let hasEnharmonicTuning = false; + const modes = tuning.tunings.map(value => { + const flatName = Tuning.getTextForTuning(value, false, TuningAccidentalMode.Flat); + const sharpName = Tuning.getTextForTuning(value, false, TuningAccidentalMode.Sharp); + if (flatName !== sharpName) { + hasEnharmonicTuning = true; + } + return flatName === sharpName ? TuningAccidentalMode.Flat : accidentalMode; + }); + return hasEnharmonicTuning ? modes : undefined; + } + private _parseGeneralMidi(track: Track, node: XmlNode): void { for (const c of node.childElements()) { switch (c.localName) { diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 4513c75e2..ae7559b58 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -40,6 +40,7 @@ import { Staff } from '@coderline/alphatab/model/Staff'; import { Track } from '@coderline/alphatab/model/Track'; import { TremoloPickingEffect, TremoloPickingStyle } from '@coderline/alphatab/model/TremoloPickingEffect'; import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; +import { TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { Voice } from '@coderline/alphatab/model/Voice'; import { AccidentalHelper } from '@coderline/alphatab/rendering/utils/AccidentalHelper'; @@ -1879,7 +1880,15 @@ export class MusicXmlImporter extends ScoreImporter { } } const tuning: number = ModelUtils.getTuningForText(tuningStep + tuningOctave) + tuningAlter; - staff.tuning[staff.tuning.length - line] = tuning; + const tuningIndex = staff.tuning.length - line; + staff.tuning[tuningIndex] = tuning; + if (tuningAlter !== 0) { + staff.stringTuning.accidentalModes ??= new Array( + staff.standardNotationLineCount + ).fill(TuningAccidentalMode.Flat); + staff.stringTuning.accidentalModes[tuningIndex] = + tuningAlter > 0 ? TuningAccidentalMode.Sharp : TuningAccidentalMode.Flat; + } } private _parseClef(element: XmlNode, bar: Bar): void { diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts index 0bd5ab102..ed638a8f3 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts @@ -81,7 +81,7 @@ import { Staff } from '@coderline/alphatab/model/Staff'; import { Track } from '@coderline/alphatab/model/Track'; import { TremoloPickingEffect, TremoloPickingStyle } from '@coderline/alphatab/model/TremoloPickingEffect'; import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; -import { Tuning } from '@coderline/alphatab/model/Tuning'; +import { Tuning, TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { WahPedal } from '@coderline/alphatab/model/WahPedal'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; @@ -362,6 +362,8 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler return ApplyNodeResult.Applied; case 'tuning': const tuning: number[] = []; + const accidentalModes: TuningAccidentalMode[] = []; + let hasExplicitAccidental = false; let hideTuning = false; let tuningName = ''; for (let i = 0; i < metaData.arguments!.arguments.length; i++) { @@ -389,6 +391,21 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler const t = ModelUtils.parseTuning(text); if (t) { tuning.push(t.realValue); + switch (t.tone.accidentalMode) { + case NoteAccidentalMode.ForceSharp: + case NoteAccidentalMode.ForceDoubleSharp: + accidentalModes.push(TuningAccidentalMode.Sharp); + hasExplicitAccidental = true; + break; + case NoteAccidentalMode.ForceFlat: + case NoteAccidentalMode.ForceDoubleFlat: + accidentalModes.push(TuningAccidentalMode.Flat); + hasExplicitAccidental = true; + break; + default: + accidentalModes.push(TuningAccidentalMode.Flat); + break; + } } else if (i === metaData.arguments!.arguments.length - 1 && tuning.length > 0) { tuningName = text; importer.addSemanticDiagnostic({ @@ -417,6 +434,7 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler importer.state.staffTuningApplied.delete(staff); staff.stringTuning = new Tuning(); staff.stringTuning.tunings = tuning; + staff.stringTuning.accidentalModes = hasExplicitAccidental ? accidentalModes : undefined; staff.stringTuning.name = tuningName; this._tuningProperties(importer, staff, staff.stringTuning, metaData); @@ -2784,7 +2802,13 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler ): AlphaTexMetaDataNode[] { const nodes: AlphaTexMetaDataNode[] = []; - AlphaTex1LanguageHandler._buildStructuralMetaDataNodes(bar, staff, nodes, isMultiVoice, voice); + AlphaTex1LanguageHandler._buildStructuralMetaDataNodes( + bar, + staff, + nodes, + isMultiVoice, + voice + ); if (!bar) { return nodes; } @@ -2880,7 +2904,10 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler return nodes; } - private static _buildStaffMetaDataNodes(nodes: AlphaTexMetaDataNode[], staff: Staff) { + private static _buildStaffMetaDataNodes( + nodes: AlphaTexMetaDataNode[], + staff: Staff + ) { const firstStaffMetaIndex = nodes.length; if (staff.capo !== 0) { @@ -2893,7 +2920,10 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler 'tuning', Atnf.args( staff.stringTuning.tunings.map( - t => Atnf.ident(Tuning.getTextForTuning(t, true)) as IAlphaTexArgumentValue + (t, i) => + Atnf.ident( + Tuning.getTextForTuning(t, true, staff.stringTuning.getAccidentalMode(i)) + ) as IAlphaTexArgumentValue ) ) ); diff --git a/packages/alphatab/src/model/Staff.ts b/packages/alphatab/src/model/Staff.ts index f016c5670..7ae521095 100644 --- a/packages/alphatab/src/model/Staff.ts +++ b/packages/alphatab/src/model/Staff.ts @@ -152,6 +152,7 @@ export class Staff { if (this.isPercussion) { this.displayTranspositionPitch = 0; this.stringTuning.tunings = [0, 0, 0, 0, 0, 0]; + this.stringTuning.accidentalModes = undefined; } if (this.stringTuning.tunings.length === 0) { this.showTablature = false; diff --git a/packages/alphatab/src/model/Tuning.ts b/packages/alphatab/src/model/Tuning.ts index a28020922..477a936e1 100644 --- a/packages/alphatab/src/model/Tuning.ts +++ b/packages/alphatab/src/model/Tuning.ts @@ -1,3 +1,19 @@ +/** + * Lists the different accidental styles used to format tuning note names. + * @public + */ +export enum TuningAccidentalMode { + /** + * Use flat note names for enharmonic tuning notes. + */ + Flat = 0, + + /** + * Use sharp note names for enharmonic tuning notes. + */ + Sharp = 1 +} + /** * This public class represents a predefined string tuning. * @json @@ -11,17 +27,27 @@ export class Tuning { private static _fourStrings: Tuning[] = []; private static _defaultTunings: Map = new Map(); - public static readonly noteNames: string[] = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; + public static readonly flatNoteNames: string[] = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; + public static readonly sharpNoteNames: string[] = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + public static readonly noteNames: string[] = Tuning.flatNoteNames; - public static getTextForTuning(tuning: number, includeOctave: boolean): string { - const parts = Tuning.getTextPartsForTuning(tuning); + public static getTextForTuning( + tuning: number, + includeOctave: boolean, + accidentalMode: TuningAccidentalMode = TuningAccidentalMode.Flat + ): string { + const parts = Tuning.getTextPartsForTuning(tuning, -1, accidentalMode); return includeOctave ? parts.join('') : parts[0]; } - public static getTextPartsForTuning(tuning: number, octaveShift: number = -1): string[] { + public static getTextPartsForTuning( + tuning: number, + octaveShift: number = -1, + accidentalMode: TuningAccidentalMode = TuningAccidentalMode.Flat + ): string[] { const octave: number = (tuning / 12) | 0; const note: number = tuning % 12; - const notes: string[] = Tuning.noteNames; + const notes = accidentalMode === TuningAccidentalMode.Sharp ? Tuning.sharpNoteNames : Tuning.flatNoteNames; return [notes[note], (octave + octaveShift).toString()]; } @@ -33,7 +59,7 @@ export class Tuning { public static getDefaultTuningFor(stringCount: number): Tuning | null { if (Tuning._defaultTunings.has(stringCount)) { const d = Tuning._defaultTunings.get(stringCount)!; - return new Tuning(d.name, d.tunings, d.isStandard); + return new Tuning(d.name, d.tunings, d.isStandard, d.accidentalModes); } return null; } @@ -134,7 +160,7 @@ export class Tuning { } } if (equals) { - return new Tuning(tuning.name, tuning.tunings, tuning.isStandard); + return new Tuning(tuning.name, tuning.tunings, tuning.isStandard, tuning.accidentalModes); } } return null; @@ -155,24 +181,47 @@ export class Tuning { */ public tunings: number[]; + /** + * Gets or sets the optional accidental style used to display each string tuning. + * The entries use the same top-string-first order as {@link tunings}. + * If omitted, the legacy display fallback is used. + * @since 1.10.0 + */ + public accidentalModes: TuningAccidentalMode[] | undefined; + /** * Initializes a new instance of the {@link Tuning} class. * @param name The name. * @param tuning The tuning. * @param isStandard if set to`true`[is standard]. + * @param accidentalModes The accidental style for each tuning value. */ - public constructor(name: string = '', tuning: number[] | null = null, isStandard: boolean = false) { + public constructor( + name: string = '', + tuning: number[] | null = null, + isStandard: boolean = false, + accidentalModes?: TuningAccidentalMode[] | null + ) { this.isStandard = isStandard; this.name = name; this.tunings = tuning ?? []; + this.accidentalModes = accidentalModes?.slice(); + } + + /** + * Gets the accidental style for a string, falling back to {@link TuningAccidentalMode.Flat} for missing entries. + * @param index The top-string-first tuning index. + */ + public getAccidentalMode(index: number): TuningAccidentalMode { + return this.accidentalModes?.[index] ?? TuningAccidentalMode.Flat; } public reset() { this.isStandard = false; this.name = ''; this.tunings = []; + this.accidentalModes = undefined; } - /** * Tries to detect the name and standard flag of the tuning from a known tuning list based diff --git a/packages/alphatab/src/model/_barrel.ts b/packages/alphatab/src/model/_barrel.ts index 5edccbd36..69bad5bd9 100644 --- a/packages/alphatab/src/model/_barrel.ts +++ b/packages/alphatab/src/model/_barrel.ts @@ -63,7 +63,7 @@ export { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; export { Staff } from '@coderline/alphatab/model/Staff'; export { Track, TrackSubElement, TrackStyle } from '@coderline/alphatab/model/Track'; export { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; -export { Tuning } from '@coderline/alphatab/model/Tuning'; +export { Tuning, TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; export { TupletGroup } from '@coderline/alphatab/model/TupletGroup'; export { VibratoType } from '@coderline/alphatab/model/VibratoType'; export { Voice, VoiceSubElement, VoiceStyle } from '@coderline/alphatab/model/Voice'; diff --git a/packages/alphatab/src/rendering/glyphs/InlineTuningGlyph.ts b/packages/alphatab/src/rendering/glyphs/InlineTuningGlyph.ts index 0c021d440..95e6dd5f6 100644 --- a/packages/alphatab/src/rendering/glyphs/InlineTuningGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/InlineTuningGlyph.ts @@ -13,12 +13,12 @@ import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementS export class InlineTuningGlyph extends Glyph { public readonly staff: RenderStaff; - private readonly _tunings: number[]; + private readonly _tuning: Tuning; public constructor(staff: RenderStaff) { super(0, 0); this.staff = staff; - this._tunings = staff.modelStaff.stringTuning.tunings; + this._tuning = staff.modelStaff.stringTuning; } public override doLayout(): void { @@ -27,8 +27,17 @@ export class InlineTuningGlyph extends Glyph { canvas.font = this.renderer.resources.elementFonts.get(NotationElement.GuitarTuning)!; let textWidth = 0; - for (const tuning of this._tunings) { - textWidth = Math.max(textWidth, canvas.measureText(Tuning.getTextForTuning(tuning, false)).width); + for (let i = 0, j = this._tuning.tunings.length; i < j; i++) { + textWidth = Math.max( + textWidth, + canvas.measureText( + Tuning.getTextForTuning( + this._tuning.tunings[i], + false, + this._tuning.getAccidentalMode(i) + ) + ).width + ); } canvas.font = oldFont; @@ -54,9 +63,9 @@ export class InlineTuningGlyph extends Glyph { using _ = ElementStyleHelper.track(canvas, TrackSubElement.StringTuning, this.staff.modelStaff.track, true); - for (let i = 0, j = this._tunings.length; i < j; i++) { + for (let i = 0, j = this._tuning.tunings.length; i < j; i++) { canvas.fillText( - Tuning.getTextForTuning(this._tunings[i], false), + Tuning.getTextForTuning(this._tuning.tunings[i], false, this._tuning.getAccidentalMode(i)), textEndX, cy + this.renderer.y + (this.renderer as LineBarRenderer).getLineY(i) ); diff --git a/packages/alphatab/src/rendering/glyphs/TuningGlyph.ts b/packages/alphatab/src/rendering/glyphs/TuningGlyph.ts index 25fc6c388..32bda0530 100644 --- a/packages/alphatab/src/rendering/glyphs/TuningGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/TuningGlyph.ts @@ -1,5 +1,5 @@ import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; -import { Tuning } from '@coderline/alphatab/model/Tuning'; +import { Tuning, TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; import { type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; import { GlyphGroup } from '@coderline/alphatab/rendering/glyphs/GlyphGroup'; import { TextGlyph } from '@coderline/alphatab/rendering/glyphs/TextGlyph'; @@ -81,9 +81,23 @@ export class TuningGlyph extends GlyphGroup { const circleHeight = this.renderer.smuflMetrics.glyphHeights.get(MusicFontSymbol.GuitarString0)! * circleScale; this.renderer.scoreRenderer.canvas!.font = res.elementFonts.get(NotationElement.GuitarTuning)!; + const hasExplicitSharp = tuning.accidentalModes?.some(mode => mode === TuningAccidentalMode.Sharp) ?? false; + let tuningLabelWidth: number; + if (hasExplicitSharp && !tuning.isStandard && tuning.tunings.length > 0) { + tuningLabelWidth = 0; + for (let i = 0; i < tuning.tunings.length; i++) { + const labelWidth = this.renderer.scoreRenderer.canvas!.measureText( + ` = ${Tuning.getTextForTuning(tuning.tunings[i], false, tuning.getAccidentalMode(i))}` + ).width; + if (labelWidth > tuningLabelWidth) { + tuningLabelWidth = labelWidth; + } + } + } else { + tuningLabelWidth = this.renderer.scoreRenderer.canvas!.measureText(' = Gb').width; + } const stringColumnWidth = - (circleHeight + this.renderer.scoreRenderer.canvas!.measureText(' = Gb').width) * - res.engravingSettings.tuningGlyphStringColumnScale; + (circleHeight + tuningLabelWidth) * res.engravingSettings.tuningGlyphStringColumnScale; this.width = Math.max( this.renderer.scoreRenderer.canvas!.measureText(this._trackLabel).width, @@ -100,7 +114,11 @@ export class TuningGlyph extends GlyphGroup { const symbol = ((MusicFontSymbol.GuitarString0 as number) + (i + 1)) as MusicFontSymbol; this.addGlyph(new MusicFontGlyph(currentX, currentY + circleHeight, circleScale, symbol)); - const str: string = ` = ${Tuning.getTextForTuning(tuning.tunings[i], false)}`; + const str: string = ` = ${Tuning.getTextForTuning( + tuning.tunings[i], + false, + tuning.getAccidentalMode(i) + )}`; this.addGlyph( new TextGlyph( currentX + circleHeight, diff --git a/packages/alphatab/src/rendering/staves/StaffSystem.ts b/packages/alphatab/src/rendering/staves/StaffSystem.ts index 19835b8bd..b852cce1f 100644 --- a/packages/alphatab/src/rendering/staves/StaffSystem.ts +++ b/packages/alphatab/src/rendering/staves/StaffSystem.ts @@ -1109,7 +1109,8 @@ export class StaffSystem { if (bracket.canPaint) { const barStartX: number = cx + bracket.firstVisibleStaffInBracket!.x; const barSize: number = bracket.width; - const barOffset: number = settings.display.accoladeBarPaddingRight; + const barOffset: number = + settings.display.accoladeBarPaddingRight; const firstStart: number = cy + bracket.firstVisibleStaffInBracket!.contentTop; const lastEnd: number = cy + bracket.lastVisibleStaffInBracket!.contentBottom; let accoladeStart: number = firstStart; diff --git a/packages/alphatab/test-data/musicxml-testsuite/71e-TabStaves.png b/packages/alphatab/test-data/musicxml-testsuite/71e-TabStaves.png index 769c09d7d..5ad7ca706 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/71e-TabStaves.png and b/packages/alphatab/test-data/musicxml-testsuite/71e-TabStaves.png differ diff --git a/packages/alphatab/test-data/musicxml4/tuning-accidentals.xml b/packages/alphatab/test-data/musicxml4/tuning-accidentals.xml new file mode 100644 index 000000000..34c5f9d03 --- /dev/null +++ b/packages/alphatab/test-data/musicxml4/tuning-accidentals.xml @@ -0,0 +1,39 @@ + + + + + Guitar + + + + + + 1 + + 0 + + + + TAB + 5 + + + 2 + + F + 1 + 3 + + + G + -1 + 3 + + + + + + diff --git a/packages/alphatab/test-data/visual-tests/layout/inline-tuning-mixed-accidentals.png b/packages/alphatab/test-data/visual-tests/layout/inline-tuning-mixed-accidentals.png new file mode 100644 index 000000000..1e82c713b Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/layout/inline-tuning-mixed-accidentals.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-mixed-accidentals.png b/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-mixed-accidentals.png new file mode 100644 index 000000000..12e9c9b78 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-mixed-accidentals.png differ diff --git a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts index f0c6c17a3..03c93ab00 100644 --- a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts +++ b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts @@ -1,8 +1,9 @@ -import { describe, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { AlphaTexExporter } from '@coderline/alphatab/exporter/AlphaTexExporter'; import { AlphaTexErrorWithDiagnostics } from '@coderline/alphatab/importer/AlphaTexImporter'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; import type { Score } from '@coderline/alphatab/model/Score'; +import { TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; import { Settings } from '@coderline/alphatab/Settings'; import { ComparisonHelpers } from 'test/model/ComparisonHelpers'; import { TestPlatform } from 'test/TestPlatform'; @@ -128,6 +129,20 @@ describe('AlphaTexExporterTest', () => { } }); + it('exports-tuning-accidental-mode', () => { + const score = ScoreLoader.loadAlphaTex('\\tuning E4 B3 F#3 D3 Gb2 E2 . r.4'); + + expect(score.tracks[0].staves[0].stringTuning.accidentalModes).toEqual([ + TuningAccidentalMode.Flat, + TuningAccidentalMode.Flat, + TuningAccidentalMode.Sharp, + TuningAccidentalMode.Flat, + TuningAccidentalMode.Flat, + TuningAccidentalMode.Flat + ]); + expect(exportAlphaTex(score)).toContain('\\tuning (E4 B3 F#3 D3 Gb2 E2)'); + }); + // Note: we just test all our importer and visual tests to cover all features it('importer', async () => { diff --git a/packages/alphatab/test/exporter/Gp7Exporter.test.ts b/packages/alphatab/test/exporter/Gp7Exporter.test.ts index 86760ed5c..8236b4efd 100644 --- a/packages/alphatab/test/exporter/Gp7Exporter.test.ts +++ b/packages/alphatab/test/exporter/Gp7Exporter.test.ts @@ -13,6 +13,7 @@ import { TechniqueSymbolPlacement } from '@coderline/alphatab/model/InstrumentAr import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import { MusicFontSymbol } from '@coderline/alphatab/model/MusicFontSymbol'; import type { Score } from '@coderline/alphatab/model/Score'; +import { TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; import { Settings } from '@coderline/alphatab/Settings'; import { XmlDocument } from '@coderline/alphatab/xml/XmlDocument'; import { ZipReader } from '@coderline/alphatab/zip/ZipReader'; @@ -723,4 +724,39 @@ describe('Gp7ExporterTest', () => { // await TestPlatform.saveFile('test-data/exporter/articulations.exported.gp', exported); }); + + it('tuning-accidental-mode', () => { + const sharpScore = ScoreLoader.loadAlphaTex('\\tuning F#4 B3 G#3 D3 A2 E2 . r.4'); + const sharpGpif = readExportedGpif(exportGp7(sharpScore)); + expect(sharpGpif).not.toContain(' importedSharpTuning.getAccidentalMode(i))).toEqual( + new Array(importedSharpTuning.tunings.length).fill(TuningAccidentalMode.Flat) + ); + + const flatScore = ScoreLoader.loadAlphaTex('\\tuning Gb4 Bb3 Eb3 Ab2 Db2 Gb2 . r.4'); + const flatGpif = readExportedGpif(exportGp7(flatScore)); + expect(flatGpif).toContain('(6).fill(TuningAccidentalMode.Flat) + ); + }); }); diff --git a/packages/alphatab/test/importer/Gp7Importer.test.ts b/packages/alphatab/test/importer/Gp7Importer.test.ts index e7a551bbd..a83d26d65 100644 --- a/packages/alphatab/test/importer/Gp7Importer.test.ts +++ b/packages/alphatab/test/importer/Gp7Importer.test.ts @@ -20,6 +20,7 @@ import { TestPlatform } from 'test/TestPlatform'; import { AutomationType } from '@coderline/alphatab/model/Automation'; import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection'; import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; +import { TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; describe('Gp7ImporterTest', () => { async function prepareImporterWithFile(name: string): Promise { @@ -52,6 +53,22 @@ describe('Gp7ImporterTest', () => { expect(score.tracks[1].name).toBe('Track 2'); }); + it('tuning-accidental-mode', async () => { + const naturalScore = (await prepareImporterWithFile('guitarpro7/strings.gp')).readScore(); + const naturalTuning = naturalScore.tracks[0].staves[0].stringTuning; + expect(naturalTuning.accidentalModes).toBeUndefined(); + expect(Array.from({ length: naturalTuning.tunings.length }, (_, i) => naturalTuning.getAccidentalMode(i))).toEqual( + new Array(naturalTuning.tunings.length).fill(TuningAccidentalMode.Flat) + ); + + const flatScore = (await prepareImporterWithFile('guitarpro7/bends-advanced.gp')).readScore(); + const flatTuning = flatScore.tracks[0].staves[0].stringTuning; + expect(flatTuning.accidentalModes).toBeUndefined(); + expect(Array.from({ length: flatTuning.tunings.length }, (_, i) => flatTuning.getAccidentalMode(i))).toEqual( + new Array(flatTuning.tunings.length).fill(TuningAccidentalMode.Flat) + ); + }); + it('notes', async () => { const reader = await prepareImporterWithFile('guitarpro7/notes.gp'); const score: Score = reader.readScore(); diff --git a/packages/alphatab/test/importer/MusicXmlImporter.test.ts b/packages/alphatab/test/importer/MusicXmlImporter.test.ts index 9d9ebd9f1..0fdb817f3 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -3,6 +3,8 @@ import { BendType } from '@coderline/alphatab/model/BendType'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import { BarNumberDisplay } from '@coderline/alphatab/model/RenderStylesheet'; import type { Score } from '@coderline/alphatab/model/Score'; +import { TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { MusicXmlImporterTestHelper } from 'test/importer/MusicXmlImporterTestHelper'; describe('MusicXmlImporterTests', () => { @@ -233,6 +235,23 @@ describe('MusicXmlImporterTests', () => { expect(score).toMatchSnapshot(); }); + it('tuning-alter-selects-accidental-mode', async () => { + let score = await MusicXmlImporterTestHelper.loadFile('test-data/musicxml4/tuning-accidentals.xml'); + const tuning = score.tracks[0].staves[0].stringTuning; + + expect(tuning.tunings).toEqual([ + ModelUtils.getTuningForText('G3') - 1, + ModelUtils.getTuningForText('F3') + 1 + ]); + expect(tuning.accidentalModes).toEqual([TuningAccidentalMode.Flat, TuningAccidentalMode.Sharp]); + + score = JsonConverter.jsObjectToScore(JsonConverter.scoreToJsObject(score)); + expect(score.tracks[0].staves[0].stringTuning.accidentalModes).toEqual([ + TuningAccidentalMode.Flat, + TuningAccidentalMode.Sharp + ]); + }); + it('timewise-basic', async () => { const score = await MusicXmlImporterTestHelper.loadFile('test-data/musicxml4/timewise-basic.xml'); expect(score).toMatchSnapshot(); diff --git a/packages/alphatab/test/model/JsonConverter.test.ts b/packages/alphatab/test/model/JsonConverter.test.ts index 0ee729c72..3ce0399aa 100644 --- a/packages/alphatab/test/model/JsonConverter.test.ts +++ b/packages/alphatab/test/model/JsonConverter.test.ts @@ -7,6 +7,7 @@ import { Color } from '@coderline/alphatab/model/Color'; import { Font, FontStyle } from '@coderline/alphatab/model/Font'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import type { Score } from '@coderline/alphatab/model/Score'; +import { TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; import { FingeringMode, NotationElement, NotationMode, TabRhythmMode } from '@coderline/alphatab/NotationSettings'; import { Settings } from '@coderline/alphatab/Settings'; import { TestPlatform } from 'test/TestPlatform'; @@ -211,4 +212,25 @@ describe('JsonConverterTest', () => { expect(settings.display.resources.mainGlyphColor.g).toBe(0); expect(settings.display.resources.mainGlyphColor.b).toBe(0); }); + + it('tuning-accidental-modes', () => { + const score = ScoreLoader.loadAlphaTex('\\tuning E4 B3 F#3 D3 Gb2 E2 . r.4'); + const roundTrip = JsonConverter.jsObjectToScore(JsonConverter.scoreToJsObject(score)); + + expect(roundTrip.tracks[0].staves[0].stringTuning.accidentalModes).toEqual([0, 0, 1, 0, 0, 0]); + }); + + it('tuning-accidental-modes-legacy-json', () => { + const score = ScoreLoader.loadAlphaTex('\\tuning E4 B3 G3 D3 A2 E2 . r.4'); + const json = JSON.parse(JsonConverter.scoreToJson(score)) as { + tracks: Array<{ staves: Array<{ stringtuning: Record }> }>; + }; + expect(json.tracks[0].staves[0].stringtuning.accidentalmodes).toBeUndefined(); + + const legacyScore = JsonConverter.jsonToScore(JSON.stringify(json)); + const tuning = legacyScore.tracks[0].staves[0].stringTuning; + expect(Array.from({ length: 6 }, (_, i) => tuning.getAccidentalMode(i))).toEqual( + new Array(6).fill(TuningAccidentalMode.Flat) + ); + }); }); diff --git a/packages/alphatab/test/model/TuningParser.test.ts b/packages/alphatab/test/model/TuningParser.test.ts index b2cc62833..59d2f2b64 100644 --- a/packages/alphatab/test/model/TuningParser.test.ts +++ b/packages/alphatab/test/model/TuningParser.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { Tuning } from '@coderline/alphatab/model/Tuning'; +import { Tuning, TuningAccidentalMode } from '@coderline/alphatab/model/Tuning'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; describe('TuningParserTest', () => { it('standard', () => { @@ -14,4 +14,28 @@ describe('TuningParserTest', () => { expect(tuning.join(',')).toBe(standard.tunings.join(',')); expect(tuningText2.join(',')).toBe(tuningText.join(',')); }); + + it('formats-accidental-mode', () => { + const tuning = ModelUtils.getTuningForText('Gb2'); + + expect(Tuning.getTextForTuning(tuning, true)).toBe('Gb2'); + expect(Tuning.getTextForTuning(tuning, true, TuningAccidentalMode.Sharp)).toBe('F#2'); + }); + + it('stores-accidental-mode-per-string', () => { + const tuning = new Tuning('Mixed', [66, 59, 54], false, [TuningAccidentalMode.Sharp]); + + expect(tuning.getAccidentalMode(0)).toBe(TuningAccidentalMode.Sharp); + expect(tuning.getAccidentalMode(1)).toBe(TuningAccidentalMode.Flat); + expect(tuning.getAccidentalMode(2)).toBe(TuningAccidentalMode.Flat); + expect(Tuning.getTextForTuning(tuning.tunings[0], true, tuning.getAccidentalMode(0))).toBe('F#4'); + expect(Tuning.getTextForTuning(tuning.tunings[1], true, tuning.getAccidentalMode(1))).toBe('B3'); + }); + + it('keeps-accidental-mode-opt-in', () => { + const tuning = new Tuning('Legacy', [66, 59, 54]); + + expect(tuning.accidentalModes).toBeUndefined(); + expect(tuning.getAccidentalMode(0)).toBe(TuningAccidentalMode.Flat); + }); }); diff --git a/packages/alphatab/test/visualTests/features/Layout.test.ts b/packages/alphatab/test/visualTests/features/Layout.test.ts index 3ebe04c86..36612e985 100644 --- a/packages/alphatab/test/visualTests/features/Layout.test.ts +++ b/packages/alphatab/test/visualTests/features/Layout.test.ts @@ -179,6 +179,21 @@ describe('LayoutTests', () => { ); }); + it('inline-tuning-mixed-accidentals', async () => { + const settings: Settings = new Settings(); + settings.display.layoutMode = LayoutMode.Parchment; + await VisualTestHelper.runVisualTestTex( + ` + \\tuningDisplayMode staff + \\tuning F#4 B3 G#3 D3 Ab2 E2 + \\staff { tabs } + 0.6.4 2.6.4 3.6.4 0.5.4 | + `, + 'test-data/visual-tests/layout/inline-tuning-mixed-accidentals.png', + settings + ); + }); + it('inline-tuning-per-track-hidden', async () => { const settings: Settings = new Settings(); settings.display.layoutMode = LayoutMode.Parchment; @@ -207,9 +222,9 @@ describe('LayoutTests', () => { await VisualTestHelper.runVisualTestTex( ` \\track { defaultSystemsLayout 3 } - \\scale 0.25 :1 c4 | \\scale 0.5 c4 | \\scale 0.25 c4 | + \\scale 0.25 :1 c4 | \\scale 0.5 c4 | \\scale 0.25 c4 | \\scale 0.5 c4 | \\scale 2 c4 | \\scale 0.5 c4 | - c4 | c4 + c4 | c4 `, 'test-data/visual-tests/layout/system-layout-tex.png', settings @@ -247,9 +262,9 @@ describe('LayoutTests', () => { it('multi-system-slur-scale-down', async () => { await VisualTestHelper.runVisualTestTex( ` - C4 {slur S1} + C4 {slur S1} | r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r - A4 {slur S1} + A4 {slur S1} `, '', undefined, @@ -268,9 +283,9 @@ describe('LayoutTests', () => { it('multi-system-slur-scale-up', async () => { await VisualTestHelper.runVisualTestTex( ` - C4 {slur S1} + C4 {slur S1} | r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r| r - A4 {slur S1} + A4 {slur S1} `, '', undefined, @@ -309,16 +324,16 @@ describe('LayoutTests', () => { \\track "T1" C4.4 *4 | r.1 | r.1 | r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | r.1 | C4 | - + \\track "T2" \\clef C3 - r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | - r.1 | c4 | r.1 | - r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | c4 | r.1 | + r.1 | r.1 | r.1 | r.1 | C4 | `, 'test-data/visual-tests/layout/hide-empty-staves.png', @@ -341,16 +356,16 @@ describe('LayoutTests', () => { \\track "T1" C4.4 *4 | r.1 | r.1 | r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | r.1 | C4 | - + \\track "T2" \\clef C3 - r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | - r.1 | c4 | r.1 | - r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | c4 | r.1 | + r.1 | r.1 | r.1 | r.1 | C4 | `, 'test-data/visual-tests/layout/hide-empty-staves-in-first.png', @@ -373,15 +388,15 @@ describe('LayoutTests', () => { \\staff {score} C4.4 *4 | r.1 | r.1 | r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | r.1 | C4 | \\staff {score} \\clef C3 - r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | - r.1 | c4 | r.1 | - r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | c4 | r.1 | + r.1 | r.1 | r.1 | r.1 | C4 | `, 'test-data/visual-tests/layout/single-staff-brackets-show.png', @@ -401,15 +416,15 @@ describe('LayoutTests', () => { \\staff {score} C4.4 *4 | r.1 | r.1 | r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | r.1 | C4 | \\staff {score} \\clef C3 - r.1 | r.1 | r.1 | - r.1 | r.1 | r.1 | - r.1 | c4 | r.1 | - r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | r.1 | r.1 | + r.1 | c4 | r.1 | + r.1 | r.1 | r.1 | r.1 | C4 | `, 'test-data/visual-tests/layout/single-staff-brackets-hide.png', @@ -488,7 +503,7 @@ describe('LayoutTests', () => { ` \\defaultBarNumberDisplay allBars C4.1 | C4.1 | C4.1 | - C4.1 | C4.1 | C4.1 + C4.1 | C4.1 | C4.1 `, 'test-data/visual-tests/layout/barnumberdisplay-stylesheet-all.png', undefined, @@ -501,7 +516,7 @@ describe('LayoutTests', () => { ` \\defaultBarNumberDisplay firstOfSystem C4.1 | C4.1 | C4.1 | - C4.1 | C4.1 | C4.1 + C4.1 | C4.1 | C4.1 `, 'test-data/visual-tests/layout/barnumberdisplay-stylesheet-first.png', undefined, @@ -514,7 +529,7 @@ describe('LayoutTests', () => { ` \\defaultBarNumberDisplay hide C4.1 | C4.1 | C4.1 | - C4.1 | C4.1 | C4.1 + C4.1 | C4.1 | C4.1 `, 'test-data/visual-tests/layout/barnumberdisplay-stylesheet-hide.png', undefined, @@ -530,7 +545,7 @@ describe('LayoutTests', () => { ` \\defaultBarNumberDisplay allBars C4.1 | \\barNumberDisplay hide C4.1 | C4.1 | - C4.1 | C4.1 | C4.1 + C4.1 | C4.1 | C4.1 `, 'test-data/visual-tests/layout/barnumberdisplay-bar-override-all.png', undefined, @@ -543,7 +558,7 @@ describe('LayoutTests', () => { ` \\defaultBarNumberDisplay firstOfSystem C4.1 | \\barNumberDisplay allBars C4.1 | C4.1 | - \\barNumberDisplay hide C4.1 | C4.1 | C4.1 + \\barNumberDisplay hide C4.1 | C4.1 | C4.1 `, 'test-data/visual-tests/layout/barnumberdisplay-bar-override-first.png', undefined, @@ -556,7 +571,7 @@ describe('LayoutTests', () => { ` \\defaultBarNumberDisplay hide C4.1 | \\barNumberDisplay allBars C4.1 | C4.1 | - \\barNumberDisplay firstOfSystem C4.1 | \\barNumberDisplay firstOfSystem C4.1 | C4.1 + \\barNumberDisplay firstOfSystem C4.1 | \\barNumberDisplay firstOfSystem C4.1 | C4.1 `, 'test-data/visual-tests/layout/barnumberdisplay-bar-override-hide.png', undefined, diff --git a/packages/alphatab/test/visualTests/features/NotationElements.test.ts b/packages/alphatab/test/visualTests/features/NotationElements.test.ts index a80dcf7b2..5a68d3ec0 100644 --- a/packages/alphatab/test/visualTests/features/NotationElements.test.ts +++ b/packages/alphatab/test/visualTests/features/NotationElements.test.ts @@ -99,6 +99,20 @@ describe('NotationElements', () => { ); }); + it('guitar-tuning-mixed-accidentals', async () => { + const tex = '\\tuning F#4 B3 G#3 D3 Ab2 E2 . 3.3*4'; + + const settings: Settings = new Settings(); + settings.display.layoutMode = LayoutMode.Page; + + settings.notation.elements.set(NotationElement.GuitarTuning, true); + await VisualTestHelper.runVisualTestTex( + tex, + 'test-data/visual-tests/notation-elements/guitar-tuning-mixed-accidentals.png', + settings + ); + }); + it('guitar-tuning-off', async () => { const tex = '\\tuning d5 b4 g4 d4 a3 d3 . 3.3*4'; diff --git a/packages/alphatex/src/metadata/staff/tuning.ts b/packages/alphatex/src/metadata/staff/tuning.ts index c8c377c97..a7890c975 100644 --- a/packages/alphatex/src/metadata/staff/tuning.ts +++ b/packages/alphatex/src/metadata/staff/tuning.ts @@ -6,7 +6,7 @@ export const tuning: MetadataTagDefinition = { tag: '\\tuning', snippet: '\\tuning {$1}$0', shortDescription: 'Set the string tuning for the staff.', - longDescription: `Defines the number of strings and their tuning for stringed (and fretted) instruments.`, + longDescription: `Defines the number of strings and their tuning for stringed (and fretted) instruments. Explicit sharp and flat accidentals, such as F# or Gb, are retained for the corresponding string when the tuning is rendered or exported.`, signatures: [ { parameters: [ @@ -44,7 +44,7 @@ export const tuning: MetadataTagDefinition = { { name: 'strings', shortDescription: 'The tuning values as pitched notes', - longDescription: `The tuning values as [pitched notes](https://alphatab.net/docs/alphatex/document-structure#pitched-notes)`, + longDescription: `The tuning values as [pitched notes](https://alphatab.net/docs/alphatex/document-structure#pitched-notes). Include an explicit # or b when the tuning should retain a sharp or flat preference for that string.`, type: alphaTab.importer.alphaTex.AlphaTexNodeType.Ident, allowAllStringTypes: true, parseMode: alphaTab.importer.alphaTex.ArgumentListParseTypesMode.ValueListWithoutParenthesis