diff --git a/package-lock.json b/package-lock.json index 9e488fd0c..be765f55d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@coderline/alphatab-monorepo", - "version": "1.8.0", + "version": "1.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@coderline/alphatab-monorepo", - "version": "1.8.0", + "version": "1.8.1", "workspaces": [ "packages/*" ], @@ -6914,7 +6914,7 @@ }, "packages/alphatab": { "name": "@coderline/alphatab", - "version": "1.8.0", + "version": "1.8.1", "license": "MPL-2.0", "devDependencies": { "@biomejs/biome": "^2.3.11", @@ -6942,7 +6942,7 @@ }, "packages/alphatex": { "name": "@coderline/alphatab-alphatex", - "version": "1.8.0", + "version": "1.8.1", "devDependencies": { "rimraf": "^6.1.2", "tslib": "^2.8.1", @@ -6952,7 +6952,7 @@ }, "packages/csharp": { "name": "@coderline/alphatab-csharp", - "version": "1.8.0", + "version": "1.8.1", "devDependencies": { "@coderline/alphatab-transpiler": "*", "rimraf": "^6.1.2" @@ -6960,14 +6960,14 @@ }, "packages/kotlin": { "name": "@coderline/alphatab-kotlin", - "version": "1.8.0" + "version": "1.8.1" }, "packages/lsp": { "name": "@coderline/alphatab-language-server", - "version": "1.8.0", + "version": "1.8.1", "license": "MPL-2.0", "dependencies": { - "@coderline/alphatab": "^1.8.0", + "@coderline/alphatab": "^1.8.1", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12" }, @@ -6991,11 +6991,11 @@ }, "packages/monaco": { "name": "@coderline/alphatab-monaco", - "version": "1.8.0", + "version": "1.8.1", "license": "MPL-2.0", "dependencies": { - "@coderline/alphatab": "^1.8.0", - "@coderline/alphatab-language-server": "^1.8.0", + "@coderline/alphatab": "^1.8.1", + "@coderline/alphatab-language-server": "^1.8.1", "monaco-editor": "^0.55.1", "vscode-languageserver-types": "^3.17.5", "vscode-oniguruma": "^2.0.1", @@ -7021,7 +7021,7 @@ }, "packages/playground": { "name": "@coderline/alphatab-playground", - "version": "1.8.0", + "version": "1.8.1", "dependencies": { "@coderline/alphatab": "*", "@fontsource/noto-sans": "^5.2.10", @@ -7045,7 +7045,7 @@ }, "packages/tooling": { "name": "@coderline/alphatab-tooling", - "version": "1.8.0", + "version": "1.8.1", "devDependencies": { "@microsoft/api-extractor": "^7.55.2", "@rollup/plugin-terser": "^0.4.4", @@ -7056,11 +7056,11 @@ }, "packages/transpiler": { "name": "@coderline/alphatab-transpiler", - "version": "1.8.0" + "version": "1.8.1" }, "packages/vite": { "name": "@coderline/alphatab-vite", - "version": "1.8.0", + "version": "1.8.1", "license": "MPL-2.0", "dependencies": { "magic-string": "^0.30.21", @@ -7088,7 +7088,7 @@ }, "packages/vscode": { "name": "alphatab-vscode", - "version": "1.8.0", + "version": "1.8.1", "license": "MPL-2.0", "devDependencies": { "@biomejs/biome": "^2.3.11", @@ -7115,7 +7115,7 @@ }, "packages/webpack": { "name": "@coderline/alphatab-webpack", - "version": "1.8.0", + "version": "1.8.1", "license": "MPL-2.0", "dependencies": { "webpack": "^5.104.1" diff --git a/package.json b/package.json index da562f7d0..061a73ed1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-monorepo", - "version": "1.8.0", + "version": "1.8.1", "description": "Monorepo for alphaTab and its related packages", "private": true, "type": "module", diff --git a/packages/alphatab/package.json b/packages/alphatab/package.json index 216c76264..b0bb8888f 100644 --- a/packages/alphatab/package.json +++ b/packages/alphatab/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab", - "version": "1.8.0", + "version": "1.8.1", "description": "alphaTab is a music notation and guitar tablature rendering library", "keywords": [ "guitar", diff --git a/packages/alphatab/src/AlphaTabApiBase.ts b/packages/alphatab/src/AlphaTabApiBase.ts index 288d0271e..6efea4815 100644 --- a/packages/alphatab/src/AlphaTabApiBase.ts +++ b/packages/alphatab/src/AlphaTabApiBase.ts @@ -10,7 +10,7 @@ import { import { AlphaTexImporter } from '@coderline/alphatab/importer/AlphaTexImporter'; import { Logger } from '@coderline/alphatab/Logger'; import { AlphaSynthMidiFileHandler } from '@coderline/alphatab/midi/AlphaSynthMidiFileHandler'; -import type { BeatTickLookupItem, IBeatVisibilityChecker } from '@coderline/alphatab/midi/BeatTickLookup'; +import type { IBeatVisibilityChecker } from '@coderline/alphatab/midi/BeatTickLookup'; import type { MetaDataEvent, MetaEvent, @@ -42,16 +42,20 @@ import { MidiTickLookupFindBeatResultCursorMode } from '@coderline/alphatab/midi/MidiTickLookup'; +import { + type ICursorHandler, + NonAnimatingCursorHandler, + ToNextBeatAnimatingCursorHandler +} from '@coderline/alphatab/CursorHandler'; import type { Beat } from '@coderline/alphatab/model/Beat'; import { ModelUtils } from '@coderline/alphatab/model/ModelUtils'; import type { Note } from '@coderline/alphatab/model/Note'; import type { Score } from '@coderline/alphatab/model/Score'; import type { Track } from '@coderline/alphatab/model/Track'; -import { PlayerMode, ScrollMode } from '@coderline/alphatab/PlayerSettings'; import type { IContainer } from '@coderline/alphatab/platform/IContainer'; import type { IMouseEventArgs } from '@coderline/alphatab/platform/IMouseEventArgs'; import type { IUiFacade } from '@coderline/alphatab/platform/IUiFacade'; -import { ResizeEventArgs } from '@coderline/alphatab/ResizeEventArgs'; +import { PlayerMode, ScrollMode } from '@coderline/alphatab/PlayerSettings'; import { BeatContainerGlyph } from '@coderline/alphatab/rendering/glyphs/BeatContainerGlyph'; import type { IScoreRenderer, RenderHints } from '@coderline/alphatab/rendering/IScoreRenderer'; import type { RenderFinishedEventArgs } from '@coderline/alphatab/rendering/RenderFinishedEventArgs'; @@ -62,6 +66,7 @@ import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; import type { BoundsLookup } from '@coderline/alphatab/rendering/utils/BoundsLookup'; import type { MasterBarBounds } from '@coderline/alphatab/rendering/utils/MasterBarBounds'; import type { StaffSystemBounds } from '@coderline/alphatab/rendering/utils/StaffSystemBounds'; +import { ResizeEventArgs } from '@coderline/alphatab/ResizeEventArgs'; import { HorizontalContinuousScrollHandler, HorizontalOffScreenScrollHandler, @@ -88,6 +93,7 @@ import type { PlaybackRangeChangedEventArgs } from '@coderline/alphatab/synth/Pl import { PlayerState } from '@coderline/alphatab/synth/PlayerState'; import type { PlayerStateChangedEventArgs } from '@coderline/alphatab/synth/PlayerStateChangedEventArgs'; import type { PositionChangedEventArgs } from '@coderline/alphatab/synth/PositionChangedEventArgs'; +import { Cursors } from '@coderline/alphatab/platform/Cursors'; /** * @internal @@ -166,6 +172,8 @@ export class AlphaTabApiBase { private _renderer: ScoreRendererWrapper; private _defaultScrollHandler?: IScrollHandler; + private _defaultCursorHandler?: ICursorHandler; + private _customCursorHandler?: ICursorHandler; /** * An indicator by how many midi-ticks the song contents are shifted. @@ -988,6 +996,88 @@ export class AlphaTabApiBase { } } + /** + * A custom cursor handler which will be used to update the cursor positions during playback. + * + * @category Properties - Player + * @since 1.8.1 + * @example + * JavaScript + * ```js + * const api = new alphaTab.AlphaTabApi(document.querySelector('#alphaTab')); + * api.customCursorHandler = { + * _customAdorner: undefined, + * onAttach(cursors) { + * this._customAdorner = document.createElement('div'); + * this._customAdorner.classList.add('cursor-adorner'); + * cursors.cursorWrapper.element.appendChild(this._customAdorner); + * }, + * onDetach(cursors) { this._customAdorner.remove(); }, + * placeBarCursor(barCursor, beatBounds) { + * const barBoundings = beatBounds.barBounds.masterBarBounds; + * const barBounds = barBoundings.visualBounds; + * barCursor.setBounds(barBounds.x, barBounds.y, barBounds.w, barBounds.h); + * }, + * placeBeatCursor(beatCursor, beatBounds, startBeatX) { + * const barBoundings = beatBounds.barBounds.masterBarBounds; + * const barBounds = barBoundings.visualBounds; + * beatCursor.transitionToX(0, startBeatX); + * beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); + * this._customAdorner.style.left = startBeatX + 'px'; + * this._customAdorner.style.top = (barBounds.y - 10) + 'px'; + * this._customAdorner.style.width = '1px'; + * this._customAdorner.style.height = '10px'; + * this._customAdorner.style.transition = 'left 0ms linear'; // stop animation + * }, + * transitionBeatCursor(beatCursor, beatBounds, startBeatX, endBeatX, duration, cursorMode) { + * this._customAdorner.style.transition = `left ${duration}ms linear`; // start animation + * this._customAdorner.style.left = endBeatX + 'px'; + * } + * } + * ``` + * + * @example + * C# + * ```cs + * var api = new AlphaTabApi(...); + * api.CustomCursorHandler = new CustomCursorHandler(); + * ``` + * + * @example + * Android + * ```kotlin + * val api = AlphaTabApi(...) + * api.customCursorHandler = CustomCursorHandler(); + * ``` + */ + public get customCursorHandler(): ICursorHandler | undefined { + return this._customCursorHandler; + } + + public set customCursorHandler(value: ICursorHandler | undefined) { + if (this._customCursorHandler === value) { + return; + } + const currentHandler = this._customCursorHandler ?? this._defaultCursorHandler; + + this._customCursorHandler = value; + if (this._cursorWrapper) { + const cursors = new Cursors( + this._cursorWrapper, + this._barCursor!, + this._beatCursor!, + this._selectionWrapper! + ); + + currentHandler?.onDetach(cursors); + if (value) { + value?.onDetach(cursors); + } else if (this._defaultCursorHandler) { + this._defaultCursorHandler!.onAttach(cursors); + } + } + } + private _tickCache: MidiTickLookup | null = null; /** @@ -1487,6 +1577,9 @@ export class AlphaTabApiBase { public set playbackRange(value: PlaybackRange | null) { this._player.playbackRange = value; + if (this._tickCache) { + this._tickCache.playbackRange = value; + } this._updateSelectionCursor(value); } @@ -1650,6 +1743,8 @@ export class AlphaTabApiBase { generator.generate(); this._tickCache = generator.tickLookup; + this._tickCache.playbackRange = this.playbackRange; + this._onMidiLoad(midiFile); const player = this._player; @@ -2070,6 +2165,10 @@ export class AlphaTabApiBase { if (!this._cursorWrapper) { return; } + const cursorHandler = this.customCursorHandler ?? this._defaultCursorHandler!; + cursorHandler?.onDetach( + new Cursors(this._cursorWrapper, this._barCursor!, this._beatCursor!, this._selectionWrapper!) + ); this.uiFacade.destroyCursors(); this._cursorWrapper = null; this._barCursor = null; @@ -2088,6 +2187,9 @@ export class AlphaTabApiBase { this._barCursor = cursors.barCursor; this._beatCursor = cursors.beatCursor; this._selectionWrapper = cursors.selectionWrapper; + const cursorHandler = this.customCursorHandler ?? this._defaultCursorHandler!; + cursorHandler?.onAttach(cursors); + this._isInitialBeatCursorUpdate = true; } if (this._currentBeat !== null) { @@ -2096,6 +2198,7 @@ export class AlphaTabApiBase { } private _updateCursors() { + this._updateCursorHandler(); this._updateScrollHandler(); const enable = this._hasCursor; @@ -2106,6 +2209,23 @@ export class AlphaTabApiBase { } } + private _cursorHandlerMode = false; + private _updateCursorHandler() { + const currentHandler = this._defaultCursorHandler; + + const cursorHandlerMode = this.settings.player.enableAnimatedBeatCursor; + // no change + if (currentHandler !== undefined && this._cursorHandlerMode === cursorHandlerMode) { + return; + } + + if (cursorHandlerMode) { + this._defaultCursorHandler = new ToNextBeatAnimatingCursorHandler(); + } else { + this._defaultCursorHandler = new NonAnimatingCursorHandler(); + } + } + private _scrollHandlerMode = ScrollMode.Off; private _scrollHandlerVertical = true; private _updateScrollHandler() { @@ -2199,10 +2319,6 @@ export class AlphaTabApiBase { forceUpdate: boolean = false ): void { const beat: Beat = lookupResult.beat; - const nextBeat: Beat | null = lookupResult.nextBeat?.beat ?? null; - const duration: number = lookupResult.duration; - const beatsToHighlight = lookupResult.beatLookup.highlightedBeats; - if (!beat) { return; } @@ -2235,18 +2351,7 @@ export class AlphaTabApiBase { this._previousStateForCursor = this._player.state; this.uiFacade.beginInvoke(() => { - this._internalCursorUpdateBeat( - beat, - nextBeat, - duration, - stop, - beatsToHighlight, - cache!, - beatBoundings!, - shouldScroll, - lookupResult.cursorMode, - cursorSpeed - ); + this._internalCursorUpdateBeat(lookupResult, stop, cache!, beatBoundings!, shouldScroll, cursorSpeed); }); } @@ -2266,19 +2371,22 @@ export class AlphaTabApiBase { } private _internalCursorUpdateBeat( - beat: Beat, - nextBeat: Beat | null, - duration: number, + lookupResult: MidiTickLookupFindBeatResult, stop: boolean, - beatsToHighlight: BeatTickLookupItem[], - cache: BoundsLookup, + boundsLookup: BoundsLookup, beatBoundings: BeatBounds, shouldScroll: boolean, - cursorMode: MidiTickLookupFindBeatResultCursorMode, cursorSpeed: number ) { - const barCursor = this._barCursor; + const beat = lookupResult.beat; + const nextBeat = lookupResult.nextBeat?.beat; + let duration = lookupResult.duration; + const beatsToHighlight = lookupResult.beatLookup.highlightedBeats; + const cursorMode = lookupResult.cursorMode; + const cursorHandler = this.customCursorHandler ?? this._defaultCursorHandler!; + const beatCursor = this._beatCursor; + const barCursor = this._barCursor; const barBoundings: MasterBarBounds = beatBoundings.barBounds.masterBarBounds; const barBounds: Bounds = barBoundings.visualBounds; @@ -2287,18 +2395,18 @@ export class AlphaTabApiBase { this._currentBeatBounds = beatBoundings; if (barCursor) { - barCursor.setBounds(barBounds.x, barBounds.y, barBounds.w, barBounds.h); + cursorHandler.placeBarCursor(barCursor, beatBoundings); } const isPlayingUpdate = this._player.state === PlayerState.Playing && !stop; - let nextBeatX: number = barBoundings.visualBounds.x + barBoundings.visualBounds.w; + let nextBeatX: number = beatBoundings.realBounds.x + beatBoundings.realBounds.w; let nextBeatBoundings: BeatBounds | null = null; // get position of next beat on same system if (nextBeat && cursorMode === MidiTickLookupFindBeatResultCursorMode.ToNextBext) { // if we are moving within the same bar or to the next bar // transition to the next beat, otherwise transition to the end of the bar. - nextBeatBoundings = cache.findBeat(nextBeat); + nextBeatBoundings = boundsLookup.findBeat(nextBeat); if ( nextBeatBoundings && nextBeatBoundings.barBounds.masterBarBounds.staffSystemBounds === barBoundings.staffSystemBounds @@ -2309,52 +2417,42 @@ export class AlphaTabApiBase { let startBeatX = beatBoundings.onNotesX; if (beatCursor) { - // relative positioning of the cursor - if (this.settings.player.enableAnimatedBeatCursor) { - const animationWidth = nextBeatX - beatBoundings.onNotesX; - const relativePosition = this._previousTick - this._currentBeat!.start; - const ratioPosition = - this._currentBeat!.tickDuration > 0 ? relativePosition / this._currentBeat!.tickDuration : 0; - startBeatX = beatBoundings.onNotesX + animationWidth * ratioPosition; - duration -= duration * ratioPosition; - - if (isPlayingUpdate) { - // we do not "reset" the cursor if we are smoothly moving from left to right. - const jumpCursor = - !previousBeatBounds || - this._isInitialBeatCursorUpdate || - barBounds.y !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.y || - startBeatX < previousBeatBounds.onNotesX || - barBoundings.index > previousBeatBounds.barBounds.masterBarBounds.index + 1; - - if (jumpCursor) { - beatCursor.transitionToX(0, startBeatX); - beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); - } - - // it can happen that the cursor reaches the target position slightly too early (especially on backing tracks) - // to avoid the cursor stopping, causing a wierd look, we animate the cursor to the double position in double time. - // beatCursor!.transitionToX((duration / cursorSpeed), nextBeatX); - const factor = cursorMode === MidiTickLookupFindBeatResultCursorMode.ToNextBext ? 2 : 1; - nextBeatX = startBeatX + (nextBeatX - startBeatX) * factor; - duration = (duration / cursorSpeed) * factor; - - // we need to put the transition to an own animation frame - // otherwise the stop animation above is not applied. - this.uiFacade.beginInvoke(() => { - beatCursor!.transitionToX(duration, nextBeatX); - }); - } else { - duration = 0; - beatCursor.transitionToX(duration, nextBeatX); - beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); + const animationWidth = nextBeatX - beatBoundings.onNotesX; + const relativePosition = this._previousTick - this._currentBeat!.start; + const ratioPosition = + this._currentBeat!.tickDuration > 0 ? relativePosition / this._currentBeat!.tickDuration : 0; + startBeatX = beatBoundings.onNotesX + animationWidth * ratioPosition; + duration -= duration * ratioPosition; + + // respect speed + duration = duration / cursorSpeed; + + if (isPlayingUpdate) { + // we do not "reset" the cursor if we are smoothly moving from left to right. + const jumpCursor = + !previousBeatBounds || + this._isInitialBeatCursorUpdate || + barBounds.y !== previousBeatBounds.barBounds.masterBarBounds.visualBounds.y || + startBeatX < previousBeatBounds.onNotesX || + barBoundings.index > previousBeatBounds.barBounds.masterBarBounds.index + 1; + + if (jumpCursor) { + cursorHandler.placeBeatCursor(beatCursor, beatBoundings, startBeatX); } + + this.uiFacade.beginInvoke(() => { + cursorHandler.transitionBeatCursor( + beatCursor, + beatBoundings, + startBeatX, + nextBeatX, + duration, + cursorMode + ); + }); } else { - // ticking cursor duration = 0; - nextBeatX = startBeatX; - beatCursor.transitionToX(duration, nextBeatX); - beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); + cursorHandler.placeBeatCursor(beatCursor, beatBoundings, startBeatX); } this._isInitialBeatCursorUpdate = false; diff --git a/packages/alphatab/src/CursorHandler.ts b/packages/alphatab/src/CursorHandler.ts new file mode 100644 index 000000000..d2edad57e --- /dev/null +++ b/packages/alphatab/src/CursorHandler.ts @@ -0,0 +1,144 @@ +import { MidiTickLookupFindBeatResultCursorMode } from '@coderline/alphatab/midi/MidiTickLookup'; +import type { Cursors } from '@coderline/alphatab/platform/Cursors'; +import type { IContainer } from '@coderline/alphatab/platform/IContainer'; +import type { BeatBounds } from '@coderline/alphatab/rendering/_barrel'; + +/** + * Classes implementing this interface can handle the cursor placement logic + * as the playback in alphaTab progresses. + * + * @public + */ +export interface ICursorHandler { + /** + * Called when this handler activates. This can be on dynamic cursor creation + * or when setting a custom handler with cursors already created. + * @param cursors A container holding information about the cursor elements. + */ + onAttach(cursors: Cursors): void; + /** + * Called when this handler deactivates. This can be on dynamic cursor destroy + * or when setting a new custom handler. + * @param cursors A container holding information about the cursor elements. + */ + onDetach(cursors: Cursors): void; + + /** + * Instructs the handler to place the bar cursor for the given beat bounds instantly . + * @param barCursor The bar cursor. + * @param beatBounds The bounds of the currently active beat. + */ + placeBarCursor(barCursor: IContainer, beatBounds: BeatBounds): void; + + /** + * Instructs the handler to place the beat cursor for the given beat bounds instantly. + * @param barCursor The beat cursor. + * @param beatBounds The bounds of the currently active beat. + */ + placeBeatCursor(beatCursor: IContainer, beatBounds: BeatBounds, startBeatX: number): void; + + /** + * Instructs the handler to initiate a transition of the beat cursor (e.g. for dynamic animation). + * @param beatCursor The beat cursor + * @param beatBounds The bounds of the currently active beat. + * @param startBeatX The X-position where the transition of the beat cursor should start. + * @param nextBeatX The X-position where the transition of the beat cursor should end + * (typically the next beat or end of bar depending on the cursor mode and seeks) + * @param duration The duration in milliseconds on how long the transition should take. + * @param cursorMode The active cursor mode for the cursor placement. + */ + transitionBeatCursor( + beatCursor: IContainer, + beatBounds: BeatBounds, + startBeatX: number, + nextBeatX: number, + duration: number, + cursorMode: MidiTickLookupFindBeatResultCursorMode + ): void; +} + +/** + * A cursor handler which animates the beat cursor to the next beat or end of the beat bounds + * depending on the cursor mode. + * @internal + */ +export class ToNextBeatAnimatingCursorHandler implements ICursorHandler { + public onAttach(_cursors: Cursors): void { + // nothing to do + } + + public onDetach(_cursors: Cursors): void { + // nothing to do + } + + public placeBeatCursor(beatCursor: IContainer, beatBounds: BeatBounds, startBeatX: number): void { + const barBoundings = beatBounds.barBounds.masterBarBounds; + const barBounds = barBoundings.visualBounds; + beatCursor.transitionToX(0, startBeatX); + beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); + } + + public placeBarCursor(barCursor: IContainer, beatBounds: BeatBounds): void { + const barBoundings = beatBounds.barBounds.masterBarBounds; + const barBounds = barBoundings.visualBounds; + barCursor.setBounds(barBounds.x, barBounds.y, barBounds.w, barBounds.h); + } + + public transitionBeatCursor( + beatCursor: IContainer, + _beatBounds: BeatBounds, + startBeatX: number, + nextBeatX: number, + duration: number, + cursorMode: MidiTickLookupFindBeatResultCursorMode + ): void { + // it can happen that the cursor reaches the target position slightly too early (especially on backing tracks) + // to avoid the cursor stopping, causing a wierd look, we animate the cursor to the double position in double time. + // beatCursor!.transitionToX((duration / cursorSpeed), nextBeatX); + const factor = cursorMode === MidiTickLookupFindBeatResultCursorMode.ToNextBext ? 2 : 1; + nextBeatX = startBeatX + (nextBeatX - startBeatX) * factor; + duration = duration * factor; + + // we need to put the transition to an own animation frame + // otherwise the stop animation above is not applied. + beatCursor!.transitionToX(duration, nextBeatX); + } +} + +/** + * A cursor handler which just places the bar and beat cursor without any animations applied. + * @internal + */ +export class NonAnimatingCursorHandler implements ICursorHandler { + public onAttach(_cursors: Cursors): void { + // nothing to do + } + + public onDetach(_cursors: Cursors): void { + // nothing to do + } + + public placeBeatCursor(beatCursor: IContainer, beatBounds: BeatBounds, startBeatX: number): void { + const barBoundings = beatBounds.barBounds.masterBarBounds; + const barBounds = barBoundings.visualBounds; + beatCursor.transitionToX(0, startBeatX); + beatCursor.setBounds(startBeatX, barBounds.y, 1, barBounds.h); + } + + public placeBarCursor(barCursor: IContainer, beatBounds: BeatBounds): void { + const barBoundings = beatBounds.barBounds.masterBarBounds; + const barBounds = barBoundings.visualBounds; + barCursor.setBounds(barBounds.x, barBounds.y, barBounds.w, barBounds.h); + } + + public transitionBeatCursor( + beatCursor: IContainer, + beatBounds: BeatBounds, + startBeatX: number, + _nextBeatX: number, + _duration: number, + _cursorMode: MidiTickLookupFindBeatResultCursorMode + ): void { + this.placeBeatCursor(beatCursor, beatBounds, startBeatX); + } +} diff --git a/packages/alphatab/src/importer/Gp3To5Importer.ts b/packages/alphatab/src/importer/Gp3To5Importer.ts index dbe90ebf8..cc74873be 100644 --- a/packages/alphatab/src/importer/Gp3To5Importer.ts +++ b/packages/alphatab/src/importer/Gp3To5Importer.ts @@ -54,6 +54,26 @@ import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection */ export class Gp3To5Importer extends ScoreImporter { private static readonly _versionString: string = 'FICHIER GUITAR PRO '; + + // NOTE: General Midi only defines percussion instruments from 35-81 + // Guitar Pro 5 allowed GS extensions (27-34 and 82-87) + // GP7-8 do not have all these definitions anymore, this lookup ensures some fallback + // (even if they are not correct) + // we can support this properly in future when we allow custom alphaTex articulation definitions + // then we don't need to rely on GP specifics anymore but handle things on export/import + private static readonly _gp5PercussionInstrumentMap = new Map([ + // High Q -> GS "High Q / Filter Snap" + [27, 42], + // Slap + [28, 60], + // Scratch Push + [29, 29], + // Scratch Pull + [30, 30], + // Square Click + [32, 31] + ]); + private _versionNumber: number = 0; private _score!: Score; private _globalTripletFeel: TripletFeel = TripletFeel.NoTripletFeel; @@ -435,9 +455,9 @@ export class Gp3To5Importer extends ScoreImporter { } /** - * Guitar Pro 3-6 changes to a bass clef if any string tuning is below B2; + * Guitar Pro 3-6 changes to a bass clef if any string tuning is below B1 */ - private static readonly _bassClefTuningThreshold = ModelUtils.parseTuning('B2')!.realValue; + private static readonly _bassClefTuningThreshold = ModelUtils.parseTuning('B1')!.realValue; public readTrack(): void { const newTrack: Track = new Track(); @@ -1225,7 +1245,9 @@ export class Gp3To5Importer extends ScoreImporter { } if (bar.staff.isPercussion) { - newNote.percussionArticulation = newNote.fret; + newNote.percussionArticulation = Gp3To5Importer._gp5PercussionInstrumentMap.has(newNote.fret) + ? Gp3To5Importer._gp5PercussionInstrumentMap.get(newNote.fret)! + : newNote.fret; newNote.string = -1; newNote.fret = -1; } 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/midi/MidiTickLookup.ts b/packages/alphatab/src/midi/MidiTickLookup.ts index c1881d4b9..c8d7ba688 100644 --- a/packages/alphatab/src/midi/MidiTickLookup.ts +++ b/packages/alphatab/src/midi/MidiTickLookup.ts @@ -4,6 +4,7 @@ import { MasterBarTickLookup } from '@coderline/alphatab/midi/MasterBarTickLooku import { MidiUtils } from '@coderline/alphatab/midi/MidiUtils'; import type { Beat } from '@coderline/alphatab/model/Beat'; import type { MasterBar } from '@coderline/alphatab/model/MasterBar'; +import type { PlaybackRange } from '@coderline/alphatab/synth/PlaybackRange'; /** * Describes how a cursor should be moving. @@ -21,9 +22,15 @@ export enum MidiTickLookupFindBeatResultCursorMode { ToNextBext = 1, /** - * The cursor should animate to the end of the bar (typically on repeats and jumps) + * @deprecated replaced by {@link ToEndOfBeat} */ - ToEndOfBar = 2 + ToEndOfBar = 2, + + /** + * The cursor should animate to the end of the **beat** (typically on repeats and jumps) + * (this is named end of bar historically) + */ + ToEndOfBeat = 3 } /** @@ -193,6 +200,12 @@ export class MidiTickLookup { */ public multiBarRestInfo: Map | null = null; + /** + * An optional playback range to consider when performing lookups. + * This will mainly influence the used {@link MidiTickLookupFindBeatResultCursorMode} + */ + public playbackRange: PlaybackRange | null = null; + /** * Finds the currently played beat given a list of tracks and the current time. * @param trackLookup The tracks indices in which to search the played beat for. @@ -228,6 +241,14 @@ export class MidiTickLookup { result = this._findBeatSlow(checker, currentBeatHint, tick, false); } + if (result) { + const playbackRange = this.playbackRange; + const isBeyondRangeEnd = playbackRange !== null && result!.start >= playbackRange.endTick; + if (isBeyondRangeEnd) { + return null; + } + } + return result; } @@ -257,10 +278,7 @@ export class MidiTickLookup { return null; } - private _fillNextBeatMultiBarRest( - current: MidiTickLookupFindBeatResult, - checker: IBeatVisibilityChecker - ) { + private _fillNextBeatMultiBarRest(current: MidiTickLookupFindBeatResult, checker: IBeatVisibilityChecker) { const group = this.multiBarRestInfo!.get(current.masterBar.masterBar.index)!; // this is a bit sensitive. we assume that the sequence of multi-rest bars and the @@ -288,22 +306,27 @@ export class MidiTickLookup { current.tickDuration = current.nextBeat.start - current.start; current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToNextBext; + // jump back if ( current.nextBeat.masterBar.masterBar.index !== endMasterBar.masterBar.index + 1 && (current.nextBeat.masterBar.masterBar.index !== endMasterBar.masterBar.index || current.nextBeat.beat.playbackStart <= current.beat.playbackStart) ) { - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; + } + // beyond end of playback range + else if (this.playbackRange !== null && this.playbackRange.endTick <= current.nextBeat.start) { + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; } } // no next beat, animate to the end of the bar (could be an incomplete bar) else { current.tickDuration = endMasterBar.nextMasterBar.end - current.start; - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; } } else { current.tickDuration = endMasterBar.end - current.start; - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; } } else { Logger.warning( @@ -313,16 +336,13 @@ export class MidiTickLookup { // this is wierd, we have a masterbar without known tick? // make a best guess with the number of bars current.tickDuration = (current.masterBar.end - current.masterBar.start) * (group.length + 1); - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; } current.calculateDuration(); } - private _fillNextBeat( - current: MidiTickLookupFindBeatResult, - checker: IBeatVisibilityChecker - ) { + private _fillNextBeat(current: MidiTickLookupFindBeatResult, checker: IBeatVisibilityChecker) { // on multibar rests take the duration until the end. if (this._isMultiBarRestResult(current)) { this._fillNextBeatMultiBarRest(current, checker); @@ -330,10 +350,7 @@ export class MidiTickLookup { this._fillNextBeatDefault(current, checker); } } - private _fillNextBeatDefault( - current: MidiTickLookupFindBeatResult, - checker: IBeatVisibilityChecker - ) { + private _fillNextBeatDefault(current: MidiTickLookupFindBeatResult, checker: IBeatVisibilityChecker) { current.nextBeat = this._findBeatInMasterBar( current.masterBar, current.beatLookup.nextBeat, @@ -355,19 +372,24 @@ export class MidiTickLookup { // no next beat, animate to the end of the bar (could be an incomplete bar) else { current.tickDuration = current.masterBar.end - current.start; - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; current.calculateDuration(); } - // if the next beat is not directly the next master bar (e.g. jumping back or forth) - // we report no next beat and animate to the end - if ( - current.nextBeat && - current.nextBeat.masterBar.masterBar.index !== current.masterBar.masterBar.index + 1 && - (current.nextBeat.masterBar.masterBar.index !== current.masterBar.masterBar.index || - current.nextBeat.beat.playbackStart <= current.beat.playbackStart) - ) { - current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBar; + if (current.nextBeat) { + // if the next beat is not directly the next master bar (e.g. jumping back or forth) + // we report no next beat and animate to the end + if ( + current.nextBeat.masterBar.masterBar.index !== current.masterBar.masterBar.index + 1 && + (current.nextBeat.masterBar.masterBar.index !== current.masterBar.masterBar.index || + current.nextBeat.beat.playbackStart <= current.beat.playbackStart) + ) { + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; + } + // the next beat might also be the beyond the selected range + else if (this.playbackRange !== null && this.playbackRange.endTick <= current.nextBeat.start) { + current.cursorMode = MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat; + } } } @@ -430,13 +452,7 @@ export class MidiTickLookup { // scan through beats and find first one which has a beat visible while (masterBar) { if (masterBar.firstBeat) { - const beat = this._findBeatInMasterBar( - masterBar, - masterBar.firstBeat, - tick, - checker, - isNextSearch - ); + const beat = this._findBeatInMasterBar(masterBar, masterBar.firstBeat, tick, checker, isNextSearch); if (beat) { return beat; 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/model/PercussionMapper.ts b/packages/alphatab/src/model/PercussionMapper.ts index cbc6e0942..6932bbcd2 100644 --- a/packages/alphatab/src/model/PercussionMapper.ts +++ b/packages/alphatab/src/model/PercussionMapper.ts @@ -1068,7 +1068,8 @@ export class PercussionMapper { } } - return 'unknown'; + // unknown combination, should not happen, fallback to some default value (Snare hit) + return 'Snare (hit)'; } public static getArticulation(n: Note): InstrumentArticulation | null { diff --git a/packages/alphatab/src/platform/_barrel.ts b/packages/alphatab/src/platform/_barrel.ts index 30f252264..33eb21d92 100644 --- a/packages/alphatab/src/platform/_barrel.ts +++ b/packages/alphatab/src/platform/_barrel.ts @@ -1,8 +1,9 @@ export { Cursors } from '@coderline/alphatab/platform/Cursors'; -export { type ICanvas, TextAlign, TextBaseline, MeasuredText } from '@coderline/alphatab/platform/ICanvas'; +export { type ICanvas, MeasuredText, TextAlign, TextBaseline } from '@coderline/alphatab/platform/ICanvas'; export type { IContainer } from '@coderline/alphatab/platform/IContainer'; export type { IMouseEventArgs } from '@coderline/alphatab/platform/IMouseEventArgs'; export type { IUiFacade } from '@coderline/alphatab/platform/IUiFacade'; +export type { IHtmlElementContainer } from '@coderline/alphatab/platform/javascript/HtmlElementContainer'; export { CssFontSvgCanvas } from '@coderline/alphatab/platform/svg/CssFontSvgCanvas'; -export { FontSizes, FontSizeDefinition } from '@coderline/alphatab/platform/svg/FontSizes'; +export { FontSizeDefinition, FontSizes } from '@coderline/alphatab/platform/svg/FontSizes'; export { SvgCanvas } from '@coderline/alphatab/platform/svg/SvgCanvas'; diff --git a/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts b/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts index 8c871cb84..a440ec5eb 100644 --- a/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts +++ b/packages/alphatab/src/platform/javascript/HtmlElementContainer.ts @@ -5,11 +5,23 @@ import { BrowserMouseEventArgs } from '@coderline/alphatab/platform/javascript/B import { Bounds } from '@coderline/alphatab/rendering/utils/Bounds'; import { Lazy } from '@coderline/alphatab/util/Lazy'; +/** + * A UI element implementation wrapping HTML elements. + * @target web + * @public + */ +export interface IHtmlElementContainer extends IContainer{ + /** + * The wrapped UI element. + */ + readonly element:HTMLElement; +} + /** * @target web * @internal */ -export class HtmlElementContainer implements IContainer { +export class HtmlElementContainer implements IHtmlElementContainer { private static _resizeObserver: Lazy = new Lazy( () => new ResizeObserver((entries: ResizeObserverEntry[]) => { 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/guitarpro5/percussion-all.gp5 b/packages/alphatab/test-data/guitarpro5/percussion-all.gp5 new file mode 100644 index 000000000..6b877c301 Binary files /dev/null and b/packages/alphatab/test-data/guitarpro5/percussion-all.gp5 differ 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/clefs-gp5.png b/packages/alphatab/test-data/visual-tests/music-notation/clefs-gp5.png new file mode 100644 index 000000000..a33ebb29b Binary files /dev/null and b/packages/alphatab/test-data/visual-tests/music-notation/clefs-gp5.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/audio/MidiTickLookup.test.ts b/packages/alphatab/test/audio/MidiTickLookup.test.ts index 71d6ffbc1..8082468a0 100644 --- a/packages/alphatab/test/audio/MidiTickLookup.test.ts +++ b/packages/alphatab/test/audio/MidiTickLookup.test.ts @@ -21,6 +21,7 @@ import type { Score } from '@coderline/alphatab/model/Score'; import { Settings } from '@coderline/alphatab/Settings'; import { TestPlatform } from 'test/TestPlatform'; import { expect } from 'chai'; +import { PlaybackRange } from '@coderline/alphatab/synth/PlaybackRange'; describe('MidiTickLookupTest', () => { function buildLookup(score: Score, settings: Settings): MidiTickLookup { @@ -663,7 +664,8 @@ describe('MidiTickLookupTest', () => { currentBeatIds: number[], nextBeatIds: number[], expectedCursorModes: MidiTickLookupFindBeatResultCursorMode[] | null = null, - skipClean: boolean = false + skipClean: boolean = false, + prepareLookup: ((lookup: MidiTickLookup) => void) | null = null ) { const buffer = ByteBuffer.fromString(tex); const settings = new Settings(); @@ -686,6 +688,10 @@ describe('MidiTickLookupTest', () => { ); } + if (prepareLookup) { + prepareLookup(lookup); + } + let currentLookup: MidiTickLookupFindBeatResult | null = null; const actualIncrementalIds: number[] = []; @@ -1119,16 +1125,16 @@ describe('MidiTickLookupTest', () => { ], [ // 1st bar - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, // 2nd bar - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, // 3rd bar - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, // 1st bar (repated) MidiTickLookupFindBeatResultCursorMode.ToNextBext, @@ -1141,10 +1147,215 @@ describe('MidiTickLookupTest', () => { MidiTickLookupFindBeatResultCursorMode.ToNextBext, // 4th bar - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar, - MidiTickLookupFindBeatResultCursorMode.ToEndOfBar + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat + ], + true + ); + }); + + it('simple-repeat', () => { + lookupTest( + ` + C4.2 * 2 | + C4.2 * 2 | + \\ro + \\rc 2 + C4.2 * 2 | + C4.2 * 2 | + C4.2 * 2 | + `, + [ + // 1st bar + 0, 960, 1920, 2880, + + // 2nd bar + 3840, 4800, 5760, 6720, + + // 3rd bar + 7680, 8640, 9600, 10560, + + // 3rd bar (repeated) + 11520, 12480, 13440, 14400, + + // 4th bar + 15360, 16320, 17280, 18240, + + // 5th bar + 19200, 20160, 21120, 22080 + ], + [0], + [ + // 1st bar + 1920, 1920, 1920, 1920, + // 2nd bar + 1920, 1920, 1920, 1920, + // 3rd bar + 1920, 1920, 1920, 1920, + // 3rd bar (repeated) + 1920, 1920, 1920, 1920, + // 4th bar + 1920, 1920, 1920, 1920, + // 5th bar + 1920, 1920, 1920, 1920 + ], + [ + // 1st bar + 0, 0, 1, 1, + // 2nd bar + 2, 2, 3, 3, + // 3rd bar + 4, 4, 5, 5, + // 3rd bar (repeated) + 4, 4, 5, 5, + // 4th bar + 6, 6, 7, 7, + // 5th bar + 8, 8, 9, 9 + ], + [ + // 1st bar + 1, 1, 2, 2, + // 2nd bar + 3, 3, 4, 4, + // 3rd bar + 5, 5, 4, 4, + // 3rd bar (repeated) + 5, 5, 6, 6, + // 4th bar + 7, 7, 8, 8, + // 5th bar + 9, 9, -1, -1 + ], + [ + // 1st bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 2nd bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 3rd bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + // 3rd bar (repeated) + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 4th bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 5th bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat ], true ); }); + + describe('playback-range', () => { + it('full-bar', () => { + lookupTest( + ` + C4.2 * 2 | + C4.2 * 2 + `, + [ + // 1st bar + 0, 960, 1920, 2880 + ], + [0], + [ + // 1st bar + 1920, 1920, 1920, 1920 + ], + [ + // 1st bar + 0, 0, 1, 1 + ], + [ + // 1st bar + 1, 1, 2, 2 + ], + [ + // 1st bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat + ], + true, + lookup => { + // whole first bar + const range = new PlaybackRange(); + range.startTick = 0; + range.endTick = 3840; + lookup.playbackRange = range; + } + ); + }); + + it('mid-bar', () => { + lookupTest( + ` + C4.2 * 2 | + C4.2 * 2 + `, + [ + // 1st bar + 0, 960, 1920, 2880, + + // 2nd bar + 3840, 4800 + ], + [0], + [ + // 1st bar + 1920, 1920, 1920, 1920, + // 2nd bar + 1920, 1920 + ], + [ + // 1st bar + 0, 0, 1, 1, + // 2nd bar + 2, 2 + ], + [ + // 1st bar + 1, 1, 2, 2, + // 2nd bar + 3, 3 + ], + [ + // 1st bar + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + MidiTickLookupFindBeatResultCursorMode.ToNextBext, + // 2nd bar + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat, + MidiTickLookupFindBeatResultCursorMode.ToEndOfBeat + ], + true, + lookup => { + // whole first bar + const range = new PlaybackRange(); + range.startTick = 0; + range.endTick = 5760; + lookup.playbackRange = range; + } + ); + }); + }); }); diff --git a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts index cd6001146..4faf7e7a2 100644 --- a/packages/alphatab/test/exporter/AlphaTexExporter.test.ts +++ b/packages/alphatab/test/exporter/AlphaTexExporter.test.ts @@ -168,6 +168,10 @@ describe('AlphaTexExporterTest', () => { await testRoundTripEqual(`conversion/full-song.gp5`); }); + it('gp5-articulation', async () => { + await testRoundTripEqual(`guitarpro5/percussion-all.gp5`); + }); + it('gp6-to-alphaTex', async () => { await testRoundTripEqual(`conversion/full-song.gpx`); }); diff --git a/packages/alphatab/test/importer/Gp5Importer.test.ts b/packages/alphatab/test/importer/Gp5Importer.test.ts index e56f85ebb..4ef957e89 100644 --- a/packages/alphatab/test/importer/Gp5Importer.test.ts +++ b/packages/alphatab/test/importer/Gp5Importer.test.ts @@ -9,6 +9,7 @@ import { BeamDirection } from '@coderline/alphatab/rendering/utils/BeamDirection import { GpImporterTestHelper } from 'test/importer/GpImporterTestHelper'; import { expect } from 'chai'; import { Clef } from '@coderline/alphatab/model/Clef'; +import { PercussionMapper } from '@coderline/alphatab/model/PercussionMapper'; describe('Gp5ImporterTest', () => { it('score-info', async () => { @@ -548,9 +549,24 @@ describe('Gp5ImporterTest', () => { it('tuning-bass-clef', async () => { const score = (await GpImporterTestHelper.prepareImporterWithFile('guitarpro5/bass-tuning.gp5')).readScore(); - expect(score.tracks[0].staves[0].bars[0].clef).to.equal(Clef.F4); + expect(score.tracks[0].staves[0].bars[0].clef).to.equal(Clef.G2); expect(score.tracks[1].staves[0].bars[0].clef).to.equal(Clef.F4); expect(score.tracks[2].staves[0].bars[0].clef).to.equal(Clef.F4); expect(score.tracks[3].staves[0].bars[0].clef).to.equal(Clef.F4); }); + + it('percusson', async () => { + const score = (await GpImporterTestHelper.prepareImporterWithFile('guitarpro5/percussion-all.gp5')).readScore(); + + let beat: Beat | null = score.tracks[0].staves[0].bars[0].voices[0].beats[0]; + + while (beat) { + if (beat.notes.length === 1) { + const articulationName = PercussionMapper.getArticulationName(beat.notes[0]); + const hasArticulation = PercussionMapper.instrumentArticulationNames.has(articulationName); + expect(hasArticulation).to.be.true; + beat = beat.nextBeat; + } + } + }); }); 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 198405172..2339a1a3d 100644 --- a/packages/alphatab/test/visualTests/features/MusicNotation.test.ts +++ b/packages/alphatab/test/visualTests/features/MusicNotation.test.ts @@ -3,6 +3,7 @@ import { NotationElement } from '@coderline/alphatab/NotationSettings'; import { Settings } from '@coderline/alphatab/Settings'; import { StaveProfile } from '@coderline/alphatab/StaveProfile'; import { ScoreLoader } from '@coderline/alphatab/importer/ScoreLoader'; +import { TestPlatform } from 'test/TestPlatform'; import { VisualTestHelper, VisualTestOptions, VisualTestRun } from 'test/visualTests/VisualTestHelper'; describe('MusicNotationTests', () => { @@ -21,6 +22,21 @@ describe('MusicNotationTests', () => { await VisualTestHelper.runVisualTest('music-notation/clefs.gp', settings); }); + it('clefs-gp5', async () => { + const score = ScoreLoader.loadScoreFromBytes( + await TestPlatform.loadFile('test-data/guitarpro5/bass-tuning.gp5') + ); + const settings: Settings = new Settings(); + settings.display.layoutMode = LayoutMode.Page; + + const referenceFileName = 'test-data/visual-tests/music-notation/clefs-gp5.png'; + + const o = new VisualTestOptions(score, [new VisualTestRun(-1, referenceFileName)], settings); + o.tracks = score.tracks.map(t => t.index); + + await VisualTestHelper.runVisualTestFull(o); + }); + it('key-signatures-mixed', async () => { const settings: Settings = new Settings(); settings.display.staveProfile = StaveProfile.Score; @@ -142,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} `; @@ -174,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/alphatex/package.json b/packages/alphatex/package.json index 5c02aed87..737389fde 100644 --- a/packages/alphatex/package.json +++ b/packages/alphatex/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-alphatex", - "version": "1.8.0", + "version": "1.8.1", "private": true, "scripts": { "lint": "biome lint", diff --git a/packages/csharp/package.json b/packages/csharp/package.json index fdb3aadfb..6e43893f7 100644 --- a/packages/csharp/package.json +++ b/packages/csharp/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-csharp", - "version": "1.8.0", + "version": "1.8.1", "description": "The C# target of alphaTab.", "private": true, "type": "module", diff --git a/packages/csharp/src/Directory.Build.props b/packages/csharp/src/Directory.Build.props index 1d300035e..66883954d 100644 --- a/packages/csharp/src/Directory.Build.props +++ b/packages/csharp/src/Directory.Build.props @@ -2,8 +2,8 @@ portable true - 1.8.0 - 1.8.0.0 + 1.8.1 + 1.8.1.0 $(AssemblyVersion) Danielku15 CoderLine diff --git a/packages/kotlin/package.json b/packages/kotlin/package.json index 9a3d25b60..5cdc5a1e8 100644 --- a/packages/kotlin/package.json +++ b/packages/kotlin/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-kotlin", - "version": "1.8.0", + "version": "1.8.1", "description": "The Kotlin target of alphaTab.", "private": true, "type": "module", diff --git a/packages/kotlin/src/android/build.gradle.kts b/packages/kotlin/src/android/build.gradle.kts index e7710b8ef..171364f61 100644 --- a/packages/kotlin/src/android/build.gradle.kts +++ b/packages/kotlin/src/android/build.gradle.kts @@ -39,7 +39,7 @@ var libAuthorId = "danielku15" var libAuthorName = "Daniel Kuschny" var libOrgUrl = "https://github.com/coderline" var libCompany = "CoderLine" -var libVersion = "1.8.0-SNAPSHOT" +var libVersion = "1.8.1-SNAPSHOT" var libProjectUrl = "https://github.com/CoderLine/alphaTab" var libGitUrlHttp = "https://github.com/CoderLine/alphaTab.git" var libGitUrlGit = "scm:git:git://github.com/CoderLine/alphaTab.git" diff --git a/packages/lsp/package.json b/packages/lsp/package.json index 8b5a7a816..ca08ad823 100644 --- a/packages/lsp/package.json +++ b/packages/lsp/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-language-server", - "version": "1.8.0", + "version": "1.8.1", "description": "A language server for alphaTab providing coding assistance for alphaTex.", "keywords": [ "guitar", @@ -34,7 +34,7 @@ "test": "mocha" }, "dependencies": { - "@coderline/alphatab": "^1.8.0", + "@coderline/alphatab": "^1.8.1", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.12" }, diff --git a/packages/monaco/package.json b/packages/monaco/package.json index 894392f09..c199cfb98 100644 --- a/packages/monaco/package.json +++ b/packages/monaco/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-monaco", - "version": "1.8.0", + "version": "1.8.1", "description": "A Monaco editor integration for alphaTab providing coding assistance for alphaTex.", "keywords": [ "guitar", @@ -31,8 +31,8 @@ "test": "mocha" }, "dependencies": { - "@coderline/alphatab": "^1.8.0", - "@coderline/alphatab-language-server": "^1.8.0", + "@coderline/alphatab": "^1.8.1", + "@coderline/alphatab-language-server": "^1.8.1", "monaco-editor": "^0.55.1", "vscode-languageserver-types": "^3.17.5", "vscode-oniguruma": "^2.0.1", diff --git a/packages/playground/package.json b/packages/playground/package.json index 539144904..fcdb5ba1b 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-playground", - "version": "1.8.0", + "version": "1.8.1", "description": "A development playground for alphaTab to test features while developing", "private": true, "type": "module", diff --git a/packages/tooling/package.json b/packages/tooling/package.json index 61f8cc3f0..86eff5678 100644 --- a/packages/tooling/package.json +++ b/packages/tooling/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-tooling", - "version": "1.8.0", + "version": "1.8.1", "type": "module", "description": "Additional build tooling for alphaTab like common build configurations", "private": true, diff --git a/packages/transpiler/package.json b/packages/transpiler/package.json index 0f115d5f3..f74f8f2fe 100644 --- a/packages/transpiler/package.json +++ b/packages/transpiler/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-transpiler", - "version": "1.8.0", + "version": "1.8.1", "type": "module", "description": "The transpiler toolkit to translate alphaTab to C# and Kotlin", "private": true, 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), diff --git a/packages/vite/package.json b/packages/vite/package.json index a2d84723c..0b17ba405 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-vite", - "version": "1.8.0", + "version": "1.8.1", "description": "A plugin for Vite to bundle alphaTab into your webapps.", "keywords": [ "guitar", diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 8976cb39d..858c05f69 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -1,6 +1,6 @@ { "name": "alphatab-vscode", - "version": "1.8.0", + "version": "1.8.1", "private": true, "description": "A Visual Studio Code extension for alphaTab providing coding assistance for alphaTex.", "keywords": [ diff --git a/packages/webpack/package.json b/packages/webpack/package.json index 17ab3d695..0ce37134b 100644 --- a/packages/webpack/package.json +++ b/packages/webpack/package.json @@ -1,6 +1,6 @@ { "name": "@coderline/alphatab-webpack", - "version": "1.8.0", + "version": "1.8.1", "description": "A plugin for WebPack to bundle alphaTab into your webapps.", "keywords": [ "guitar",