diff --git a/packages/alphatab/src/importer/GpifParser.ts b/packages/alphatab/src/importer/GpifParser.ts index 703399ce4..ceffcb228 100644 --- a/packages/alphatab/src/importer/GpifParser.ts +++ b/packages/alphatab/src/importer/GpifParser.ts @@ -2581,6 +2581,9 @@ export class GpifParser { switch (c.localName) { case 'Accidental': switch (c.innerText) { + case '': + note.accidentalMode = NoteAccidentalMode.ForceNatural; + break; case 'x': note.accidentalMode = NoteAccidentalMode.ForceDoubleSharp; break; diff --git a/packages/alphatab/src/importer/MusicXmlImporter.ts b/packages/alphatab/src/importer/MusicXmlImporter.ts index 549da5689..41ae4f533 100644 --- a/packages/alphatab/src/importer/MusicXmlImporter.ts +++ b/packages/alphatab/src/importer/MusicXmlImporter.ts @@ -162,7 +162,8 @@ class TrackInfo { // no display pitch defined? musicXmlStaffSteps = 4; // middle of bar } else { - musicXmlStaffSteps = AccidentalHelper.calculateNoteSteps(bar.keySignature, bar.clef, noteValue); + const spelling = ModelUtils.resolveSpelling(bar.keySignature, noteValue, NoteAccidentalMode.Default); + musicXmlStaffSteps = AccidentalHelper.calculateNoteSteps(bar.clef, spelling); } // to translate this into the "staffLine" semantics we need to subtract additionally the steps "missing" from the absent lines diff --git a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts index 555793e11..4b38647d0 100644 --- a/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts +++ b/packages/alphatab/src/importer/alphaTex/AlphaTex1LanguageHandler.ts @@ -60,6 +60,7 @@ import { Fingers } from '@coderline/alphatab/model/Fingers'; import { GolpeType } from '@coderline/alphatab/model/GolpeType'; import { GraceType } from '@coderline/alphatab/model/GraceType'; import { HarmonicType } from '@coderline/alphatab/model/HarmonicType'; +import { KeySignature } from '@coderline/alphatab/model/KeySignature'; import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; import { Lyrics } from '@coderline/alphatab/model/Lyrics'; import { BeamingRules, type MasterBar } from '@coderline/alphatab/model/MasterBar'; @@ -3318,7 +3319,13 @@ export class AlphaTex1LanguageHandler implements IAlphaTexLanguageImportHandler Atnf.prop(properties, 'slur', Atnf.identValue(slurId)); } - if (note.accidentalMode !== NoteAccidentalMode.Default) { + // NOTE: it would be better to check via accidentalhelper what accidentals we really need to force + const skipAccidental = + note.accidentalMode === NoteAccidentalMode.Default || + (note.beat.voice.bar.keySignature === KeySignature.C && + note.accidentalMode === NoteAccidentalMode.ForceNatural); + + if (!skipAccidental) { Atnf.prop( properties, 'acc', diff --git a/packages/alphatab/src/model/ModelUtils.ts b/packages/alphatab/src/model/ModelUtils.ts index 37afff3ec..eb914357f 100644 --- a/packages/alphatab/src/model/ModelUtils.ts +++ b/packages/alphatab/src/model/ModelUtils.ts @@ -4,6 +4,7 @@ import { Bar } from '@coderline/alphatab/model/Bar'; import { Beat } from '@coderline/alphatab/model/Beat'; import { Duration } from '@coderline/alphatab/model/Duration'; import type { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; import { MasterBar } from '@coderline/alphatab/model/MasterBar'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; import { HeaderFooterStyle, type Score, ScoreStyle, type ScoreSubElement } from '@coderline/alphatab/model/Score'; @@ -37,6 +38,26 @@ export class TuningParseResultTone { } } +/** + * @internal + * @record + */ +export interface ResolvedSpelling { + degree: number; + accidentalOffset: number; + chroma: number; + octave: number; +} + +/** + * @internal + * @record + */ +interface SpellingBase { + degree: number; + accidentalOffset: number; +} + /** * This public class contains some utilities for working with model public classes * @partial @@ -784,147 +805,6 @@ export class ModelUtils { } } - /** - * a lookup list containing an info whether the notes within an octave - * need an accidental rendered. the accidental symbol is determined based on the type of key signature. - */ - private static _keySignatureLookup: Array = [ - // Flats (where the value is true, a flat accidental is required for the notes) - [true, true, true, true, true, true, true, true, true, true, true, true], - [true, true, true, true, true, false, true, true, true, true, true, true], - [false, true, true, true, true, false, true, true, true, true, true, true], - [false, true, true, true, true, false, false, false, true, true, true, true], - [false, false, false, true, true, false, false, false, true, true, true, true], - [false, false, false, true, true, false, false, false, false, false, true, true], - [false, false, false, false, false, false, false, false, false, false, true, true], - // natural - [false, false, false, false, false, false, false, false, false, false, false, false], - // sharps (where the value is true, a flat accidental is required for the notes) - [false, false, false, false, false, true, true, false, false, false, false, false], - [true, true, false, false, false, true, true, false, false, false, false, false], - [true, true, false, false, false, true, true, true, true, false, false, false], - [true, true, true, true, false, true, true, true, true, false, false, false], - [true, true, true, true, false, true, true, true, true, true, true, false], - [true, true, true, true, true, true, true, true, true, true, true, false], - [true, true, true, true, true, true, true, true, true, true, true, true] - ]; - - /** - * Contains the list of notes within an octave have accidentals set. - * @internal - */ - public static accidentalNotes: boolean[] = [ - false, - true, - false, - true, - false, - false, - true, - false, - true, - false, - true, - false - ]; - - /** - * @internal - */ - public static computeAccidental( - keySignature: KeySignature, - accidentalMode: NoteAccidentalMode, - noteValue: number, - quarterBend: boolean, - currentAccidental: AccidentalType | null = null - ) { - const ks: number = keySignature; - const ksi: number = ks + 7; - const index: number = noteValue % 12; - - const accidentalForKeySignature: AccidentalType = ksi < 7 ? AccidentalType.Flat : AccidentalType.Sharp; - const hasKeySignatureAccidentalSetForNote: boolean = ModelUtils._keySignatureLookup[ksi][index]; - const hasNoteAccidentalWithinOctave: boolean = ModelUtils.accidentalNotes[index]; - - // the general logic is like this: - // - we check if the key signature has an accidental defined - // - we calculate which accidental a note needs according to its index in the octave - // - if the accidental is already placed at this line, nothing needs to be done, otherwise we place it - // - if there should not be an accidental, but there is one in the key signature, we clear it. - - // the exceptions are: - // - for quarter bends we just place the corresponding accidental - // - the accidental mode can enforce the accidentals for the note - - let accidentalToSet: AccidentalType = AccidentalType.None; - if (quarterBend) { - accidentalToSet = hasNoteAccidentalWithinOctave ? accidentalForKeySignature : AccidentalType.Natural; - switch (accidentalToSet) { - case AccidentalType.Natural: - accidentalToSet = AccidentalType.NaturalQuarterNoteUp; - break; - case AccidentalType.Sharp: - accidentalToSet = AccidentalType.SharpQuarterNoteUp; - break; - case AccidentalType.Flat: - accidentalToSet = AccidentalType.FlatQuarterNoteUp; - break; - } - } else { - // define which accidental should be shown ignoring what might be set on the KS already - switch (accidentalMode) { - case NoteAccidentalMode.ForceSharp: - accidentalToSet = AccidentalType.Sharp; - break; - case NoteAccidentalMode.ForceDoubleSharp: - accidentalToSet = AccidentalType.DoubleSharp; - break; - case NoteAccidentalMode.ForceFlat: - accidentalToSet = AccidentalType.Flat; - break; - case NoteAccidentalMode.ForceDoubleFlat: - accidentalToSet = AccidentalType.DoubleFlat; - break; - default: - // if note has an accidental in the octave, we place a symbol - // according to the Key Signature - if (hasNoteAccidentalWithinOctave) { - accidentalToSet = accidentalForKeySignature; - } else if (hasKeySignatureAccidentalSetForNote) { - // note does not get an accidental, but KS defines one -> Naturalize - accidentalToSet = AccidentalType.Natural; - } - break; - } - - // do we need an accidental on the note? - if (accidentalToSet !== AccidentalType.None) { - // if there is no accidental on the line, and the key signature has it set already, we clear it on the note - if (currentAccidental != null) { - if (currentAccidental === accidentalToSet) { - accidentalToSet = AccidentalType.None; - } - } - // if there is no accidental on the line, and the key signature has it set already, we clear it on the note - else if (hasKeySignatureAccidentalSetForNote && accidentalToSet === accidentalForKeySignature) { - accidentalToSet = AccidentalType.None; - } - } else { - // if we don't want an accidental, but there is already one applied, we place a naturalize accidental - // and clear the registration - if (currentAccidental !== null) { - if (currentAccidental === AccidentalType.Natural) { - accidentalToSet = AccidentalType.None; - } else { - accidentalToSet = AccidentalType.Natural; - } - } - } - } - - return accidentalToSet; - } - /** * @internal */ @@ -964,4 +844,290 @@ export class ModelUtils { return systemIndex < systemsLayout.length ? systemsLayout[systemIndex] : defaultSystemsLayout; } + + // 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# + ], + // 1: C#/Db + [ + { degree: 0, accidentalOffset: 1 }, // C# + { degree: 1, accidentalOffset: -1 }, // Db + { degree: 6, accidentalOffset: 2 } // B## + ], + // 2: D + [ + { degree: 1, accidentalOffset: 0 }, // D + { degree: 0, accidentalOffset: 2 }, // C## + { degree: 2, accidentalOffset: -2 } // Ebb + ], + // 3: D#/Eb + [ + { degree: 1, accidentalOffset: 1 }, // D# + { degree: 2, accidentalOffset: -1 }, // Eb + { degree: 3, accidentalOffset: -2 } // Fbb + ], + // 4: E + [ + { degree: 2, accidentalOffset: 0 }, // E + { degree: 1, accidentalOffset: 2 }, // D## + { degree: 3, accidentalOffset: -1 } // Fb + ], + // 5: F + [ + { degree: 3, accidentalOffset: 0 }, // F + { degree: 2, accidentalOffset: 1 }, // E# + { degree: 4, accidentalOffset: -2 } // Gbb + ], + // 6: F#/Gb + [ + { degree: 3, accidentalOffset: 1 }, // F# + { degree: 4, accidentalOffset: -1 }, // Gb + { degree: 2, accidentalOffset: 2 } // E## + ], + // 7: G + [ + { degree: 4, accidentalOffset: 0 }, // G + { degree: 3, accidentalOffset: 2 }, // F## + { degree: 5, accidentalOffset: -2 } // Abb + ], + // 8: G#/Ab + [ + { degree: 4, accidentalOffset: 1 }, // G# + { degree: 5, accidentalOffset: -1 } // Ab + ], + // 9: A + [ + { degree: 5, accidentalOffset: 0 }, // A + { degree: 4, accidentalOffset: 2 }, // G## + { degree: 6, accidentalOffset: -2 } // Bbb + ], + // 10: A#/Bb + [ + { degree: 5, accidentalOffset: 1 }, // A# + { degree: 6, accidentalOffset: -1 }, // Bb + { degree: 0, accidentalOffset: -2 } // Cbb + ], + // 11: B + [ + { degree: 6, accidentalOffset: 0 }, // B + { degree: 5, accidentalOffset: 2 }, // A## + { degree: 0, accidentalOffset: -1 } // Cb + ] + ]; + private static readonly _sharpKeySignatureOrder: number[] = [3, 0, 4, 1, 5, 2, 6]; // F C G D A E B + private static readonly _flatKeySignatureOrder: number[] = [6, 2, 5, 1, 4, 0, 3]; // B E A D G C F + + 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++) { + const row = [0, 0, 0, 0, 0, 0, 0]; + if (ks > 0) { + for (let i = 0; i < ks; i++) { + row[ModelUtils._sharpKeySignatureOrder[i]] = 1; + } + } else if (ks < 0) { + for (let i = 0; i < -ks; i++) { + row[ModelUtils._flatKeySignatureOrder[i]] = -1; + } + } + lookup.push(row); + } + return lookup; + } + + public static getKeySignatureAccidentalOffset(keySignature: KeySignature, degree: number): number { + return ModelUtils._keySignatureAccidentalByDegree[(keySignature as number) + 7][degree]; + } + + public static resolveSpelling( + keySignature: KeySignature, + noteValue: number, + accidentalMode: NoteAccidentalMode + ): ResolvedSpelling { + const chroma = ModelUtils.flooredDivision(noteValue, 12); + + 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; + } + } + + const baseSemitone = ModelUtils._degreeSemitones[spelling.degree] + spelling.accidentalOffset; + const octave = Math.floor((noteValue - baseSemitone) / 12) - 1; + + return { + degree: spelling.degree, + accidentalOffset: spelling.accidentalOffset, + chroma, + octave + }; + } + + public static computeAccidental( + keySignature: KeySignature, + accidentalMode: NoteAccidentalMode, + noteValue: number, + quarterBend: boolean, + currentAccidentalOffset: number | null = null + ) { + const spelling = ModelUtils.resolveSpelling(keySignature, noteValue, accidentalMode); + return ModelUtils.computeAccidentalForSpelling( + keySignature, + accidentalMode, + spelling, + quarterBend, + currentAccidentalOffset + ); + } + + public static computeAccidentalForSpelling( + keySignature: KeySignature, + accidentalMode: NoteAccidentalMode, + spelling: ResolvedSpelling, + quarterBend: boolean, + currentAccidentalOffset: number | null = null + ) { + if (accidentalMode === NoteAccidentalMode.ForceNone) { + return AccidentalType.None; + } + + if (quarterBend) { + if (spelling.accidentalOffset > 0) { + return AccidentalType.SharpQuarterNoteUp; + } + if (spelling.accidentalOffset < 0) { + return AccidentalType.FlatQuarterNoteUp; + } + return AccidentalType.NaturalQuarterNoteUp; + } + + const desiredOffset = spelling.accidentalOffset; + const ksOffset = ModelUtils.getKeySignatureAccidentalOffset(keySignature, spelling.degree); + + // already active in bar -> no accidental needed + if (currentAccidentalOffset === desiredOffset) { + return AccidentalType.None; + } + + // key signature already defines the accidental and no explicit accidental is active + if (currentAccidentalOffset == null && desiredOffset === ksOffset) { + return AccidentalType.None; + } + + return ModelUtils.accidentalOffsetToType(desiredOffset); + } + + 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; + } + + const preferFlat = ModelUtils.keySignatureIsFlat(keySignature); + return preferFlat ? ModelUtils._flatPreferredSpellings[chroma] : ModelUtils._sharpPreferredSpellings[chroma]; + } + + private static readonly _majorKeySignatureTonicDegrees: number[] = [ + // Flats: Cb, Gb, Db, Ab, Eb, Bb, F + 0, 4, 1, 5, 2, 6, 3, + // Natural: C + 0, + // Sharps: G, D, A, E, B, F#, C# + 4, 1, 5, 2, 6, 3, 0 + ]; + + private static readonly _minorKeySignatureTonicDegrees: number[] = [ + // Flats: Ab, Eb, Bb, F, C, G, D + 5, 2, 6, 3, 0, 4, 1, + // Natural: A + 5, + // Sharps: E, B, F#, C#, G#, D#, A# + 2, 6, 3, 0, 4, 1, 5 + ]; + + public static getKeySignatureTonicDegree(keySignature: KeySignature, keySignatureType: KeySignatureType): number { + const ksi = (keySignature as number) + 7; + return keySignatureType === KeySignatureType.Minor + ? ModelUtils._minorKeySignatureTonicDegrees[ksi] + : ModelUtils._majorKeySignatureTonicDegrees[ksi]; + } } diff --git a/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts b/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts index 786fec086..5e9a5be56 100644 --- a/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/NumberedBeatGlyph.ts @@ -18,7 +18,6 @@ import type { Glyph } from '@coderline/alphatab/rendering/glyphs/Glyph'; import { NumberedNoteHeadGlyph } from '@coderline/alphatab/rendering/glyphs/NumberedNoteHeadGlyph'; import { SpacingGlyph } from '@coderline/alphatab/rendering/glyphs/SpacingGlyph'; import type { NumberedBarRenderer } from '@coderline/alphatab/rendering/NumberedBarRenderer'; -import { AccidentalHelper } from '@coderline/alphatab/rendering/utils/AccidentalHelper'; import type { BeatBounds } from '@coderline/alphatab/rendering/utils/BeatBounds'; import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; import { ElementStyleHelper } from '@coderline/alphatab/rendering/utils/ElementStyleHelper'; @@ -28,7 +27,6 @@ import { NoteBounds } from '@coderline/alphatab/rendering/utils/NoteBounds'; * @internal */ export class NumberedBeatPreNotesGlyph extends BeatGlyphBase { - public isNaturalizeAccidental = false; public accidental: AccidentalType = AccidentalType.None; public skipLayout = false; @@ -48,34 +46,31 @@ export class NumberedBeatPreNotesGlyph extends BeatGlyphBase { if (this.container.beat.notes.length > 0) { const note = this.container.beat.notes[0]; - // Notes - // - Compared to standard notation accidentals: - // - Flat keysigs: When there is a naturalize symbol (against key signature, not naturalizing same line) we have a # in Numbered notation - // - Flat keysigs: When there is a flat symbol standard notation we also have a flat in Numbered notation - // - C keysig: A sharp on standard notation is a sharp on numbered notation - // - # keysigs: When there is a # symbol on standard notation we also a sharp in numbered notation - // - # keysigs: When there is a naturalize symbol (against key signature, not naturalizing same line) we have a flat in Numbered notation - - // Or generally: - // - numbered notation has the same accidentals as standard notation if applied - // - when the standard notation naturalizes the accidental from the key signature, the numbered notation has the reversed accidental - - const accidentalMode = note ? note.accidentalMode : NoteAccidentalMode.Default; - const noteValue = AccidentalHelper.getNoteValue(note); - let accidentalToSet: AccidentalType = ModelUtils.computeAccidental( + const spelling = ModelUtils.resolveSpelling( this.renderer.bar.keySignature, - accidentalMode, - noteValue, - note.hasQuarterToneOffset + note.displayValue, + note.accidentalMode ); - if (accidentalToSet === AccidentalType.Natural) { - const ks = this.renderer.bar.keySignature as number; - const ksi = ks + 7; - const naturalizeAccidentalForKeySignature: AccidentalType = - ksi < 7 ? AccidentalType.Sharp : AccidentalType.Flat; - accidentalToSet = naturalizeAccidentalForKeySignature; - this.isNaturalizeAccidental = true; + const ksOffset = ModelUtils.getKeySignatureAccidentalOffset( + this.renderer.bar.keySignature, + spelling.degree + ); + const requiredOffset = spelling.accidentalOffset - ksOffset; + + let accidentalToSet: AccidentalType = AccidentalType.None; + if (note.accidentalMode !== NoteAccidentalMode.ForceNone) { + if (note.hasQuarterToneOffset) { + if (requiredOffset > 0) { + accidentalToSet = AccidentalType.SharpQuarterNoteUp; + } else if (requiredOffset < 0) { + accidentalToSet = AccidentalType.FlatQuarterNoteUp; + } else { + accidentalToSet = AccidentalType.NaturalQuarterNoteUp; + } + } else if (requiredOffset !== 0) { + accidentalToSet = ModelUtils.accidentalOffsetToType(requiredOffset); + } } // do we need an accidental on the note? @@ -201,22 +196,22 @@ export class NumberedBeatGlyph extends BeatOnNoteGlyphBase { return 0; } - public static readonly majorKeySignatureOneValues: Array = [ - // Flats - 59, 66, 61, 68, 63, 58, 65, - // natural + private static readonly _majorKeySignatureOneValues: Array = [ + // Flats: Cb, Gb, Db, Ab, Eb, Bb, F + 59, 66, 61, 68, 63, 70, 65, + // natural: C 60, - // sharps (where the value is true, a flat accidental is required for the notes) + // sharps: G, D, A, E, B, F#, C# 67, 62, 69, 64, 71, 66, 61 ]; - public static readonly minorKeySignatureOneValues: Array = [ - // Flats - 71, 66, 73, 68, 63, 70, 65, - // natural - 72, - // sharps (where the value is true, a flat accidental is required for the notes) - 67, 74, 69, 64, 71, 66, 73 + private static readonly _minorKeySignatureOneValues: Array = [ + // Flats: Ab, Eb, Bb, F, C, G, D + 68, 63, 70, 65, 60, 67, 62, + // natural: A + 69, + // sharps: E, B, F#, C#, G#, D#, A# + 64, 71, 66, 61, 68, 63, 70 ]; public override doLayout(): void { @@ -234,47 +229,34 @@ export class NumberedBeatGlyph extends BeatOnNoteGlyphBase { let numberWithinOctave = '0'; if (this.container.beat.notes.length > 0) { const note = this.container.beat.notes[0]; - const kst = this.renderer.bar.keySignatureType; - const ks = this.renderer.bar.keySignature as number; - const ksi = ks + 7; - - const oneNoteValues = - kst === KeySignatureType.Minor - ? NumberedBeatGlyph.minorKeySignatureOneValues - : NumberedBeatGlyph.majorKeySignatureOneValues; - const oneNoteValue = oneNoteValues[ksi]; - if (note.isDead) { numberWithinOctave = 'X'; } else { - const noteValue = note.displayValue - oneNoteValue; + const ks = this.renderer.bar.keySignature; + const kst = this.renderer.bar.keySignatureType; + const ksi = (ks as number) + 7; - const index = noteValue < 0 ? ((noteValue % 12) + 12) % 12 : noteValue % 12; + const oneNoteValues = + kst === KeySignatureType.Minor + ? NumberedBeatGlyph._minorKeySignatureOneValues + : NumberedBeatGlyph._majorKeySignatureOneValues; - octaveDots = noteValue < 0 ? ((Math.abs(noteValue) + 12) / 12) | 0 : (noteValue / 12) | 0; - if (noteValue < 0) { - octaveDots *= -1; - } - const stepList = - ModelUtils.keySignatureIsSharp(ks) || ModelUtils.keySignatureIsNatural(ks) - ? AccidentalHelper.flatNoteSteps - : AccidentalHelper.sharpNoteSteps; - - let steps = stepList[index] + 1; - - const hasAccidental = ModelUtils.accidentalNotes[index]; - if ( - hasAccidental && - !(this.container.preNotes as NumberedBeatPreNotesGlyph).isNaturalizeAccidental - ) { - if (ksi < 7) { - steps++; - } else { - steps--; - } - } + const oneNoteValue = oneNoteValues[ksi]; + + const spelling = ModelUtils.resolveSpelling(ks, note.displayValue, note.accidentalMode); - numberWithinOctave = steps.toString(); + const tonicDegree = ModelUtils.getKeySignatureTonicDegree(ks, kst); + + const effectiveTonic = + kst === KeySignatureType.Minor + ? (tonicDegree + 2) % 7 // relative major + : tonicDegree; + + const degreeDistance = (spelling.degree - effectiveTonic + 7) % 7; + numberWithinOctave = (degreeDistance + 1).toString(); + + const noteValue = note.displayValue - oneNoteValue; + octaveDots = Math.floor(noteValue / 12); } } diff --git a/packages/alphatab/src/rendering/glyphs/NumberedKeySignatureGlyph.ts b/packages/alphatab/src/rendering/glyphs/NumberedKeySignatureGlyph.ts index 92bb162be..a380ec851 100644 --- a/packages/alphatab/src/rendering/glyphs/NumberedKeySignatureGlyph.ts +++ b/packages/alphatab/src/rendering/glyphs/NumberedKeySignatureGlyph.ts @@ -27,11 +27,12 @@ export class NumberedKeySignatureGlyph extends EffectGlyph { public override doLayout(): void { super.doLayout(); - const text = '1 = '; + let text = ''; let text2 = ''; let accidental = AccidentalType.None; switch (this._keySignatureType) { case KeySignatureType.Major: + text = '1 = '; switch (this._keySignature) { case KeySignature.Cb: text2 = ' C'; @@ -95,6 +96,7 @@ export class NumberedKeySignatureGlyph extends EffectGlyph { } break; case KeySignatureType.Minor: + text = '6 = '; switch (this._keySignature) { case KeySignature.Cb: text2 = ' a'; diff --git a/packages/alphatab/src/rendering/utils/AccidentalHelper.ts b/packages/alphatab/src/rendering/utils/AccidentalHelper.ts index 29de7718e..23490e441 100644 --- a/packages/alphatab/src/rendering/utils/AccidentalHelper.ts +++ b/packages/alphatab/src/rendering/utils/AccidentalHelper.ts @@ -2,8 +2,7 @@ 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 type { KeySignature } from '@coderline/alphatab/model/KeySignature'; -import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { ModelUtils, type ResolvedSpelling } from '@coderline/alphatab/model/ModelUtils'; import type { Note } from '@coderline/alphatab/model/Note'; import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; @@ -44,16 +43,11 @@ export class AccidentalHelper { private static _octaveSteps: number[] = [38, 32, 30, 26, 38]; /** - * The step offsets of the notes within an octave in case of for sharp keysignatures + * Diatonic step offsets within an octave. */ - public static readonly sharpNoteSteps: number[] = [0, 0, 1, 1, 2, 3, 3, 4, 4, 5, 5, 6]; + private static readonly _diatonicSteps: number[] = [0, 1, 2, 3, 4, 5, 6]; - /** - * The step offsets of the notes within an octave in case of for flat keysignatures - */ - public static readonly flatNoteSteps: number[] = [0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6]; - - private _registeredAccidentals: Map = new Map(); + private _registeredAccidentals: Map = new Map(); private _appliedScoreSteps: Map = new Map(); private _appliedScoreStepsByValue: Map = new Map(); private _notesByValue: Map = new Map(); @@ -88,25 +82,7 @@ export class AccidentalHelper { } public static getNoteValue(note: Note) { - let noteValue: number = note.displayValue; - - // adjust note height according to accidentals enforced - switch (note.accidentalMode) { - case NoteAccidentalMode.ForceDoubleFlat: - noteValue += 2; - break; - case NoteAccidentalMode.ForceDoubleSharp: - noteValue -= 2; - break; - case NoteAccidentalMode.ForceFlat: - noteValue += 1; - break; - case NoteAccidentalMode.ForceSharp: - noteValue -= 1; - break; - } - - return noteValue; + return note.displayValue; } /** @@ -146,7 +122,8 @@ export class AccidentalHelper { if (note.isPercussion) { steps = AccidentalHelper.getPercussionSteps(note); } else { - steps = AccidentalHelper.calculateNoteSteps(bar.keySignature, bar.clef, noteValue); + const spelling = ModelUtils.resolveSpelling(bar.keySignature, noteValue, note.accidentalMode); + steps = AccidentalHelper.calculateNoteSteps(bar.clef, spelling); } return steps; } @@ -167,18 +144,19 @@ export class AccidentalHelper { steps = AccidentalHelper.getPercussionSteps(note!); } else { const accidentalMode = note ? note.accidentalMode : NoteAccidentalMode.Default; - steps = AccidentalHelper.calculateNoteSteps(this._bar.keySignature, this._bar.clef, noteValue); + const spelling = ModelUtils.resolveSpelling(this._bar.keySignature, noteValue, accidentalMode); + steps = AccidentalHelper.calculateNoteSteps(this._bar.clef, spelling); - const currentAccidental = this._registeredAccidentals.has(steps) + const currentAccidentalOffset = this._registeredAccidentals.has(steps) ? this._registeredAccidentals.get(steps)! : null; - accidentalToSet = ModelUtils.computeAccidental( + accidentalToSet = ModelUtils.computeAccidentalForSpelling( this._bar.keySignature, accidentalMode, - noteValue, + spelling, quarterBend, - currentAccidental + currentAccidentalOffset ); let skipAccidental = false; @@ -208,14 +186,15 @@ export class AccidentalHelper { if (skipAccidental) { accidentalToSet = AccidentalType.None; - } else { - // do we need an accidental on the note? - if (accidentalToSet !== AccidentalType.None) { - this._registeredAccidentals.set(steps, accidentalToSet); - } } break; } + + const shouldRegister = !quarterBend && accidentalToSet !== AccidentalType.None; + + if (shouldRegister) { + this._registeredAccidentals.set(steps, spelling.accidentalOffset); + } } if (note) { @@ -275,24 +254,15 @@ export class AccidentalHelper { return this._beatSteps.has(b.id) ? this._beatSteps.get(b.id)!.minStepsNote : null; } - public static calculateNoteSteps(keySignature: KeySignature, clef: Clef, noteValue: number): number { - const value: number = noteValue; - const ks: number = keySignature as number; + public static calculateNoteSteps(clef: Clef, spelling: ResolvedSpelling): number { const clefValue: number = clef as number; - const index: number = value % 12; - const octave: number = ((value / 12) | 0) - 1; // Initial Position let steps: number = AccidentalHelper._octaveSteps[clefValue]; // Move to Octave - steps -= octave * AccidentalHelper._stepsPerOctave; - // get the step list for the current keySignature - const stepList = - ModelUtils.keySignatureIsSharp(ks) || ModelUtils.keySignatureIsNatural(ks) - ? AccidentalHelper.sharpNoteSteps - : AccidentalHelper.flatNoteSteps; - - steps -= stepList[index]; + steps -= spelling.octave * AccidentalHelper._stepsPerOctave; + // Move within octave + steps -= AccidentalHelper._diatonicSteps[spelling.degree]; return steps; } @@ -310,4 +280,4 @@ export class AccidentalHelper { } return 0; } -} +} \ No newline at end of file diff --git a/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png b/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png index c4275b71f..91c991471 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png and b/packages/alphatab/test-data/musicxml-samples/BrookeWestSample.png differ diff --git a/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png b/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png index e4f33cc95..38a3af5ce 100644 Binary files a/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png and b/packages/alphatab/test-data/musicxml-samples/DebuMandSample.png differ diff --git a/packages/alphatab/test-data/musicxml-testsuite/13a-KeySignatures.png b/packages/alphatab/test-data/musicxml-testsuite/13a-KeySignatures.png index a02e913b2..9e50b7255 100644 Binary files a/packages/alphatab/test-data/musicxml-testsuite/13a-KeySignatures.png and b/packages/alphatab/test-data/musicxml-testsuite/13a-KeySignatures.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced-alphatex.png b/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced-alphatex.png new file mode 100644 index 000000000..fce15b99f Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/music-notation/accidentals-advanced-alphatex.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-g2.png b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-g2.png index e55d69aca..72e2539ec 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-g2.png and b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures-g2.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures.png b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures.png index e55d69aca..72e2539ec 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/key-signatures.png and b/packages/alphatab/test-data/visual-tests/music-notation/key-signatures.png differ diff --git a/packages/alphatab/test-data/visual-tests/music-notation/notes-rests-beams.png b/packages/alphatab/test-data/visual-tests/music-notation/notes-rests-beams.png index cb15da25c..3d47be520 100644 Binary files a/packages/alphatab/test-data/visual-tests/music-notation/notes-rests-beams.png and b/packages/alphatab/test-data/visual-tests/music-notation/notes-rests-beams.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png index 7dd3f91d8..056c970d9 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered-tuplets.png differ diff --git a/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png b/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png index dd428f249..37dd291f0 100644 Binary files a/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png and b/packages/alphatab/test-data/visual-tests/special-tracks/numbered.png differ diff --git a/packages/alphatab/test/model/AccidentalResolutionEdge.test.ts b/packages/alphatab/test/model/AccidentalResolutionEdge.test.ts new file mode 100644 index 000000000..6ee0a080d --- /dev/null +++ b/packages/alphatab/test/model/AccidentalResolutionEdge.test.ts @@ -0,0 +1,59 @@ +import { AccidentalType } from '@coderline/alphatab/model/AccidentalType'; +import { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; +import { expect } from 'chai'; + +describe('AccidentalResolutionEdgeTests', () => { + it('spells B# in C# major for pitch C natural', () => { + const ks = KeySignature.CSharp; + const noteValue = 60; // C4 + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); + expect(spelling.degree).to.equal(6); // B + expect(spelling.accidentalOffset).to.equal(1); // B# + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, false, null); + expect(accidental).to.equal(AccidentalType.None); + }); + + it('spells Fb in Cb major for pitch E natural', () => { + const ks = KeySignature.Cb; + const noteValue = 64; // E4 + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); + expect(spelling.degree).to.equal(3); // F + expect(spelling.accidentalOffset).to.equal(-1); // Fb + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, false, null); + expect(accidental).to.equal(AccidentalType.None); + }); + + it('forces double sharp spelling when requested', () => { + const ks = KeySignature.C; + const noteValue = 62; // D + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceDoubleSharp); + expect(spelling.degree).to.equal(0); // C + expect(spelling.accidentalOffset).to.equal(2); // C## + const accidental = ModelUtils.computeAccidentalForSpelling( + ks, + NoteAccidentalMode.ForceDoubleSharp, + spelling, + false, + null + ); + expect(accidental).to.equal(AccidentalType.DoubleSharp); + }); + + it('forces double flat spelling when requested', () => { + const ks = KeySignature.C; + const noteValue = 62; // D + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceDoubleFlat); + expect(spelling.degree).to.equal(2); // E + expect(spelling.accidentalOffset).to.equal(-2); // Ebb + const accidental = ModelUtils.computeAccidentalForSpelling( + ks, + NoteAccidentalMode.ForceDoubleFlat, + spelling, + false, + null + ); + expect(accidental).to.equal(AccidentalType.DoubleFlat); + }); +}); \ No newline at end of file diff --git a/packages/alphatab/test/model/AccidentalResolutionTests.test.ts b/packages/alphatab/test/model/AccidentalResolutionTests.test.ts new file mode 100644 index 000000000..a498eb6c8 --- /dev/null +++ b/packages/alphatab/test/model/AccidentalResolutionTests.test.ts @@ -0,0 +1,129 @@ +import { AccidentalType } from '@coderline/alphatab/model/AccidentalType'; +import { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; +import { expect } from 'chai'; + +describe('AccidentalResolutionTests', () => { + const degreeSemitones = [0, 2, 4, 5, 7, 9, 11]; + + function noteValueForDegree(keySignature: KeySignature, degree: number, octave: number): number { + const ksOffset = ModelUtils.getKeySignatureAccidentalOffset(keySignature, degree); + const baseSemitone = degreeSemitones[degree] + ksOffset; + return (octave + 1) * 12 + baseSemitone; + } + + const allKeySignatures: KeySignature[] = [ + KeySignature.Cb, + KeySignature.Gb, + KeySignature.Db, + KeySignature.Ab, + KeySignature.Eb, + KeySignature.Bb, + KeySignature.F, + KeySignature.C, + KeySignature.G, + KeySignature.D, + KeySignature.A, + KeySignature.E, + KeySignature.B, + KeySignature.FSharp, + KeySignature.CSharp + ]; + + it('diatonic notes require no accidental in each key signature', () => { + for (const ks of allKeySignatures) { + 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}`).to.equal(degree); + expect(spelling.accidentalOffset, `ks=${ks} degree=${degree}`).to.equal( + ModelUtils.getKeySignatureAccidentalOffset(ks, degree) + ); + + const accidental = ModelUtils.computeAccidentalForSpelling( + ks, + NoteAccidentalMode.Default, + spelling, + false, + null + ); + expect(accidental, `ks=${ks} degree=${degree}`).to.equal(AccidentalType.None); + } + } + }); + + it('spells E# in F# major for pitch F natural', () => { + const ks = KeySignature.FSharp; + const noteValue = 65; // F natural + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); + expect(spelling.degree).to.equal(2); // E + expect(spelling.accidentalOffset).to.equal(1); // E# + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, false, null); + expect(accidental).to.equal(AccidentalType.None); + }); + + it('spells Cb in Cb major for pitch B natural', () => { + const ks = KeySignature.Cb; + const noteValue = 59; // B natural + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); + expect(spelling.degree).to.equal(0); // C + expect(spelling.accidentalOffset).to.equal(-1); // Cb + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, false, null); + expect(accidental).to.equal(AccidentalType.None); + }); + + it('forces flat spelling preference when requested', () => { + const ks = KeySignature.C; + const noteValue = 61; // C# / Db + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceFlat); + expect(spelling.degree).to.equal(1); // D + expect(spelling.accidentalOffset).to.equal(-1); // Db + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.ForceFlat, spelling, false, null); + expect(accidental).to.equal(AccidentalType.Flat); + }); + + it('forces sharp spelling preference when requested', () => { + const ks = KeySignature.C; + const noteValue = 61; // C# / Db + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceSharp); + expect(spelling.degree).to.equal(0); // C + expect(spelling.accidentalOffset).to.equal(1); // C# + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.ForceSharp, spelling, false, null); + expect(accidental).to.equal(AccidentalType.Sharp); + }); + + it('force natural displays a natural accidental when key signature would otherwise apply one', () => { + const ks = KeySignature.D; // F#, C# + const noteValue = 65; // F natural + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceNatural); + expect(spelling.degree).to.equal(3); // F + expect(spelling.accidentalOffset).to.equal(0); // natural + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.ForceNatural, spelling, false, null); + expect(accidental).to.equal(AccidentalType.Natural); + }); + + it('force none suppresses accidentals regardless of spelling', () => { + const ks = KeySignature.C; + const noteValue = 61; // C# + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceNone); + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.ForceNone, spelling, false, null); + expect(accidental).to.equal(AccidentalType.None); + }); + + it('no accidental when current accidental already matches', () => { + const ks = KeySignature.C; + const noteValue = 61; // C# + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, false, 1); + expect(accidental).to.equal(AccidentalType.None); + }); + + it('quarter tone accidentals are chosen when quarter bend is true', () => { + const ks = KeySignature.C; + const noteValue = 61; // C# -> requires sharp + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, true, null); + expect(accidental).to.equal(AccidentalType.SharpQuarterNoteUp); + }); +}); \ No newline at end of file diff --git a/packages/alphatab/test/model/ComparisonHelpers.ts b/packages/alphatab/test/model/ComparisonHelpers.ts index 806ff2924..8b022b5a7 100644 --- a/packages/alphatab/test/model/ComparisonHelpers.ts +++ b/packages/alphatab/test/model/ComparisonHelpers.ts @@ -99,6 +99,7 @@ export class ComparisonHelpers { // note level 'ratioposition', 'percussionarticulation', + 'accidentalmode', // we need a better way to check defaults against forced modes // for now ignore the automations as they get reorganized from beat to masterbar level // which messes with the 1:1 validation diff --git a/packages/alphatab/test/model/KeySignatureUtils.test.ts b/packages/alphatab/test/model/KeySignatureUtils.test.ts new file mode 100644 index 000000000..e0c5cc2ad --- /dev/null +++ b/packages/alphatab/test/model/KeySignatureUtils.test.ts @@ -0,0 +1,41 @@ +import { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { KeySignatureType } from '@coderline/alphatab/model/KeySignatureType'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { expect } from 'chai'; + +describe('KeySignatureUtilsTests', () => { + it('sharp key signatures apply accidentals in F C G D A E B order', () => { + const ksG = KeySignature.G; // 1 sharp -> F# + expect(ModelUtils.getKeySignatureAccidentalOffset(ksG, 3)).to.equal(1); // F + expect(ModelUtils.getKeySignatureAccidentalOffset(ksG, 0)).to.equal(0); // C + expect(ModelUtils.getKeySignatureAccidentalOffset(ksG, 6)).to.equal(0); // B + + const ksD = KeySignature.D; // 2 sharps -> F#, C# + expect(ModelUtils.getKeySignatureAccidentalOffset(ksD, 3)).to.equal(1); // F + expect(ModelUtils.getKeySignatureAccidentalOffset(ksD, 0)).to.equal(1); // C + expect(ModelUtils.getKeySignatureAccidentalOffset(ksD, 4)).to.equal(0); // G + }); + + it('flat key signatures apply accidentals in B E A D G C F order', () => { + const ksF = KeySignature.F; // 1 flat -> Bb + expect(ModelUtils.getKeySignatureAccidentalOffset(ksF, 6)).to.equal(-1); // B + expect(ModelUtils.getKeySignatureAccidentalOffset(ksF, 2)).to.equal(0); // E + + const ksBb = KeySignature.Bb; // 2 flats -> Bb, Eb + expect(ModelUtils.getKeySignatureAccidentalOffset(ksBb, 6)).to.equal(-1); // B + expect(ModelUtils.getKeySignatureAccidentalOffset(ksBb, 2)).to.equal(-1); // E + expect(ModelUtils.getKeySignatureAccidentalOffset(ksBb, 5)).to.equal(0); // A + }); + + it('major tonic degree matches expected scale degree', () => { + expect(ModelUtils.getKeySignatureTonicDegree(KeySignature.C, KeySignatureType.Major)).to.equal(0); // C + expect(ModelUtils.getKeySignatureTonicDegree(KeySignature.G, KeySignatureType.Major)).to.equal(4); // G + expect(ModelUtils.getKeySignatureTonicDegree(KeySignature.Eb, KeySignatureType.Major)).to.equal(2); // Eb + }); + + it('minor tonic degree matches expected scale degree', () => { + expect(ModelUtils.getKeySignatureTonicDegree(KeySignature.C, KeySignatureType.Minor)).to.equal(5); // A + expect(ModelUtils.getKeySignatureTonicDegree(KeySignature.G, KeySignatureType.Minor)).to.equal(2); // E + expect(ModelUtils.getKeySignatureTonicDegree(KeySignature.Bb, KeySignatureType.Minor)).to.equal(4); // G (relative minor) + }); +}); \ No newline at end of file diff --git a/packages/alphatab/test/model/NoteSteps.test.ts b/packages/alphatab/test/model/NoteSteps.test.ts new file mode 100644 index 000000000..eed9961c5 --- /dev/null +++ b/packages/alphatab/test/model/NoteSteps.test.ts @@ -0,0 +1,44 @@ +import { Clef } from '@coderline/alphatab/model/Clef'; +import { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; +import { AccidentalHelper } from '@coderline/alphatab/rendering/utils/AccidentalHelper'; +import { expect } from 'chai'; + +describe('NoteStepsTests', () => { + it('calculates known steps for C4 in G2 and F4 clef', () => { + const spelling = ModelUtils.resolveSpelling(KeySignature.C, 60, NoteAccidentalMode.Default); // C4 + const stepsG2 = AccidentalHelper.calculateNoteSteps(Clef.G2, spelling); + const stepsF4 = AccidentalHelper.calculateNoteSteps(Clef.F4, spelling); + + expect(stepsG2).to.equal(10); + expect(stepsF4).to.equal(-2); + }); + + it('octave shift changes steps by 7', () => { + const spellingC4 = ModelUtils.resolveSpelling(KeySignature.C, 60, NoteAccidentalMode.Default); // C4 + const spellingC5 = ModelUtils.resolveSpelling(KeySignature.C, 72, NoteAccidentalMode.Default); // C5 + const stepsC4 = AccidentalHelper.calculateNoteSteps(Clef.G2, spellingC4); + const stepsC5 = AccidentalHelper.calculateNoteSteps(Clef.G2, spellingC5); + expect(stepsC4 - stepsC5).to.equal(7); + }); + + it('adjacent diatonic degrees differ by one step', () => { + const spellingC4 = ModelUtils.resolveSpelling(KeySignature.C, 60, NoteAccidentalMode.Default); // C4 + const spellingD4 = ModelUtils.resolveSpelling(KeySignature.C, 62, NoteAccidentalMode.Default); // D4 + const stepsC4 = AccidentalHelper.calculateNoteSteps(Clef.G2, spellingC4); + const stepsD4 = AccidentalHelper.calculateNoteSteps(Clef.G2, spellingD4); + expect(stepsC4 - stepsD4).to.equal(1); + }); + + it('same pitch with different spelling yields different steps', () => { + const noteValue = 61; // C# / Db + const spellingSharp = ModelUtils.resolveSpelling(KeySignature.C, noteValue, NoteAccidentalMode.ForceSharp); + const spellingFlat = ModelUtils.resolveSpelling(KeySignature.C, noteValue, NoteAccidentalMode.ForceFlat); + + const stepsSharp = AccidentalHelper.calculateNoteSteps(Clef.G2, spellingSharp); + const stepsFlat = AccidentalHelper.calculateNoteSteps(Clef.G2, spellingFlat); + + expect(stepsSharp - stepsFlat).to.equal(1); // C# (degree 0) above Db (degree 1) + }); +}); \ No newline at end of file diff --git a/packages/alphatab/test/model/NoteStepsAdditional.test.ts b/packages/alphatab/test/model/NoteStepsAdditional.test.ts new file mode 100644 index 000000000..d36c8e247 --- /dev/null +++ b/packages/alphatab/test/model/NoteStepsAdditional.test.ts @@ -0,0 +1,26 @@ +import { Clef } from '@coderline/alphatab/model/Clef'; +import { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; +import { AccidentalHelper } from '@coderline/alphatab/rendering/utils/AccidentalHelper'; +import { expect } from 'chai'; + +describe('NoteStepsAdditionalTests', () => { + it('same pitch yields different steps across clefs', () => { + const spelling = ModelUtils.resolveSpelling(KeySignature.C, 60, NoteAccidentalMode.Default); // C4 + const stepsG2 = AccidentalHelper.calculateNoteSteps(Clef.G2, spelling); + const stepsC4 = AccidentalHelper.calculateNoteSteps(Clef.C4, spelling); + expect(stepsG2 - stepsC4).to.equal(8); + }); + + it('enharmonic spelling changes steps (C# vs Db)', () => { + const noteValue = 61; // C#/Db + const spellingSharp = ModelUtils.resolveSpelling(KeySignature.C, noteValue, NoteAccidentalMode.ForceSharp); + const spellingFlat = ModelUtils.resolveSpelling(KeySignature.C, noteValue, NoteAccidentalMode.ForceFlat); + + const stepsSharp = AccidentalHelper.calculateNoteSteps(Clef.G2, spellingSharp); + const stepsFlat = AccidentalHelper.calculateNoteSteps(Clef.G2, spellingFlat); + + expect(stepsSharp - stepsFlat).to.equal(1); + }); +}); \ No newline at end of file diff --git a/packages/alphatab/test/model/QuarterToneAccidentals.test.ts b/packages/alphatab/test/model/QuarterToneAccidentals.test.ts new file mode 100644 index 000000000..5ea377c87 --- /dev/null +++ b/packages/alphatab/test/model/QuarterToneAccidentals.test.ts @@ -0,0 +1,31 @@ +import { AccidentalType } from '@coderline/alphatab/model/AccidentalType'; +import { KeySignature } from '@coderline/alphatab/model/KeySignature'; +import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; +import { NoteAccidentalMode } from '@coderline/alphatab/model/NoteAccidentalMode'; +import { expect } from 'chai'; + +describe('QuarterToneAccidentalsTests', () => { + it('uses natural quarter tone when no key signature offset is required', () => { + const ks = KeySignature.C; + const noteValue = 60; // C4 + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.Default); + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.Default, spelling, true, null); + expect(accidental).to.equal(AccidentalType.NaturalQuarterNoteUp); + }); + + it('uses sharp quarter tone for positive offset', () => { + const ks = KeySignature.C; + const noteValue = 61; // C# + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceSharp); + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.ForceSharp, spelling, true, null); + expect(accidental).to.equal(AccidentalType.SharpQuarterNoteUp); + }); + + it('uses flat quarter tone for negative offset', () => { + const ks = KeySignature.C; + const noteValue = 61; // Db + const spelling = ModelUtils.resolveSpelling(ks, noteValue, NoteAccidentalMode.ForceFlat); + const accidental = ModelUtils.computeAccidentalForSpelling(ks, NoteAccidentalMode.ForceFlat, spelling, true, null); + expect(accidental).to.equal(AccidentalType.FlatQuarterNoteUp); + }); +}); \ No newline at end of file diff --git a/packages/alphatab/test/visualTests/features/MusicNotation.test.ts b/packages/alphatab/test/visualTests/features/MusicNotation.test.ts index 80d9f35cc..2339a1a3d 100644 --- a/packages/alphatab/test/visualTests/features/MusicNotation.test.ts +++ b/packages/alphatab/test/visualTests/features/MusicNotation.test.ts @@ -158,7 +158,7 @@ describe('MusicNotationTests', () => { const ocatve = 4; const notes = ['C', 'D', 'E', 'F', 'G', 'A', 'B']; - const accidentalModes = ['', '#', '##', 'b', 'bb']; + const accidentalModes = ['n', '#', '##', 'b', 'bb']; for (const keySignature of keySignatures) { tex += `\\ks ${keySignature} `; @@ -190,8 +190,8 @@ describe('MusicNotationTests', () => { await VisualTestHelper.runVisualTestFull( new VisualTestOptions( score, - [new VisualTestRun(-1, 'test-data/visual-tests/music-notation/accidentals-advanced.png')], - settings + [new VisualTestRun(-1, 'test-data/visual-tests/music-notation/accidentals-advanced-alphatex.png')], + settings ) ); }); diff --git a/packages/transpiler/src/csharp/CSharpAstTransformer.ts b/packages/transpiler/src/csharp/CSharpAstTransformer.ts index f242c7dff..8c94a04f2 100644 --- a/packages/transpiler/src/csharp/CSharpAstTransformer.ts +++ b/packages/transpiler/src/csharp/CSharpAstTransformer.ts @@ -1214,7 +1214,7 @@ export default class CSharpAstTransformer { nodeType: cs.SyntaxKind.PropertyDeclaration, isAbstract: false, isOverride: false, - isStatic: false, + isStatic: true, isVirtual: false, name: this.context.toPascalCase(d.name.getText()), type: this.createUnresolvedTypeNode(null, d.type ?? d, type),