diff --git a/package-lock.json b/package-lock.json index 9ee058c449..b66b39eea5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2405,24 +2405,24 @@ "license": "CC0-1.0" }, "node_modules/@coderline/alphaskia": { - "version": "3.5.147", - "resolved": "https://registry.npmjs.org/@coderline/alphaskia/-/alphaskia-3.5.147.tgz", - "integrity": "sha512-9RDWV6cralGAvKFU/aozCPAverWcBTQJHpUh6Y7gBlggEwsVjorIp3tuzVCDfV+SqkSMsJtZOiJ9R2CdJKJIVw==", + "version": "4.0.157", + "resolved": "https://registry.npmjs.org/@coderline/alphaskia/-/alphaskia-4.0.157.tgz", + "integrity": "sha512-0ghwQRue4JBoxwC7A6hb8CzUPLyW2Ic4zDtCGSG3lh6fjxsNrJPd5tQJ+VKfOcxAM5ofjOxM1EbOHu2ogAOZMw==", "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@coderline/alphaskia-linux": "^3.0.0", - "@coderline/alphaskia-macos": "^3.0.0", - "@coderline/alphaskia-windows": "^3.0.0" + "@coderline/alphaskia-linux": "^4.0.0", + "@coderline/alphaskia-macos": "^4.0.0", + "@coderline/alphaskia-windows": "^4.0.0" } }, "node_modules/@coderline/alphaskia-linux": { - "version": "3.5.147", - "resolved": "https://registry.npmjs.org/@coderline/alphaskia-linux/-/alphaskia-linux-3.5.147.tgz", - "integrity": "sha512-6PoaTdAedZDsfH8JbVEH1PkxeSgQ2hNRWY5cz32QgzknOC7uXBjBD2PP3zAr3bfFj58E+mwo5R9flQtVvYv5Pg==", + "version": "4.0.157", + "resolved": "https://registry.npmjs.org/@coderline/alphaskia-linux/-/alphaskia-linux-4.0.157.tgz", + "integrity": "sha512-h6jbpDY6IPu8vVDqcA8nIEQqUca8LtWESz6b06sM2kQmp+Vfe6wq9NbUDX+OG1i6Xc4fuigvS6RHNiJdDJ1lSQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -2430,9 +2430,9 @@ } }, "node_modules/@coderline/alphaskia-macos": { - "version": "3.5.147", - "resolved": "https://registry.npmjs.org/@coderline/alphaskia-macos/-/alphaskia-macos-3.5.147.tgz", - "integrity": "sha512-J4GQ9JZmfMnu302ouGBgvBV50AAcGwIvRSYN3awiwMh96aC+0VQZjNISxnohtUzmrotupK22L+pYVUI6YijBGA==", + "version": "4.0.157", + "resolved": "https://registry.npmjs.org/@coderline/alphaskia-macos/-/alphaskia-macos-4.0.157.tgz", + "integrity": "sha512-fJ9+lu4gn18QVgIWLOE2dwobTqHnN+YtvQMrGqz6AhVb/5Tt69CCz/QN2sBheAzLdXpRC90cH4oFkJAplgwiKg==", "dev": true, "license": "BSD-3-Clause", "optional": true, @@ -2441,9 +2441,9 @@ } }, "node_modules/@coderline/alphaskia-windows": { - "version": "3.5.147", - "resolved": "https://registry.npmjs.org/@coderline/alphaskia-windows/-/alphaskia-windows-3.5.147.tgz", - "integrity": "sha512-T8G4oE2bacNuI1mDoR6l7pXpbrR4ww2tdPlshrm/8En//Y4kY0EbDRP3Vd9qr3E8HP1Zy8peMXoE0e7Rt3c4Mg==", + "version": "4.0.157", + "resolved": "https://registry.npmjs.org/@coderline/alphaskia-windows/-/alphaskia-windows-4.0.157.tgz", + "integrity": "sha512-faeoaaKlhRiDRYxU0AhbBRIDp8MBSdtQ45wAk3VhAHIGhzLQUbp2f+WzQqUAlgvBEp2g7RFtVv/AGsx0dVSxuw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -24091,9 +24091,9 @@ "license": "MPL-2.0", "devDependencies": { "@biomejs/biome": "^2.6.4", - "@coderline/alphaskia": "^3.5.147", - "@coderline/alphaskia-linux": "^3.5.147", - "@coderline/alphaskia-windows": "^3.5.147", + "@coderline/alphaskia": "^4.0.157", + "@coderline/alphaskia-linux": "^4.0.157", + "@coderline/alphaskia-windows": "^4.0.157", "@types/node": "^26.6.6", "opentype.js": "^2.0.0", "rimraf": "^6.1.3", diff --git a/packages/alphatab/font/noto-sans-math/NotoSansMath-Regular.ttf b/packages/alphatab/font/noto-sans-math/NotoSansMath-Regular.ttf new file mode 100644 index 0000000000..a59e95c7ee Binary files /dev/null and b/packages/alphatab/font/noto-sans-math/NotoSansMath-Regular.ttf differ diff --git a/packages/alphatab/font/noto-sans-math/OFL.txt b/packages/alphatab/font/noto-sans-math/OFL.txt new file mode 100644 index 0000000000..8d02339152 --- /dev/null +++ b/packages/alphatab/font/noto-sans-math/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2022 The Noto Project Authors (https://github.com/notofonts/math) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://openfontlicense.org + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/alphatab/font/noto-sans-sc/NotoSansSC-Regular.otf b/packages/alphatab/font/noto-sans-sc/NotoSansSC-Regular.otf new file mode 100644 index 0000000000..fc0fda9394 Binary files /dev/null and b/packages/alphatab/font/noto-sans-sc/NotoSansSC-Regular.otf differ diff --git a/packages/alphatab/font/noto-sans-sc/OFL.txt b/packages/alphatab/font/noto-sans-sc/OFL.txt new file mode 100644 index 0000000000..d952d62c06 --- /dev/null +++ b/packages/alphatab/font/noto-sans-sc/OFL.txt @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/packages/alphatab/package.json b/packages/alphatab/package.json index b68765c820..cd4cff3c04 100644 --- a/packages/alphatab/package.json +++ b/packages/alphatab/package.json @@ -93,9 +93,9 @@ }, "devDependencies": { "@biomejs/biome": "^2.6.4", - "@coderline/alphaskia": "^3.5.147", - "@coderline/alphaskia-linux": "^3.5.147", - "@coderline/alphaskia-windows": "^3.5.147", + "@coderline/alphaskia": "^4.0.157", + "@coderline/alphaskia-linux": "^4.0.157", + "@coderline/alphaskia-windows": "^4.0.157", "@types/node": "^26.6.6", "opentype.js": "^2.0.0", "rimraf": "^6.1.3", diff --git a/packages/alphatab/scripts/Serializer.read.ts b/packages/alphatab/scripts/Serializer.read.ts index 57eb26df67..6ee49e5abc 100644 --- a/packages/alphatab/scripts/Serializer.read.ts +++ b/packages/alphatab/scripts/Serializer.read.ts @@ -188,7 +188,13 @@ function propertyRead(serializable: TypeSchema, prop: TypeProperty, importer: Im if (!itemType.isOwnType || itemType.isEnumType) { return `${field} = ${readValueExpr(type, 'r')};`; } - const add = jsonAddMethod(prop) ?? `${prop.name}.push`; + if (itemType.isJsonImmutable) { + importer(itemType.typeAsString, itemType.modulePath); + const read = `const items: ${itemType.typeAsString}[] = [];\nr.readArray(rr => {\nitems.push(${itemType.typeAsString}.fromJson(rr.readValue())!);\n});\n${field} = items;`; + return isNullableOrOptional(type) ? `if (!r.isNullish()) {\n${read}\n} else {\n${field} = null;\n}` : read; + } + // the item callback is a closure in which the null check of the field does not narrow + const add = jsonAddMethod(prop) ?? `${prop.name}${isNullableOrOptional(type) ? '!' : ''}.push`; const read = `${field} = [];\nr.readArray(rr => {\nconst i = ${constructExpr(itemType, importer)};\n${childRead(itemType, 'i', 'rr', ctx, importer)}\nobj.${add}(i);\n});`; return isNullableOrOptional(type) ? `if (!r.isNullish()) {\n${read}\n}` : read; } diff --git a/packages/alphatab/scripts/Serializer.write.ts b/packages/alphatab/scripts/Serializer.write.ts index bf2dae2886..6fee2a7611 100644 --- a/packages/alphatab/scripts/Serializer.write.ts +++ b/packages/alphatab/scripts/Serializer.write.ts @@ -95,6 +95,14 @@ function propertyWrite(serializable: TypeSchema, prop: TypeProperty, id: number, } if (type.isArray) { const itemType = type.arrayItemType!; + if (itemType.isJsonImmutable) { + importer(itemType.typeAsString, itemType.modulePath); + return nullGuard( + type, + field, + `w.arrayField(${id}, ${field}!.length);\nfor (const i of ${field}!) {\nw.writeValue(${itemType.typeAsString}.toJson(i));\n}\nw.endArray();` + ); + } if (itemType.isOwnType && !itemType.isEnumType) { const itemSerializer = `${itemType.typeAsString}Serializer`; importer(itemSerializer, findSerializerModule(itemType)); diff --git a/packages/alphatab/scripts/SerializerFillEmitter.ts b/packages/alphatab/scripts/SerializerFillEmitter.ts index 090e4ad6a4..f8a62c4c10 100644 --- a/packages/alphatab/scripts/SerializerFillEmitter.ts +++ b/packages/alphatab/scripts/SerializerFillEmitter.ts @@ -40,6 +40,8 @@ function immutableValue(type: TypeWithNullableInfo, importer: Importer): string switch (type.typeAsString) { case 'Color': return 'new Color(1, 2, 3, 4)'; + case 'SpelledPitch': + return 'new SpelledPitch(3, 1, 4)'; case 'Font': importer('FontStyle', type.modulePath); importer('FontWeight', type.modulePath); diff --git a/packages/alphatab/src/exporter/AlphaTexExporter.ts b/packages/alphatab/src/exporter/AlphaTexExporter.ts index 6f9e1ea530..1c8e9c3a38 100644 --- a/packages/alphatab/src/exporter/AlphaTexExporter.ts +++ b/packages/alphatab/src/exporter/AlphaTexExporter.ts @@ -28,9 +28,9 @@ import type { Beat } from '@coderline/alphatab/model/Beat'; import type { Note } from '@coderline/alphatab/model/Note'; import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; import type { Score } from '@coderline/alphatab/model/Score'; +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; import type { Staff } from '@coderline/alphatab/model/Staff'; import type { Track } from '@coderline/alphatab/model/Track'; -import { Tuning } from '@coderline/alphatab/model/Tuning'; import { Settings } from '@coderline/alphatab/Settings'; /** @@ -677,7 +677,7 @@ export class AlphaTexExporter extends ScoreExporter { } else if (data.isPiano) { note.noteValue = { nodeType: AlphaTexNodeType.Ident, - text: Tuning.getTextForTuning(data.realValueWithoutHarmonic, true) + text: SpelledPitch.formatValue(data.realValueWithoutHarmonic, true, 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 a087482bd6..f8e4ab85d9 100644 --- a/packages/alphatab/src/exporter/GpifWriter.ts +++ b/packages/alphatab/src/exporter/GpifWriter.ts @@ -41,6 +41,7 @@ import type { Score } from '@coderline/alphatab/model/Score'; import { SimileMark } from '@coderline/alphatab/model/SimileMark'; import { SlideInType } from '@coderline/alphatab/model/SlideInType'; import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; import type { Staff } from '@coderline/alphatab/model/Staff'; import type { Track } from '@coderline/alphatab/model/Track'; import { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; @@ -117,8 +118,8 @@ export class GpifWriter { // routes through staff.tuning — needs to match the tuning we // gave the assigner. The notes are modified, hence the // Gp7Exporter passes a copy of the input score. - const savedTunings = staff.tuning; - if (needsFingering && stringedTuning !== null && savedTunings.length === 0) { + const savedPitches = staff.stringTuning.pitches; + if (needsFingering && stringedTuning !== null && savedPitches.length === 0) { staff.stringTuning.tunings = stringedTuning.slice(); } @@ -150,7 +151,7 @@ export class GpifWriter { } } } - staff.stringTuning.tunings = savedTunings; + staff.stringTuning.pitches = savedPitches; } } } @@ -450,7 +451,6 @@ export class GpifWriter { } } - private static readonly _steps: string[] = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; private static readonly _accidentals: string[] = ['bb', 'b', '', '#', 'x']; private _writePitchForValue( @@ -465,9 +465,9 @@ export class GpifWriter { this._writePitch( properties, propertyName, - GpifWriter._steps[spelling.degree], + SpelledPitch.stepName(spelling.step), (spelling.octave + 1).toString(), - GpifWriter._accidentals[spelling.accidentalOffset + 2] + GpifWriter._accidentals[spelling.alter + 2] ); } @@ -1259,6 +1259,7 @@ export class GpifWriter { } } this._tuningByStaff.set(staff, tuning); + const isTuningFlat = GpifWriter._isTuningFlat(staff, tuning); if (tuning.length > 0) { const tuningProperty = properties.addElement('Property'); @@ -1266,7 +1267,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 (isTuningFlat) { + tuningProperty.addElement('Flat'); + } if (staff.isPercussion) { tuningProperty.addElement('Instrument').innerText = 'Undefined'; @@ -1328,12 +1331,36 @@ export class GpifWriter { staff.tuning.map(_ => '0').join('') ); - this._writeSimplePropertyNode(properties, 'TuningFlat', 'Enable', null); + if (isTuningFlat) { + this._writeSimplePropertyNode(properties, 'TuningFlat', 'Enable', null); + } this._writeDiagramCollection(properties, staff, 'DiagramCollection'); this._writeDiagramCollection(properties, staff, 'DiagramWorkingSet'); } + /** + * Guitar Pro stores one sharp or flat preference per tuning. + * Sharps are written if every string with an accidental is spelled with a sharp, otherwise flats. + */ + private static _isTuningFlat(staff: Staff, tuning: number[]): boolean { + const stringTuning = staff.stringTuning; + if (tuning !== stringTuning.tunings) { + return true; + } + let hasSharp = false; + for (const pitch of stringTuning.pitches) { + const alter = pitch.alter; + if (alter < 0) { + return true; + } + if (alter > 0) { + hasSharp = true; + } + } + return !hasSharp; + } + 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/SerializerSchema.ts b/packages/alphatab/src/generated/SerializerSchema.ts index cfaa1c0429..1da7047e5e 100644 --- a/packages/alphatab/src/generated/SerializerSchema.ts +++ b/packages/alphatab/src/generated/SerializerSchema.ts @@ -12,5 +12,5 @@ export class SerializerSchema { /** * A hash of the field ids, keys and types of all serialized classes (50 classes). */ - public static readonly fingerprint: number = 0xc4694831; + public static readonly fingerprint: number = 0x4671941c; } diff --git a/packages/alphatab/src/generated/model/TuningSerializer.ts b/packages/alphatab/src/generated/model/TuningSerializer.ts index b845bef907..aa011c074b 100644 --- a/packages/alphatab/src/generated/model/TuningSerializer.ts +++ b/packages/alphatab/src/generated/model/TuningSerializer.ts @@ -6,12 +6,13 @@ import { Tuning } from "@coderline/alphatab/model/Tuning"; import type { IObjectReader } from "@coderline/alphatab/io/IObjectReader"; import { RecordFields } from "@coderline/alphatab/io/RecordFields"; +import { SpelledPitch } from "@coderline/alphatab/model/SpelledPitch"; import type { IObjectWriter } from "@coderline/alphatab/io/IObjectWriter"; /** * @internal */ export class TuningSerializer { - private static readonly _fields: RecordFields = new RecordFields(["isstandard", "name", "tunings"]); + private static readonly _fields: RecordFields = new RecordFields(["isstandard", "name", "tunings", "pitches"]); public static read(obj: Tuning, reader: IObjectReader): void { reader.readRecord(TuningSerializer._fields, (id, k, r) => { switch (id) { @@ -24,10 +25,20 @@ export class TuningSerializer { case 2: obj.tunings = r.readValue()! as number[]; return true; + case 3: + TuningSerializer._readPitches(obj, r); + return true; } return false; }); } + private static _readPitches(obj: Tuning, r: IObjectReader): void { + const items: SpelledPitch[] = []; + r.readArray(rr => { + items.push(SpelledPitch.fromJson(rr.readValue())!); + }); + obj.pitches = items; + } public static write(obj: Tuning | null | undefined, w: IObjectWriter): void { if (!obj) { w.writeNull(); @@ -36,7 +47,11 @@ export class TuningSerializer { w.startObject(TuningSerializer._fields); w.booleanField(0, obj.isStandard); w.stringField(1, obj.name); - w.valueField(2, obj.tunings); + w.arrayField(3, obj.pitches!.length); + for (const i of obj.pitches!) { + w.writeValue(SpelledPitch.toJson(i)); + } + w.endArray(); w.endObject(); } } diff --git a/packages/alphatab/src/importer/AlphaTexImporter.ts b/packages/alphatab/src/importer/AlphaTexImporter.ts index ce49854b13..b9dbe8b4a6 100644 --- a/packages/alphatab/src/importer/AlphaTexImporter.ts +++ b/packages/alphatab/src/importer/AlphaTexImporter.ts @@ -575,13 +575,13 @@ export class AlphaTexImporter extends ScoreImporter implements IAlphaTexImporter detectedNoteKind = undefined; // don't know on those notes } } else { - const tuning = ModelUtils.parseTuning(str); - if (tuning) { + const pitch = ModelUtils.parsePitch(str); + if (pitch) { detectedNoteKind = AlphaTexStaffNoteKind.Pitched; - octave = tuning.octave; - tone = tuning.tone.noteValue; + octave = pitch.noteOctave; + tone = pitch.noteTone; if (this._state.accidentalMode === AlphaTexAccidentalMode.Explicit) { - accidentalMode = tuning.tone.accidentalMode; + accidentalMode = pitch.accidentalMode; } } else { detectedNoteKind = AlphaTexStaffNoteKind.Articulation; diff --git a/packages/alphatab/src/importer/CapellaParser.ts b/packages/alphatab/src/importer/CapellaParser.ts index c33343a1f1..6215aeee01 100644 --- a/packages/alphatab/src/importer/CapellaParser.ts +++ b/packages/alphatab/src/importer/CapellaParser.ts @@ -838,9 +838,9 @@ export class CapellaParser { private _parseHead(beat: Beat, articulation: Note, element: XmlNode) { const note = new Note(); - const pitch = ModelUtils.parseTuning(element.getAttribute('pitch')); - note.octave = pitch!.octave - 1; - note.tone = pitch!.tone.noteValue; + const pitch = ModelUtils.parsePitch(element.getAttribute('pitch')); + note.octave = pitch!.noteOctave - 1; + note.tone = pitch!.noteTone; note.isStaccato = articulation.isStaccato; note.accentuated = articulation.accentuated; beat.addNote(note); diff --git a/packages/alphatab/src/importer/Gp3To5Importer.ts b/packages/alphatab/src/importer/Gp3To5Importer.ts index 9a5f99a752..b48161dc68 100644 --- a/packages/alphatab/src/importer/Gp3To5Importer.ts +++ b/packages/alphatab/src/importer/Gp3To5Importer.ts @@ -598,7 +598,7 @@ export class Gp3To5Importer extends ScoreImporter { /** * Guitar Pro 3-6 changes to a bass clef if any string tuning is below B1 */ - private static readonly _bassClefTuningThreshold = ModelUtils.parseTuning('B1')!.realValue; + private static readonly _bassClefTuningThreshold = ModelUtils.parsePitch('B1')!.pitch.value; public readTrack(): void { const newTrack: Track = new Track(); diff --git a/packages/alphatab/src/importer/GpifParser.ts b/packages/alphatab/src/importer/GpifParser.ts index 7dae73de87..11de2f65d5 100644 --- a/packages/alphatab/src/importer/GpifParser.ts +++ b/packages/alphatab/src/importer/GpifParser.ts @@ -44,6 +44,7 @@ import { Section } from '@coderline/alphatab/model/Section'; import { SimileMark } from '@coderline/alphatab/model/SimileMark'; import { SlideInType } from '@coderline/alphatab/model/SlideInType'; import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; import type { Staff } from '@coderline/alphatab/model/Staff'; import { Track } from '@coderline/alphatab/model/Track'; import { TremoloPickingEffect } from '@coderline/alphatab/model/TremoloPickingEffect'; @@ -984,24 +985,14 @@ export class GpifParser { 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; - } + const staffPitches = GpifParser._parseTuningPitches(node); + if (staffPitches !== null) { + staff.stringTuning.pitches = staffPitches; + } + const label = node.findChildElement('Label'); + if (label !== null) { + staff.stringTuning.name = label.innerText; } - break; case 'DiagramCollection': case 'ChordCollection': @@ -1169,17 +1160,37 @@ export class GpifParser { } } + /** + * Parses the strings of a tuning property, they are spelled with sharps unless the tuning is marked as flat. + * @returns The pitches of the strings or null if the property has no pitches. + */ + private static _parseTuningPitches(node: XmlNode): SpelledPitch[] | null { + let values: number[] | null = null; + let preferFlat = false; + for (const c of node.childElements()) { + switch (c.localName) { + case 'Pitches': + const tuningParts: string[] = GpifParser._splitSafe(c.innerText); + values = new Array(tuningParts.length); + for (let i: number = 0; i < values.length; i++) { + values[values.length - 1 - i] = GpifParser._parseIntSafe(tuningParts[i], 0); + } + break; + case 'Flat': + preferFlat = true; + break; + } + } + return values === null ? null : values.map(v => SpelledPitch.fromValue(v, preferFlat)); + } + 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); - } + const trackPitches = GpifParser._parseTuningPitches(node) ?? []; for (const staff of track.staves) { - staff.stringTuning.tunings = tuning; + staff.stringTuning.pitches = trackPitches.slice(); } break; case 'DiagramCollection': diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 795b339f97..f0af4a6fd3 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -44,6 +44,7 @@ import { PickStroke } from '@coderline/alphatab/model/PickStroke'; import { BarNumberDisplay, LyricsPlacement } from '@coderline/alphatab/model/RenderStylesheet'; import { Score } from '@coderline/alphatab/model/Score'; import { Section } from '@coderline/alphatab/model/Section'; +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; import { Staff } from '@coderline/alphatab/model/Staff'; import { Track } from '@coderline/alphatab/model/Track'; import { TremoloPickingEffect, TremoloPickingStyle } from '@coderline/alphatab/model/TremoloPickingEffect'; @@ -1951,6 +1952,7 @@ export class MusicXmlImporter extends ScoreImporter { } private _parseStaffDetails(element: XmlNode, staff: Staff): void { + let pitches: SpelledPitch[] | null = null; for (const c of element.childElements()) { switch (c.localName) { // case 'staff-type': Ignored @@ -1959,7 +1961,10 @@ export class MusicXmlImporter extends ScoreImporter { break; // case 'line-detail': Not supported case 'staff-tuning': - this._parseStaffTuning(c, staff); + if (pitches === null) { + pitches = this._startStaffTuning(staff); + } + MusicXmlImporter._parseStaffTuning(c, pitches); break; case 'capo': staff.capo = Number.parseInt(c.innerText, 10); @@ -1967,15 +1972,28 @@ export class MusicXmlImporter extends ScoreImporter { // case 'staff-size': Not supported } } + + if (pitches !== null) { + staff.stringTuning.pitches = pitches; + } } - private _parseStaffTuning(element: XmlNode, staff: Staff): void { - if (staff.stringTuning.tunings.length === 0) { - staff.showTablature = true; - staff.showStandardNotation = false; - staff.stringTuning.tunings = new Array(staff.standardNotationLineCount).fill(0); + private _startStaffTuning(staff: Staff): SpelledPitch[] { + if (staff.stringTuning.pitches.length > 0) { + return staff.stringTuning.pitches.slice(); } + staff.showTablature = true; + staff.showStandardNotation = false; + const pitches: SpelledPitch[] = []; + const lowestC = SpelledPitch.fromValue(0, true); + for (let i = 0; i < staff.standardNotationLineCount; i++) { + pitches.push(lowestC); + } + return pitches; + } + + private static _parseStaffTuning(element: XmlNode, pitches: SpelledPitch[]): void { const line: number = Number.parseInt(element.getAttribute('line'), 10); let tuningStep: string = 'C'; let tuningOctave: string = ''; @@ -1987,14 +2005,28 @@ export class MusicXmlImporter extends ScoreImporter { break; case 'tuning-alter': tuningAlter = Number.parseFloat(c.innerText); + tuningAlter = Number.isNaN(tuningAlter) ? 0 : tuningAlter | 0; // no microtones supported break; case 'tuning-octave': tuningOctave = c.innerText; break; } } - const tuning: number = ModelUtils.getTuningForText(tuningStep + tuningOctave) + tuningAlter; - staff.tuning[staff.tuning.length - line] = tuning; + + // line 1 is the lowest string, the pitches start with the highest string + const index = pitches.length - line; + if (index < 0 || index >= pitches.length) { + return; + } + + const natural = ModelUtils.parsePitch(tuningStep + tuningOctave); + if (natural === null) { + pitches[index] = SpelledPitch.fromValue(-1 + tuningAlter, true); + } else if (tuningAlter >= -2 && tuningAlter <= 2) { + pitches[index] = new SpelledPitch(natural.pitch.step, tuningAlter, natural.pitch.octave); + } else { + pitches[index] = SpelledPitch.fromValue(natural.pitch.value + tuningAlter, true); + } } private _parseClef(element: XmlNode, bar: Bar): void { @@ -4338,7 +4370,7 @@ export class MusicXmlImporter extends ScoreImporter { note.octave = 0; note.tone = 0; } else { - const value: number = octave * 12 + (ModelUtils.getToneForText(step)?.noteValue ?? 0); + const value: number = octave * 12 + MusicXmlImporter._stepSemitones(step); note.octave = (value / 12) | 0; note.tone = value - note.octave * 12; } @@ -4346,6 +4378,11 @@ export class MusicXmlImporter extends ScoreImporter { return note; } + private static _stepSemitones(step: string): number { + const s = step.length === 1 ? SpelledPitch.stepForLetter(step.charCodeAt(0)) : -1; + return s >= 0 ? SpelledPitch.stepSemitones(s) : 0; + } + private _parsePitch(element: XmlNode): Note { let step: string = ''; let semitones: number = 0; @@ -4369,7 +4406,7 @@ export class MusicXmlImporter extends ScoreImporter { } semitones = semitones | 0; // no microtones supported - const value: number = octave * 12 + (ModelUtils.getToneForText(step)?.noteValue ?? 0) + semitones; + const value: number = octave * 12 + MusicXmlImporter._stepSemitones(step) + semitones; const note = new Note(); note.octave = (value / 12) | 0; diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts index 377b393d65..cb2f0ce279 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts @@ -90,6 +90,7 @@ import { Section } from '@coderline/alphatab/model/Section'; import { SimileMark } from '@coderline/alphatab/model/SimileMark'; import { SlideInType } from '@coderline/alphatab/model/SlideInType'; import { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; import { Staff } from '@coderline/alphatab/model/Staff'; import { NumberedNoteMode, @@ -397,7 +398,7 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler staff.capo = (metaData.arguments!.arguments[0] as AlphaTexNumberLiteral).value; return ApplyNodeResult.Applied; case 'tuning': - const tuning: number[] = []; + const pitches: SpelledPitch[] = []; let hideTuning = false; let tuningName = ''; for (let i = 0; i < metaData.arguments!.arguments.length; i++) { @@ -422,10 +423,10 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler }); break; default: - const t = ModelUtils.parseTuning(text); + const t = ModelUtils.parsePitch(text); if (t) { - tuning.push(t.realValue); - } else if (i === metaData.arguments!.arguments.length - 1 && tuning.length > 0) { + pitches.push(t.pitch); + } else if (i === metaData.arguments!.arguments.length - 1 && pitches.length > 0) { tuningName = text; importer.addSemanticDiagnostic({ code: AlphaTexDiagnosticCode.AT305, @@ -452,7 +453,7 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler importer.state.staffHasExplicitTuning.add(staff); importer.state.staffTuningApplied.delete(staff); staff.stringTuning = new Tuning(); - staff.stringTuning.tunings = tuning; + staff.stringTuning.pitches = pitches; staff.stringTuning.name = tuningName; this._tuningProperties(importer, staff, staff.stringTuning, metaData); @@ -1846,10 +1847,10 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler beat.text = (p.arguments!.arguments[0] as AlphaTexTextNode).text; return ApplyNodeResult.Applied; case 'restdisplaypitch': { - const tuning = ModelUtils.parseTuning((p.arguments!.arguments[0] as AlphaTexTextNode).text); - if (tuning !== null) { - beat.restDisplayTone = tuning.tone.noteValue; - beat.restDisplayOctave = tuning.octave - 1; + const pitch = ModelUtils.parsePitch((p.arguments!.arguments[0] as AlphaTexTextNode).text); + if (pitch !== null) { + beat.restDisplayTone = pitch.noteTone; + beat.restDisplayOctave = pitch.noteOctave - 1; } else { importer.addSemanticDiagnostic({ code: AlphaTexDiagnosticCode.AT212, @@ -3248,9 +3249,7 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler const tuning = Atnf.meta( 'tuning', Atnf.args( - staff.stringTuning.tunings.map( - t => Atnf.ident(Tuning.getTextForTuning(t, true)) as IAlphaTexArgumentValue - ) + staff.stringTuning.pitches.map(p => Atnf.ident(p.toString()) as IAlphaTexArgumentValue) ) ); nodes.push(tuning); diff --git a/packages/alphatab/src/model/ModelUtils.ts b/packages/alphatab/src/model/ModelUtils.ts index 08087ea13c..367eaac83f 100644 --- a/packages/alphatab/src/model/ModelUtils.ts +++ b/packages/alphatab/src/model/ModelUtils.ts @@ -11,43 +11,42 @@ import { HeaderFooterStyle, type Score, ScoreStyle, type ScoreSubElement } from import type { Staff } from '@coderline/alphatab/model/Staff'; import type { Track } from '@coderline/alphatab/model/Track'; import { Voice } from '@coderline/alphatab/model/Voice'; +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; import type { Settings } from '@coderline/alphatab/Settings'; import { SynthConstants } from '@coderline/alphatab/synth/SynthConstants'; /** + * The result of parsing a textual pitch like `C4`, `F#3` or `Gbb2`. * @internal */ -export class TuningParseResult { - public note: string | null = null; - public tone: TuningParseResultTone = new TuningParseResultTone(); - public octave: number = 0; +export class PitchParseResult { + public readonly pitch: SpelledPitch; + /** + * The accidental mode matching the accidental as written (e.g. {@link NoteAccidentalMode.ForceNatural} for `Cn4`). + */ + public readonly accidentalMode: NoteAccidentalMode; - public get realValue(): number { - return this.octave * 12 + this.tone.noteValue; + public constructor(pitch: SpelledPitch, accidentalMode: NoteAccidentalMode) { + this.pitch = pitch; + this.accidentalMode = accidentalMode; } -} -/** - * @internal - */ -export class TuningParseResultTone { - public noteValue: number; - public accidentalMode: NoteAccidentalMode; - public constructor(noteValue: number = 0, accidentalMode: NoteAccidentalMode = NoteAccidentalMode.Default) { - this.noteValue = noteValue; - this.accidentalMode = accidentalMode; + /** + * The value for `Note.tone`: steps flattened below C roll over into the octave below (`Cb4` = 11), + * steps sharpened above B stay in the octave of the step (`B#3` = 12). + */ + public get noteTone(): number { + const tone = SpelledPitch.stepSemitones(this.pitch.step) + this.pitch.alter; + return tone < 0 ? tone + 12 : tone; } -} -/** - * @internal - * @record - */ -export interface ResolvedSpelling { - degree: number; - accidentalOffset: number; - chroma: number; - octave: number; + /** + * The value for `Note.octave` matching {@link noteTone}. + */ + public get noteOctave(): number { + const tone = SpelledPitch.stepSemitones(this.pitch.step) + this.pitch.alter; + return tone < 0 ? this.pitch.octave : this.pitch.octave + 1; + } } /** @@ -55,8 +54,8 @@ export interface ResolvedSpelling { * @record */ interface SpellingBase { - degree: number; - accidentalOffset: number; + step: number; + alter: number; } /** @@ -124,14 +123,6 @@ export class ModelUtils { } } - /** - * Checks if the given string is a tuning inticator. - * @param name - */ - public static isTuning(name: string): boolean { - return !!ModelUtils.parseTuning(name); - } - /** * @internal */ @@ -140,7 +131,13 @@ export class ModelUtils { 0x64 /* d */, 0x65 /* e */, 0x66 /* f */, 0x67 /* g */, 0x61 /* a */, 0x62 /* b */, 0x23 /* # */ ]); - public static parseTuning(name: string): TuningParseResult | null { + /** + * Parses a textual pitch in the form `` like `C4`, `f#3` or `Gbb2`. + * The step is case insensitive, the accidental is any key of {@link accidentalModeMapping}. + * Digits are collected as octave wherever they appear after the step. + * @returns The parsed pitch or null if the text is no valid pitch. + */ + public static parsePitch(name: string): PitchParseResult | null { let note: string = ''; let octave: string = ''; for (let i: number = 0; i < name.length; i++) { @@ -165,94 +162,39 @@ export class ModelUtils { return null; } - const result: TuningParseResult = new TuningParseResult(); - - result.octave = Number.parseInt(octave, 10) + 1; - result.note = note.toLowerCase(); - const tone = ModelUtils.getToneForText(result.note); - if (tone === null) { + const step = SpelledPitch.stepForLetter(note.charCodeAt(0)); + if (step < 0) { return null; } - result.tone = tone; - - // if tone.noteValue is negative (eg. on Cb note) - // we adjust roll-over to a lower octave - if (result.tone.noteValue < 0) { - result.octave--; - result.tone.noteValue += 12; - } - - return result; - } - - public static getTuningForText(str: string): number { - const result: TuningParseResult | null = ModelUtils.parseTuning(str); - if (!result) { - return -1; - } - return result.realValue; - } - - public static getToneForText(note: string): TuningParseResultTone | null { - const noteName = note.substring(0, 1); - const accidental = note.substring(1); - - let noteValue: number; - let noteAccidenalMode: NoteAccidentalMode; - - switch (noteName.toLowerCase()) { - case 'c': - noteValue = 0; - break; - case 'd': - noteValue = 2; - break; - case 'e': - noteValue = 4; - break; - case 'f': - noteValue = 5; - break; - case 'g': - noteValue = 7; - break; - case 'a': - noteValue = 9; - break; - case 'b': - noteValue = 11; - break; - default: - return null; - } + const accidental = note.substring(1).toLowerCase(); if (!ModelUtils.accidentalModeMapping.has(accidental)) { return null; } + const accidentalMode = ModelUtils.accidentalModeMapping.get(accidental)!; - noteAccidenalMode = ModelUtils.parseAccidentalMode(accidental); - switch (noteAccidenalMode) { - case NoteAccidentalMode.Default: - break; - case NoteAccidentalMode.ForceNone: - break; - case NoteAccidentalMode.ForceNatural: - break; + return new PitchParseResult( + new SpelledPitch(step, ModelUtils.accidentalModeToAlter(accidentalMode), Number.parseInt(octave, 10)), + accidentalMode + ); + } + + /** + * The semitones the given accidental mode shifts the written step by. + * Modes without a sharp or flat do not shift the step. + */ + public static accidentalModeToAlter(accidentalMode: NoteAccidentalMode): number { + switch (accidentalMode) { case NoteAccidentalMode.ForceSharp: - noteValue++; - break; + return 1; case NoteAccidentalMode.ForceDoubleSharp: - noteValue += 2; - break; + return 2; case NoteAccidentalMode.ForceFlat: - noteValue--; - break; + return -1; case NoteAccidentalMode.ForceDoubleFlat: - noteValue -= 2; - break; + return -2; } - - return new TuningParseResultTone(noteValue, noteAccidenalMode); + return 0; } /** @@ -960,111 +902,79 @@ export class ModelUtils { // diatonic accidentals - private static readonly _degreeSemitones: number[] = [0, 2, 4, 5, 7, 9, 11]; - - private static readonly _sharpPreferredSpellings: SpellingBase[] = [ - { degree: 0, accidentalOffset: 0 }, // C - { degree: 0, accidentalOffset: 1 }, // C# - { degree: 1, accidentalOffset: 0 }, // D - { degree: 1, accidentalOffset: 1 }, // D# - { degree: 2, accidentalOffset: 0 }, // E - { degree: 3, accidentalOffset: 0 }, // F - { degree: 3, accidentalOffset: 1 }, // F# - { degree: 4, accidentalOffset: 0 }, // G - { degree: 4, accidentalOffset: 1 }, // G# - { degree: 5, accidentalOffset: 0 }, // A - { degree: 5, accidentalOffset: 1 }, // A# - { degree: 6, accidentalOffset: 0 } // B - ]; - - private static readonly _flatPreferredSpellings: SpellingBase[] = [ - { degree: 0, accidentalOffset: 0 }, // C - { degree: 1, accidentalOffset: -1 }, // Db - { degree: 1, accidentalOffset: 0 }, // D - { degree: 2, accidentalOffset: -1 }, // Eb - { degree: 2, accidentalOffset: 0 }, // E - { degree: 3, accidentalOffset: 0 }, // F - { degree: 4, accidentalOffset: -1 }, // Gb - { degree: 4, accidentalOffset: 0 }, // G - { degree: 5, accidentalOffset: -1 }, // Ab - { degree: 5, accidentalOffset: 0 }, // A - { degree: 6, accidentalOffset: -1 }, // Bb - { degree: 6, accidentalOffset: 0 } // B - ]; - // 12 chromatic pitch classes with always 3 possible spellings in the // accidental range of bb..## private static readonly _spellingCandidates: SpellingBase[][] = [ // 0: C [ - { degree: 0, accidentalOffset: 0 }, // C - { degree: 1, accidentalOffset: -2 }, // Dbb - { degree: 6, accidentalOffset: 1 } // B# + { step: 0, alter: 0 }, // C + { step: 1, alter: -2 }, // Dbb + { step: 6, alter: 1 } // B# ], // 1: C#/Db [ - { degree: 0, accidentalOffset: 1 }, // C# - { degree: 1, accidentalOffset: -1 }, // Db - { degree: 6, accidentalOffset: 2 } // B## + { step: 0, alter: 1 }, // C# + { step: 1, alter: -1 }, // Db + { step: 6, alter: 2 } // B## ], // 2: D [ - { degree: 1, accidentalOffset: 0 }, // D - { degree: 0, accidentalOffset: 2 }, // C## - { degree: 2, accidentalOffset: -2 } // Ebb + { step: 1, alter: 0 }, // D + { step: 0, alter: 2 }, // C## + { step: 2, alter: -2 } // Ebb ], // 3: D#/Eb [ - { degree: 1, accidentalOffset: 1 }, // D# - { degree: 2, accidentalOffset: -1 }, // Eb - { degree: 3, accidentalOffset: -2 } // Fbb + { step: 1, alter: 1 }, // D# + { step: 2, alter: -1 }, // Eb + { step: 3, alter: -2 } // Fbb ], // 4: E [ - { degree: 2, accidentalOffset: 0 }, // E - { degree: 1, accidentalOffset: 2 }, // D## - { degree: 3, accidentalOffset: -1 } // Fb + { step: 2, alter: 0 }, // E + { step: 1, alter: 2 }, // D## + { step: 3, alter: -1 } // Fb ], // 5: F [ - { degree: 3, accidentalOffset: 0 }, // F - { degree: 2, accidentalOffset: 1 }, // E# - { degree: 4, accidentalOffset: -2 } // Gbb + { step: 3, alter: 0 }, // F + { step: 2, alter: 1 }, // E# + { step: 4, alter: -2 } // Gbb ], // 6: F#/Gb [ - { degree: 3, accidentalOffset: 1 }, // F# - { degree: 4, accidentalOffset: -1 }, // Gb - { degree: 2, accidentalOffset: 2 } // E## + { step: 3, alter: 1 }, // F# + { step: 4, alter: -1 }, // Gb + { step: 2, alter: 2 } // E## ], // 7: G [ - { degree: 4, accidentalOffset: 0 }, // G - { degree: 3, accidentalOffset: 2 }, // F## - { degree: 5, accidentalOffset: -2 } // Abb + { step: 4, alter: 0 }, // G + { step: 3, alter: 2 }, // F## + { step: 5, alter: -2 } // Abb ], // 8: G#/Ab [ - { degree: 4, accidentalOffset: 1 }, // G# - { degree: 5, accidentalOffset: -1 } // Ab + { step: 4, alter: 1 }, // G# + { step: 5, alter: -1 } // Ab ], // 9: A [ - { degree: 5, accidentalOffset: 0 }, // A - { degree: 4, accidentalOffset: 2 }, // G## - { degree: 6, accidentalOffset: -2 } // Bbb + { step: 5, alter: 0 }, // A + { step: 4, alter: 2 }, // G## + { step: 6, alter: -2 } // Bbb ], // 10: A#/Bb [ - { degree: 5, accidentalOffset: 1 }, // A# - { degree: 6, accidentalOffset: -1 }, // Bb - { degree: 0, accidentalOffset: -2 } // Cbb + { step: 5, alter: 1 }, // A# + { step: 6, alter: -1 }, // Bb + { step: 0, alter: -2 } // Cbb ], // 11: B [ - { degree: 6, accidentalOffset: 0 }, // B - { degree: 5, accidentalOffset: 2 }, // A## - { degree: 0, accidentalOffset: -1 } // Cb + { step: 6, alter: 0 }, // B + { step: 5, alter: 2 }, // A## + { step: 0, alter: -1 } // Cb ] ]; private static readonly _sharpKeySignatureOrder: number[] = [3, 0, 4, 1, 5, 2, 6]; // F C G D A E B @@ -1073,24 +983,6 @@ export class ModelUtils { private static readonly _keySignatureAccidentalByDegree: number[][] = ModelUtils._buildKeySignatureAccidentalByDegree(); - private static readonly _accidentalOffsetToType = new Map([ - [-2, AccidentalType.DoubleFlat], - [-1, AccidentalType.Flat], - [0, AccidentalType.Natural], - [1, AccidentalType.Sharp], - [2, AccidentalType.DoubleSharp] - ]); - - private static readonly _forcedAccidentalOffsetByMode = new Map([ - [NoteAccidentalMode.ForceSharp, 1], - [NoteAccidentalMode.ForceDoubleSharp, 2], - [NoteAccidentalMode.ForceFlat, -1], - [NoteAccidentalMode.ForceDoubleFlat, -2], - [NoteAccidentalMode.ForceNatural, 0], - [NoteAccidentalMode.ForceNone, 0], - [NoteAccidentalMode.Default, Number.NaN] - ]); - private static _buildKeySignatureAccidentalByDegree(): number[][] { const lookup: number[][] = []; for (let ks = -7; ks <= 7; ks++) { @@ -1113,36 +1005,52 @@ export class ModelUtils { return ModelUtils._keySignatureAccidentalByDegree[(keySignature as number) + 7][degree]; } + /** + * Resolves how the given note value is written in the given key signature. + * A forced accidental mode selects the matching enharmonic spelling if there is one, + * otherwise the spelling of the key signature is used, otherwise sharps or flats depending on the key signature. + */ public static resolveSpelling( keySignature: KeySignature, noteValue: number, accidentalMode: NoteAccidentalMode - ): ResolvedSpelling { + ): SpelledPitch { const chroma = ModelUtils.flooredDivision(noteValue, 12); + const candidates = ModelUtils._spellingCandidates[chroma]; + + let step = -1; + let alter = 0; - const preferred = ModelUtils._getPreferredSpellingForKeySignature(keySignature, chroma); - const desiredOffset = ModelUtils._forcedAccidentalOffsetByMode.has(accidentalMode) - ? ModelUtils._forcedAccidentalOffsetByMode.get(accidentalMode)! - : Number.NaN; - - let spelling: SpellingBase = preferred; - if (!Number.isNaN(desiredOffset)) { - const candidates = ModelUtils._spellingCandidates[chroma]; - const exact = candidates.find(c => c.accidentalOffset === desiredOffset); - if (exact) { - spelling = exact; + if (accidentalMode !== NoteAccidentalMode.Default) { + const desiredAlter = ModelUtils.accidentalModeToAlter(accidentalMode); + for (let i = 0; i < candidates.length; i++) { + const c = candidates[i]; + if (c.alter === desiredAlter) { + step = c.step; + alter = c.alter; + break; + } } } - const baseSemitone = ModelUtils._degreeSemitones[spelling.degree] + spelling.accidentalOffset; - const octave = Math.floor((noteValue - baseSemitone) / 12) - 1; + if (step === -1) { + for (let i = 0; i < candidates.length; i++) { + const c = candidates[i]; + if (ModelUtils.getKeySignatureAccidentalOffset(keySignature, c.step) === c.alter) { + step = c.step; + alter = c.alter; + break; + } + } + } - return { - degree: spelling.degree, - accidentalOffset: spelling.accidentalOffset, - chroma, - octave - }; + if (step === -1) { + step = SpelledPitch.preferredStep(chroma, ModelUtils.keySignatureIsFlat(keySignature)); + alter = chroma - SpelledPitch.stepSemitones(step); + } + + const octave = Math.floor((noteValue - (SpelledPitch.stepSemitones(step) + alter)) / 12) - 1; + return new SpelledPitch(step, alter, octave); } /** @@ -1166,7 +1074,7 @@ export class ModelUtils { const forced = ModelUtils.resolveSpelling(keySignature, noteValue, accidentalMode); const preferred = ModelUtils.resolveSpelling(keySignature, noteValue, NoteAccidentalMode.Default); - if (forced.degree === preferred.degree && forced.accidentalOffset === preferred.accidentalOffset) { + if (forced.step === preferred.step && forced.alter === preferred.alter) { return NoteAccidentalMode.Default; } return accidentalMode; @@ -1192,7 +1100,7 @@ export class ModelUtils { public static computeAccidentalForSpelling( keySignature: KeySignature, accidentalMode: NoteAccidentalMode, - spelling: ResolvedSpelling, + spelling: SpelledPitch, quarterBend: boolean, currentAccidentalOffset: number | null = null ) { @@ -1201,17 +1109,17 @@ export class ModelUtils { } if (quarterBend) { - if (spelling.accidentalOffset > 0) { + if (spelling.alter > 0) { return AccidentalType.SharpQuarterNoteUp; } - if (spelling.accidentalOffset < 0) { + if (spelling.alter < 0) { return AccidentalType.FlatQuarterNoteUp; } return AccidentalType.NaturalQuarterNoteUp; } - const desiredOffset = spelling.accidentalOffset; - const ksOffset = ModelUtils.getKeySignatureAccidentalOffset(keySignature, spelling.degree); + const desiredOffset = spelling.alter; + const ksOffset = ModelUtils.getKeySignatureAccidentalOffset(keySignature, spelling.step); // already active in bar -> no accidental needed if (currentAccidentalOffset === desiredOffset) { @@ -1227,23 +1135,19 @@ export class ModelUtils { } public static accidentalOffsetToType(offset: number): AccidentalType { - return ModelUtils._accidentalOffsetToType.has(offset) - ? ModelUtils._accidentalOffsetToType.get(offset)! - : AccidentalType.None; - } - - private static _getPreferredSpellingForKeySignature(keySignature: KeySignature, chroma: number): SpellingBase { - const candidates = ModelUtils._spellingCandidates[chroma]; - - const ksMatch = candidates.find( - c => ModelUtils.getKeySignatureAccidentalOffset(keySignature, c.degree) === c.accidentalOffset - ); - if (ksMatch) { - return ksMatch; + switch (offset) { + case -2: + return AccidentalType.DoubleFlat; + case -1: + return AccidentalType.Flat; + case 0: + return AccidentalType.Natural; + case 1: + return AccidentalType.Sharp; + case 2: + return AccidentalType.DoubleSharp; } - - const preferFlat = ModelUtils.keySignatureIsFlat(keySignature); - return preferFlat ? ModelUtils._flatPreferredSpellings[chroma] : ModelUtils._sharpPreferredSpellings[chroma]; + return AccidentalType.None; } private static readonly _majorKeySignatureTonicDegrees: number[] = [ diff --git a/packages/alphatab/src/model/SpelledPitch.ts b/packages/alphatab/src/model/SpelledPitch.ts new file mode 100644 index 0000000000..236a9ea92b --- /dev/null +++ b/packages/alphatab/src/model/SpelledPitch.ts @@ -0,0 +1,316 @@ +import { FormatError } from '@coderline/alphatab/FormatError'; + +/** + * A pitch as it is written in notation: a diatonic step with an accidental in an octave. + * Enharmonic spellings like `F#3` and `Gb3` share the same {@link value} but are distinct pitches. + * Pitches are immutable, in JSON they are written as text like `F#3`. + * @since 1.10.0 + * @json_immutable + * @public + */ +export class SpelledPitch { + private static readonly _stepSemitones: number[] = [0, 2, 4, 5, 7, 9, 11]; + private static readonly _stepNames: string[] = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; + // indexed by chroma + private static readonly _sharpPreferredSteps: number[] = [0, 0, 1, 1, 2, 3, 3, 4, 4, 5, 5, 6]; + private static readonly _flatPreferredSteps: number[] = [0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6]; + // indexed by chroma, for allocation free formatting of the preferred spellings + private static readonly _sharpPreferredNames: string[] = [ + 'C', + 'C#', + 'D', + 'D#', + 'E', + 'F', + 'F#', + 'G', + 'G#', + 'A', + 'A#', + 'B' + ]; + private static readonly _flatPreferredNames: string[] = [ + 'C', + 'Db', + 'D', + 'Eb', + 'E', + 'F', + 'Gb', + 'G', + 'Ab', + 'A', + 'Bb', + 'B' + ]; + + // indexed by step * 5 + alter + 2 + private static readonly _displayNames: string[] = SpelledPitch._buildDisplayNames(); + + /** + * The diatonic step (letter) of the pitch: 0 = C, 1 = D, 2 = E, 3 = F, 4 = G, 5 = A, 6 = B. + * @since 1.10.0 + */ + public readonly step: number; + + /** + * The accidental of the pitch in semitones: -2 = double flat, -1 = flat, 0 = natural, 1 = sharp, 2 = double sharp. + * @since 1.10.0 + */ + public readonly alter: number; + + /** + * The octave of the written step in scientific pitch notation (`C4` is the middle C). + * The accidental does not change the octave: `B#3` sounds like `C4` and `Cb4` sounds like `B3`. + * @since 1.10.0 + */ + public readonly octave: number; + + /** + * Initializes a new instance of the {@link SpelledPitch} class. + * @param step The diatonic step (0 = C ... 6 = B). + * @param alter The accidental in semitones. + * @param octave The octave of the written step. + * @since 1.10.0 + */ + public constructor(step: number = 0, alter: number = 0, octave: number = 0) { + this.step = step; + this.alter = alter; + this.octave = octave; + } + + /** + * The MIDI value of the pitch (`C4` = 60). + * @since 1.10.0 + */ + public get value(): number { + return (this.octave + 1) * 12 + SpelledPitch._stepSemitones[this.step] + this.alter; + } + + /** + * The pitch class of the pitch in the range 0-11 (C = 0, C#/Db = 1 ... B = 11). + * @since 1.10.0 + */ + public get chroma(): number { + return SpelledPitch._floorMod(SpelledPitch._stepSemitones[this.step] + this.alter, 12); + } + + /** + * The written name of the pitch without octave, like `C`, `F#` or `Bbb`. + * @since 1.10.0 + */ + public toNameString(): string { + return SpelledPitch._stepNames[this.step] + SpelledPitch.accidentalText(this.alter); + } + + /** + * The written name of the pitch with octave, like `C4`, `F#3` or `Bbb2`. + */ + public toString(): string { + return this.toNameString() + this.octave.toString(); + } + + /** + * The written name of the pitch without octave with the accidental as music symbol character, like `F♯`. + * @internal + */ + public toDisplayNameString(): string { + if (this.alter < -2 || this.alter > 2) { + return this.toNameString(); + } + return SpelledPitch._displayNames[this.step * 5 + this.alter + 2]; + } + + private static _buildDisplayNames(): string[] { + // double flat, flat, natural, sharp, double sharp + const accidentals = ['\uD834\uDD2B', '\u266D', '', '\u266F', '\uD834\uDD2A']; + const names: string[] = []; + for (const step of SpelledPitch._stepNames) { + for (const accidental of accidentals) { + names.push(step + accidental); + } + } + return names; + } + + /** + * Creates a pitch from its JSON representation: the written name with octave like `F#3`, + * or a MIDI value which is spelled with flats. + * @internal + */ + public static fromJson(v: unknown): SpelledPitch | null { + if (v === null || v === undefined) { + return null; + } + + if (v instanceof SpelledPitch) { + return v; + } + + switch (typeof v) { + case 'number': + return SpelledPitch.fromValue(v as number, true); + case 'string': { + const pitch = SpelledPitch._parseName(v as string); + if (pitch !== null) { + return pitch; + } + break; + } + } + throw new FormatError('Unsupported format for spelled pitch'); + } + + /** + * @internal + */ + public static toJson(obj: SpelledPitch | null): string | null { + return obj === null ? null : obj.toString(); + } + + /** + * Parses the text written by {@link toString}. + */ + private static _parseName(text: string): SpelledPitch | null { + if (text.length < 2) { + return null; + } + const step = SpelledPitch.stepForLetter(text.charCodeAt(0)); + if (step < 0) { + return null; + } + + let i = 1; + let alter = 0; + while (i < text.length && text.charCodeAt(i) === 0x23 /* # */) { + alter++; + i++; + } + if (alter === 0) { + while (i < text.length && text.charCodeAt(i) === 0x62 /* b */) { + alter--; + i++; + } + } + if (alter < -2 || alter > 2) { + return null; + } + + const octaveStart = i; + if (i < text.length && text.charCodeAt(i) === 0x2d /* - */) { + i++; + } + if (i === text.length) { + return null; + } + while (i < text.length) { + const c = text.charCodeAt(i); + if (c < 0x30 || c > 0x39) { + return null; + } + i++; + } + + return new SpelledPitch(step, alter, Number.parseInt(text.substring(octaveStart), 10)); + } + + /** + * Creates the pitch for the given MIDI value, spelled with a sharp or a flat when it is not a natural pitch. + * Fractional values are truncated. + * @internal + */ + public static fromValue(value: number, preferFlat: boolean): SpelledPitch { + const v = value | 0; + const chroma = SpelledPitch._floorMod(v, 12); + const step = preferFlat ? SpelledPitch._flatPreferredSteps[chroma] : SpelledPitch._sharpPreferredSteps[chroma]; + return new SpelledPitch(step, chroma - SpelledPitch._stepSemitones[step], Math.floor(v / 12) - 1); + } + + /** + * Formats the given MIDI value like {@link fromValue} followed by {@link toString} without creating the pitch. + * @internal + */ + public static formatValue(value: number, includeOctave: boolean, preferFlat: boolean): string { + const v = value | 0; + const chroma = SpelledPitch._floorMod(v, 12); + const name = preferFlat ? SpelledPitch._flatPreferredNames[chroma] : SpelledPitch._sharpPreferredNames[chroma]; + return includeOctave ? name + (Math.floor(v / 12) - 1).toString() : name; + } + + /** + * The semitone offset of the given step within the octave (C = 0, D = 2 ... B = 11). + * @internal + */ + public static stepSemitones(step: number): number { + return SpelledPitch._stepSemitones[step]; + } + + /** + * The uppercase letter of the given step. + * @internal + */ + public static stepName(step: number): string { + return SpelledPitch._stepNames[step]; + } + + /** + * The step for the given letter (case insensitive) as character code, or -1 if it is no step letter. + * @internal + */ + public static stepForLetter(charCode: number): number { + switch (charCode) { + case 0x43 /* C */: + case 0x63 /* c */: + return 0; + case 0x44 /* D */: + case 0x64 /* d */: + return 1; + case 0x45 /* E */: + case 0x65 /* e */: + return 2; + case 0x46 /* F */: + case 0x66 /* f */: + return 3; + case 0x47 /* G */: + case 0x67 /* g */: + return 4; + case 0x41 /* A */: + case 0x61 /* a */: + return 5; + case 0x42 /* B */: + case 0x62 /* b */: + return 6; + } + return -1; + } + + /** + * The preferred step for the given pitch class when spelling with sharps or flats. + * @internal + */ + public static preferredStep(chroma: number, preferFlat: boolean): number { + return preferFlat ? SpelledPitch._flatPreferredSteps[chroma] : SpelledPitch._sharpPreferredSteps[chroma]; + } + + /** + * The textual accidental for the given alter (`bb`, `b`, empty, `#`, `##`). + * @internal + */ + public static accidentalText(alter: number): string { + switch (alter) { + case -2: + return 'bb'; + case -1: + return 'b'; + case 1: + return '#'; + case 2: + return '##'; + } + return ''; + } + + private static _floorMod(a: number, b: number): number { + return a - b * Math.floor(a / b); + } +} diff --git a/packages/alphatab/src/model/Tuning.ts b/packages/alphatab/src/model/Tuning.ts index a280209221..c4b9a2fa43 100644 --- a/packages/alphatab/src/model/Tuning.ts +++ b/packages/alphatab/src/model/Tuning.ts @@ -1,3 +1,5 @@ +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; + /** * This public class represents a predefined string tuning. * @json @@ -11,13 +13,25 @@ export class Tuning { private static _fourStrings: Tuning[] = []; private static _defaultTunings: Map = new Map(); + /** + * The flat spelled note names of the 12 pitch classes. + * @deprecated Use {@link SpelledPitch} for the written name of a pitch. + */ public static readonly noteNames: string[] = ['C', 'Db', 'D', 'Eb', 'E', 'F', 'Gb', 'G', 'Ab', 'A', 'Bb', 'B']; + /** + * Gets the flat spelled name of the given tuning value, optionally with octave. + * @deprecated Use {@link SpelledPitch.toNameString} or {@link SpelledPitch.toString} for the written name of a pitch. + */ public static getTextForTuning(tuning: number, includeOctave: boolean): string { const parts = Tuning.getTextPartsForTuning(tuning); return includeOctave ? parts.join('') : parts[0]; } + /** + * Gets the flat spelled name and the octave of the given tuning value. + * @deprecated Use {@link SpelledPitch} for the written name of a pitch. + */ public static getTextPartsForTuning(tuning: number, octaveShift: number = -1): string[] { const octave: number = (tuning / 12) | 0; const note: number = tuning % 12; @@ -32,8 +46,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 Tuning._defaultTunings.get(stringCount)!._copy(); } return null; } @@ -123,6 +136,10 @@ export class Tuning { * @returns The known tuning. */ public static findTuning(strings: number[]): Tuning | null { + return Tuning._findPreset(strings)?._copy() ?? null; + } + + private static _findPreset(strings: number[]): Tuning | null { const tunings: Tuning[] = Tuning.getPresetsFor(strings.length); for (let t: number = 0, tc: number = tunings.length; t < tc; t++) { const tuning: Tuning = tunings[t]; @@ -134,12 +151,18 @@ export class Tuning { } } if (equals) { - return new Tuning(tuning.name, tuning.tunings, tuning.isStandard); + return tuning; } } return null; } + private _copy(): Tuning { + const copy = new Tuning(this.name, null, this.isStandard); + copy.pitches = this._pitches.slice(); + return copy; + } + /** * Gets or sets whether this is the standard tuning for this number of strings. */ @@ -150,10 +173,36 @@ export class Tuning { */ public name: string; + private _pitches: SpelledPitch[] = []; + private _tunings: number[] = []; + /** - * Gets or sets the values for each string of the instrument. + * Gets or sets the MIDI values of the strings, starting with the highest string. + * The values are taken from {@link pitches}: setting them spells the strings with flats, + * changing the returned array does not change the tuning. + * @json_read_only */ - public tunings: number[]; + public get tunings(): number[] { + return this._tunings; + } + + public set tunings(value: number[]) { + this.pitches = value.map(v => SpelledPitch.fromValue(v, true)); + } + + /** + * Gets or sets the written pitches of the strings, starting with the highest string. + * Changing the returned array does not change the tuning, assign a new array instead. + * @since 1.10.0 + */ + public get pitches(): SpelledPitch[] { + return this._pitches; + } + + public set pitches(value: SpelledPitch[]) { + this._pitches = value; + this._tunings = value.map(p => p.value); + } /** * Initializes a new instance of the {@link Tuning} class. @@ -164,22 +213,23 @@ export class Tuning { public constructor(name: string = '', tuning: number[] | null = null, isStandard: boolean = false) { this.isStandard = isStandard; this.name = name; - this.tunings = tuning ?? []; + if (tuning !== null) { + this.tunings = tuning; + } } public reset() { this.isStandard = false; this.name = ''; - this.tunings = []; + this.pitches = []; } - /** * Tries to detect the name and standard flag of the tuning from a known tuning list based * on the string values. */ public finish() { - const knownTuning = Tuning.findTuning(this.tunings); + const knownTuning = Tuning._findPreset(this._tunings); if (knownTuning) { if (this.name.length === 0) { this.name = knownTuning.name; diff --git a/packages/alphatab/src/model/_barrel.ts b/packages/alphatab/src/model/_barrel.ts index 18696b985a..1d79a7a175 100644 --- a/packages/alphatab/src/model/_barrel.ts +++ b/packages/alphatab/src/model/_barrel.ts @@ -61,6 +61,7 @@ export { Section } from '@coderline/alphatab/model/Section'; export { SimileMark } from '@coderline/alphatab/model/SimileMark'; export { SlideInType } from '@coderline/alphatab/model/SlideInType'; export { SlideOutType } from '@coderline/alphatab/model/SlideOutType'; +export { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; export { Staff } from '@coderline/alphatab/model/Staff'; export { Track, TrackSubElement, TrackStyle } from '@coderline/alphatab/model/Track'; export { TripletFeel } from '@coderline/alphatab/model/TripletFeel'; diff --git a/packages/alphatab/src/rendering/glyphs/InlineTuningGlyph.ts b/packages/alphatab/src/rendering/glyphs/InlineTuningGlyph.ts index 0c021d440e..a43a30da25 100644 --- a/packages/alphatab/src/rendering/glyphs/InlineTuningGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/InlineTuningGlyph.ts @@ -1,5 +1,5 @@ import { TrackSubElement } from '@coderline/alphatab/model/Track'; -import { Tuning } from '@coderline/alphatab/model/Tuning'; +import type { Tuning } from '@coderline/alphatab/model/Tuning'; import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { type ICanvas, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; import { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; @@ -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 { @@ -26,9 +26,10 @@ export class InlineTuningGlyph extends Glyph { const oldFont = canvas.font; canvas.font = this.renderer.resources.elementFonts.get(NotationElement.GuitarTuning)!; + const textMeasures = this.renderer.scoreRenderer.layout!.textMeasures; let textWidth = 0; - for (const tuning of this._tunings) { - textWidth = Math.max(textWidth, canvas.measureText(Tuning.getTextForTuning(tuning, false)).width); + for (const pitch of this._tuning.pitches) { + textWidth = Math.max(textWidth, textMeasures.measureText(canvas, pitch.toDisplayNameString()).width); } canvas.font = oldFont; @@ -54,9 +55,10 @@ 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++) { + const pitches = this._tuning.pitches; + for (let i = 0, j = pitches.length; i < j; i++) { canvas.fillText( - Tuning.getTextForTuning(this._tunings[i], false), + pitches[i].toDisplayNameString(), textEndX, cy + this.renderer.y + (this.renderer as LineBarRenderer).getLineY(i) ); diff --git a/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts b/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts index ae982f951b..dcd08c8eca 100644 --- a/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts @@ -6,9 +6,10 @@ import { Duration } from '@coderline/alphatab/model/Duration'; import { GraceType } from '@coderline/alphatab/model/GraceType'; import type { KeySignature } from '@coderline/alphatab/model/KeySignature'; import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; -import { ModelUtils, type ResolvedSpelling } from '@coderline/alphatab/model/ModelUtils'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import { type Note, NoteSubElement } from '@coderline/alphatab/model/Note'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; +import type { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; import { NoteXPosition, NoteYPosition } from '@coderline/alphatab/rendering/BarRendererBase'; import { AccidentalGlyph } from '@coderline/alphatab/rendering/glyphs/AccidentalGlyph'; import { AccidentalGroupGlyph } from '@coderline/alphatab/rendering/glyphs/AccidentalGroupGlyph'; @@ -379,7 +380,7 @@ export class NumberedBeatGlyph extends BeatOnNoteGlyphBase { kst === KeySignatureType.Minor ? (tonicDegree + 2) % 7 // relative major : tonicDegree; - const degree = (spelling.degree - effectiveTonic + 7) % 7; + const degree = (spelling.step - effectiveTonic + 7) % 7; const octave = NumberedBeatGlyph._getOctave(bar, note); const head = new NumberedNoteHeadGlyph( 0, @@ -398,14 +399,14 @@ export class NumberedBeatGlyph extends BeatOnNoteGlyphBase { private static _resolveAccidental( keySignature: KeySignature, note: Note, - spelling: ResolvedSpelling + spelling: SpelledPitch ): AccidentalType { if (note.accidentalMode === NoteAccidentalMode.ForceNone) { return AccidentalType.None; } - const ksOffset = ModelUtils.getKeySignatureAccidentalOffset(keySignature, spelling.degree); - const requiredOffset = spelling.accidentalOffset - ksOffset; + const ksOffset = ModelUtils.getKeySignatureAccidentalOffset(keySignature, spelling.step); + const requiredOffset = spelling.alter - ksOffset; if (note.hasQuarterToneOffset) { if (requiredOffset > 0) { diff --git a/packages/alphatab/src/rendering/glyphs/TuningGlyph.ts b/packages/alphatab/src/rendering/glyphs/TuningGlyph.ts index 25fc6c388c..b6618125c8 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 type { Tuning } 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'; @@ -80,17 +80,38 @@ export class TuningGlyph extends GlyphGroup { const circleScale = this.renderer.smuflMetrics.tuningGlyphCircleNumberScale; const circleHeight = this.renderer.smuflMetrics.glyphHeights.get(MusicFontSymbol.GuitarString0)! * circleScale; - this.renderer.scoreRenderer.canvas!.font = res.elementFonts.get(NotationElement.GuitarTuning)!; - const stringColumnWidth = - (circleHeight + this.renderer.scoreRenderer.canvas!.measureText(' = Gb').width) * - res.engravingSettings.tuningGlyphStringColumnScale; + const font = res.elementFonts.get(NotationElement.GuitarTuning)!; + this.renderer.scoreRenderer.canvas!.font = font; + let nameWidth = this.renderer.scoreRenderer.canvas!.measureText(' = Gb').width; + + // strings are only listed for non-standard tunings + let names: TextGlyph[] | null = null; + if (!tuning.isStandard) { + names = []; + for (const pitch of tuning.pitches) { + const name = new TextGlyph( + 0, + 0, + ` = ${pitch.toDisplayNameString()}`, + font, + TextAlign.Left, + TextBaseline.Middle + ); + name.renderer = this.renderer; + name.doLayout(); + nameWidth = Math.max(nameWidth, name.width); + names.push(name); + } + } + + const stringColumnWidth = (circleHeight + nameWidth) * res.engravingSettings.tuningGlyphStringColumnScale; this.width = Math.max( this.renderer.scoreRenderer.canvas!.measureText(this._trackLabel).width, Math.max(this.renderer.scoreRenderer.canvas!.measureText(tuning.name).width, 2 * stringColumnWidth) ); - if (!tuning.isStandard) { + if (names !== null) { // Strings const stringsPerColumn: number = Math.ceil(tuning.tunings.length / 2.0) | 0; let currentX: number = 0; @@ -100,17 +121,10 @@ 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)}`; - this.addGlyph( - new TextGlyph( - currentX + circleHeight, - currentY + circleHeight / 2, - str, - res.elementFonts.get(NotationElement.GuitarTuning)!, - TextAlign.Left, - TextBaseline.Middle - ) - ); + const name = names[i]; + name.x = currentX + circleHeight; + name.y = currentY + circleHeight / 2; + this.addGlyph(name); currentY += circleHeight + this.renderer.smuflMetrics.tuningGlyphStringRowPadding; const bottomY = currentY; if (this.height < bottomY) { diff --git a/packages/alphatab/src/rendering/utils/AccidentalHelper.ts b/packages/alphatab/src/rendering/utils/AccidentalHelper.ts index f5e3c74e48..ffca1d0660 100644 --- a/packages/alphatab/src/rendering/utils/AccidentalHelper.ts +++ b/packages/alphatab/src/rendering/utils/AccidentalHelper.ts @@ -2,11 +2,12 @@ import { AccidentalType } from '@coderline/alphatab/model/AccidentalType'; import type { Bar } from '@coderline/alphatab/model/Bar'; import type { Beat } from '@coderline/alphatab/model/Beat'; import type { Clef } from '@coderline/alphatab/model/Clef'; -import { ModelUtils, type ResolvedSpelling } from '@coderline/alphatab/model/ModelUtils'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import type { Note } from '@coderline/alphatab/model/Note'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; import { Ottavia } from '@coderline/alphatab/model/Ottavia'; import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; +import type { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; import type { LineBarRenderer } from '@coderline/alphatab/rendering/LineBarRenderer'; import type { ScoreBarRenderer } from '@coderline/alphatab/rendering/ScoreBarRenderer'; @@ -201,7 +202,7 @@ export class AccidentalHelper { registered = new Map(); this._registeredAccidentals = registered; } - registered.set(steps, spelling.accidentalOffset); + registered.set(steps, spelling.alter); } } @@ -287,7 +288,7 @@ export class AccidentalHelper { return beatSteps ? beatSteps.minStepsNote : null; } - public static calculateNoteSteps(clef: Clef, spelling: ResolvedSpelling): number { + public static calculateNoteSteps(clef: Clef, spelling: SpelledPitch): number { const clefValue: number = clef as number; // Initial Position @@ -295,7 +296,7 @@ export class AccidentalHelper { // Move to Octave steps -= spelling.octave * AccidentalHelper._stepsPerOctave; // Move within octave - steps -= AccidentalHelper._diatonicSteps[spelling.degree]; + steps -= AccidentalHelper._diatonicSteps[spelling.step]; return steps; } diff --git a/packages/alphatab/test-data/guitarpro6/tuning-alteration.gpx b/packages/alphatab/test-data/guitarpro6/tuning-alteration.gpx new file mode 100644 index 0000000000..4ae8dc6f12 Binary files /dev/null and b/packages/alphatab/test-data/guitarpro6/tuning-alteration.gpx differ diff --git a/packages/alphatab/test-data/guitarpro8/tuning-alteration.gp b/packages/alphatab/test-data/guitarpro8/tuning-alteration.gp new file mode 100644 index 0000000000..7e911008fa Binary files /dev/null and b/packages/alphatab/test-data/guitarpro8/tuning-alteration.gp differ diff --git a/packages/alphatab/test-data/musicxml-samples/Echigo.png b/packages/alphatab/test-data/musicxml-samples/Echigo.png index 314a93c1e9..c95ff9615c 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/Echigo.png and b/packages/alphatab/test-data/musicxml-samples/Echigo.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/22a-Noteheads.png b/packages/alphatab/test-data/musicxml-testsuite/22a-Noteheads.png index 5bb145a53b..e403211f53 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/22a-Noteheads.png and b/packages/alphatab/test-data/musicxml-testsuite/22a-Noteheads.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png b/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png index 64629c85cf..e176982919 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png and b/packages/alphatab/test-data/musicxml-testsuite/31a-Directions.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png b/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png index 0670cbd013..406f88e91d 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png and b/packages/alphatab/test-data/musicxml-testsuite/31d-Directions-Compounds.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png b/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png index e5188cbebd..9da2e89a7e 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png and b/packages/alphatab/test-data/musicxml-testsuite/32a-Notations.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/32b-Articulations-Texts.png b/packages/alphatab/test-data/musicxml-testsuite/32b-Articulations-Texts.png index c9fce38b60..c552e37e7d 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/32b-Articulations-Texts.png and b/packages/alphatab/test-data/musicxml-testsuite/32b-Articulations-Texts.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/61a-Lyrics.png b/packages/alphatab/test-data/musicxml-testsuite/61a-Lyrics.png index 1aaa5adc34..9e70d3b534 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/61a-Lyrics.png and b/packages/alphatab/test-data/musicxml-testsuite/61a-Lyrics.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/71a-Chordnames.png b/packages/alphatab/test-data/musicxml-testsuite/71a-Chordnames.png index 88bd3e2d6a..2c75177ff5 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/71a-Chordnames.png and b/packages/alphatab/test-data/musicxml-testsuite/71a-Chordnames.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/71c-ChordsFrets.png b/packages/alphatab/test-data/musicxml-testsuite/71c-ChordsFrets.png index ef33dd2978..79b2e7003f 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/71c-ChordsFrets.png and b/packages/alphatab/test-data/musicxml-testsuite/71c-ChordsFrets.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/71e-TabStaves.png b/packages/alphatab/test-data/musicxml-testsuite/71e-TabStaves.png index c45c88886d..bd884cf1fd 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/musicxml-testsuite/71f-AllChordTypes.png b/packages/alphatab/test-data/musicxml-testsuite/71f-AllChordTypes.png index 91f516ebb7..e50ae1fdf8 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/71f-AllChordTypes.png and b/packages/alphatab/test-data/musicxml-testsuite/71f-AllChordTypes.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/72c-TransposingInstruments-Change.png b/packages/alphatab/test-data/musicxml-testsuite/72c-TransposingInstruments-Change.png index 7a6f10ff30..996370bdf4 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/72c-TransposingInstruments-Change.png and b/packages/alphatab/test-data/musicxml-testsuite/72c-TransposingInstruments-Change.png differ diff --git a/packages/alphatab/test-data/musicxml3/chord-diagram.png b/packages/alphatab/test-data/musicxml3/chord-diagram.png index ddac90e138..cdc7cec5d6 100644 Binary files a/packages/alphatab/test-data/musicxml3/chord-diagram.png and b/packages/alphatab/test-data/musicxml3/chord-diagram.png differ diff --git a/packages/alphatab/test-data/musicxml4/tuning-alter.xml b/packages/alphatab/test-data/musicxml4/tuning-alter.xml new file mode 100644 index 0000000000..5c3ab09b50 --- /dev/null +++ b/packages/alphatab/test-data/musicxml4/tuning-alter.xml @@ -0,0 +1,75 @@ + + + + + Guitar Tab + + + + + + 1 + + 0 + + + 1 + + TAB + 5 + + + 6 + + E + -1 + 2 + + + G + 1 + 2 + + + D + 3 + + + G + -1 + 3 + + + A + 1 + 3 + + + E + 0.5 + 4 + + + + + + E + -1 + 2 + + 4 + 1 + whole + + + 6 + 0 + + + + + + diff --git a/packages/alphatab/test-data/visual-tests/general/colors-disabled.png b/packages/alphatab/test-data/visual-tests/general/colors-disabled.png index c4fa8443c3..35694a54c0 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/colors-disabled.png and b/packages/alphatab/test-data/visual-tests/general/colors-disabled.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/colors.png b/packages/alphatab/test-data/visual-tests/general/colors.png index 07ae517f88..899b97495b 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/colors.png and b/packages/alphatab/test-data/visual-tests/general/colors.png differ diff --git a/packages/alphatab/test-data/visual-tests/general/font-fallback.png b/packages/alphatab/test-data/visual-tests/general/font-fallback.png index 1478d1a5ee..d9c104a5e3 100644 Binary files a/packages/alphatab/test-data/visual-tests/general/font-fallback.png and b/packages/alphatab/test-data/visual-tests/general/font-fallback.png differ diff --git a/packages/alphatab/test-data/visual-tests/layout/inline-tuning-accidentals.png b/packages/alphatab/test-data/visual-tests/layout/inline-tuning-accidentals.png new file mode 100644 index 0000000000..f228245090 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/layout/inline-tuning-accidentals.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/clefs-gp5.png b/packages/alphatab/test-data/visual-tests/music-notation/clefs-gp5.png index e257eb6c47..334c132749 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/clefs-gp5.png and b/packages/alphatab/test-data/visual-tests/music-notation/clefs-gp5.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-accidentals.png b/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-accidentals.png new file mode 100644 index 0000000000..03e1acf32c Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-accidentals.png differ diff --git a/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-alteration.png b/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-alteration.png new file mode 100644 index 0000000000..94edc8ceb3 Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/notation-elements/guitar-tuning-alteration.png differ diff --git a/packages/alphatab/test/audio/MidiFileGenerator.test.ts b/packages/alphatab/test/audio/MidiFileGenerator.test.ts index 1e1c944cd2..30733be74d 100644 --- a/packages/alphatab/test/audio/MidiFileGenerator.test.ts +++ b/packages/alphatab/test/audio/MidiFileGenerator.test.ts @@ -25,8 +25,8 @@ import { GraceType } from '@coderline/alphatab/model/GraceType'; import type { Note } from '@coderline/alphatab/model/Note'; import type { PlaybackInformation } from '@coderline/alphatab/model/PlaybackInformation'; import type { Score } from '@coderline/alphatab/model/Score'; +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; import { TremoloPickingEffect, TremoloPickingStyle } from '@coderline/alphatab/model/TremoloPickingEffect'; -import { Tuning } from '@coderline/alphatab/model/Tuning'; import { VibratoType } from '@coderline/alphatab/model/VibratoType'; import { Settings } from '@coderline/alphatab/Settings'; import { AlphaSynth } from '@coderline/alphatab/synth/AlphaSynth'; @@ -2246,7 +2246,7 @@ describe('MidiFileGeneratorTest', () => { .filter(e => e instanceof FlatNoteEvent) .map( e => - `Note: ${Tuning.getTextForTuning((e as FlatNoteEvent).key, true)} ${(e as FlatNoteEvent).length}` + `Note: ${SpelledPitch.formatValue((e as FlatNoteEvent).key, true, true)} ${(e as FlatNoteEvent).length}` ); expect(noteEvents).toMatchSnapshot(); diff --git a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts index 771148afe1..fe9763e7ff 100644 --- a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts +++ b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts @@ -298,6 +298,23 @@ describe('AlphaTexExporterTest', () => { ComparisonHelpers.alphaTexExportRoundtripEqual('accidental-mode-only-when-needed', reimported, score); }); + it('tuning-spellings', () => { + const score = ScoreLoader.loadAlphaTex('\\tuning (D#4 Bb3 Gb3 C#3 Ab2 Eb2) . 0.6'); + const exported = exportAlphaTex(score); + expect(exported).toContain('\\tuning (D#4 Bb3 Gb3 C#3 Ab2 Eb2)'); + + const reimported = ScoreLoader.loadAlphaTex(exported); + const tuning = reimported.tracks[0].staves[0].stringTuning; + expect(tuning.pitches.map(p => p.toString())).toEqual(['D#4', 'Bb3', 'Gb3', 'C#3', 'Ab2', 'Eb2']); + ComparisonHelpers.alphaTexExportRoundtripEqual('tuning-spellings', reimported, score); + }); + + it('tuning-values-use-flats', () => { + const score = ScoreLoader.loadAlphaTex('\\tuning (E4 B3 G3 D3 A2 E2) . 0.6'); + score.tracks[0].staves[0].stringTuning.tunings = [63, 58, 54, 49, 44, 39]; + expect(exportAlphaTex(score)).toContain('\\tuning (Eb4 Bb3 Gb3 Db3 Ab2 Eb2)'); + }); + it('staff-display', () => { const score = ScoreLoader.loadAlphaTex( '\\track \\staff {score tabs} \\tuning (E4 B3 G3 D3 A2 E2) 3.3.4 3.3 3.3 3.3 | 3.3 3.3 3.3 3.3 | 3.3 3.3 3.3 3.3' diff --git a/packages/alphatab/test/exporter/Gp7Exporter.test.ts b/packages/alphatab/test/exporter/Gp7Exporter.test.ts index 562fcab4fc..9b6a42c293 100644 --- a/packages/alphatab/test/exporter/Gp7Exporter.test.ts +++ b/packages/alphatab/test/exporter/Gp7Exporter.test.ts @@ -829,4 +829,60 @@ describe('Gp7ExporterTest', () => { // await TestPlatform.saveFile('test-data/exporter/articulations.exported.gp', exported); }); + + function readTuningFlags(exported: Uint8Array): boolean[] { + const settings = new Settings(); + const zip = new ZipReader(ByteBuffer.fromBuffer(exported), settings.importer.maxDecodingBufferSize).read(); + const gpif = IOHelper.toString(zip.find(e => e.fileName === 'score.gpif')!.data, settings.importer.encoding); + const xml = new XmlDocument(); + xml.parse(gpif); + + const flags: boolean[] = []; + for (const track of xml.findChildElement('GPIF')!.findChildElement('Tracks')!.childElements()) { + const staffProps = track + .findChildElement('Staves')! + .findChildElement('Staff')! + .findChildElement('Properties')!; + let tuningFlat = false; + let flat = false; + for (const prop of staffProps.childElements()) { + const name = prop.getAttribute('name'); + if (name === 'Tuning') { + flat = prop.findChildElement('Flat') !== null; + } else if (name === 'TuningFlat') { + tuningFlat = true; + } + } + // both are written together + expect(tuningFlat).toBe(flat); + flags.push(flat); + } + return flags; + } + + it('tuning-alteration', async () => { + const expected = (await loadScore('guitarpro8/tuning-alteration.gp'))!; + const exported = exportGp7(expected); + + // tunings without accidentals keep the flat preference + expect(readTuningFlags(exported)).toEqual([false, true, false, true]); + + const actual = prepareImporterWithBytes(exported).readScore(); + for (let i = 0; i < expected.tracks.length; i++) { + const expectedTuning = expected.tracks[i].staves[0].stringTuning; + const actualTuning = actual.tracks[i].staves[0].stringTuning; + expect(actualTuning.pitches.map(p => p.toString())).toEqual( + expectedTuning.pitches.map(p => p.toString()) + ); + } + }); + + it('tuning-alteration-mixed', async () => { + // Guitar Pro has one preference per tuning: mixed spellings are written with flats + const score = ScoreLoader.loadAlphaTex('\\tuning (D#4 Bb3 Gb3 C#3 Ab2 Eb2) . 0.6'); + expect(readTuningFlags(exportGp7(score))).toEqual([true]); + + const sharp = ScoreLoader.loadAlphaTex('\\tuning (D#4 A#3 F#3 C#3 G#2 D#2) . 0.6'); + expect(readTuningFlags(exportGp7(sharp))).toEqual([false]); + }); }); diff --git a/packages/alphatab/test/generated/SerializerFill.ts b/packages/alphatab/test/generated/SerializerFill.ts index 1e19800bdd..1b6e4b416f 100644 --- a/packages/alphatab/test/generated/SerializerFill.ts +++ b/packages/alphatab/test/generated/SerializerFill.ts @@ -171,6 +171,7 @@ import { TrackSerializer } from "@coderline/alphatab/generated/model/TrackSerial import { TremoloPickingEffectSerializer } from "@coderline/alphatab/generated/model/TremoloPickingEffectSerializer"; import { TremoloPickingStyle } from "@coderline/alphatab/model/TremoloPickingEffect"; import { TuningSerializer } from "@coderline/alphatab/generated/model/TuningSerializer"; +import { SpelledPitch } from "@coderline/alphatab/model/SpelledPitch"; import { VoiceStyle } from "@coderline/alphatab/model/Voice"; import { VoiceStyleSerializer } from "@coderline/alphatab/generated/model/VoiceStyleSerializer"; import { VoiceSubElement } from "@coderline/alphatab/model/Voice"; @@ -1334,7 +1335,8 @@ export class SerializerFill { obj.isStandard = !obj.isStandard; obj.name = 'x ä€𝄞'; { - obj.tunings = [7.25]; + obj.pitches = []; + obj.pitches.push(new SpelledPitch(3, 1, 4)); } } public static fillVoiceStyle(obj: VoiceStyle, depth: number = 0): void { diff --git a/packages/alphatab/test/importer/AlphaTexImporter.test.ts b/packages/alphatab/test/importer/AlphaTexImporter.test.ts index d28232b04d..c72fa2fb0c 100644 --- a/packages/alphatab/test/importer/AlphaTexImporter.test.ts +++ b/packages/alphatab/test/importer/AlphaTexImporter.test.ts @@ -2895,4 +2895,40 @@ describe('AlphaTexImporterTest', () => { expect(score.masterBars.map(mb => mb.barNumberText)).toEqual(['0', '1', '2']); expect(score.masterBars[0].customBarNumber).toBe(0); }); + + it('tuning-spellings', () => { + const score = parseTex('\\tuning (D#4 Bb3 Gb3 c#3 ab2 Fb2) . 0.6'); + const tuning = score.tracks[0].staves[0].stringTuning; + expect(tuning.tunings).toEqual([63, 58, 54, 49, 44, 40]); + expect(tuning.pitches.map(p => [p.step, p.alter, p.octave])).toEqual([ + [1, 1, 4], + [6, -1, 3], + [4, -1, 3], + [0, 1, 3], + [5, -1, 2], + [3, -1, 2] + ]); + }); + + it('tuning-mode-without-strings', () => { + const score = parseTex('\\tuning piano . C4'); + expect(score.tracks[0].staves[0].stringTuning.pitches).toHaveLength(0); + }); + + it('pitched-notes-across-octave-boundary', () => { + const score = parseTex('\\tuning piano . B#3 B##3 Cb4 Cbb4'); + const notes = score.tracks[0].staves[0].bars[0].voices[0].beats.map(b => b.notes[0]); + expect(notes.map(n => n.tone)).toEqual([12, 13, 11, 10]); + expect(notes.map(n => n.octave)).toEqual([4, 4, 4, 4]); + expect(notes.map(n => n.realValue)).toEqual([60, 61, 59, 58]); + }); + + it('rest-display-pitch-across-octave-boundary', () => { + const score = parseTex('\\tuning piano . r.4{restDisplayPitch Cb4} r.4{restDisplayPitch B#3}'); + const beats = score.tracks[0].staves[0].bars[0].voices[0].beats; + expect(beats[0].restDisplayTone).toBe(11); + expect(beats[0].restDisplayOctave).toBe(3); + expect(beats[1].restDisplayTone).toBe(12); + expect(beats[1].restDisplayOctave).toBe(3); + }); }); diff --git a/packages/alphatab/test/importer/Gp8Importer.test.ts b/packages/alphatab/test/importer/Gp8Importer.test.ts index 3435ae5c65..5787b4014a 100644 --- a/packages/alphatab/test/importer/Gp8Importer.test.ts +++ b/packages/alphatab/test/importer/Gp8Importer.test.ts @@ -558,6 +558,20 @@ describe('Gp8ImporterTest', () => { expect(checkPercussionStrings(orphan)).toBe(1301); }); + it('tuning-alteration', async () => { + const score = (await prepareImporterWithFile('guitarpro8/tuning-alteration.gp')).readScore(); + const spellings = score.tracks.map(t => { + const tuning = t.staves[0].stringTuning; + return tuning.pitches.map(p => p.toString()).join(' '); + }); + expect(spellings).toEqual([ + 'D#4 A#3 F#3 C#3 G#2 D#2', + 'Eb4 Bb3 Gb3 Db3 Ab2 Eb2', + 'D#4 A#3 F#3 C#3 G#2 C#2', + 'E4 B3 G3 D3 A2 E2' + ]); + }); + it('tuning-before-staff', async () =>{ const reader = await prepareImporterWithFile('guitarpro8/tuning-before-staff.gp'); const score = reader.readScore(); diff --git a/packages/alphatab/test/importer/GpxImporter.test.ts b/packages/alphatab/test/importer/GpxImporter.test.ts index a90c6505e1..095cf11328 100644 --- a/packages/alphatab/test/importer/GpxImporter.test.ts +++ b/packages/alphatab/test/importer/GpxImporter.test.ts @@ -402,4 +402,19 @@ describe('GpxImporterTest', () => { expect(drums.showTablature).toBe(true); expect(drums.showStandardNotation).toBe(true); }); + + it('tuning-alteration', async () => { + // GP6 has no flat preference for tunings, strings are spelled with sharps like in GP7+ without the preference + const score = (await prepareImporterWithFile('guitarpro6/tuning-alteration.gpx')).readScore(); + const spellings = score.tracks.map(t => { + const tuning = t.staves[0].stringTuning; + return tuning.pitches.map(p => p.toString()).join(' '); + }); + expect(spellings).toEqual([ + 'D#4 A#3 F#3 C#3 G#2 D#2', + 'D#4 A#3 F#3 C#3 G#2 D#2', + 'D#4 A#3 F#3 C#3 G#2 C#2', + 'E4 B3 G3 D3 A2 E2' + ]); + }); }); diff --git a/packages/alphatab/test/importer/MusicXmlImporter.test.ts b/packages/alphatab/test/importer/MusicXmlImporter.test.ts index 7569b8c09d..4c9aa3351f 100644 --- a/packages/alphatab/test/importer/MusicXmlImporter.test.ts +++ b/packages/alphatab/test/importer/MusicXmlImporter.test.ts @@ -364,8 +364,8 @@ describe('MusicXmlImporterTests', () => { ); const context = `note ${i}`; expect(bar.index, context).toBe(expected[i][0]); - expect(spelling.degree, context).toBe(expected[i][1]); - expect(spelling.accidentalOffset, context).toBe(expected[i][2]); + expect(spelling.step, context).toBe(expected[i][1]); + expect(spelling.alter, context).toBe(expected[i][2]); expect(spelling.octave, context).toBe(expected[i][3]); i++; } @@ -427,6 +427,31 @@ describe('MusicXmlImporterTests', () => { expect(trumpet.isPalmMute).toBe(false); }); + it('tuning-alter', async () => { + const score = await MusicXmlImporterTestHelper.loadFile('test-data/musicxml4/tuning-alter.xml'); + const tuning = score.tracks[0].staves[0].stringTuning; + + // top string first, line 1 is the lowest string + // the quarter tone alter of the highest string is truncated + expect(tuning.tunings).toEqual([64, 58, 54, 50, 44, 39]); + expect(tuning.pitches.map(p => [p.step, p.alter, p.octave])).toEqual([ + [2, 0, 4], + [5, 1, 3], + [4, -1, 3], + [1, 0, 3], + [4, 1, 2], + [2, -1, 2] + ]); + expect(tuning.pitches.map(p => p.toNameString())).toEqual([ + 'E', + 'A#', + 'Gb', + 'D', + 'G#', + 'Eb' + ]); + }); + it('string-annotation', async () => { const score = await MusicXmlImporterTestHelper.loadFile('test-data/musicxml4/string-annotation.xml'); diff --git a/packages/alphatab/test/importer/__snapshots__/AlphaTexImporter.test.ts.snap b/packages/alphatab/test/importer/__snapshots__/AlphaTexImporter.test.ts.snap index 6fe4c266fa..7affb81b34 100644 --- a/packages/alphatab/test/importer/__snapshots__/AlphaTexImporter.test.ts.snap +++ b/packages/alphatab/test/importer/__snapshots__/AlphaTexImporter.test.ts.snap @@ -60,13 +60,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -140,13 +140,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -177,13 +177,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -257,13 +257,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -350,13 +350,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -430,13 +430,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -512,13 +512,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -594,13 +594,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -631,13 +631,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -713,13 +713,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -808,13 +808,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -890,13 +890,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -948,13 +948,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -1031,13 +1031,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -1090,13 +1090,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -1173,13 +1173,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -1268,13 +1268,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -1350,13 +1350,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -1443,13 +1443,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -1646,13 +1646,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -1849,13 +1849,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -1924,13 +1924,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -2127,13 +2127,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -2369,13 +2369,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -2591,13 +2591,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -2816,13 +2816,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -3041,13 +3041,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -3116,13 +3116,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -3341,13 +3341,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -3605,13 +3605,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -3830,13 +3830,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -3927,13 +3927,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -4152,13 +4152,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -4252,13 +4252,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -4477,13 +4477,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, @@ -4573,13 +4573,13 @@ Map { "stringtuning" => Map { "isstandard" => true, "name" => "Guitar Standard Tuning", - "tunings" => [ - 64, - 59, - 55, - 50, - 45, - 40, + "pitches" => [ + "E4", + "B3", + "G3", + "D3", + "A2", + "E2", ], }, }, diff --git a/packages/alphatab/test/model/AccidentalResolutionEdge.test.ts b/packages/alphatab/test/model/AccidentalResolutionEdge.test.ts index 03d7025cd6..e9376cbc39 100644 --- a/packages/alphatab/test/model/AccidentalResolutionEdge.test.ts +++ b/packages/alphatab/test/model/AccidentalResolutionEdge.test.ts @@ -8,8 +8,8 @@ describe('AccidentalResolutionEdgeTests', () => { const ks = KeySignature.CSharp; const noteValue = 60; // C4 const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); - expect(spelling.degree).toBe(6); // B - expect(spelling.accidentalOffset).toBe(1); // B# + expect(spelling.step).toBe(6); // B + expect(spelling.alter).toBe(1); // B# const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, false, null); expect(accidental).toBe(AccidentalType.None); }); @@ -18,8 +18,8 @@ describe('AccidentalResolutionEdgeTests', () => { const ks = KeySignature.Cb; const noteValue = 64; // E4 const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); - expect(spelling.degree).toBe(3); // F - expect(spelling.accidentalOffset).toBe(-1); // Fb + expect(spelling.step).toBe(3); // F + expect(spelling.alter).toBe(-1); // Fb const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, false, null); expect(accidental).toBe(AccidentalType.None); }); @@ -28,8 +28,8 @@ describe('AccidentalResolutionEdgeTests', () => { const ks = KeySignature.C; const noteValue = 62; // D const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceDoubleSharp); - expect(spelling.degree).toBe(0); // C - expect(spelling.accidentalOffset).toBe(2); // C## + expect(spelling.step).toBe(0); // C + expect(spelling.alter).toBe(2); // C## const accidental = ModelUtils.computeAccidentalForSpelling( ks, NoteAccidentalMode.ForceDoubleSharp, @@ -44,8 +44,8 @@ describe('AccidentalResolutionEdgeTests', () => { const ks = KeySignature.C; const noteValue = 62; // D const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceDoubleFlat); - expect(spelling.degree).toBe(2); // E - expect(spelling.accidentalOffset).toBe(-2); // Ebb + expect(spelling.step).toBe(2); // E + expect(spelling.alter).toBe(-2); // Ebb const accidental = ModelUtils.computeAccidentalForSpelling( ks, NoteAccidentalMode.ForceDoubleFlat, diff --git a/packages/alphatab/test/model/AccidentalResolutionTests.test.ts b/packages/alphatab/test/model/AccidentalResolutionTests.test.ts index 72538607e1..3d5f6d858c 100644 --- a/packages/alphatab/test/model/AccidentalResolutionTests.test.ts +++ b/packages/alphatab/test/model/AccidentalResolutionTests.test.ts @@ -35,8 +35,8 @@ describe('AccidentalResolutionTests', () => { for (let degree = 0; degree < 7; degree++) { const noteValue = noteValueForDegree(ks, degree, 4); const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); - expect(spelling.degree, `ks=${ks} degree=${degree}`).toBe(degree); - expect(spelling.accidentalOffset, `ks=${ks} degree=${degree}`).toBe( + expect(spelling.step, `ks=${ks} degree=${degree}`).toBe(degree); + expect(spelling.alter, `ks=${ks} degree=${degree}`).toBe( ModelUtils.getKeySignatureAccidentalOffset(ks, degree) ); @@ -56,8 +56,8 @@ describe('AccidentalResolutionTests', () => { const ks = KeySignature.FSharp; const noteValue = 65; // F natural const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); - expect(spelling.degree).toBe(2); // E - expect(spelling.accidentalOffset).toBe(1); // E# + expect(spelling.step).toBe(2); // E + expect(spelling.alter).toBe(1); // E# const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, false, null); expect(accidental).toBe(AccidentalType.None); }); @@ -66,8 +66,8 @@ describe('AccidentalResolutionTests', () => { const ks = KeySignature.Cb; const noteValue = 59; // B natural const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); - expect(spelling.degree).toBe(0); // C - expect(spelling.accidentalOffset).toBe(-1); // Cb + expect(spelling.step).toBe(0); // C + expect(spelling.alter).toBe(-1); // Cb const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, false, null); expect(accidental).toBe(AccidentalType.None); }); @@ -76,8 +76,8 @@ describe('AccidentalResolutionTests', () => { const ks = KeySignature.C; const noteValue = 61; // C# / Db const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceFlat); - expect(spelling.degree).toBe(1); // D - expect(spelling.accidentalOffset).toBe(-1); // Db + expect(spelling.step).toBe(1); // D + expect(spelling.alter).toBe(-1); // Db const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.ForceFlat, spelling, false, null); expect(accidental).toBe(AccidentalType.Flat); }); @@ -86,8 +86,8 @@ describe('AccidentalResolutionTests', () => { const ks = KeySignature.C; const noteValue = 61; // C# / Db const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceSharp); - expect(spelling.degree).toBe(0); // C - expect(spelling.accidentalOffset).toBe(1); // C# + expect(spelling.step).toBe(0); // C + expect(spelling.alter).toBe(1); // C# const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.ForceSharp, spelling, false, null); expect(accidental).toBe(AccidentalType.Sharp); }); @@ -96,8 +96,8 @@ describe('AccidentalResolutionTests', () => { const ks = KeySignature.D; // F#, C# const noteValue = 65; // F natural const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceNatural); - expect(spelling.degree).toBe(3); // F - expect(spelling.accidentalOffset).toBe(0); // natural + expect(spelling.step).toBe(3); // F + expect(spelling.alter).toBe(0); // natural const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.ForceNatural, spelling, false, null); expect(accidental).toBe(AccidentalType.Natural); }); @@ -142,8 +142,8 @@ describe('AccidentalResolutionTests', () => { const noteValue = (octave + 1) * 12 + degreeSemitones[degree] + offset; const spelling = ModelUtils.resolveSpelling(ks, noteValue, modes[offset + 2]); const context = `ks=${ks} degree=${degree} offset=${offset} octave=${octave}`; - expect(spelling.degree, context).toBe(degree); - expect(spelling.accidentalOffset, context).toBe(offset); + expect(spelling.step, context).toBe(degree); + expect(spelling.alter, context).toBe(offset); expect(spelling.octave, context).toBe(octave); } } diff --git a/packages/alphatab/test/model/JsonConverter.test.ts b/packages/alphatab/test/model/JsonConverter.test.ts index ecf7d2fdb2..77043da88c 100644 --- a/packages/alphatab/test/model/JsonConverter.test.ts +++ b/packages/alphatab/test/model/JsonConverter.test.ts @@ -1,6 +1,7 @@ import { MapObjectReader } from '@coderline/alphatab/io/MapObjectReader'; import { describe, expect, it } from 'vitest'; import { NoteSerializer } from '@coderline/alphatab/generated/model/NoteSerializer'; +import { TuningSerializer } from '@coderline/alphatab/generated/model/TuningSerializer'; import { SettingsSerializer } from '@coderline/alphatab/generated/SettingsSerializer'; import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; @@ -14,6 +15,8 @@ import { Font, FontStyle, FontWeight } from '@coderline/alphatab/model/Font'; import { JsonConverter } from '@coderline/alphatab/model/JsonConverter'; import { Note, NoteStyle, NoteSubElement } from '@coderline/alphatab/model/Note'; import { type Score, ScoreStyle, ScoreSubElement } from '@coderline/alphatab/model/Score'; +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; +import { Tuning } from '@coderline/alphatab/model/Tuning'; import { TrackStyle, TrackSubElement } from '@coderline/alphatab/model/Track'; import { VoiceStyle, VoiceSubElement } from '@coderline/alphatab/model/Voice'; import { FingeringMode, NotationElement, NotationMode, TabRhythmMode } from '@coderline/alphatab/NotationSettings'; @@ -509,4 +512,45 @@ describe('JsonConverterTest', () => { expect(drum.percussionArticulation).toBe(0); expect(drum.realValue).toBe(42); }); + + it('tuning-pitches', () => { + const expected = ScoreLoader.loadAlphaTex('\\tuning E4 B3 G3 D3 A2 E2 . 0.6'); + expected.tracks[0].staves[0].stringTuning.pitches = [ + new SpelledPitch(1, 1, 4), + new SpelledPitch(6, -1, 3), + new SpelledPitch(3, 1, 3), + new SpelledPitch(0, 1, 3), + new SpelledPitch(5, -1, 2), + new SpelledPitch(2, -1, 2) + ]; + + const actual = JsonConverter.jsObjectToScore(JsonConverter.scoreToJsObject(expected)); + const actualTuning = actual.tracks[0].staves[0].stringTuning; + expect(actualTuning.tunings).toEqual([63, 58, 54, 49, 44, 39]); + expect(actualTuning.pitches.map(p => p.toString())).toEqual(['D#4', 'Bb3', 'F#3', 'C#3', 'Ab2', 'Eb2']); + }); + + function tuningFromJson(raw: Map): Tuning { + const tuning = new Tuning(); + TuningSerializer.read(tuning, new MapObjectReader(raw)); + return tuning; + } + + it('tuning-pitches-json', () => { + const names: string[] = ['D#4', 'bb3', 'Cbb-1']; + const tuning = tuningFromJson(new Map([['pitches', names]])); + expect(tuning.pitches.map(p => p.toString())).toEqual(['D#4', 'Bb3', 'Cbb-1']); + expect(tuning.tunings).toEqual([63, 58, -2]); + + // MIDI values are spelled with flats + const values: unknown[] = [54]; + expect(tuningFromJson(new Map([['pitches', values]])).pitches[0].toString()).toBe('Gb3'); + }); + + it('tuning-legacy-json', () => { + // up to 1.9 the strings were stored as MIDI values + const values: number[] = [63, 58, 54]; + const tuning = tuningFromJson(new Map([['tunings', values]])); + expect(tuning.pitches.map(p => p.toString())).toEqual(['Eb4', 'Bb3', 'Gb3']); + }); }); diff --git a/packages/alphatab/test/model/ModelUtils.test.ts b/packages/alphatab/test/model/ModelUtils.test.ts index 781d8598d5..f5ee1f2af8 100644 --- a/packages/alphatab/test/model/ModelUtils.test.ts +++ b/packages/alphatab/test/model/ModelUtils.test.ts @@ -37,13 +37,22 @@ describe('ModelUtilsTests', () => { ); }); - describe('parseTuning', () => { + describe('parsePitch', () => { function test(s:string, toneValue:number, accidental:NoteAccidentalMode, octave:number) { - const result = ModelUtils.parseTuning(s); + const result = ModelUtils.parsePitch(s); expect(result).toBeTruthy(); - expect(result!.tone.noteValue).toBe(toneValue); - expect(result!.tone.accidentalMode).toBe(accidental); - expect(result!.octave).toBe(octave); + expect(result!.noteTone).toBe(toneValue); + expect(result!.accidentalMode).toBe(accidental); + expect(result!.noteOctave).toBe(octave); + } + + function testSpelling(s: string, step: number, alter: number, octave: number, value: number) { + const result = ModelUtils.parsePitch(s); + expect(result).toBeTruthy(); + expect(result!.pitch.step).toBe(step); + expect(result!.pitch.alter).toBe(alter); + expect(result!.pitch.octave).toBe(octave); + expect(result!.pitch.value).toBe(value); } it('octave-c', () => test('C4', 0, NoteAccidentalMode.Default, 5)) @@ -76,5 +85,24 @@ describe('ModelUtilsTests', () => { it('c-forceDoubleSharp-short2', () => test('Cx4', 2, NoteAccidentalMode.ForceDoubleSharp, 5)) it('c-forceFlat-short', () => test('Cb4', 11, NoteAccidentalMode.ForceFlat, 4)) it('c-forceDoubleFlat-short', () => test('Cbb4', 10, NoteAccidentalMode.ForceDoubleFlat, 4)) + + it('b-forceSharp-short', () => test('B#3', 12, NoteAccidentalMode.ForceSharp, 4)) + it('b-forceDoubleSharp-short', () => test('B##3', 13, NoteAccidentalMode.ForceDoubleSharp, 4)) + + it('spelling-natural', () => testSpelling('E4', 2, 0, 4, 64)) + it('spelling-sharp', () => testSpelling('F#3', 3, 1, 3, 54)) + it('spelling-flat', () => testSpelling('Gb3', 4, -1, 3, 54)) + it('spelling-lowercase', () => testSpelling('gb3', 4, -1, 3, 54)) + it('spelling-flat-below-c', () => testSpelling('Cb4', 0, -1, 4, 59)) + it('spelling-sharp-above-b', () => testSpelling('B#3', 6, 1, 3, 60)) + it('spelling-double-flat', () => testSpelling('Bbb2', 6, -2, 2, 45)) + it('spelling-natural-sign', () => testSpelling('Cn4', 0, 0, 4, 60)) + it('spelling-octave-after-accidental-digit', () => testSpelling('C4#', 0, 1, 4, 61)) + + it('invalid-accidental-only', () => expect(ModelUtils.parsePitch('#4')).toBeNull()) + it('invalid-no-octave', () => expect(ModelUtils.parsePitch('C')).toBeNull()) + it('invalid-octave-first', () => expect(ModelUtils.parsePitch('4C')).toBeNull()) + it('invalid-letter', () => expect(ModelUtils.parsePitch('H4')).toBeNull()) + it('invalid-accidental', () => expect(ModelUtils.parsePitch('Cq4')).toBeNull()) }); }); diff --git a/packages/alphatab/test/model/SpelledPitch.test.ts b/packages/alphatab/test/model/SpelledPitch.test.ts new file mode 100644 index 0000000000..6e2034c7f7 --- /dev/null +++ b/packages/alphatab/test/model/SpelledPitch.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; + +describe('SpelledPitchTests', () => { + describe('value', () => { + it('natural', () => expect(new SpelledPitch(0, 0, 4).value).toBe(60)); + it('sharp', () => expect(new SpelledPitch(3, 1, 3).value).toBe(54)); + it('flat', () => expect(new SpelledPitch(4, -1, 3).value).toBe(54)); + it('sharp-above-b', () => expect(new SpelledPitch(6, 1, 3).value).toBe(60)); + it('double-sharp-above-b', () => expect(new SpelledPitch(6, 2, 3).value).toBe(61)); + it('flat-below-c', () => expect(new SpelledPitch(0, -1, 4).value).toBe(59)); + it('double-flat-below-c', () => expect(new SpelledPitch(0, -2, 4).value).toBe(58)); + it('lowest-octave', () => expect(new SpelledPitch(0, 0, -1).value).toBe(0)); + }); + + describe('chroma', () => { + it('natural', () => expect(new SpelledPitch(2, 0, 4).chroma).toBe(4)); + it('sharp-above-b', () => expect(new SpelledPitch(6, 1, 3).chroma).toBe(0)); + it('flat-below-c', () => expect(new SpelledPitch(0, -1, 4).chroma).toBe(11)); + it('double-flat-below-c', () => expect(new SpelledPitch(0, -2, 4).chroma).toBe(10)); + }); + + describe('text', () => { + it('natural', () => expect(new SpelledPitch(2, 0, 4).toString()).toBe('E4')); + it('sharp', () => expect(new SpelledPitch(3, 1, 3).toString()).toBe('F#3')); + it('flat', () => expect(new SpelledPitch(4, -1, 3).toString()).toBe('Gb3')); + it('double-sharp', () => expect(new SpelledPitch(0, 2, 4).toString()).toBe('C##4')); + it('double-flat', () => expect(new SpelledPitch(6, -2, 2).toString()).toBe('Bbb2')); + it('name-without-octave', () => expect(new SpelledPitch(4, -1, 3).toNameString()).toBe('Gb')); + }); + + describe('fromValue', () => { + function test(value: number, preferFlat: boolean, step: number, alter: number, octave: number) { + const pitch = SpelledPitch.fromValue(value, preferFlat); + expect(pitch.step).toBe(step); + expect(pitch.alter).toBe(alter); + expect(pitch.octave).toBe(octave); + } + + it('natural-flat', () => test(64, true, 2, 0, 4)); + it('natural-sharp', () => test(64, false, 2, 0, 4)); + it('black-key-flat', () => test(63, true, 2, -1, 4)); + it('black-key-sharp', () => test(63, false, 1, 1, 4)); + it('c', () => test(60, true, 0, 0, 4)); + it('b', () => test(59, false, 6, 0, 3)); + it('fractional-truncates', () => test(63.5, true, 2, -1, 4)); + it('negative', () => test(-1, true, 6, 0, -2)); + }); + + describe('formatValue', () => { + it('matches fromValue', () => { + for (let v = 0; v < 128; v++) { + for (const preferFlat of [true, false]) { + expect(SpelledPitch.formatValue(v, true, preferFlat)).toBe( + SpelledPitch.fromValue(v, preferFlat).toString() + ); + expect(SpelledPitch.formatValue(v, false, preferFlat)).toBe( + SpelledPitch.fromValue(v, preferFlat).toNameString() + ); + } + } + }); + + it('value round trip', () => { + for (let v = 0; v < 128; v++) { + expect(SpelledPitch.fromValue(v, true).value).toBe(v); + expect(SpelledPitch.fromValue(v, false).value).toBe(v); + } + }); + }); + + describe('json', () => { + it('writes-name', () => expect(SpelledPitch.toJson(new SpelledPitch(4, -1, 3))).toBe('Gb3')); + it('reads-name', () => expect(SpelledPitch.fromJson('F#3')!.toString()).toBe('F#3')); + it('reads-double-accidentals', () => { + expect(SpelledPitch.fromJson('C##4')!.toString()).toBe('C##4'); + expect(SpelledPitch.fromJson('Bbb2')!.toString()).toBe('Bbb2'); + }); + it('reads-negative-octave', () => expect(SpelledPitch.fromJson('C-1')!.value).toBe(0)); + it('reads-value-as-flat', () => expect(SpelledPitch.fromJson(63)!.toString()).toBe('Eb4')); + it('reads-null', () => expect(SpelledPitch.fromJson(null)).toBeNull()); + it('round-trips-all-spellings', () => { + for (let step = 0; step < 7; step++) { + for (let alter = -2; alter <= 2; alter++) { + const pitch = new SpelledPitch(step, alter, 3); + const read = SpelledPitch.fromJson(SpelledPitch.toJson(pitch))!; + expect(read.step).toBe(step); + expect(read.alter).toBe(alter); + expect(read.octave).toBe(3); + } + } + }); + it('rejects-invalid', () => { + for (const invalid of ['', 'H4', 'C', 'C#', 'C###4', 'Cb#4', 'C4x', '4']) { + expect(() => SpelledPitch.fromJson(invalid), invalid).toThrow(); + } + }); + }); + + describe('display', () => { + it('symbols', () => { + expect(new SpelledPitch(3, 1, 3).toDisplayNameString()).toBe('F\u266F'); + expect(new SpelledPitch(4, -1, 3).toDisplayNameString()).toBe('G\u266D'); + expect(new SpelledPitch(0, 2, 3).toDisplayNameString()).toBe('C\uD834\uDD2A'); + expect(new SpelledPitch(6, -2, 3).toDisplayNameString()).toBe('B\uD834\uDD2B'); + expect(new SpelledPitch(2, 0, 3).toDisplayNameString()).toBe('E'); + }); + }); +}); diff --git a/packages/alphatab/test/model/TuningParser.test.ts b/packages/alphatab/test/model/TuningParser.test.ts index b2cc628333..29bf75f3d7 100644 --- a/packages/alphatab/test/model/TuningParser.test.ts +++ b/packages/alphatab/test/model/TuningParser.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { Tuning } from '@coderline/alphatab/model/Tuning'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { SpelledPitch } from '@coderline/alphatab/model/SpelledPitch'; describe('TuningParserTest', () => { it('standard', () => { const standard: Tuning = Tuning.getDefaultTuningFor(6)!; @@ -8,10 +9,57 @@ describe('TuningParserTest', () => { const tuning = new Array(tuningText.length); const tuningText2: string[] = new Array(tuningText.length); for (let i: number = 0; i < tuningText.length; i++) { - tuning[i] = ModelUtils.getTuningForText(tuningText[i]); + tuning[i] = ModelUtils.parsePitch(tuningText[i])!.pitch.value; tuningText2[i] = Tuning.getTextForTuning(tuning[i], true); } expect(tuning.join(',')).toBe(standard.tunings.join(',')); expect(tuningText2.join(',')).toBe(tuningText.join(',')); }); + + describe('pitches', () => { + function createTuning(): Tuning { + const tuning = new Tuning(); + tuning.pitches = [ + new SpelledPitch(1, 1, 4), + new SpelledPitch(6, -1, 3), + new SpelledPitch(3, 1, 3), + new SpelledPitch(0, 1, 3), + new SpelledPitch(5, -1, 2), + new SpelledPitch(2, -1, 2) + ]; + return tuning; + } + + it('tunings-from-pitches', () => { + const tuning = createTuning(); + expect(tuning.tunings).toEqual([63, 58, 54, 49, 44, 39]); + expect(tuning.pitches.map(p => p.toString())).toEqual(['D#4', 'Bb3', 'F#3', 'C#3', 'Ab2', 'Eb2']); + }); + + it('tunings-spelled-with-flats', () => { + const tuning = createTuning(); + tuning.tunings = [64, 61, 54, 49, 44, 40]; + expect(tuning.tunings).toEqual([64, 61, 54, 49, 44, 40]); + expect(tuning.pitches.map(p => p.toString())).toEqual(['E4', 'Db4', 'Gb3', 'Db3', 'Ab2', 'E2']); + }); + + it('constructor-spelled-with-flats', () => { + const tuning = new Tuning('Test', [63, 58]); + expect(tuning.pitches.map(p => p.toString())).toEqual(['Eb4', 'Bb3']); + }); + + it('reset-clears-strings', () => { + const tuning = createTuning(); + tuning.reset(); + expect(tuning.tunings).toHaveLength(0); + expect(tuning.pitches).toHaveLength(0); + }); + + it('presets-are-copies', () => { + const tuning = Tuning.getDefaultTuningFor(6)!; + tuning.pitches = [new SpelledPitch(1, 1, 4)]; + expect(Tuning.getDefaultTuningFor(6)!.tunings).toEqual([64, 59, 55, 50, 45, 40]); + expect(Tuning.findTuning([64, 59, 55, 50, 45, 40])!.pitches).toHaveLength(6); + }); + }); }); diff --git a/packages/alphatab/test/visualTests/VisualTestHelper.ts b/packages/alphatab/test/visualTests/VisualTestHelper.ts index e05d92c167..47e3918a0a 100644 --- a/packages/alphatab/test/visualTests/VisualTestHelper.ts +++ b/packages/alphatab/test/visualTests/VisualTestHelper.ts @@ -226,6 +226,8 @@ export class VisualTestHelper { 'font/noto-serif/NotoSerif-Bold.otf', 'font/noto-serif/NotoSerif-BoldItalic.otf', 'font/noto-music/NotoMusic-Regular.otf', + 'font/noto-sans-math/NotoSansMath-Regular.ttf', + 'font/noto-sans-sc/NotoSansSC-Regular.otf', 'font/noto-color-emoji/NotoColorEmoji_WindowsCompatible.ttf', 'font/petaluma/Petaluma.otf' ]; @@ -251,20 +253,28 @@ export class VisualTestHelper { Environment.highDpiFactor = 1; // test data is in scale 1 settings.core.enableLazyLoading = false; - settings.display.resources.tablatureFont.families = ['Noto Sans', 'Noto Music', 'Noto Color Emoji']; - settings.display.resources.graceFont.families = ['Noto Sans', 'Noto Music', 'Noto Color Emoji']; - settings.display.resources.numberedNotationFont.families = ['Noto Sans', 'Noto Music', 'Noto Color Emoji']; - settings.display.resources.numberedNotationGraceFont.families = ['Noto Sans', 'Noto Music', 'Noto Color Emoji']; + settings.display.resources.tablatureFont.families = VisualTestHelper._testFontFamilies('Noto Sans'); + settings.display.resources.graceFont.families = VisualTestHelper._testFontFamilies('Noto Sans'); + settings.display.resources.numberedNotationFont.families = VisualTestHelper._testFontFamilies('Noto Sans'); + settings.display.resources.numberedNotationGraceFont.families = VisualTestHelper._testFontFamilies('Noto Sans'); for (const f of settings.display.resources.elementFonts.values()) { if (f.families.includes('sans-serif')) { - f.families = ['Noto Sans', 'Noto Music', 'Noto Color Emoji']; + f.families = VisualTestHelper._testFontFamilies('Noto Sans'); } else { - f.families = ['Noto Serif', 'Noto Music', 'Noto Color Emoji']; + f.families = VisualTestHelper._testFontFamilies('Noto Serif'); } } } + /** + * The registered test fonts covering every character drawn by the tests, so no font of the operating system is used. + * Noto Sans Math also has the accidentals and must come after Noto Music. + */ + private static _testFontFamilies(textFamily: string): string[] { + return [textFamily, 'Noto Music', 'Noto Sans Math', 'Noto Sans SC', 'Noto Color Emoji']; + } + /** * Loads `tex` with `settings` and applies `configure`; alphaSkia and the test fonts are * ready for rendering afterwards. diff --git a/packages/alphatab/test/visualTests/features/General.test.ts b/packages/alphatab/test/visualTests/features/General.test.ts index 7cea08441d..4e45a4a7af 100644 --- a/packages/alphatab/test/visualTests/features/General.test.ts +++ b/packages/alphatab/test/visualTests/features/General.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { StaveProfile } from '@coderline/alphatab/StaveProfile'; import { TabRhythmMode } from '@coderline/alphatab/NotationSettings'; import { Settings } from '@coderline/alphatab/Settings'; -import { VisualTestHelper } from 'test/visualTests/VisualTestHelper'; +import { VisualTestHelper, type VisualTestOptions } from 'test/visualTests/VisualTestHelper'; import { TestPlatform } from 'test/TestPlatform'; import { Logger } from '@coderline/alphatab/Logger'; import { BarStyle, BarSubElement } from '@coderline/alphatab/model/Bar'; @@ -290,17 +290,28 @@ describe('GeneralTests', () => { ); }); + async function renderColors(failures: Error[], configure: (o: VisualTestOptions) => void): Promise { + try { + await VisualTestHelper.runVisualTest('general/colors.gp', undefined, configure); + } catch (e) { + failures.push(e as Error); + } + } + it('color-performance', async () => { + // both references are compared on every run, a mismatch of the first one must not hide the second one + const failures: Error[] = []; + // warm-up for (let i = 0; i < 5; i++) { - await VisualTestHelper.runVisualTest('general/colors.gp', undefined, o => { + await renderColors(failures, o => { enableColoring(o.score); }); } for (let i = 0; i < 10; i++) { let coloredStart: number = 0; - await VisualTestHelper.runVisualTest('general/colors.gp', undefined, o => { + await renderColors(failures, o => { enableColoring(o.score); coloredStart = performance.now(); }); @@ -308,7 +319,7 @@ describe('GeneralTests', () => { let defaultStart: number = 0; - await VisualTestHelper.runVisualTest('general/colors.gp', undefined, o => { + await renderColors(failures, o => { o.runs[0].referenceFileName = 'test-data/visual-tests/general/colors-disabled.png'; defaultStart = performance.now(); }); @@ -322,6 +333,10 @@ describe('GeneralTests', () => { Logger.info('Test-color-performance', 'Colored', i, coloredDuration); Logger.info('Test-color-performance', 'Default', i, defaultDuration); } + + if (failures.length > 0) { + throw failures[0]; + } }); it('font-fallback', async () => { diff --git a/packages/alphatab/test/visualTests/features/Layout.test.ts b/packages/alphatab/test/visualTests/features/Layout.test.ts index f0100249a9..957deb942f 100644 --- a/packages/alphatab/test/visualTests/features/Layout.test.ts +++ b/packages/alphatab/test/visualTests/features/Layout.test.ts @@ -232,6 +232,21 @@ describe('LayoutTests', () => { ); }); + it('inline-tuning-accidentals', async () => { + const settings: Settings = new Settings(); + settings.display.layoutMode = LayoutMode.Parchment; + await VisualTestHelper.runVisualTestTex( + ` + \\tuningDisplayMode staff + \\tuning (D#4 Bb3 Gb3 C#3 Ab2 Ebb2) + \\staff { tabs } + 0.6.4 2.6.4 3.6.4 0.5.4 | + `, + 'test-data/visual-tests/layout/inline-tuning-accidentals.png', + settings + ); + }); + it('inline-tuning-per-track-hidden', async () => { const settings: Settings = new Settings(); settings.display.layoutMode = LayoutMode.Parchment; diff --git a/packages/alphatab/test/visualTests/features/NotationElements.test.ts b/packages/alphatab/test/visualTests/features/NotationElements.test.ts index a80dcf7b20..3153dba74f 100644 --- a/packages/alphatab/test/visualTests/features/NotationElements.test.ts +++ b/packages/alphatab/test/visualTests/features/NotationElements.test.ts @@ -1,7 +1,9 @@ import { describe, it } from 'vitest'; import { LayoutMode } from '@coderline/alphatab/LayoutMode'; import { Settings } from '@coderline/alphatab/Settings'; -import { VisualTestHelper, VisualTestOptions } from 'test/visualTests/VisualTestHelper'; +import { VisualTestHelper, VisualTestOptions, VisualTestRun } from 'test/visualTests/VisualTestHelper'; +import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; +import { TestPlatform } from 'test/TestPlatform'; import { NotationElement } from '@coderline/alphatab/NotationSettings'; describe('NotationElements', () => { @@ -99,6 +101,38 @@ describe('NotationElements', () => { ); }); + it('guitar-tuning-accidentals', async () => { + const tex = '\\tuning (D#4 Bb3 Gb3 C#3 Ab2 Ebb2) . 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-accidentals.png', + settings + ); + }); + + it('guitar-tuning-alteration', async () => { + const settings: Settings = new Settings(); + settings.display.layoutMode = LayoutMode.Page; + settings.notation.elements.set(NotationElement.GuitarTuning, true); + + const score = ScoreLoader.loadScoreFromBytes( + await TestPlatform.loadFile('test-data/guitarpro8/tuning-alteration.gp'), + settings + ); + const options = new VisualTestOptions( + score, + [new VisualTestRun(-1, 'test-data/visual-tests/notation-elements/guitar-tuning-alteration.png')], + settings + ); + options.tracks = score.tracks.map(t => t.index); + await VisualTestHelper.runVisualTestFull(options); + }); + 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 c8c377c97c..63e1d637e6 100644 --- a/packages/alphatex/src/metadata/staff/tuning.ts +++ b/packages/alphatex/src/metadata/staff/tuning.ts @@ -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). The tuning is displayed as written: \`F#3\` and \`Gb3\` are the same tuning value but keep their sharp or flat spelling.`, type: alphaTab.importer.alphaTex.AlphaTexNodeType.Ident, allowAllStringTypes: true, parseMode: alphaTab.importer.alphaTex.ArgumentListParseTypesMode.ValueListWithoutParenthesis @@ -58,6 +58,10 @@ export const tuning: MetadataTagDefinition = { \\tuning (A1 D2 A2 D3 G3 B3 E4) { hide label "Special Guitar Tuning" } 4.1 3.1 2.1 1.1 \\track "Track 2" + \\staff {tabs} + \\tuning (D#4 A#3 F#3 C#3 G#2 D#2) + 4.1 3.1 2.1 1.1 + \\track "Track 3" \\tuning piano C4 D4 E4 F4 `, diff --git a/packages/csharp/src/AlphaTab.Test/AlphaTab.Test.csproj b/packages/csharp/src/AlphaTab.Test/AlphaTab.Test.csproj index 90e617be97..ea4d394f1d 100644 --- a/packages/csharp/src/AlphaTab.Test/AlphaTab.Test.csproj +++ b/packages/csharp/src/AlphaTab.Test/AlphaTab.Test.csproj @@ -14,10 +14,10 @@ - - - - + + + + diff --git a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs index c20cbca491..b5ae2214d4 100644 --- a/packages/csharp/src/AlphaTab.Test/Test/Globals.cs +++ b/packages/csharp/src/AlphaTab.Test/Test/Globals.cs @@ -34,6 +34,11 @@ public static Expector Expect(Action actual) return new Expector(actual); } + public static Expector Expect(Action actual, string message) + { + return new Expector(actual, message); + } + public static void Fail(object? message) { Assert.Fail(Convert.ToString(message) ?? string.Empty); diff --git a/packages/csharp/src/AlphaTab.Windows/AlphaTab.Windows.csproj b/packages/csharp/src/AlphaTab.Windows/AlphaTab.Windows.csproj index 74ba1142e3..2ce624bf8e 100644 --- a/packages/csharp/src/AlphaTab.Windows/AlphaTab.Windows.csproj +++ b/packages/csharp/src/AlphaTab.Windows/AlphaTab.Windows.csproj @@ -27,7 +27,7 @@ - + diff --git a/packages/csharp/src/AlphaTab/AlphaTab.csproj b/packages/csharp/src/AlphaTab/AlphaTab.csproj index c1c8b53c9b..ad33857459 100644 --- a/packages/csharp/src/AlphaTab/AlphaTab.csproj +++ b/packages/csharp/src/AlphaTab/AlphaTab.csproj @@ -19,7 +19,7 @@ - + diff --git a/packages/csharp/src/AlphaTab/Core/EcmaScript/Number.cs b/packages/csharp/src/AlphaTab/Core/EcmaScript/Number.cs index 8eb94af5a7..e35cf580fd 100644 --- a/packages/csharp/src/AlphaTab/Core/EcmaScript/Number.cs +++ b/packages/csharp/src/AlphaTab/Core/EcmaScript/Number.cs @@ -10,6 +10,11 @@ internal static class Number public const double MAX_SAFE_INTEGER = 9007199254740991.0; public const double NaN = double.NaN; + public static bool IsInteger(double v) + { + return !double.IsNaN(v) && !double.IsInfinity(v) && System.Math.Floor(v) == v; + } + public static double ParseInt(char c) { return ParseInt(c.ToString()); diff --git a/packages/kotlin/src/core/src/main/java/alphaTab/collections/DoubleList.kt b/packages/kotlin/src/core/src/main/java/alphaTab/collections/DoubleList.kt index de9097912c..1d21783edc 100644 --- a/packages/kotlin/src/core/src/main/java/alphaTab/collections/DoubleList.kt +++ b/packages/kotlin/src/core/src/main/java/alphaTab/collections/DoubleList.kt @@ -95,6 +95,14 @@ public class DoubleList : IDoubleIterable { return mapped } + public fun map(transform: (v: Double, i: Double) -> TOut): List { + val mapped = List() + for (i in 0 until _size) { + mapped.push(transform(_items[i], i.toDouble())) + } + return mapped + } + public fun map(transform: (v: Double) -> Double): DoubleList { val mapped = DoubleList(_size) for (i in 0 until _size) { diff --git a/packages/kotlin/src/core/src/main/java/alphaTab/collections/List.kt b/packages/kotlin/src/core/src/main/java/alphaTab/collections/List.kt index 79be8f90cd..f661086391 100644 --- a/packages/kotlin/src/core/src/main/java/alphaTab/collections/List.kt +++ b/packages/kotlin/src/core/src/main/java/alphaTab/collections/List.kt @@ -185,6 +185,10 @@ public class List : Iterable { return List(ArrayListWithRemoveRange(_data.subList(start.toInt(), _data.size))) } + public fun slice(start: Double, end: Double): List { + return List(ArrayListWithRemoveRange(_data.subList(start.toInt(), end.toInt()))) + } + public fun shift(): T { return _data.removeAt(0) } diff --git a/packages/kotlin/src/core/src/main/java/alphaTab/core/ecmaScript/Number.kt b/packages/kotlin/src/core/src/main/java/alphaTab/core/ecmaScript/Number.kt index f7c589db43..0f366a815b 100644 --- a/packages/kotlin/src/core/src/main/java/alphaTab/core/ecmaScript/Number.kt +++ b/packages/kotlin/src/core/src/main/java/alphaTab/core/ecmaScript/Number.kt @@ -17,6 +17,10 @@ internal class Number { return s.isNaN() } + inline fun isInteger(v: Double): Boolean { + return v.isFinite() && kotlin.math.floor(v) == v + } + inline fun parseFloat(s: String): Double { return s.toDoubleOrNaN() } diff --git a/packages/kotlin/src/gradle/libs.versions.toml b/packages/kotlin/src/gradle/libs.versions.toml index 95fd1ca016..440c12f4ca 100644 --- a/packages/kotlin/src/gradle/libs.versions.toml +++ b/packages/kotlin/src/gradle/libs.versions.toml @@ -11,7 +11,7 @@ appcompat = "1.8.0" coreKtx = "1.19.1" junit = "4.13.2" junitVersion = "1.3.0" -alphaskia = "3.5.147" +alphaskia = "4.0.157" mavenPublish = "0.37.0" klaxon = "5.6" animalsniffer = "2.0.1" diff --git a/website/docs/features/export/export-to-guitar-pro-7.mdx b/website/docs/features/export/export-to-guitar-pro-7.mdx index 9bf07c732d..c8399d6f80 100644 --- a/website/docs/features/export/export-to-guitar-pro-7.mdx +++ b/website/docs/features/export/export-to-guitar-pro-7.mdx @@ -70,6 +70,7 @@ Since 1.9.0 the exporter also handles the following: - Guitar Pro supports four voices per bar. Voices with a higher index move into unused slots. When all four slots are taken, the remaining voices are dropped and a warning is logged. - Notes keep their spelling in every key, for example a B♭ in F major stays a B♭. - The tuning display mode of the stylesheet (tuning above the score or on the staff lines) is written to the binary stylesheet. See [Tuning display](/docs/features/render/tuning-display). +- Since 1.10.0 the tuning keeps its sharp or flat display. Guitar Pro stores one choice per tuning: it is written as sharps when every string with an accidental is spelled with a sharp, otherwise as flats. See [Sharp and flat tunings](/docs/features/render/tuning-display#sharp-and-flat-tunings). - Percussion notes keep their string, so drum tablature survives the round trip. See [Drum and percussion notation](/docs/features/render/drum-tabs). - The exporter works on a copy of the score. `api.score` stays unchanged. diff --git a/website/docs/features/render/tuning-display.mdx b/website/docs/features/render/tuning-display.mdx index 0a2dbce80f..953a4aa1d3 100644 --- a/website/docs/features/render/tuning-display.mdx +++ b/website/docs/features/render/tuning-display.mdx @@ -49,6 +49,52 @@ With `TuningDisplayMode.Staff`, alphaTab writes the note name of each open strin - In alphaTex, [`\tuningDisplayMode`](/docs/alphatex/score-metadata#tuningdisplaymode) takes `score` or `staff`. The alphaTex exporter writes the tag when the mode is not `score`. - Guitar Pro 7 and 8 files store the mode in their stylesheet. alphaTab reads it on import and writes it back in the [Guitar Pro 7 exporter](/docs/features/export/export-to-guitar-pro-7). +## Sharp and flat tunings *(new in 1.10)* {#sharp-and-flat-tunings} + +alphaTab labels every string with its written pitch. A string tuned to the same pitch as `F#3` or `Gb3` keeps the spelling of the source file, and the score tuning and the staff tuning show it with the accidental characters ♯ and ♭ of the tuning font. + +{` +\\track "Guitar" +\\staff { tabs } +\\tuning (D#4 A#3 F#3 C#3 G#2 D#2) +:4 0.6 2.5 2.4 0.3 | 0.2 2.3 0.1 r +\\track "Baritone" +\\staff { tabs } +\\tuning (Bb3 F3 Db3 Ab2 Eb2 Bb1) +:4 0.6 2.5 2.4 0.3 | 0.2 2.3 0.1 r +`} + +- alphaTex keeps the tuning as written in [`\tuning`](/docs/alphatex/staff-metadata#tuning) and writes it back on export. +- MusicXML files define the spelling with `tuning-step` and `tuning-alter`. +- Guitar Pro files mark a tuning written with flats, all other tunings are written with sharps. The [Guitar Pro 7 exporter](/docs/features/export/export-to-guitar-pro-7) writes the choice back. +- Formats without a spelling, like Guitar Pro 3-5, and tunings set as MIDI values are written with flats, as in earlier versions. + +In code, [`Tuning.pitches`](/docs/reference/types/model/tuning/#pitches) holds a [`SpelledPitch`](/docs/reference/types/model/spelledpitch) for every string, starting with the highest string. [`Tuning.tunings`](/docs/reference/types/model/tuning/#tunings) gives the MIDI values of the same strings; setting it spells the strings with flats. Assign a new array to change the tuning: + +```js +import * as alphaTab from '@coderline/alphatab'; + +const container = getAlphaTabContainer(); +const api = new alphaTab.AlphaTabApi(container, { + core: { file: '/scores/song.gp' }, +}); + +api.scoreLoaded.on((score) => { + const tuning = score.tracks[0].staves[0].stringTuning; + // D# A# F# C# G# D#, written with sharps + tuning.pitches = [ + new alphaTab.model.SpelledPitch(1, 1, 4), + new alphaTab.model.SpelledPitch(5, 1, 3), + new alphaTab.model.SpelledPitch(3, 1, 3), + new alphaTab.model.SpelledPitch(0, 1, 3), + new alphaTab.model.SpelledPitch(4, 1, 2), + new alphaTab.model.SpelledPitch(1, 1, 2), + ]; + console.log(tuning.pitches[0].toString(), tuning.tunings[0]); // D#4 63 + api.render(); +}); +``` + ## Show it in code ```js @@ -86,5 +132,6 @@ api.scoreLoaded.on((score) => { - [Toggling notation elements off](/docs/features/render/coverage-toggle-elements) — hide the `GuitarTuning` element globally through `notation.elements`. - [`globalDisplayTuning`](/docs/reference/types/model/renderstylesheet/#globaldisplaytuning), [`perTrackDisplayTuning`](/docs/reference/types/model/renderstylesheet/#pertrackdisplaytuning), and [`tuningDisplayMode`](/docs/reference/types/model/renderstylesheet/#tuningdisplaymode) on the [`RenderStylesheet`](/docs/reference/types/model/renderstylesheet) reference. - [`TuningDisplayMode`](/docs/reference/types/model/tuningdisplaymode) — the enum reference. +- [`Tuning`](/docs/reference/types/model/tuning) and [`SpelledPitch`](/docs/reference/types/model/spelledpitch) — the tuning values and their written spelling. - [`display.inlineTuningPaddingRight`](/docs/reference/settings/display/inlinetuningpaddingright) and [`\tuningDisplayMode`](/docs/alphatex/score-metadata#tuningdisplaymode) — the padding setting and the alphaTex tag. - [`scoreLoaded`](/docs/reference/api/scoreloaded) and [`render`](/docs/reference/api/render) — the API surface used above. diff --git a/website/docs/formats/guitar-pro-6.mdx b/website/docs/formats/guitar-pro-6.mdx index d3987c0d5e..8469be759a 100644 --- a/website/docs/formats/guitar-pro-6.mdx +++ b/website/docs/formats/guitar-pro-6.mdx @@ -143,7 +143,7 @@ import { FileFormatFeatureTable, FileFormatFeatureGroup, FileFormatFeatureRow, F +#### render: sharp and flat tunings +https://github.com/CoderLine/alphaTab/issues/2802 + +Tunings keep the sharp or flat spelling of the source file per string, and the score tuning and the staff tuning show the accidentals as ♯ and ♭. alphaTex keeps the spelling written in `\tuning` and writes it back on export, the Guitar Pro importer and exporter read and write the sharp or flat choice of the tuning, and the MusicXML importer reads the spelling from `tuning-step` and `tuning-alter`. The new [`Tuning.pitches`](/docs/reference/types/model/tuning/#pitches) holds the written pitch of every string as a [`SpelledPitch`](/docs/reference/types/model/spelledpitch); [`Tuning.tunings`](/docs/reference/types/model/tuning/#tunings) gives the MIDI values of the same strings. Details: [Sharp and flat tunings](/docs/features/render/tuning-display#sharp-and-flat-tunings). + +{` +\\track "Guitar" +\\staff {tabs} +\\tuning (D#4 Bb3 Gb3 C#3 Ab2 Eb2) +:4 0.6 2.5 2.4 0.3 | 0.2 2.3 0.1 r +`} + ### Kotlin #### kotlin: platform-independent alphaTab-core library @@ -65,8 +77,19 @@ alphaTab for Kotlin is published as two packages, like the .NET version. The new * Low octave dots on numbered staves sit below the duration lines instead of between digit and lines, as in printed jiǎnpǔ. * Numbered staves show all notes of chords and all voices. Set `notes` of the numbered staff configuration to `NumberedNoteMode.SingleNote` (alphaTex: `\defaultStaffDisplay numbered { notes singleNote }`) for one digit per beat; see [Single note mode](/docs/features/render/numbered-notation#single-note-mode). +* Tuning names show the accidental as `♭` and `♯` (`E♭`) instead of the letter `b` (`Eb`). +* Guitar Pro tunings with accidentals are written with sharps unless the file marks them as flat, like in Guitar Pro. Guitar Pro 6 files have no such mark and show sharps. * Lyrics of tab-only tracks are shown below the tab staff instead of above it. With standard notation and tabs, they sit directly below the standard notation staff. Set [`score.stylesheet.lyricsPlacement`](/docs/reference/types/model/renderstylesheet/#lyricsplacement) to `LyricsPlacement.Above` (alphaTex: `\lyricsPlacement above`) to show them above the staff. +#### Tuning pitches + +* [`Tuning.pitches`](/docs/reference/types/model/tuning/#pitches) is the stored tuning, [`Tuning.tunings`](/docs/reference/types/model/tuning/#tunings) is derived from it. Assign a new array to change a tuning: changing the array returned by `tunings` in place does not change the tuning anymore. +* The score JSON stores the tuning as `pitches` with the written names like `"D#4"`. JSON with the `tunings` values of earlier versions is still read. + +#### Deprecated APIs + +* `Tuning.noteNames`, `Tuning.getTextForTuning` and `Tuning.getTextPartsForTuning` are deprecated and keep their behavior. Use [`Tuning.pitches`](/docs/reference/types/model/tuning/#pitches) and [`SpelledPitch`](/docs/reference/types/model/spelledpitch) for the written name of a string. + #### Kotlin packages * Android apps need no change: `net.alphatab:alphaTab` brings in `net.alphatab:alphaTab-core` in the same version. Builds that do not resolve transitive dependencies must add `alphaTab-core` themselves. diff --git a/website/src/components/DirectImporterDemo/index.tsx b/website/src/components/DirectImporterDemo/index.tsx index b9ad97b007..65686e55ee 100644 --- a/website/src/components/DirectImporterDemo/index.tsx +++ b/website/src/components/DirectImporterDemo/index.tsx @@ -25,10 +25,9 @@ function inspect(score: alphaTab.model.Score): InspectedScore { trackCount: score.tracks.length, tracks: score.tracks.map((track) => { const bars = track.staves[0]?.bars.length ?? 0; - const stringTunings = track.staves[0]?.stringTuning?.tunings ?? []; - const tuningNames = [...stringTunings] + const tuningNames = (track.staves[0]?.stringTuning?.pitches ?? []) + .map((pitch) => pitch.toNameString()) .reverse() - .map((midi) => alphaTab.model.Tuning.getTextForTuning(midi, false)) .join(" "); return { name: track.name || "(unnamed)",