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/platform/javascript/BrowserMouseEventArgs.ts b/packages/alphatab/src/platform/javascript/BrowserMouseEventArgs.ts index 5e72ada6b..1a13498d1 100644 --- a/packages/alphatab/src/platform/javascript/BrowserMouseEventArgs.ts +++ b/packages/alphatab/src/platform/javascript/BrowserMouseEventArgs.ts @@ -9,6 +9,8 @@ import type { HtmlElementContainer } from '@coderline/alphatab/platform/javascri export class BrowserMouseEventArgs implements IMouseEventArgs { public readonly mouseEvent: MouseEvent; + private readonly _allowPreventDefault: boolean; + public get isLeftMouseButton(): boolean { return this.mouseEvent.button === 0; } @@ -28,10 +30,13 @@ export class BrowserMouseEventArgs implements IMouseEventArgs { } public preventDefault(): void { - this.mouseEvent.preventDefault(); + if (this._allowPreventDefault) { + this.mouseEvent.preventDefault(); + } } - public constructor(e: MouseEvent) { + public constructor(e: MouseEvent, allowPreventDefault: boolean = true) { this.mouseEvent = e; + this._allowPreventDefault = allowPreventDefault; } } diff --git a/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts b/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts index 4f2c7d147..ac10d4f97 100644 --- a/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts +++ b/packages/alphatab/src/platform/javascript/BrowserUiFacade.ts @@ -277,7 +277,11 @@ export class BrowserUiFacade implements IUiFacade { canvasElement.style.overflow = 'hidden'; canvasElement.style.lineHeight = '0'; canvasElement.style.position = 'relative'; - return new HtmlElementContainer(canvasElement); + return new HtmlElementContainer( + canvasElement, + () => this.getScrollContainer(), + () => this._api.settings.player.enableUserInteraction + ); } public setCanvasOverflow(canvasElement: IContainer, overflow: number, isVertical: boolean): void { diff --git a/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts b/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts index a440ec5eb..cdadc30e4 100644 --- a/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts +++ b/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts @@ -17,11 +17,19 @@ export interface IHtmlElementContainer extends IContainer{ readonly element:HTMLElement; } +type MouseEventListener = (arg: IMouseEventArgs) => void; + /** * @target web * @internal */ export class HtmlElementContainer implements IHtmlElementContainer { + private static readonly _touchLongPressDelay = 60; + private static readonly _touchMoveSlop = 80; + private static readonly _edgeScrollThreshold = 50; + private static readonly _edgeScrollMaxStep = 32; + private static readonly _compatibilityMouseSuppressionDelay = 800; + private static _resizeObserver: Lazy = new Lazy( () => new ResizeObserver((entries: ResizeObserverEntry[]) => { @@ -35,6 +43,24 @@ export class HtmlElementContainer implements IHtmlElementContainer { ); private _resizeListeners: number = 0; + private _mouseDownListeners: MouseEventListener[] = []; + private _mouseMoveListeners: MouseEventListener[] = []; + private _mouseUpListeners: MouseEventListener[] = []; + private _nativeMouseListenersActive = false; + private _activeTouchPointerId: number | null = null; + private _pendingTouchDown: PointerEvent | null = null; + private _touchLongPressTimer: ReturnType | null = null; + private _touchSelectionActive = false; + private _touchGestureCancelled = false; + private _touchPointerCaptured = false; + private _suppressCompatibilityMouseEvents = false; + private _compatibilityMouseSuppressionTimer: ReturnType | null = null; + private _touchStartPageX = 0; + private _touchStartPageY = 0; + private readonly _touchMoveOptions: AddEventListenerOptions = { + capture: true, + passive: false + }; public get width(): number { return this.element.offsetWidth; @@ -78,55 +104,35 @@ export class HtmlElementContainer implements IHtmlElementContainer { public readonly element: HTMLElement; - public constructor(element: HTMLElement) { + public constructor( + element: HTMLElement, + private readonly _edgeScrollContainer: (() => IContainer) | null = null, + private readonly _canStartTouchSelection: () => boolean = () => true + ) { this.element = element; this.mouseDown = { on: (value: any) => { - const nativeListener: (e: MouseEvent) => void = e => { - value(new BrowserMouseEventArgs(e)); - }; - this.element.addEventListener('mousedown', nativeListener, true); - return () => { - this.element.removeEventListener('mousedown', nativeListener, true); - }; + this._addMouseEventListener(this._mouseDownListeners, value); + return () => this._removeMouseEventListener(this._mouseDownListeners, value); }, - off: (_value: any) => { - // not supported due to wrapping - } + off: (value: any) => this._removeMouseEventListener(this._mouseDownListeners, value) }; this.mouseUp = { on: (value: any) => { - const nativeListener: (e: MouseEvent) => void = e => { - value(new BrowserMouseEventArgs(e)); - }; - - this.element.addEventListener('mouseup', nativeListener, true); - - return () => { - this.element.removeEventListener('mouseup', nativeListener, true); - }; + this._addMouseEventListener(this._mouseUpListeners, value); + return () => this._removeMouseEventListener(this._mouseUpListeners, value); }, - off: (_value: any) => { - // not supported due to wrapping - } + off: (value: any) => this._removeMouseEventListener(this._mouseUpListeners, value) }; this.mouseMove = { on: (value: any) => { - const nativeListener: (e: MouseEvent) => void = e => { - value(new BrowserMouseEventArgs(e)); - }; - this.element.addEventListener('mousemove', nativeListener, true); - - return () => { - this.element.removeEventListener('mousemove', nativeListener, true); - }; + this._addMouseEventListener(this._mouseMoveListeners, value); + return () => this._removeMouseEventListener(this._mouseMoveListeners, value); }, - off: (_: any) => { - // not supported due to wrapping - } + off: (value: any) => this._removeMouseEventListener(this._mouseMoveListeners, value) }; const container = this; @@ -151,6 +157,293 @@ export class HtmlElementContainer implements IHtmlElementContainer { }; } + private _addMouseEventListener(listeners: MouseEventListener[], value: MouseEventListener): void { + listeners.push(value); + this._ensureNativeMouseListeners(); + } + + private _removeMouseEventListener(listeners: MouseEventListener[], value: MouseEventListener): void { + const index = listeners.indexOf(value); + if (index >= 0) { + listeners.splice(index, 1); + } + this._releaseNativeMouseListenersIfUnused(); + } + + private _ensureNativeMouseListeners(): void { + if (this._nativeMouseListenersActive) { + return; + } + + this.element.addEventListener('mousedown', this._onMouseDown, true); + this.element.addEventListener('mousemove', this._onMouseMove, true); + this.element.addEventListener('mouseup', this._onMouseUp, true); + if (this._supportsPointerEvents) { + this.element.addEventListener('pointerdown', this._onPointerDown, true); + this.element.addEventListener('pointermove', this._onPointerMove, true); + this.element.addEventListener('pointerup', this._onPointerUp, true); + this.element.addEventListener('pointercancel', this._onPointerCancel, true); + } + this._nativeMouseListenersActive = true; + } + + private _releaseNativeMouseListenersIfUnused(): void { + if ( + !this._nativeMouseListenersActive || + this._mouseDownListeners.length + this._mouseMoveListeners.length + this._mouseUpListeners.length > 0 + ) { + return; + } + + this._resetTouchGesture(true); + this._clearCompatibilityMouseSuppression(); + this.element.removeEventListener('mousedown', this._onMouseDown, true); + this.element.removeEventListener('mousemove', this._onMouseMove, true); + this.element.removeEventListener('mouseup', this._onMouseUp, true); + if (this._supportsPointerEvents) { + this.element.removeEventListener('pointerdown', this._onPointerDown, true); + this.element.removeEventListener('pointermove', this._onPointerMove, true); + this.element.removeEventListener('pointerup', this._onPointerUp, true); + this.element.removeEventListener('pointercancel', this._onPointerCancel, true); + } + this._nativeMouseListenersActive = false; + } + + private get _supportsPointerEvents(): boolean { + const ownerWindow = this.element.ownerDocument?.defaultView; + return !!ownerWindow && 'PointerEvent' in ownerWindow; + } + + private readonly _onMouseDown = (e: MouseEvent): void => { + if (this._suppressCompatibilityMouseEvents) { + return; + } + this._emitMouseEvent(this._mouseDownListeners, e); + }; + + private readonly _onMouseMove = (e: MouseEvent): void => { + if (this._suppressCompatibilityMouseEvents) { + return; + } + this._emitMouseEvent(this._mouseMoveListeners, e); + }; + + private readonly _onMouseUp = (e: MouseEvent): void => { + if (this._suppressCompatibilityMouseEvents) { + return; + } + this._emitMouseEvent(this._mouseUpListeners, e); + }; + + private readonly _onPointerDown = (e: PointerEvent): void => { + if (e.pointerType !== 'touch') { + return; + } + + if (!e.isPrimary || this._activeTouchPointerId !== null) { + return; + } + + this._activeTouchPointerId = e.pointerId; + this._pendingTouchDown = e; + this._touchGestureCancelled = false; + this._touchSelectionActive = false; + this._touchStartPageX = e.pageX; + this._touchStartPageY = e.pageY; + this._clearTouchLongPressTimer(); + if (this._canStartTouchSelection()) { + this._touchLongPressTimer = setTimeout(() => { + if ( + this._activeTouchPointerId === e.pointerId && + !this._touchGestureCancelled && + this._canStartTouchSelection() + ) { + this._activateTouchSelection(e); + } + }, HtmlElementContainer._touchLongPressDelay); + } + }; + + private readonly _onPointerMove = (e: PointerEvent): void => { + if (e.pointerType !== 'touch') { + return; + } + + if (e.pointerId !== this._activeTouchPointerId || !e.isPrimary) { + return; + } + + if (!this._touchSelectionActive) { + if (this._isPastTouchMoveSlop(e)) { + this._touchGestureCancelled = true; + this._clearTouchLongPressTimer(); + } + return; + } + + if (this._canStartTouchSelection()) { + e.preventDefault(); + } + this._emitMouseEvent(this._mouseMoveListeners, e); + if (this._canStartTouchSelection()) { + this._autoScrollAtEdge(new BrowserMouseEventArgs(e)); + } + }; + + private readonly _onPointerUp = (e: PointerEvent): void => { + if (e.pointerType !== 'touch') { + return; + } + + if (e.pointerId !== this._activeTouchPointerId || !e.isPrimary) { + return; + } + + if (this._touchSelectionActive) { + if (this._canStartTouchSelection()) { + e.preventDefault(); + } + this._emitMouseEvent(this._mouseUpListeners, e); + this._suppressCompatibilityMouseEventsAfterTouch(); + } else if (!this._touchGestureCancelled && this._pendingTouchDown) { + this._emitMouseEvent(this._mouseDownListeners, this._pendingTouchDown, false); + this._emitMouseEvent(this._mouseUpListeners, e, false); + this._suppressCompatibilityMouseEventsAfterTouch(); + } + + this._resetTouchGesture(true); + }; + + private readonly _onPointerCancel = (e: PointerEvent): void => { + if (e.pointerType !== 'touch' || e.pointerId !== this._activeTouchPointerId) { + return; + } + + if (this._touchSelectionActive) { + this._emitMouseEvent(this._mouseUpListeners, e); + this._suppressCompatibilityMouseEventsAfterTouch(); + } + this._resetTouchGesture(true); + }; + + private readonly _onActiveTouchMove = (e: TouchEvent): void => { + if (this._touchSelectionActive && this._canStartTouchSelection()) { + e.preventDefault(); + } + }; + + private _activateTouchSelection(e: PointerEvent): void { + if (!this._canStartTouchSelection()) { + return; + } + this._touchSelectionActive = true; + this._pendingTouchDown = null; + if (this.element.setPointerCapture) { + this.element.setPointerCapture(e.pointerId); + this._touchPointerCaptured = true; + } + this.element.addEventListener('touchmove', this._onActiveTouchMove, this._touchMoveOptions); + this._emitMouseEvent(this._mouseDownListeners, e); + } + + private _resetTouchGesture(releasePointerCapture: boolean): void { + const pointerId = this._activeTouchPointerId; + this._clearTouchLongPressTimer(); + this.element.removeEventListener('touchmove', this._onActiveTouchMove, true); + if (releasePointerCapture && this._touchPointerCaptured && pointerId !== null && this.element.releasePointerCapture) { + this.element.releasePointerCapture(pointerId); + } + this._activeTouchPointerId = null; + this._pendingTouchDown = null; + this._touchSelectionActive = false; + this._touchGestureCancelled = false; + this._touchPointerCaptured = false; + } + + private _isPastTouchMoveSlop(e: PointerEvent): boolean { + const x = e.pageX - this._touchStartPageX; + const y = e.pageY - this._touchStartPageY; + return x * x + y * y > HtmlElementContainer._touchMoveSlop * HtmlElementContainer._touchMoveSlop; + } + + private _clearTouchLongPressTimer(): void { + if (this._touchLongPressTimer !== null) { + clearTimeout(this._touchLongPressTimer); + this._touchLongPressTimer = null; + } + } + + private _suppressCompatibilityMouseEventsAfterTouch(): void { + this._clearCompatibilityMouseSuppression(); + this._suppressCompatibilityMouseEvents = true; + this._compatibilityMouseSuppressionTimer = setTimeout(() => { + this._suppressCompatibilityMouseEvents = false; + this._compatibilityMouseSuppressionTimer = null; + }, HtmlElementContainer._compatibilityMouseSuppressionDelay); + } + + private _clearCompatibilityMouseSuppression(): void { + if (this._compatibilityMouseSuppressionTimer !== null) { + clearTimeout(this._compatibilityMouseSuppressionTimer); + this._compatibilityMouseSuppressionTimer = null; + } + this._suppressCompatibilityMouseEvents = false; + } + + private _emitMouseEvent(listeners: MouseEventListener[], e: MouseEvent, allowPreventDefault: boolean = true): void { + const args = new BrowserMouseEventArgs(e, allowPreventDefault); + for (const listener of [...listeners]) { + listener(args); + } + } + + private _autoScrollAtEdge(e: IMouseEventArgs): void { + if (!this._edgeScrollContainer) { + return; + } + + const scrollContainer = this._edgeScrollContainer(); + const scrollElement = (scrollContainer as HtmlElementContainer).element; + const x = e.getX(scrollContainer); + const y = e.getY(scrollContainer); + const width = scrollElement.clientWidth || scrollContainer.width; + const height = scrollElement.clientHeight || scrollContainer.height; + const scrollLeftStep = HtmlElementContainer._getEdgeScrollStep(x, width); + const scrollTopStep = HtmlElementContainer._getEdgeScrollStep(y, height); + + if (scrollLeftStep !== 0) { + scrollContainer.scrollLeft = HtmlElementContainer._clamp( + scrollContainer.scrollLeft + scrollLeftStep, + 0, + Math.max(0, scrollElement.scrollWidth - width) + ); + } + + if (scrollTopStep !== 0) { + scrollContainer.scrollTop = HtmlElementContainer._clamp( + scrollContainer.scrollTop + scrollTopStep, + 0, + Math.max(0, scrollElement.scrollHeight - height) + ); + } + } + + private static _getEdgeScrollStep(distanceFromStart: number, viewportSize: number): number { + const threshold = HtmlElementContainer._edgeScrollThreshold; + const distanceFromEnd = viewportSize - distanceFromStart; + if (distanceFromStart < threshold) { + return -Math.min(HtmlElementContainer._edgeScrollMaxStep, threshold - distanceFromStart); + } + if (distanceFromEnd < threshold) { + return Math.min(HtmlElementContainer._edgeScrollMaxStep, threshold - distanceFromEnd); + } + return 0; + } + + private static _clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); + } + public stopAnimation(): void { this.element.style.transition = 'none'; } 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/platform/javascript/HtmlElementContainer.test.ts b/packages/alphatab/test/platform/javascript/HtmlElementContainer.test.ts new file mode 100644 index 000000000..32a349fa4 --- /dev/null +++ b/packages/alphatab/test/platform/javascript/HtmlElementContainer.test.ts @@ -0,0 +1,514 @@ +import type { IContainer } from '@coderline/alphatab/platform/IContainer'; +import type { IMouseEventArgs } from '@coderline/alphatab/platform/IMouseEventArgs'; +import { HtmlElementContainer } from '@coderline/alphatab/platform/javascript/HtmlElementContainer'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +type TestEventListener = EventListenerOrEventListenerObject; + +interface TestListenerRegistration { + listener: TestEventListener; + options?: boolean | AddEventListenerOptions; +} + +interface TestPointerEventOptions { + pointerType?: string; + pointerId?: number; + isPrimary?: boolean; + button?: number; + pageX?: number; + pageY?: number; +} + +interface TestMouseEventOptions { + button?: number; + pageX?: number; + pageY?: number; +} + +interface MutableTestEvent { + type: string; + defaultPrevented: boolean; + preventDefault(): void; +} + +class TestHtmlElement { + public readonly style: Partial = {}; + public readonly ownerDocument = { + defaultView: { + pageXOffset: 5, + pageYOffset: 7, + PointerEvent: class PointerEvent {} + } + }; + public offsetWidth = 100; + public offsetHeight = 100; + public scrollLeft = 0; + public scrollTop = 0; + public scrollWidth = 100; + public scrollHeight = 100; + public clientWidth = 100; + public clientHeight = 100; + public readonly setPointerCapture = vi.fn(); + public readonly releasePointerCapture = vi.fn(); + private readonly _listeners = new Map(); + private _bounds = { left: 10, top: 20, width: 100, height: 100 }; + + public setBounds(left: number, top: number, width: number, height: number): void { + this._bounds = { left, top, width, height }; + this.clientWidth = width; + this.clientHeight = height; + } + + public getBoundingClientRect(): DOMRect { + const { left, top, width, height } = this._bounds; + return { + left, + top, + width, + height, + x: left, + y: top, + right: left + width, + bottom: top + height, + toJSON() { + return this; + } + } as DOMRect; + } + + public getClientRects(): DOMRect[] { + return [this.getBoundingClientRect()]; + } + + public appendChild(_child: HTMLElement): void {} + + public addEventListener( + type: string, + listener: TestEventListener, + options?: boolean | AddEventListenerOptions + ): void { + const listeners = this._listeners.get(type) ?? []; + listeners.push({ listener, options }); + this._listeners.set(type, listeners); + } + + public removeEventListener( + type: string, + listener: TestEventListener, + options?: boolean | EventListenerOptions + ): void { + const listeners = this._listeners.get(type) ?? []; + const capture = typeof options === 'boolean' ? options : options?.capture; + this._listeners.set( + type, + listeners.filter(registration => { + const registrationCapture = + typeof registration.options === 'boolean' + ? registration.options + : registration.options?.capture; + return registration.listener !== listener || registrationCapture !== capture; + }) + ); + } + + public dispatch(type: string, event: MutableTestEvent): void { + event.type = type; + const listeners = [...(this._listeners.get(type) ?? [])]; + for (const registration of listeners) { + if (typeof registration.listener === 'function') { + registration.listener.call(this, event as Event); + } else { + registration.listener.handleEvent(event as Event); + } + } + } + + public listenerCount(type: string): number { + return this._listeners.get(type)?.length ?? 0; + } + + public listenerOptions(type: string): (boolean | AddEventListenerOptions | undefined)[] { + return (this._listeners.get(type) ?? []).map(registration => registration.options); + } +} + +function createContainer( + edgeScrollContainer: IContainer | null = null, + canStartTouchSelection: () => boolean = () => true +): { + element: TestHtmlElement; + container: HtmlElementContainer; +} { + const element = new TestHtmlElement(); + const container = new HtmlElementContainer( + element as unknown as HTMLElement, + edgeScrollContainer ? () => edgeScrollContainer : null, + canStartTouchSelection + ); + return { element, container }; +} + +function createPointerEvent(options: TestPointerEventOptions = {}): PointerEvent & MutableTestEvent { + const event = { + type: '', + pointerType: options.pointerType ?? 'mouse', + pointerId: options.pointerId ?? 1, + isPrimary: options.isPrimary ?? true, + button: options.button ?? 0, + pageX: options.pageX ?? 0, + pageY: options.pageY ?? 0, + defaultPrevented: false, + preventDefault() { + event.defaultPrevented = true; + } + }; + return event as PointerEvent & MutableTestEvent; +} + +function createMouseEvent(options: TestMouseEventOptions = {}): MouseEvent & MutableTestEvent { + const event = { + type: '', + button: options.button ?? 0, + pageX: options.pageX ?? 0, + pageY: options.pageY ?? 0, + defaultPrevented: false, + preventDefault() { + event.defaultPrevented = true; + } + }; + return event as MouseEvent & MutableTestEvent; +} + +function createTouchEvent(): TouchEvent & MutableTestEvent { + const event = { + type: '', + defaultPrevented: false, + preventDefault() { + event.defaultPrevented = true; + } + }; + return event as TouchEvent & MutableTestEvent; +} + +describe('HtmlElementContainer', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('emits desktop mouse events from native mouse listeners and preserves button state', () => { + const { element, container } = createContainer(); + const calls: string[] = []; + const downArgs: IMouseEventArgs[] = []; + container.mouseDown.on(e => { + calls.push('down'); + downArgs.push(e); + }); + container.mouseMove.on(() => calls.push('move')); + container.mouseUp.on(() => calls.push('up')); + + element.dispatch('mousedown', createMouseEvent({ button: 0 })); + element.dispatch('mousemove', createMouseEvent({ button: 0 })); + element.dispatch('mouseup', createMouseEvent({ button: 0 })); + element.dispatch('mousedown', createMouseEvent({ button: 2 })); + + expect(calls).toEqual(['down', 'move', 'up', 'down']); + expect(downArgs[0].isLeftMouseButton).toBe(true); + expect(downArgs[1].isLeftMouseButton).toBe(false); + expect(element.listenerCount('pointerdown')).toBe(1); + expect(element.listenerCount('mousedown')).toBe(1); + }); + + it('ignores mouse pointer events so desktop mouse is not emitted twice', () => { + const { element, container } = createContainer(); + const calls: string[] = []; + container.mouseDown.on(() => calls.push('down')); + container.mouseMove.on(() => calls.push('move')); + container.mouseUp.on(() => calls.push('up')); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'mouse' })); + element.dispatch('pointermove', createPointerEvent({ pointerType: 'mouse' })); + element.dispatch('pointerup', createPointerEvent({ pointerType: 'mouse' })); + + expect(calls).toEqual([]); + }); + + it('forwards mouse preventDefault to the native MouseEvent', () => { + const { element, container } = createContainer(); + container.mouseDown.on(e => e.preventDefault()); + + const mouseDown = createMouseEvent(); + element.dispatch('mousedown', mouseDown); + + expect(mouseDown.defaultPrevented).toBe(true); + }); + + it('emits touch tap down and up without preventing native tap events', () => { + vi.useFakeTimers(); + const { element, container } = createContainer(); + const downArgs: IMouseEventArgs[] = []; + const upArgs: IMouseEventArgs[] = []; + container.mouseDown.on(e => { + downArgs.push(e); + e.preventDefault(); + }); + container.mouseUp.on(e => { + upArgs.push(e); + e.preventDefault(); + }); + + const pointerDown = createPointerEvent({ pointerType: 'touch', pageX: 45, pageY: 68 }); + const pointerUp = createPointerEvent({ pointerType: 'touch', pageX: 46, pageY: 69 }); + element.dispatch('pointerdown', pointerDown); + element.dispatch('pointerup', pointerUp); + vi.advanceTimersByTime(100); + + expect(downArgs).toHaveLength(1); + expect(upArgs).toHaveLength(1); + expect(downArgs[0].getX(container)).toBe(30); + expect(downArgs[0].getY(container)).toBe(41); + expect(pointerDown.defaultPrevented).toBe(false); + expect(pointerUp.defaultPrevented).toBe(false); + }); + + it('suppresses touch compatibility mouse alphaTab events without blocking native listeners', () => { + vi.useFakeTimers(); + const { element, container } = createContainer(); + const calls: string[] = []; + const nativeMouseDown = vi.fn(); + container.mouseDown.on(e => { + calls.push('down'); + e.preventDefault(); + }); + container.mouseUp.on(() => calls.push('up')); + element.addEventListener('mousedown', nativeMouseDown); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch' })); + element.dispatch('pointerup', createPointerEvent({ pointerType: 'touch' })); + const compatibilityMouseDown = createMouseEvent(); + element.dispatch('mousedown', compatibilityMouseDown); + element.dispatch('mouseup', createMouseEvent()); + + expect(calls).toEqual(['down', 'up']); + expect(nativeMouseDown).toHaveBeenCalledTimes(1); + expect(compatibilityMouseDown.defaultPrevented).toBe(false); + }); + + it('keeps long press alive through small touch jitter before activation', () => { + vi.useFakeTimers(); + const { element, container } = createContainer(); + const calls: string[] = []; + container.mouseDown.on(() => calls.push('down')); + container.mouseUp.on(() => calls.push('up')); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 50 })); + vi.advanceTimersByTime(50); + element.dispatch('pointermove', createPointerEvent({ pointerType: 'touch', pageX: 54, pageY: 53 })); + vi.advanceTimersByTime(50); + element.dispatch('pointerup', createPointerEvent({ pointerType: 'touch', pageX: 54, pageY: 53 })); + + expect(calls).toEqual(['down', 'up']); + }); + + it('cancels touch long press when the finger moves before activation', () => { + vi.useFakeTimers(); + const { element, container } = createContainer(); + const calls: string[] = []; + container.mouseDown.on(() => calls.push('down')); + container.mouseMove.on(() => calls.push('move')); + container.mouseUp.on(() => calls.push('up')); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 50 })); + vi.advanceTimersByTime(50); + element.dispatch('pointermove', createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 70 })); + vi.advanceTimersByTime(100); + element.dispatch('pointerup', createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 70 })); + + expect(calls).toEqual([]); + }); + + it('emits long-press touch selection, prevents native touchmove, and edge-scrolls while active', () => { + vi.useFakeTimers(); + const scrollElement = new TestHtmlElement(); + scrollElement.setBounds(0, 0, 100, 100); + scrollElement.scrollTop = 50; + scrollElement.scrollHeight = 200; + const scrollContainer = new HtmlElementContainer(scrollElement as unknown as HTMLElement); + const { element, container } = createContainer(scrollContainer); + const calls: string[] = []; + container.mouseDown.on(() => calls.push('down')); + container.mouseMove.on(() => calls.push('move')); + container.mouseUp.on(() => calls.push('up')); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 50 })); + vi.advanceTimersByTime(100); + + const touchMove = createTouchEvent(); + element.dispatch('touchmove', touchMove); + const pointerMove = createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 102 }); + element.dispatch('pointermove', pointerMove); + element.dispatch('pointerup', createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 102 })); + + expect(calls).toEqual(['down', 'move', 'up']); + expect(touchMove.defaultPrevented).toBe(true); + expect(pointerMove.defaultPrevented).toBe(true); + expect(element.setPointerCapture).toHaveBeenCalledWith(1); + expect(element.releasePointerCapture).toHaveBeenCalledWith(1); + expect(element.listenerOptions('touchmove')).toEqual([]); + expect(scrollContainer.scrollTop).toBe(82); + }); + + it('does not capture or prevent touch selection when interaction is disabled', () => { + vi.useFakeTimers(); + const scrollElement = new TestHtmlElement(); + scrollElement.setBounds(0, 0, 100, 100); + scrollElement.scrollTop = 50; + scrollElement.scrollHeight = 200; + const scrollContainer = new HtmlElementContainer(scrollElement as unknown as HTMLElement); + const { element, container } = createContainer(scrollContainer, () => false); + const calls: string[] = []; + container.mouseDown.on(() => calls.push('down')); + container.mouseMove.on(() => calls.push('move')); + container.mouseUp.on(() => calls.push('up')); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 50 })); + vi.advanceTimersByTime(100); + const touchMove = createTouchEvent(); + element.dispatch('touchmove', touchMove); + const pointerMove = createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 102 }); + element.dispatch('pointermove', pointerMove); + element.dispatch('pointerup', createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 102 })); + + expect(calls).toEqual([]); + expect(touchMove.defaultPrevented).toBe(false); + expect(pointerMove.defaultPrevented).toBe(false); + expect(element.setPointerCapture).not.toHaveBeenCalled(); + expect(scrollContainer.scrollTop).toBe(50); + }); + + it('still emits touch tap events when interaction is disabled', () => { + vi.useFakeTimers(); + const { element, container } = createContainer(null, () => false); + const calls: string[] = []; + container.mouseDown.on(() => calls.push('down')); + container.mouseUp.on(() => calls.push('up')); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 50 })); + vi.advanceTimersByTime(100); + element.dispatch('pointerup', createPointerEvent({ pointerType: 'touch', pageX: 50, pageY: 50 })); + + expect(calls).toEqual(['down', 'up']); + expect(element.setPointerCapture).not.toHaveBeenCalled(); + }); + + it('resets active touch selection on pointercancel and emits a final up only when active', () => { + vi.useFakeTimers(); + const { element, container } = createContainer(); + const calls: string[] = []; + container.mouseDown.on(() => calls.push('down')); + container.mouseUp.on(() => calls.push('up')); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch', pointerId: 1 })); + element.dispatch('pointercancel', createPointerEvent({ pointerType: 'touch', pointerId: 1 })); + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch', pointerId: 2 })); + vi.advanceTimersByTime(100); + element.dispatch('pointercancel', createPointerEvent({ pointerType: 'touch', pointerId: 2 })); + + expect(calls).toEqual(['down', 'up']); + expect(element.releasePointerCapture).toHaveBeenCalledWith(2); + }); + + it('ignores secondary touch pointers while a primary touch is pending', () => { + vi.useFakeTimers(); + const { element, container } = createContainer(); + const calls: string[] = []; + container.mouseDown.on(() => calls.push('down')); + container.mouseMove.on(() => calls.push('move')); + container.mouseUp.on(() => calls.push('up')); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch', pointerId: 1 })); + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch', pointerId: 2 })); + element.dispatch('pointermove', createPointerEvent({ pointerType: 'touch', pointerId: 2 })); + vi.advanceTimersByTime(100); + element.dispatch('pointerup', createPointerEvent({ pointerType: 'touch', pointerId: 2 })); + element.dispatch('pointerup', createPointerEvent({ pointerType: 'touch', pointerId: 1 })); + + expect(calls).toEqual(['down', 'up']); + }); + + it('removes native listeners and clears pending touch timers after unsubscribe', () => { + vi.useFakeTimers(); + const { element, container } = createContainer(); + const down = vi.fn(); + const up = vi.fn(); + const unregisterDown = container.mouseDown.on(down); + const unregisterUp = container.mouseUp.on(up); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch' })); + unregisterDown(); + unregisterUp(); + vi.advanceTimersByTime(100); + + expect(down).not.toHaveBeenCalled(); + expect(up).not.toHaveBeenCalled(); + expect(element.listenerCount('pointerdown')).toBe(0); + expect(element.listenerCount('pointermove')).toBe(0); + expect(element.listenerCount('pointerup')).toBe(0); + expect(element.listenerCount('pointercancel')).toBe(0); + expect(element.listenerCount('mousedown')).toBe(0); + expect(element.listenerCount('mousemove')).toBe(0); + expect(element.listenerCount('mouseup')).toBe(0); + expect(element.listenerCount('touchmove')).toBe(0); + }); + + it('releases pointer capture when unsubscribing during active touch selection', () => { + vi.useFakeTimers(); + const { element, container } = createContainer(); + const unregisterDown = container.mouseDown.on(vi.fn()); + const unregisterMove = container.mouseMove.on(vi.fn()); + const unregisterUp = container.mouseUp.on(vi.fn()); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'touch', pointerId: 3 })); + vi.advanceTimersByTime(100); + unregisterDown(); + unregisterMove(); + unregisterUp(); + + expect(element.releasePointerCapture).toHaveBeenCalledWith(3); + expect(element.listenerCount('touchmove')).toBe(0); + }); + + it('continues emitting to remaining listeners when a listener unsubscribes during dispatch', () => { + const { element, container } = createContainer(); + const calls: string[] = []; + let unregisterFirst = () => {}; + unregisterFirst = container.mouseDown.on(() => { + calls.push('first'); + unregisterFirst(); + }); + container.mouseDown.on(() => calls.push('second')); + + element.dispatch('mousedown', createMouseEvent()); + + expect(calls).toEqual(['first', 'second']); + }); + + it('treats pen as mouse-like compatibility input instead of touch long-press input', () => { + vi.useFakeTimers(); + const { element, container } = createContainer(); + const calls: string[] = []; + container.mouseDown.on(() => calls.push('down')); + container.mouseMove.on(() => calls.push('move')); + container.mouseUp.on(() => calls.push('up')); + + element.dispatch('pointerdown', createPointerEvent({ pointerType: 'pen' })); + vi.advanceTimersByTime(100); + element.dispatch('pointermove', createPointerEvent({ pointerType: 'pen' })); + element.dispatch('pointerup', createPointerEvent({ pointerType: 'pen' })); + element.dispatch('mousedown', createMouseEvent()); + element.dispatch('mousemove', createMouseEvent()); + element.dispatch('mouseup', createMouseEvent()); + + expect(calls).toEqual(['down', 'move', 'up']); + expect(element.setPointerCapture).not.toHaveBeenCalled(); + }); +}); 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